Compare commits

..

3 Commits

Author SHA1 Message Date
ttipo be473078cc docs(rules): CI 러너 이름 · CPU 시간 한도 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 19m55s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 11m6s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m0s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 14s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m7s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
docker 스킬에 러너의 compose 프로젝트 이름(RUNNER_NAME)과 이미지 판을, testing 규칙에
CPU 시간 한도는 지금 값의 10배쯤 둔다는 것을 적는다(CPU 시간도 부하에서 는다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:12 +09:00
ttipo 52c6ebecb6 test(backend): CPU 시간 한도를 부하에 견디게 — CI 에서 평소의 4~7배
첫 CI(같은 PC 의 러너)에서 백엔드 단위 테스트의 CPU 시간 단언 두 곳이 넘쳤다 — CI 와 같은
Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때 평소의 4~7배로 재졌다
(이미지 많은 정화 1.6초 → 5.75초 · 한도 5초, 한도 가까운 본문의 DTO 검사 0.08초 → 0.59초 · 한도 0.5초).

한도를 평소의 10배쯤으로 다시 잡는다 — 막으려는 옛 경로(제곱)는 여전히 확실히 넘는다.
여유가 6.7배뿐이던 HTML 비용 사전 검사도 함께 늘린다(0.3초 · 옛 경로 172초).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:12 +09:00
ttipo ec1919e4bf fix(docker): CI 러너 — compose 프로젝트 이름을 러너 이름으로 · 이미지 판 고정
프로젝트 이름이 비어 있으면 폴더 이름 ci 가 되어, 같은 템플릿을 쓰는 다른 프로젝트의 러너와
프로젝트 · 서비스(ci / runner)가 겹친다. 그러면 up 이 그쪽 컨테이너를 지우고 이 러너로 다시 만든다
(같은 PC 의 다른 프로젝트 러너가 project=ci 였다 — 띄우기 전에 찾았다). 이름을 RUNNER_NAME 으로 둔다.

러너 이미지는 latest 대신 같은 digest 인 0.6.1 로 고정한다(이미지 버전 고정 규칙).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:12 +09:00
10 changed files with 36 additions and 11 deletions
+2
View File
@@ -14,6 +14,8 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
+3
View File
@@ -242,6 +242,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
```
- 러너가 없으면 워크플로는 **"Waiting for a runner"** 로 대기만 한다.
- compose 프로젝트 이름은 `RUNNER_NAME` 이다(`name:`) — 폴더 이름 `ci` 로 두면 같은 템플릿을 쓰는 다른 프로젝트의 러너와 겹쳐 `up` 이 그쪽 컨테이너를 다시 만든다.
같은 PC 에 러너가 여럿이면 `RUNNER_NAME` 을 겹치지 않게 짓는다(이 저장소는 `arasystem-runner`).
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
@@ -318,6 +320,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
| nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) |
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다 |
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
새 태그가 있는지와 digest 는 `docker buildx imagetools inspect <이미지>:<태그>` 로 확인한다 (2026-10-27: Stalwart `v0.16`·`v0.16.27`, nginx `stable-alpine`·`1.30.6-alpine3.24` 가 각각 같은 digest).
+8 -3
View File
@@ -168,7 +168,12 @@ describe('JSON 본문의 모양 한도 (JSON_SHAPE_LIMITS — DTO 변환 · 검
});
describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () => {
/** 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 0.5초 안에 400 이다 */
/**
* 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 해도 400 이 싸게 난다.
* CPU 시간(test/cpu-time.ts) — 지금 0.02~0.08초. 한도 2초는 부하를 견디는 값이다: 2026-10-28 CI 와 같은 Linux 컨테이너에서
* 메모리가 모자라고 테스트가 함께 돌 때 0.59초까지 재졌다(평소의 7배 — 그때 한도 0.5초로 CI 가 실패했다)
*/
const DTO_CPU_BUDGET_MS = 2_000;
const filtersWithManyUnknowns = {
version: 'v',
rules: Array.from({ length: 50 }, (_, index) => ({
@@ -230,7 +235,7 @@ describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () =>
{ ids: Array.from({ length: 1_000 }, (_, index) => `m${index}`) },
],
])(
'%s — 모양 확인을 지나도 DTO 검사가 0.5초 안에 400',
'%s — 모양 확인을 지나도 DTO 검사가 2초 안에 400 (CPU 시간)',
async (_label, metatype, body) => {
const text = JSON.stringify(body);
const pipe = createValidationPipe();
@@ -242,7 +247,7 @@ describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () =>
await expect(
pipe.transform(parsed, { type: 'body', metatype }),
).rejects.toBeInstanceOf(BadRequestException);
expect(cpuMs()).toBeLessThan(500);
expect(cpuMs()).toBeLessThan(DTO_CPU_BUDGET_MS);
},
);
+1 -1
View File
@@ -42,7 +42,7 @@ export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
* 지금 계약의 어떤 본문보다 넉넉히 둔다: 가장 긴 배열은 받는 사람 · id 100(발송 본문의 첨부 목록 상한도 100), 가장 깊은 본문은 5단
* (필터 — 본문 › rules › 규칙 › conditions › 조건), 값이 가장 많은 본문은 필터 50개를 모두 채운 저장(약 1,600개).
* 넘으면 400 VAL_001 — 이 한도를 넘는 계약을 새로 만들면 여기부터 올린다.
* 한도까지 채운 본문도 DTO 변환 · 검사가 0.5초 안에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다)
* 한도까지 채운 본문도 DTO 변환 · 검사가 0.1초 안쪽에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다 — 부하를 견디게 한도는 2초)
*/
export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
maxArrayLength: 1_000,
@@ -13,8 +13,9 @@ import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
/**
* CPU 시간 한도 (벽시계가 아니다 — test/cpu-time.ts). 지금 검사는 2MB 를 끝까지 읽어도 CPU 0.3초 안쪽이고 공격 모양은 0.15초 안쪽에 멈춘다.
* 막으려는 제곱 경로는 같은 공격 입력을 정화하는 것 — '<b>' 2MB 에 172초다.
* 한도는 평소의 30배쯤 — CPU 시간도 부하에서는 몇 배로 는다(2026-10-28 CI 와 같은 Linux 컨테이너에서 평소의 4~7배, test/cpu-time.ts)
*/
const GENEROUS_CPU_MS = 2_000;
const GENEROUS_CPU_MS = 10_000;
/** 보내는 서식 본문 한도 (계약 2절 — DTO 가 막는다) */
const OUTGOING_MAX_CHARS = 500_000;
@@ -257,9 +257,10 @@ describe('sanitizeMailHtml', () => {
const result = sanitizeMailHtml('x<img>'.repeat(count));
expect(result.html).toBe('x'.repeat(count));
// CPU 시간 (test/cpu-time.ts) — 지금 1.1~1.6초(부하 중에도). 예전 경로(결과를 이미지마다 다시 자름)는 27만 개(1.6MB)에 15초, 이 크기면 20초가 넘는다.
// 벽시계로 재던 때는 바쁜 PC 에서 5.9초·9.1초로 재져 실패했다 (2026-10-27)
expect(cpuMs()).toBeLessThan(5_000);
// CPU 시간 (test/cpu-time.ts) — 지금 1.1~1.6초. 예전 경로(결과를 이미지마다 다시 자름)는 27만 개(1.6MB)에 15초, 이 크기면 20초가 넘는다.
// 벽시계로 재던 때는 바쁜 PC 에서 5.9초·9.1초로 재져 실패했다 (2026-10-27). CPU 시간도 2026-10-28 CI 와 같은 Linux 컨테이너에서
// 메모리가 모자라고 테스트가 함께 돌 때 5.75초까지 재져 한도 5초로 실패했다 — 그래서 평소의 10배 가까이, 예전 경로(20초+)보다는 아래로 둔다
expect(cpuMs()).toBeLessThan(15_000);
});
});
+3 -1
View File
@@ -6,7 +6,9 @@
* 전체 테스트 4번 중 2번이 실패했다. CPU 시간은 이 프로세스가 실제로 계산한 시간만 세므로 다른 작업이 많아도 거의 늘지 않는다 —
* "계산량이 입력 길이에 비례하는가(예전의 제곱 경로가 돌아오지 않았는가)"를 보는 단언에 쓴다.
*
* - 프로세스 전체(GC·JIT 스레드 포함)의 user + system 시간이다 — 한도는 지금 값보다 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다.
* - 프로세스 전체(GC·JIT 스레드 포함)의 user + system 시간이다 — 한도는 지금 값의 10배쯤 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다.
* CPU 시간도 늘지 않는 것은 아니다 — 2026-10-28 CI 와 같은 Linux 컨테이너(Docker Desktop)에서 메모리가 모자라고 테스트가 함께 돌 때
* 평소의 4~7배로 재져, 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다(이미지 많은 정화 1.6초 → 5.75초, DTO 검사 0.08초 → 0.59초).
* - 기다리는 시간(I/O·타이머)은 CPU 시간에 들어가지 않는다 — 그런 단언(예: 파일이 지워질 때까지 기다리기)에는 쓰지 않는다.
* - Windows 에서는 CPU 시간이 스케줄러 틱(약 15.6ms) 단위로 는다 — 수십 ms 아래를 비교하는 데 쓰지 않는다.
*
+2 -1
View File
@@ -12,7 +12,8 @@ GITEA_INSTANCE_URL=https://git.wandergo.net
# (여러 저장소에서 공용으로 쓰려면 관리자 화면의 인스턴스 레벨 토큰을 사용)
GITEA_RUNNER_REGISTRATION_TOKEN=
# 러너 식별 이름 — 여러 대를 띄운다면 구분되게 짓는다
# 러너 식별 이름 — 컨테이너 이름이자 compose 프로젝트 이름이다. 같은 PC 에 다른 프로젝트의 러너가 있으면 겹치지 않게 짓는다
# (예: <프로젝트>-runner — 겹치면 `up` 이 그쪽 컨테이너를 지우고 다시 만든다)
RUNNER_NAME=gitea-runner
# 라벨: <워크플로의 runs-on>:docker://<잡을 실행할 이미지>
+4
View File
@@ -97,6 +97,10 @@ docker stop <컨테이너명>
## 주의
- **같은 PC 에 다른 프로젝트의 러너가 있으면 `RUNNER_NAME` 을 겹치지 않게** 짓습니다. compose 의 프로젝트 이름이 이 값이고(`docker-compose.runner.yml` 의 `name:`),
컨테이너 이름도 이 값입니다. 프로젝트 이름을 비워 두면 폴더 이름 `ci` 가 되어, 같은 템플릿을 쓰는 다른 프로젝트의 러너(역시 `ci` · 서비스 `runner`)와 겹치고
`up` 이 그쪽 컨테이너를 지운 뒤 이 러너로 다시 만듭니다 (2026-10-28 에 겪을 뻔했다 — 띄우기 전에 `docker compose ls -a` 로 이름을 본다).
- 러너 이미지는 판까지 고정합니다(`gitea/act_runner:0.6.1`) — 올릴 때는 docker 스킬의 "이미지 버전 고정" 갱신 규칙을 따릅니다.
- **토큰은 `ci/.env` 에만** 둡니다. 이 파일은 루트 `.gitignore` 로 커밋되지 않습니다.
- `runner-data/` 에 등록 정보가 저장됩니다. 지우면 토큰으로 재등록해야 합니다.
- 러너는 도커 소켓에 접근하므로 **호스트에 대한 사실상의 관리자 권한**을 갖습니다.
+7 -1
View File
@@ -5,9 +5,15 @@
#
# 사용법은 ci/README.md 참조.
# 프로젝트 이름 — 러너 이름을 그대로 쓴다. 비워 두면 폴더 이름(`ci`)이 되어, 같은 템플릿을 쓰는 다른 프로젝트의 러너와
# 프로젝트 · 서비스가 겹친다(둘 다 ci / runner) — 그러면 `up` 이 그쪽 컨테이너를 지우고 이 러너로 다시 만든다
# (2026-10-28 — 같은 PC 의 다른 프로젝트 러너가 project=ci 였다). 러너 이름은 컨테이너 이름이라 어차피 PC 안에서 겹치면 안 된다
name: ${RUNNER_NAME:-gitea-runner}
services:
runner:
image: gitea/act_runner:latest
# 판까지 고정한다 (docker 스킬 "이미지 버전 고정" — latest 는 그날의 최신을 받는다). 0.6.1 = 2026-10-28 의 latest 와 같은 digest
image: gitea/act_runner:0.6.1
container_name: ${RUNNER_NAME:-gitea-runner}
restart: always
environment: