docs(rules): 11단계 부품 — 공개 링크 보안 · 대용량 업로드 예외 · 스킬 표 · env
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m57s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m47s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m52s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 1m53s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m28s
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

- security: 대용량 첨부는 확장자 허용 목록만 보는 예외와 그 세 겹, 공개 링크(토큰 해시 · 공개 호스트 분리 · 조건부 UPDATE ·
  경로마다 전체 상한 · 앞단과 백엔드가 경로를 같게 읽기 — 대소문자 두 겹).
- nestjs: 파일 저장 · 조각 올리기 / 공개 링크 · 내려받기 행, 라우팅은 대소문자를 가린다(createHttpAdapter), 경로 숫자 함정,
  확장 때 옮길 것(ChunkGate · 검사 차례 · cron · FILE_BLOB_STORE).
- vue3: 대용량 첨부 행. docker: 두 server · 스니펫 · 받기 location · limit_conn · 머리글 버퍼 · ClamAV 절 · 이미지 표 · 볼륨 · CI ·
  Git Bash 경로 변환 함정. env-structure · checklist: SHARE_PUBLIC_URL(포털과 다른 호스트 — CORS_ORIGIN 에 넣지 않음) · CLAMAV_HOST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-10-31 00:38:42 +09:00
parent 3461c361f8
commit 8f0a6da83c
6 changed files with 135 additions and 12 deletions
+93 -8
View File
@@ -18,7 +18,8 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
```bash
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절)
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절.
# 대용량 첨부의 악성 검사기까지 보려면 COMPOSE_PROFILES=mail,clamav 와 CLAMAV_HOST=clamav — 아래 "대용량 첨부" 절)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
@@ -32,6 +33,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다 (예시: `https://portal.arasystem.it.kr`).
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). `CORS_ORIGIN`·`TRUST_PROXY` 는 운영 필수다.
> 대용량 첨부(11단계)의 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 예 `https://files.arasystem.it.kr`, **`CORS_ORIGIN` 에 넣지 않는다**)·`CLAMAV_HOST`(`clamav`)도 운영 필수다.
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
```bash
@@ -45,12 +47,16 @@ sudo docker compose --env-file .env.production logs -f backend
### 같은 주소(origin) 배포 — 입구는 프론트 nginx 하나
```
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ / 화면(SPA)
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ 포털 호스트 / 화면(SPA) · /api/
(포털 · 공개 내려받기 두 호스트) ├─ 공개 호스트 /s/ 화면 · /api/public-links/ 만 (11단계 — SHARE_PUBLIC_URL)
└─ /api/ ─(edge)─▶ <PROJECT_NAME>-backend :3000
│ (app-network = 메일 엔진의 신뢰 대역)
├─▶ db · redis
└─▶ mail :8080 (JMAP·관리 API)
└─(mail-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
├─▶ mail :8080 (JMAP·관리 API)
│ └─(mail-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
└─(clamav)─▶ clamav :3310 (악성 검사 — 대용량 첨부)
▲ 서명 볼륨(읽기 전용)
clamav-updater ─(clamav-egress)─▶ 서명 서버
```
| 네트워크 | 붙는 컨테이너 | 비고 |
@@ -60,6 +66,8 @@ sudo docker compose --env-file .env.production logs -f backend
| `<PROJECT_NAME>_app-network` | backend · db · redis · mail (dev 는 mailpit 도) | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) 고정 — **메일 엔진의 신뢰 대역**. frontend 는 붙이지 않는다 |
| `<PROJECT_NAME>_mail-dns` | mail · mail-resolver | `MAIL_RESOLVER_SUBNET`(기본 `172.16.73.0/24`) 고정 · `internal`(바깥 길 없음). 리졸버 주소 `MAIL_RESOLVER_IP`(기본 `172.16.73.254`)를 mail 의 `dns` 와 엔진 DNS 가 쓴다 |
| `<PROJECT_NAME>_mail-dns-egress` | mail-resolver | 리졸버가 바깥 DNS 서버에 묻는 길. 서브넷은 고정하지 않는다(도커 기본 풀) — 서버의 사설망(VPC)과 겹치지 않는지 본다 |
| `<PROJECT_NAME>_clamav` | backend · clamav | `internal`(바깥 길 없음) — 검사기는 바깥에서 온 파일을 여니 바깥 · 다른 컨테이너에 닿지 못하게. clamd 는 평문 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 고정하지 않는다 |
| `<PROJECT_NAME>_clamav-egress` | clamav-updater | 서명 갱신(freshclam)이 서명 서버에 묻는 길 — updater 혼자 붙는다(백엔드 · 검사기와 잇지 않는다). 서브넷은 도커 기본 풀 — VPC 와 겹치지 않는지 본다 |
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
@@ -73,15 +81,39 @@ sudo docker compose --env-file .env.production logs -f backend
- 본문 한도: `/api/mail-attachments` = `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m), `/api/files` = `UPLOAD_MAX_SIZE_MB` + 1MB(기본 6m),
**메일 발송 `POST /api/messages` 와 임시 저장 `/api/drafts` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 요청들만 4MB 파서.
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
**대용량 첨부의 조각 `PUT /api/file-uploads/{id}/chunks/{n}` = 17m 고정**(조각 16MiB 는 계약 상수 — 바꾸려면 계약 · 백엔드 · 화면을 함께, 11단계 —
이 경로만 오류 로그를 `error` 부터 남긴다: 본문을 임시 파일에 받는다는 warn 이 조각마다 남았다),
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` · 대용량 조각 · 대용량 받기 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
머리글 · 오류 JSON · 접근 로그는 `/api` 와 같다. ⚠️ 서버의 ping 간격을 바꾸면 90s 도 함께 본다.
열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다(이미지 기본값 워커마다 1024).
열린 연결이 있으면 frontend 컨테이너를 멈출 때 nginx 가 기다리다 유예 시간을 다 쓰고 강제 종료된다 — 종료 코드 137 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
- **대용량 첨부 (11단계 — 계약 4절 "공개 호스트" · "운영 확인")**: server 가 둘이다.
- 포털(`default_server` · `server_name _`)과 공개 내려받기(`server_name` = `SHARE_PUBLIC_URL` 의 호스트 이름). `nginx-env.envsh` 가 그 호스트 이름을
`CORS_ORIGIN` 의 호스트 이름들과 견준다(백엔드 `file-sharing.settings.ts` 와 같은 판정 — 포트는 보지 않는다).
다르면 공개 server 가 화면 `/s/` · 화면 자산 · `/api/public-links/` **만** 열고(그 밖의 화면 404 · API 404 `RES_001` — 백엔드에 넘기지 않는다) 포털은 그 둘을 404 로 닫는다.
비었거나 같으면(개발) 포털이 함께 열고 공개 server 이름은 어느 요청에도 맞지 않는 `share.invalid` 다.
- 공개 경로 · 오류 location 은 템플릿이 아닌 스니펫이다(이미지의 `/etc/nginx/snippets/` — `${NGINX_*}` 를 쓰지 않는다):
`nginx-share-open.conf` · `nginx-share-closed.conf`(server 단계 — 화면 `/s/` 만), `nginx-share-api-open.conf` · `nginx-share-api-closed.conf`(`/api/` 블록 안 — 공개 API, `~*`),
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
버퍼링을 끈 받기에서 `proxy_read_timeout` 은 돌지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 `send_timeout`(기본 60s)으로 끊는다 — 느려도 조금씩 받으면 이어진다(2026-10-30 확인).
- **동시 연결 상한**(`limit_conn` — 받기가 연결 둘과 백엔드의 파일을 잡으므로): 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 서버 전체는 경로마다 따로 공개 400 · 앱 안 200
(함께 세면 바깥 IP 몇십 개로 직원의 앱 안 받기까지 막힌다 — 2026-10-30 리뷰).
넘으면 429 `REQ_001`(JSON). 클라이언트 주소는 `X-Forwarded-For` 의 **마지막 칸**(외부 프록시가 이어 붙인 값 — 없으면 접속 주소)이다 — 앞 칸을 꾸며도 바뀌지 않는다.
8080 에 프록시를 건너뛰고 바로 붙는 길이 있으면 주소마다 한도는 피한다(경로마다 서버 전체 한도는 그대로) — 그 길을 막는 것이 ACG · proxy 네트워크다. IPv6 는 /64 로 묶지 않는다.
⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`) —
백엔드 라우팅이 대소문자를 가리지 않던 때 `/FILES/` · `/api/Messages/…` 처럼 글자 하나로 상한과 `X-Accel-Buffering` 넘김을 비켜 갔다(2026-10-30 리뷰).
같은 블록의 prefix 에 `^~` 가 있으면 그 아래 경로는 블록의 정규식을 건너뛰므로 `/api/messages` 는 `^~` 없이 둔다. id · 토큰 칸은 `.+`(nginx 는 `%2F` 를 풀어 location 을 고른다).
포털이 닫는 공개 API 도 `~* ^/api/public-links(?:/|$)` 다. 백엔드도 라우팅이 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
거절은 접근 로그(429)로만 본다(`limit_conn_log_level info` — 오류 로그에 토큰째 요청 줄이 쏟아지지 않게).
- 그릇: 워커마다 `worker_connections 4096`(이미지 기본 1024 — `frontend/Dockerfile` 이 바꾸고 바뀌었는지 grep 으로 확인해 못 바꾸면 빌드 실패) · compose 의 `ulimits nofile 8192`.
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
@@ -97,11 +129,15 @@ sudo docker compose --env-file .env.production logs -f backend
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
**대용량 첨부의 내려받기 토큰도 가린다**(11단계) — 요청 줄 · Referer 의 `/s/<토큰>` · `/api/public-links/<토큰>…` 의 토큰 칸을 `***` 로(대소문자 무관, absolute-form 포함),
그리고 경로 모양과 상관없이 토큰 모양(43자 base64url)으로도 — `//s/` · `/%73/` · `/api//public-links/` 같은 변형을 경로 모양만으로는 놓쳤다(2026-10-30 리뷰).
토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 백엔드도 `maskUrlForLog()` 로 가린다.
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다(오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다).
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
(그리고 500·503) `SYS_001`. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`(공개 server 의 1m 넘는 본문 포함), 받기 동시 연결 초과 429 `REQ_001`(11단계),
nginx 가 닫은 경로 404 `RES_001`(공개 server 의 그 밖 `/api` · 포털이 닫은 공개 API — 대소문자 변형도), 백엔드 응답 없음 502·시간 초과 504
(그리고 500·503) `SYS_001`. 이름 있는 location 은 두 server 가 함께 넣는 `nginx-api-errors.conf` 에 있다 — `error_page` 를 하나라도 적은 location 은 위의 것을 물려받지 않아 셋을 다 적는다. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
@@ -114,9 +150,12 @@ sudo docker compose --env-file .env.production logs -f backend
(CI 의 infra 잡이 같은 검사를 한다). 값을 바꿔 렌더링 결과를 보려면
`docker run --rm -e PROJECT_NAME=x -e MAIL_ATTACHMENT_MAX_SIZE_MB=30 <tag> nginx -T | grep client_max_body_size`.
`PROJECT_NAME`(또는 `NGINX_BACKEND_UPSTREAM`) 없이 띄우면 일부러 기동을 멈춘다.
공개 호스트를 가를 때와 아닐 때 설정이 다르다 — 두 꼴을 다 본다: `-e SHARE_PUBLIC_URL=https://files.example.kr -e CORS_ORIGIN=https://portal.example.kr` 와 둘 다 없이.
- **템플릿 값이 잘못되면 frontend 가 뜨지 않는다** (`frontend/nginx-env.envsh`, fail-fast) — `docker logs <PROJECT_NAME>-frontend` 의 `nginx-env.envsh:` 줄을 본다.
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
- `SHARE_PUBLIC_URL`(11단계): 빈 값(공개 호스트를 가르지 않음) 또는 출처 `http(s)://호스트[:포트]` 만 — 경로 · 끝의 `/` · 끝 점(nginx 의 `$host` 는 끝 점을 떼어 백엔드 판정과 어긋난다) ·
점 · 하이픈으로 시작하거나 끝나는 이름 · `;` · 공백 · `@` · 대괄호를 거부한다(`server_name` 에 지시어를 끼워 넣지 못하게). 백엔드 env 검사도 같은 값을 거절한다 — 한쪽만 뜨는 반쪽 배포가 되지 않게
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
frontend 는 TLS 를 하지 않고 호스트 포트도 열지 않는다 — **예외: 프록시가 다른 서버에 있을 때**(NCP 등 — 같은 VPC 의 Nginx Proxy Manager)는
선택 파일 `docker-compose.remote-proxy.yml` 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 **사설 IP 에만**(`FRONTEND_PRIVATE_BIND`) 게시한다.
@@ -153,12 +192,25 @@ sudo docker compose --env-file .env.production logs -f backend
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·
`/api/recipient-suggestions?q=…`)에 검색어 · 임시 저장 id 가 들어간다 — 프론트 nginx 와 같은 기준([[logging]]).
화면의 `Referrer-Policy: strict-origin` 덕분에 Referer 로는 새지 않지만, 새로고침 · 직접 들어온 요청 줄은 외부 프록시가 그대로 받는다.
- **대용량 첨부의 공개 내려받기 호스트를 잇는다** (운영 확인 항목, 11단계 — 절차 · NPM 칸은 mail/README.md "대용량 첨부 (11단계 — 인프라)"):
- DNS(`files.` A → 외부 프록시 서버) · TLS 인증서 · Proxy Host 를 포털과 **같은 frontend:8080** 으로 — `Host` 를 바꾸지 않고 넘긴다(nginx 와 백엔드가 Host 로 가른다).
`X-Forwarded-For` 는 포털과 같게 이어 붙인다(받기 동시 연결 상한이 마지막 칸을 센다).
- **이 호스트는 접근 로그를 끈다** — 경로에 내려받기 토큰이 있다(우리 nginx 는 가리지만 외부 프록시는 원문을 남긴다). 오류 로그에도 토큰째 남으니 볼 수 있는 사람을 한정한다.
- 받기 응답은 흘려보낸다 — nginx 계열은 우리가 넘기는 `X-Accel-Buffering: no` 를 따른다(SSE 와 같다). 2GB 까지 이어지므로 응답 **전체** 시간 상한이 있으면 없애거나 넉넉히.
- 포털 호스트의 본문 한도는 조각 올리기 때문에 **17m 이상**이어야 한다(위 첨부 한도 21m 이면 된다).
- **두 호스트 모두 머리글 버퍼를 키운다** — `proxy_buffer_size 16k;` 와 `proxy_busy_buffers_size 16k;` 를 함께(NPM 2.16.0 은 기본 4k · 한 줄만 넣으면 `nginx -t` 가 실패해 그 Proxy Host 가 Offline).
긴 한글 이름 파일의 받기 응답 머리글(3.5KB)이 4k 를 넘으면 그 받기만 502 다.
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
- **대용량 첨부 파일은 named volume `<PROJECT_NAME>_files`**(`/app/files` — 경로는 compose 가 `FILE_STORAGE_DIR` 로 고정, 루트 env 로 바꾸지 않는다)에 쌓인다. **개발도 named volume** 이다 —
바인드 마운트면 2GB 를 Windows 너머로 쓰고 Defender 가 EICAR 시험 파일을 지운다. 그래서 `backend/Dockerfile` 의 builder 단계가 `/app/files` 를 node 소유로 만들어
개발 볼륨도 소유자를 물려받는다(2026-10-30 확인). **백업하지 않는다** — 30일짜리 전송물이고 기록은 DB 에 있다(서버를 되살리면 그 전의 링크는 파일이 없어 받지 못한다).
디스크는 백엔드가 올리기를 만들 때 여유(`FILE_STORAGE_MIN_FREE_GB`)를 본다 — 같은 디스크를 악성 검사의 임시 파일(아래 "대용량 첨부" 절)이 함께 쓴다.
악성 검사 서명 볼륨 `<PROJECT_NAME>_clamav-db`(110MB 남짓)도 백업하지 않는다 — 지워도 clamav-updater 가 다시 받는다.
---
@@ -266,6 +318,10 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
`docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
11단계부터는 nginx 를 두 꼴(공개 호스트를 가를 때 · 아닐 때)로 `nginx -t` 하고 렌더링 결과(server 이름 · 포털이 닫는지 · 받기 영역 400 · 200)와 틀린 `SHARE_PUBLIC_URL` 의 기동 거부를 본다.
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다.
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
@@ -360,6 +416,30 @@ node --env-file=.env.development mail/scripts/setup.mjs
---
## 대용량 첨부 — 악성 검사기 (ClamAV) · 운영은 늘, 개발은 profile `clamav`
백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (계약 4절 "악성 검사", `backend/src/modules/file-storage/clamav-client.ts`).
설정은 `mail/clamav/`, 판정 실측표 · 메모리 예산 · 문제 해결은 [`mail/README.md`](../../../mail/README.md) "대용량 첨부 (11단계 — 인프라)".
- **컨테이너 둘**: `clamav`(clamd — 백엔드와만 닿는 internal 네트워크 하나, 서명 볼륨은 읽기 전용)와 `clamav-updater`(freshclam — 자기 바깥 길에만).
바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다 — 파일 해석기에 구멍이 나도(2026년에만 heap overflow CVE 여럿) 바깥 · 다른 컨테이너에 닿지 못하게.
새 서명은 clamd 가 10분마다 스스로 본다(`SelfCheck 600` — updater 가 알려 주지 않는다). clamd 는 서명이 생긴 뒤 뜬다(처음 기동은 받는 1분 남짓을 기다린다).
- **개발**: 기본은 꺼져 있고 `CLAMAV_HOST` 가 비면 백엔드는 검사하지 않고 다 올리자마자 "검사하지 못함"이다. 켜려면 루트 env 에
`COMPOSE_PROFILES=mail,clamav` 와 `CLAMAV_HOST=clamav` **둘 다**(clamd 1GB 남짓 · 처음에 서명 110MB). 운영은 `CLAMAV_HOST` 가 비면 백엔드가 뜨지 않는다.
- **설정 `mail/clamav/clamd.conf`** — 이미지 기본 설정을 통째로 바꾼다(바꾼 뒤 `up -d --force-recreate clamav`):
- `StreamMaxLength` = `MaxFileSize` = 2047M(한 파일 한도의 최대값 — 넘는 스트림은 `INSTREAM size limit exceeded. ERROR` → 백엔드는 설정 오류로 error), `MaxScanSize` 4094M,
`MaxScanTime` 5분(1.4 에서는 ZIP 에만 걸린다 — 7z · tar.gz 는 MaxScanSize 까지 시간 상한 없이 푼다).
- **`AlertExceedsMax yes` · `AlertEncrypted yes` 는 끄지 않는다** — 끄면 한도를 넘거나 암호가 걸린 파일이 `stream: OK`(깨끗함)로 온다(확인함). 백엔드는 이 두 휴리스틱을 "검사하지 못함"으로 가른다.
- `ConcurrentDatabaseReload no` — 켜면 새 서명을 다 읽을 때까지 두 벌(2GB 남짓)을 들어 한도 2g 를 넘는다. 대신 다시 읽는 30초 안팎 동안 검사 · PING 이 기다린다(백엔드는 다시 한다).
- 받은 스트림 · 푼 내용은 컨테이너 쓰기 계층의 `/tmp` 에 잠시 쓴다 — 동시 검사 2 × (2GB + 푼 내용) — tmpfs 로 바꾸지 않는다(메모리 한도에 잡힌다).
검사 중에 죽으면(OOM 등) 그 임시 파일이 쓰기 계층에 남아 재시작마다 쌓였다(2026-10-30 확인) — 그래서 entrypoint 가 `sh -c 'rm -rf /tmp/clamav-*; exec clamd …'` 다.
- **자원**: clamd 메모리 1GB 안팎(서명 363만 개 — 서명이 늘면 는다) · 한도 2g, CPU 는 검사 하나가 코어 하나(2,047MB 에 5분 안팎 — 개발 PC) · `cpus 2.0` · `cpu_shares 512`
(서버가 2 vCPU 보다 적으면 낮춘다). updater 는 평소 수십 MB, 처음 받을 때 0.7GB 남짓 · 한도 1g.
- **상태**: clamav 의 healthcheck 는 PING(다시 읽는 동안 세 번까지 기다린다), updater 는 daily 서명이 이틀 안에 바뀌었는지 — 갱신이 이틀 넘게 멈추면 unhealthy(검사는 옛 서명으로 이어 간다).
- 이미지는 Alpine 판이라 **linux/amd64 만** 있다 — ARM 서버면 `_base-debian`(같은 판)을 쓴다. 1.4 LTS 가 끝나면(2027-08 무렵) 서명을 받지 못한다 — 그 전에 다음 LTS 로.
---
## 규칙
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
@@ -372,6 +452,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
| 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 처음 설치·재실행과 웹메일을 다시 확인한다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
| ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) |
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
@@ -474,6 +555,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
- **메일 리졸버**: 이미지가 root 로 떠서 권한을 내리는 꼴이라 처음부터 `user: "101:102"`(이미지의 unbound 사용자) · `cap_drop: ALL` · `no-new-privileges` 로 띄운다.
53 에 붙는 것은 도커가 컨테이너마다 `net.ipv4.ip_unprivileged_port_start=0` 으로 두기 때문이다. 호스트 포트는 어떤 환경에서도 열지 않는다(열린 리졸버가 되지 않게).
- **악성 검사기(clamav · clamav-updater)**: 이미지의 `/init` 은 root 로 소유자를 바꾸고 둘을 함께 띄운다 — 쓰지 않고 `entrypoint` 로 clamd · freshclam 을 바로,
`user: "100:101"`(이미지의 clamav 사용자) · `cap_drop: ALL` · `no-new-privileges` · `init: true`. 설정 파일은 그 사용자가 읽을 수 있어야 한다(644). 호스트 포트는 어떤 환경에서도 열지 않는다.
- **Git Bash 에서 컨테이너 안 경로를 인자로 주지 않는다** — MSYS 가 `/dev/null` 같은 인자를 Windows 경로로 바꿔 `docker exec … wget -O /dev/null` 이
저장소에 `nul` 파일을 만든다(2026-10-30). `MSYS_NO_PATHCONV=1` 을 앞에 붙이거나 PowerShell 로 돌린다.
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`
(프록시가 다른 서버에 있으면 `.env.production` 의 `COMPOSE_FILE` 로 `docker-compose.remote-proxy.yml` 을 더한다 — compose 가 `--env-file` 의 `COMPOSE_FILE` · `COMPOSE_PROFILES` 를 읽는다.
빈 `COMPOSE_FILE=` 은 compose 가 실패하니 쓰지 않을 때는 주석으로 둔다).