feat(docker): 대용량 첨부 — ClamAV 검사기 · 공개 내려받기 호스트 · 받기 동시 연결 상한

- ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용),
  freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav.
  clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README).
  검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다.
- nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다).
  조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k,
  limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200.
  받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다).
  접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다.
- 파일은 named volume <PROJECT_NAME>_files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다.
- CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부.
- mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:21 +09:00
parent c5cc1a6705
commit 93147b88b7
19 changed files with 1176 additions and 103 deletions
+197 -3
View File
@@ -7,7 +7,9 @@
- 설정값 SSOT: 루트 `.env.{APP_ENV}` 의 메일 섹션 ([env-structure](../.claude/rules/env-structure.md))
- 초기화·계정 구성: [`scripts/setup.mjs`](./scripts/setup.mjs) — 의존성 없음, 여러 번 실행해도 안전
- 이름은 루트 env 의 `PROJECT_NAME` 을 따른다 — 컨테이너 `${PROJECT_NAME}-mail`, 볼륨 `${PROJECT_NAME}_mail-config`·`_mail-data`,
네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend).
대용량 첨부(11단계)는 볼륨 `${PROJECT_NAME}_files`(파일) · `_clamav-db`(악성 검사 서명), 컨테이너 `${PROJECT_NAME}-clamav` · `-clamav-updater`,
네트워크 `${PROJECT_NAME}_clamav`(backend ↔ 검사기) · `_clamav-egress`(서명 갱신) — [악성 검사 (ClamAV)](#악성-검사-clamav). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
```bash
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.development)" # 운영은 .env.production
```
@@ -28,11 +30,22 @@
메일 엔진은 **app-network 대역 전체를 신뢰한다**(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만
붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다.
엔진의 DNS 는 메일 전용 리졸버(`mail-resolver`)가 맡는다 — mail 과 리졸버만 붙는 `mail-dns` 로 닿는다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
대용량 첨부(11단계)의 파일은 엔진이 아니라 backend 의 볼륨에 두고, 악성 검사기(`clamav`)는 backend 와만 닿는 `clamav` 네트워크에 둔다 —
app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악성-검사-clamav)).
```
브라우저(직원) ─▶ portal. ┐ ┌─(app-network)─▶ db · mail
받는 사람(바깥) ─▶ files. ┴▶ 외부 프록시 ─▶ frontend nginx ─(edge)─▶ backend ─┤ /app/files = 볼륨 <PROJECT_NAME>_files
(Host 로 server 를 가른다) └─(clamav — internal)─▶ clamav(clamd) ◀─ 볼륨 <PROJECT_NAME>_clamav-db(읽기 전용)
clamav-updater(freshclam) ─(clamav-egress)─▶ database.clamav.net
```
| 구성 요소 | 위치 | 비고 |
| --- | --- | --- |
| `mail` 서비스 | `docker-compose.yml` (profile `mail`) | named volume `<PROJECT_NAME>_mail-config`(config.json) · `<PROJECT_NAME>_mail-data`(RocksDB) |
| `mail-resolver` | `docker-compose.yml` (profile `mail`) · 설정 `mail/resolver/unbound.conf` | 엔진 전용 재귀 DNS(unbound) — mail 만 닿는 `mail-dns`(internal)와 자기 바깥 길 `mail-dns-egress` 에만 붙는다. 상태 없음(볼륨 없음) — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl) |
| `clamav` · `clamav-updater` | `docker-compose.yml` (운영은 늘 · 개발은 profile `clamav`) · 설정 `mail/clamav/clamd.conf` · `freshclam.conf` | 대용량 첨부의 악성 검사기(clamd)와 서명 갱신(freshclam) — 둘로 나눠 검사기는 바깥 길이 없다. 서명은 named volume `<PROJECT_NAME>_clamav-db` — [악성 검사 (ClamAV)](#악성-검사-clamav) |
| 대용량 첨부 파일 | backend 의 named volume `<PROJECT_NAME>_files` → `/app/files` (개발도 같다) | 기록은 DB. 30일짜리 전송물이라 백업하지 않는다 — [백업](#백업) · [대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트) |
| dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 |
| `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` |
| setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" |
@@ -77,6 +90,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
- 출력의 `[생성]`·`[변경]`·`[유지]` 로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부 `[유지]` 여야 정상이다.
- ⚠️ **외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다.** 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다.
그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(`example.net` 등)만 쓴다.
- **악성 검사기(ClamAV)는 개발에서 기본으로 띄우지 않는다**(메모리 1GB 남짓 · 처음 기동에 서명 110MB 를 받는다) — 그동안 다 올린 대용량 파일은 바로 "검사하지 못함"이다.
켜려면 `.env.development` 에 `COMPOSE_PROFILES=mail,clamav` 와 `CLAMAV_HOST=clamav` 를 두고 `up -d`(backend 가 새 값으로 다시 만들어진다) —
[악성 검사 (ClamAV)](#악성-검사-clamav). 서명은 볼륨에 남아 다음에는 바로 뜬다.
- 개발 메일 데이터를 버리고 처음부터:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit
@@ -249,6 +265,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
`data/` 가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (`data/.gitkeep` 이 있어 클론 때 생긴다).
- **`MAIL_ADMIN_LOCAL_PART`** 를 bootstrap 전에 정한다 — mailcow 에 실제 `admin@` 메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다).
- **`MAIL_DIRECTORY_LOCAL_PART`**(사내 목록 계정, 기본 `mail-directory`)가 옮겨 올 메일함과 겹치지 않는다 — 겹치면 setup 이 그 계정을 건드리지 않고 실패한다 ([사내 목록 계정](#사내-목록-계정)).
- **대용량 첨부(11단계)** — `.env.production` 의 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — `https://files.arasystem.it.kr`, **`CORS_ORIGIN` 에 넣지 않는다**) ·
`CLAMAV_HOST=clamav`(둘 다 운영 필수 — 없으면 백엔드가 뜨지 않는다), 그 호스트의 DNS · 외부 프록시 Proxy Host
([대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트)), 악성 검사 서명 갱신의 바깥 HTTPS(`database.clamav.net`) ·
메모리 · 디스크([메모리 예산](#메모리-예산) — 8GB 서버에 스택 전체 + ClamAV). 첫 `up` 은 `clamav-updater` 가 서명을 받는 동안(1~3분) `clamav` 가 기다린다
복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
@@ -581,6 +601,161 @@ sudo docker logs "${PROJECT_NAME}-mail" 2>&1 | grep 'cannot validate DNSSEC'
---
## 대용량 첨부 (11단계 — 인프라)
일반 첨부 한도(20MB · 합계 25MB)를 넘는 파일은 메일 엔진이 아니라 **backend 의 볼륨**(`<PROJECT_NAME>_files` → `/app/files`)에 조각으로 올리고,
메일에는 받는 사람이 로그인 없이 여는 내려받기 링크(`SHARE_PUBLIC_URL/s/<토큰>`)를 넣는다. 다 올린 파일은 **ClamAV** 로 검사한다.
데이터 · 규칙은 [계약 4절](../docs/mail/api-contract.md) "대용량 첨부", env 는 루트 예시 env 의 "대용량 첨부" 칸이다. 이 절은 컨테이너 · 프록시 · 운영만 다룬다.
### 악성 검사 (ClamAV)
```
backend ─(clamav — internal · 바깥 길 없음)─▶ clamav clamd :3310 (INSTREAM) ◀── 볼륨 <PROJECT_NAME>_clamav-db (읽기 전용)
clamav-updater freshclam ──(clamav-egress)──▶ database.clamav.net (HTTPS · DNS)
└──────────────── 볼륨 <PROJECT_NAME>_clamav-db (씀) ──┘
```
| 항목 | 값 |
| --- | --- |
| 서비스 | `docker-compose.yml` 의 `clamav`(검사기 clamd) · `clamav-updater`(서명 갱신 freshclam). **운영은 profile 없이 늘 뜬다**(`CLAMAV_HOST` 가 운영 필수라 검사기가 없으면 검사가 서 버린다). 개발은 profile `clamav` — `COMPOSE_PROFILES=mail,clamav` + `CLAMAV_HOST=clamav` 일 때만 |
| 이미지 | 공식 `clamav/clamav:1.4.6_base` — digest `sha256:e7399106…`(2026-10-30 manifest list, linux/amd64 만). 1.4 는 ClamAV 의 **LTS**(2024-08 부터 3년 — 서명을 받을 수 있는 기간, <https://docs.clamav.net/faq/faq-eol.html>), 1.4.6 은 2026-08-07 보안 릴리스(2주가 지났다). `_base` 는 서명이 들지 않은 이미지다(서명은 볼륨에). 같은 판의 Debian 판(`_base-debian`)은 200MB 검사가 17% 느렸다(23.7초 · 20.3초) |
| 둘로 나눈 까닭 | clamd 는 바깥에서 온 파일(악의적인 압축 · 문서)을 연다 — 그 해석기에 구멍이 나도(2026 년에만 heap overflow CVE 여럿) 바깥 · 다른 컨테이너에 닿지 못하게 clamd 는 internal 네트워크 하나에만 둔다. 바깥에 나가는 것은 서명을 받는 freshclam 뿐이고, 그것은 backend · clamd 와 잇지 않는다. 새 서명은 clamd 가 10분마다(`SelfCheck`) 볼륨을 보고 다시 읽는다(`NotifyClamd` 를 쓰지 않는다) |
| 네트워크 | `clamav` — backend ↔ clamav 전용, **internal**. `clamav-egress` — updater 혼자. 둘 다 서브넷은 도커가 정한다(주소로 믿는 곳이 없다 — 기본 주소 풀 172.17~31.x · 192.168.x 이 서버 VPC 와 겹치지 않는지 본다). clamd 는 암호화 없는 TCP 이고 접속을 가리지 않으므로 다른 컨테이너를 붙이지 않는다. **app-network(신뢰 대역)에는 붙이지 않는다** |
| 실행 | 둘 다 이미지의 `clamav` 사용자(100:101) · 권한(capability) 모두 뺌 · `no-new-privileges` · `init: true`(PID 1 tini). 이미지의 `/init` 은 root 로 소유자를 바꾸고 freshclam 과 clamd 를 함께 띄우므로 쓰지 않고 `clamd --foreground` · `freshclam --daemon --foreground` 를 바로 띄운다(멈추면 컨테이너가 끝나 재시작 정책을 탄다). clamd 는 띄우기 전에 `/tmp` 의 `clamav-*`(지난번에 검사 중 죽으며 남긴 임시 파일)를 지운다 — 아래 "임시 디스크". 호스트 포트는 어떤 환경에서도 열지 않는다 |
| healthcheck | clamav — 이미지의 `clamdcheck.sh`(3310 PING → PONG). updater — 서명(main · daily)이 있고 daily 가 **이틀 안에** 바뀌었는지(멈추면 unhealthy 로 보인다 — 검사는 옛 서명으로 이어 간다). clamav 는 updater 가 healthy 가 된 뒤에 뜬다(`required: false` — 서명이 오래됐다고 검사기를 세우지 않는다) |
| 서명 갱신 | 하루 12번 묻고(DNS TXT `current.cvd.clamav.net`) 새 판이 있을 때만 받는다(cdiff). 받은 서명은 바꿔 넣기 전에 따로 읽어 본다(`TestDatabases yes` — 깨진 서명이 clamd 를 멈추지 않게). 처음 기동은 main · daily · bytecode 110MB(2026-10-30 — 서명 363만 개)를 받는 데 1분 안팎이었다 |
| 설정 | [`clamav/clamd.conf`](./clamav/clamd.conf) · [`clamav/freshclam.conf`](./clamav/freshclam.conf) — 이미지 기본 설정을 통째로 바꾼다(저장소는 umask 022 로 클론 — 컨테이너 사용자 100 이 읽어야 한다). 바꾼 뒤에는 `up -d --force-recreate clamav`(또는 `clamav-updater`). CI 의 infra 잡이 `clamconf` 로 읽고 아래 값이 그대로인지 본다 |
**한도 · 판정** (`clamd.conf`) — 한 파일 한도(`MAIL_LARGE_FILE_MAX_SIZE_MB`, 최대 2,047MB)까지 끝까지 검사한다:
| 설정 | 값 | 까닭 |
| --- | --- | --- |
| `StreamMaxLength` | 2047M | INSTREAM 으로 받는 양. 같으면 받고 1바이트 넘으면 `INSTREAM size limit exceeded. ERROR` — 백엔드는 이보다 큰 파일을 만들지 않는다 |
| `MaxFileSize` | 2047M | 한 파일(압축 안의 파일 포함). ClamAV 는 2GiB − 1 넘게는 검사하지 못한다(1.4.6 이 그 위를 2,147,483,645 로 줄이고 경고) |
| `MaxScanSize` | 4094M | 파일 하나에서 검사하는 양의 합(압축을 푼 내용까지). 푼 내용은 `/tmp`(컨테이너 쓰기 계층)에 잠시 쓴다 |
| `MaxScanTime` | 300000(5분) | 1.4 에서는 **ZIP 에만** 걸린다. 백엔드가 다 보낸 뒤 판정을 기다리는 시간(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분, 아래 "백엔드 쪽 시간")보다 짧게 둔다 — 넘으면 백엔드가 끊고 미뤄서 처음부터 다시 검사한다 |
| `AlertExceedsMax` · `AlertEncrypted` | yes · yes | **끄면 한도를 넘거나 암호가 걸린 파일이 `stream: OK`(깨끗함)로 온다**(아래 표의 "끈 때") |
| `ConcurrentDatabaseReload` | no | 서명을 다시 읽을 때 두 벌을 들지 않는다 — 1GB 그대로, 그 30초 안팎 동안 검사 · PING 이 기다린다 |
판정 실측 (2026-10-30, 개발 — clamd 1.4.6 · 위 설정. 백엔드와 같은 규약의 INSTREAM 시험 클라이언트, 그리고 백엔드로 끝까지):
| 보낸 것 | clamd 의 답 (`\0` 앞) | 백엔드 판정 |
| --- | --- | --- |
| 깨끗한 파일(18B · 10MB · 40MB · **2,047MB 무작위**) | `stream: OK` | clean |
| EICAR · EICAR 를 든 zip | `stream: Eicar-Test-Signature FOUND` | infected |
| 암호 zip(ZipCrypto · AES-256 — 안에 EICAR 가 있어도) | `stream: Heuristics.Encrypted.Zip FOUND` | unscannable |
| 암호 7z(내용만 · 머리글까지) | `stream: Heuristics.Encrypted.7Zip FOUND` | unscannable |
| 사용자 암호 pdf / 소유자 암호만 있는 pdf | `stream: Heuristics.Encrypted.PDF FOUND` / `stream: OK` | unscannable / clean |
| 20겹 zip · 파일 12,000개 zip | `stream: Heuristics.Limits.Exceeded.MaxRecursion FOUND` · `…MaxFiles FOUND` | unscannable |
| 풀면 2,100MB · 4,500MB 인 zip(0 으로 채움) | `stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND`(5분) | unscannable |
| 풀면 600MB 인 zip | `stream: OK`(100초) | clean |
| 암호 항목 + EICAR 항목이 함께 든 zip · 한도 넘는 항목 + EICAR | `stream: Eicar-Test-Signature FOUND` — 진짜 서명이 휴리스틱보다 먼저다 | infected |
| `StreamMaxLength` 보다 큰 스트림(시험 clamd — 10M 에 20MB · 10M+1B) | `INSTREAM size limit exceeded. ERROR` 를 보내고 **FIN** — 클라이언트가 계속 보내면 clamd 쪽이 **RST**. 클라이언트가 답을 읽기 전에 RST 가 오면 답 없이 `EPIPE` 다(같은 시험에서 한 번은 답, 한 번은 `EPIPE`) | (백엔드 — 설정 오류로 검사 중 그대로 · error) |
| 끈 때(`AlertExceedsMax no`, 시험 clamd — MaxFileSize 5M) | 7MB 무작위 · 20겹 zip · 파일 12,000개 zip 모두 `stream: OK` | ⚠️ clean 이 된다 |
| ⚠️ 시험 clamd(MaxFileSize 5M · MaxScanSize 8M)에서 **zip 안의** 한도 넘는 항목(6MB · 600MB 의 0) | `AlertExceedsMax yes` 인데도 `stream: OK` | clean — 아래 "알려진 한계" |
- 로그는 `instream(<백엔드 주소>@<포트>): <이름> FOUND` 만 남는다(파일 이름 없음 · 깨끗한 파일은 남기지 않음). 백엔드는 판정 이름 · 파일 id 를 남긴다.
- **알려진 한계** — (1) 1.4.6 은 zip 안의 항목이 `MaxFileSize`·`MaxScanSize` 를 넘으면 그 항목을 건너뛰고 알리지 않을 때가 있다(위 표의 마지막 줄 — 운영 값에서는
풀면 2GB 가 넘는 zip 이 먼저 `MaxScanTime` 에 걸렸다). (2) 검사 한 번은 코어 하나로 **2,047MB 에 5분 안팎**이다(이 PC — 1분에 400MB, 운영 CPU 가 느리면 더 걸린다).
zip 밖의 압축(7z · tar.gz 등)은 시간 상한 없이 `MaxScanSize`(4GB)까지 푼다 — 4,094MB 를 코어 하나로 10분 안팎, 운영 CPU 가 2~3배 느리면 20~30분이다.
백엔드의 판정 대기(30분 + 1MB 마다 1초)가 그것을 덮는다 — 넘으면 끊고 미뤄서 처음부터 다시 검사한다(그동안 "검사 중").
(3) 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다(계약 4절).
- **백엔드 쪽 시간** (`backend/src/modules/file-storage/file-storage.constants.ts`) — 연결 10초, 흘려보내는 동안 아무것도 오가지 않으면 10분(clamd 는 받는 대로 임시 파일에 적는다),
다 보낸 뒤 판정까지 **30분 + 보낸 1MB 마다 1초**(`clamavVerdictTimeoutMs` — 2,047MB 면 3,847초 ≈ 64분). 연결에는 TCP keepalive(첫 대기 60초)를 켜서
컨테이너째 사라진 clamd(RST 도 오지 않는다)를 긴 판정 대기와 상관없이 1분 남짓에 알아차린다. 어느 쪽이든 끊기면 미뤄서 다시 한다.
- **CPU** — `cpus: 2.0`(백엔드가 한 번에 2개까지 보낸다) · `cpu_shares: 512`(다른 컨테이너와 다툴 때 절반 몫 — 웹메일 · 메일 엔진이 먼저). 서버가 2 vCPU 보다 적으면 `cpus` 를 낮춘다(넘으면 `up` 이 실패한다).
- **임시 디스크** — clamd 는 받은 스트림을 `/tmp` 에 다 쓴 뒤 검사하고 지운다(2,047MB 시험 — 2GB 썼다가 0 으로). 한 번에 2개 × (2GB + 푼 내용 4GB 까지)가 도커 data-root 의 디스크(볼륨과 같은 디스크)에 잠시 든다.
검사 중에 clamd 가 죽으면(OOM 등) 그 임시 파일(`/tmp/clamav-<hex>.tmp`)은 지워지지 않고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다 —
그래서 compose 가 clamd 를 띄우기 전에 `rm -rf /tmp/clamav-*` 한다(2026-10-30 시험 clamd: 800MB 를 흘려보내다 죽이기를 두 번 — 고치기 전에는 324MB → 681MB 로 쌓였고, 고친 뒤에는 다시 뜰 때마다 비었다).
`recreate` 없이 `restart` 만 해도 지운다.
```bash
# 상태 — 둘 다 healthy, 서명 판(daily 번호 · 날짜)
sudo docker compose --env-file .env.production ps clamav clamav-updater
sudo docker exec "${PROJECT_NAME}-clamav" sh -c 'echo VERSION | nc localhost 3310' # ClamAV 1.4.6/<daily 판>/<날짜>
sudo docker logs --tail 20 "${PROJECT_NAME}-clamav-updater" # daily.cld updated / database is up-to-date
# 판정 시험 — EICAR 는 파일로 두지 않는다(서버 백신이 지울 수 있다). 백엔드 컨테이너의 node 로 clamav 에 INSTREAM 한 번
sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').connect(3310,'clamav'),e=Buffer.from('WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=','base64'),h=Buffer.alloc(4);h.writeUInt32BE(e.length);s.on('connect',()=>s.end(Buffer.concat([Buffer.from('zINSTREAM\0'),h,e,Buffer.alloc(4)])));s.on('data',d=>console.log(String(d)))"
# → stream: Eicar-Test-Signature FOUND
```
### 대용량 첨부 — 공개 내려받기 호스트
받는 사람은 포털과 **다른 호스트**(`SHARE_PUBLIC_URL` — 운영 `https://files.arasystem.it.kr`)에서 받는다. 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지
차단 목록(세이프 브라우징 등)에 오르는 위험을 떼어 놓고, 포털 쿠키(호스트 한정)도 그 호스트로 가지 않는다. 외부 프록시는 포털과 **같은 사설 IP:8080** 으로 넘긴다 —
프론트 nginx 가 `Host` 로 server 를 가른다(`frontend/nginx.conf`):
| | 포털 (`portal.` — default_server) | 공개 내려받기 (`files.` — `SHARE_PUBLIC_URL` 의 호스트 이름) |
| --- | --- | --- |
| 화면 | 웹메일 전부. `/s/…` 는 404 | `/s/<토큰>`(SPA 의 공개 라우트 — `index.html`, `no-cache` · `X-Robots-Tag: noindex, nofollow` · `Referrer-Policy: no-referrer`) · `/assets/` · `/favicon.ico` 만. 그 밖(`/` · `/index.html` · `/mail/…`)은 404 |
| API | `/api/…` 전부. `/api/public-links/…` 는 404 `RES_001`(JSON — 대소문자 변형 `/api/Public-Links/…` 도) | `/api/public-links/…` 만(대소문자를 가리지 않는다). 그 밖의 `/api/…` 는 404 `RES_001`, 1m 넘는 본문은 413 `FILE_002`(둘 다 JSON) |
| 백엔드 | 같은 판정으로 두 겹 막는다 — `Host` 의 호스트 이름이 공개 호스트면 `/api/public-links/` 만, 아니면 그것만 404(`modules/file-sharing/public-host.ts`) | |
- `SHARE_PUBLIC_URL` 이 비었거나 그 호스트 이름이 `CORS_ORIGIN` 에 있으면 따로 두지 않는다 — 포털이 둘 다 연다(개발 · 같은 호스트 운영. `nginx-env.envsh` 가 고르고 `docker logs` 첫 줄에 남긴다).
**운영은 넣지 않는다** — `CORS_ORIGIN` 에 공개 호스트를 넣으면 분리가 꺼진다(백엔드가 운영에서는 기동을 막는다).
`SHARE_PUBLIC_URL` 의 모양(출처 — 경로 · 끝의 `/` · 끝 점 없이)이 틀리면 frontend 가 뜨지 않는다(`nginx-env.envsh:` 줄).
- **조각 올리기** `PUT /api/file-uploads/{id}/chunks/{n}` 만 본문 **17m**(조각 16MiB + 여유 — 계약 상수라 env 로 바꾸지 않는다) · 읽기 90초. 요청 본문은 nginx 가 다 받아 둔 뒤 넘긴다(`/tmp/client_temp`).
그 밖의 `/api/file-uploads` · `/api/file-quota` · `/api/share-links` · `/api/share-link-lookups` 는 JSON 1m 그대로다. 조각 요청은 포털 호스트로 온다.
이 경로의 오류 로그는 `error` 부터다 — 조각마다 nginx 가 `a client request body is buffered to a temporary file`(warn)을 남겨 2GB 파일 하나에 128줄이 쌓였다(본문 버퍼는 키우지 않는다 — 동시 조각 수 × 16MiB 가 메모리에 앉는다).
- **받기** `/api/public-links/<토큰>/files/…`(공개) · `/api/messages/<id>/large-files/…`(앱 안) — 응답 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(실시간 알림과 같다),
`Range` · `If-Range` 는 그대로 넘긴다(백엔드가 206 · 416). 머리글 버퍼는 16k(`proxy_buffer_size` — 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이라 기본 4k 로는 빠듯하다).
**동시 연결** — 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 전체는 공개 400 · 앱 안 200 을 **따로** 센다(공개 받기가 가득 차도 직원의 앱 안 받기는 된다). 넘으면 429 `REQ_001`(JSON).
클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값)이다 — 앞에 꾸민 값을 붙여도 마지막 칸으로 센다.
두 경로는 **대소문자 · `%2F` · 겹 `/` 를 가리지 않고** 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아 `/api/Messages/…/LARGE-FILES/…` 도 받기 처리기에 닿기 때문이다
(nginx 의 대소문자를 가리는 prefix 에 기대면 한도 · 버퍼링 끔을 비켜 간다 — 2026-10-30 리뷰. CI 의 infra 잡 "대용량 받기 경로 시험"이 변형마다 실제 요청으로 본다).
그릇은 nginx 워커마다 `worker_connections` 4096(이미지 기본 1024 에서 올림 — `frontend/Dockerfile`) · frontend 의 열린 파일 수 8192(compose `ulimits`)다 —
받기 600 개(연결 1,200) · 실시간 알림(브라우저마다 연결 둘) · 포털 요청이 워커 하나에 몰려도 모자라지 않게
(2026-10-30 운영 이미지 시험: 받기 600 개를 붙잡아 둔 채 401번째 공개 · 201번째 앱 안 받기가 429, 그때도 다른 API · 실시간 알림은 200).
- **느린 받는 사람** (2026-10-30, 운영 이미지 · 개발 백엔드 · 40MB 파일): 조금씩이라도 받아 가면 끊지 않는다 — 초당 120KB 로 80초(읽기 제한 60초를 넘겨) 그대로 받았다.
`proxy_read_timeout` 은 받는 쪽이 느려 nginx 가 백엔드를 읽지 않는 동안에는 돌지 않았다(5초로 줄인 시험에서도 15초 동안 받았다). 받는 쪽이 **아무것도 받지 않으면** `send_timeout`(60초 — 기본값)에
끊긴다 — 소켓 버퍼만큼(5MB) 보낸 뒤 61.7초에 닫고 그 연결 수를 돌려준다(10초로 줄인 시험에서는 10.9초). 끊긴 받기는 브라우저 · 내려받기 관리자가 `Range` 로 이어 받는다.
- **접근 로그** — 토큰을 `***` 로 가린다(요청 줄 · Referer — 예 `"GET /api/public-links/***/files/<id> HTTP/1.1"`). 두 가지로 겹쳐 본다:
`/s/<…>` · `/api/public-links/<…>` 의 그 칸(대소문자 무시 · 길이 상관없음), 그리고 토큰 모양(base64url 딱 43자 — 앞뒤가 `[A-Za-z0-9_-]` 가 아닌 덩어리, `%XY` 바로 뒤의 덩어리)이면 경로 어디서든 —
nginx 는 정규화해 받지만 로그는 받은 그대로라 `//s/<토큰>` · `/./s/<토큰>` · `/%73/<토큰>` · `/x/../s/<토큰>` · `/api/public%2Dlinks/<토큰>` · `/api/public-links%2F<토큰>` 같은 꼴도 가린다.
43자가 아닌 값(UUID 36자 · 메일 id · 화면 자산 이름 — 가장 긴 것 35자)은 그대로 남는다. 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴은 가리지 못한다(그 토큰을 가진 본인만 만든다).
오류 로그(502 · 504 등)는 지금 정책대로 요청 줄을 그대로 남긴다(운영 담당만 읽는다). 동시 연결 초과는 오류 로그에 남기지 않는다(접근 로그의 429 로 본다).
**외부 프록시 (서버 A 의 Nginx Proxy Manager)** — [포털 Proxy Host](#nginx-proxy-manager-설정)와 같은 칸으로 하나 더 만든다:
| 칸 | 값 |
| --- | --- |
| Domain Names | `files.arasystem.it.kr` (가비아 `files` A → 서버 A 공인 IP — 이미 있다) |
| Scheme · Forward Hostname / IP · Port | `http` · 서버 B 사설 IP · `8080` — 포털과 같은 곳(Host 는 NPM 이 그대로 넘긴다 — `proxy.conf` 의 `proxy_set_header Host $host`) |
| Cache Assets · Block Common Exploits · Websockets | 끔 · 끔 · 끔 (포털과 같다) |
| SSL | Let's Encrypt 새 인증서(`files.`) · Force SSL · HTTP/2 |
| Advanced | 포털과 같은 다섯 줄(`proxy_read_timeout 150s;` · `access_log off;` · `real_ip_header proxy_protocol;` · `proxy_buffer_size 16k;` · `proxy_busy_buffers_size 16k;` — [포털 Proxy Host](#nginx-proxy-manager-설정)). **`access_log off;` 는 꼭** — 경로에 내려받기 토큰이 있다(NPM 의 `proxy` 형식은 경로를 그대로 남긴다) |
- 포털 Proxy Host 의 본문 한도(NPM 기본 2000m)는 조각 17MB 보다 크다 — 줄였다면 17MB 이상, 읽기는 이미 150초다.
- 받기 응답은 우리가 넘기는 `X-Accel-Buffering: no` 를 NPM(nginx)이 따라 버퍼링하지 않는다 — `proxy_ignore_headers` 로 이 머리글을 무시하게 했다면 그 호스트에 `proxy_buffering off;` 를 더한다
(버퍼링하면 느린 받는 사람마다 NPM 이 임시 파일을 1GB 까지 만든다).
- 받기 응답의 머리글(한글 255자 파일 이름이면 3.5KB 쯤)은 NPM 의 머리글 버퍼에도 들어가야 한다 — NPM v2.16.0 은 버퍼 크기를 정하지 않아 nginx 기본(4k — 그 이미지의 페이지 크기 4096)이고,
넘치면 NPM 이 502(HTML)를 낸다. 그래서 두 Proxy Host 의 Advanced 에 `proxy_buffer_size 16k;` 와 **`proxy_busy_buffers_size 16k;` 를 함께** 넣는다 —
앞의 것만 넣으면 nginx 가 설정을 거절해(`"proxy_busy_buffers_size" must be less than the size of all "proxy_buffers" minus one buffer`) NPM 이 그 Proxy Host 를 Offline 으로 둔다
(2026-10-30, `jc21/nginx-proxy-manager:2.16.0` 이미지의 OpenResty 1.31.1.1 로 `nginx -t` — 한 줄만이면 실패, 두 줄이면 통과. 이미지의 `nginx.conf` · `conf.d/include/*.conf` 에는 `proxy_buffer*` 가 없다).
- 확인: 바깥에서 `curl -sI https://files.arasystem.it.kr/` → 404 · `curl -s https://files.arasystem.it.kr/api/auth/me` → 404 `RES_001` · `curl -s https://portal.arasystem.it.kr/api/public-links/x` → 404 `RES_001`,
보낸 메일의 링크(`/s/…`)가 열리고 받아진다. 서버 B 의 `sudo docker logs --tail 5 "${PROJECT_NAME}-frontend"` 에 토큰이 `***` 로 보인다.
### 메모리 예산
8GB 서버(서버 B) 하나에 스택 전체가 올라간다. 한도(compose `deploy.resources.limits.memory`)의 합이 실제 메모리를 넘지 않게 둔다 — 평소에는 한도보다 훨씬 적게 쓴다.
| 컨테이너 | 한도 | 평소 (2026-10-30 개발에서 잰 값) | 가장 클 때 |
| --- | --- | --- | --- |
| clamav (clamd) | 2g | 0.95GB — 서명 363만 개를 읽은 뒤 | 서명을 다시 읽을 때 1.0GB(두 벌을 들지 않는다), 2GB 검사 중 1.0~1.2GB + 받은 파일의 페이지 캐시(한도 안에서 줄여 쓴다) |
| clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 |
| mail | 1g | 0.1~0.3GB | |
| backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 |
| db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | |
| **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
- **ClamAV 의 `ConcurrentDatabaseReload` 를 켜지 않는다**(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다.
한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다.
- 서명은 해마다 는다 — `docker stats` 의 clamav 가 한도의 80%(1.6GB)를 넘으면 한도를 올리거나(합을 다시 본다) 서버를 키운다.
- 디스크(블록 스토리지 100GB · 도커 data-root `/data/docker`) — 대용량 첨부 파일(직원마다 10GB · `FILE_STORAGE_MAX_TOTAL_GB`) · 메일 볼륨 · DB · 이미지 · ClamAV 의 임시 파일(검사 중 최대 12GB)이 함께 쓴다.
백엔드는 올리기를 만들 때 `FILE_STORAGE_MIN_FREE_GB`(10GB)를 남긴다 — 메일 볼륨이 자랄 몫을 생각해 `FILE_STORAGE_MAX_TOTAL_GB` 를 정해 둔다(예: 40).
---
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.
@@ -962,6 +1137,8 @@ imapsync \
| --- | --- |
| `<PROJECT_NAME>_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** |
| `<PROJECT_NAME>_mail-data` | RocksDB — 메일·계정·설정 전체 |
| `<PROJECT_NAME>_files` | 대용량 첨부 파일(11단계) — **백업하지 않는다**(보낸 날부터 30일짜리 전송물 · 계약 4절). 기록(이름 · 크기 · 링크)은 DB 백업에 든다 — 복원한 뒤 바이트가 없는 파일은 받는 쪽에 "찾을 수 없음"이다. ⚠️ `docker compose down -v` 가 지운다 |
| `<PROJECT_NAME>_clamav-db` | 악성 검사 서명 — 백업하지 않는다. 지우면 `clamav-updater` 가 다시 받는다(1분 안팎) |
RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다.
멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.
@@ -1187,7 +1364,7 @@ TRUST_PROXY=172.16.72.0/24,10.0.1.11 # <EDGE_NETWOR
| SSL › Force SSL | 켬 | |
| SSL › HTTP/2 Support | **켬** | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(`proxy_http_version 1.1`) |
| SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 |
| Advanced › Custom Nginx Configuration | 아래 세 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
| Advanced › Custom Nginx Configuration | 아래 다섯 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
```nginx
# 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s
@@ -1198,10 +1375,15 @@ access_log off;
# 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header).
# 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다
real_ip_header proxy_protocol;
# 대용량 첨부 받기(11단계 — 앱 안 받기는 이 포털로 온다)의 응답 머리글이 한글 255자 파일 이름이면 3.5KB 쯤이다 — 기본 4k 로는 빠듯하다(넘치면 502).
# 두 줄을 함께 — 앞의 것만 넣으면 nginx 가 설정을 거절해 이 Proxy Host 가 Offline 이 된다(아래 표 "머리글 버퍼")
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
```
- **`files.arasystem.it.kr`** — 11단계(대용량 첨부의 공개 내려받기, `SHARE_PUBLIC_URL`)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 —
위와 같은 칸 · 같은 고급 설정, 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다. 그 호스트에서 무엇을 열지는 11단계에서 정한다.
위와 같은 칸 · 같은 고급 설정(다섯 줄), 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다.
그 호스트에서 여는 것 · 확인은 [대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트).
- 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때 `nginx -t` 를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 — `backend/internal/nginx.js`).
v2.16.0 기본값과 이 포털에 필요한 것:
@@ -1218,6 +1400,7 @@ v2.16.0 기본값과 이 포털에 필요한 것:
| `X-Forwarded-For` | `proxy.conf` 의 `$proxy_add_x_forwarded_for` (이어 붙인다) | 이어 붙인다 | 그대로 |
| `X-Real-IP` 믿기 | `set_real_ip_from` 10/8 · 172.16/12 · 192.168/16 + `real_ip_header X-Real-IP` | 사설 대역에서 와도 접속 주소 그대로 | 고급 칸 `real_ip_header proxy_protocol;` |
| 접근 로그 | Proxy Host 마다 `proxy` 형식 — `"$request_uri"`(쿼리째) · Referer | 쿼리를 남기지 않는다 | 고급 칸 `access_log off;` |
| 머리글 버퍼 | 정하지 않음 → nginx 기본 `proxy_buffer_size` 4k · `proxy_buffers 8 4k` (이미지의 페이지 크기 4096 — 2026-10-30 이미지로 확인) | 대용량 받기의 응답 머리글 3.5KB + 여유 | 고급 칸 `proxy_buffer_size 16k;` + `proxy_busy_buffers_size 16k;` — 앞의 것만이면 `proxy_busy_buffers_size` 의 기본(2 × 16k)이 `proxy_buffers` 의 "하나 뺀 합"(28k)을 넘어 `nginx -t` 가 실패한다(NPM 2.16.0 이미지의 OpenResty 1.31.1.1 로 확인). 버퍼링을 끈 받기는 본문도 이 크기로 넘긴다 |
**Block Common Exploits**(`block-exploits.conf`)는 `$query_string` 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것:
@@ -1326,6 +1509,17 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
| `MAIL_SPAMHAUS_DQS_KEY 는 Spamhaus 가 준 26자 영문 · 숫자 키여야 합니다` · `운영의 MAIL_SPAMHAUS_DQS_KEY 는 셸이 아니라 …` | 키를 `.env.production` 에 따옴표 · 공백 없이 그대로 넣는다(셸에는 두지 않는다 — `unset MAIL_SPAMHAUS_DQS_KEY`) |
| `[주의] 스팸 차단 목록 …(STWT_…)을 찾지 못해 맞추지 않았습니다` | 엔진 기본 항목을 지웠거나 이름을 바꿨다 — 바꿨다면 그 항목의 조회 이름 · 켜짐을 관리 화면 › Settings › Spam Filter › DNSBL 에서 직접 본다 |
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*`. `delivery.ip-lookup-failed … hostname = "mailpit"` 이면 개발 엔진의 DNS 가 `Custom` 이다 — setup 을 다시 돌리면 `System` 으로 돌아온다 |
| 대용량 파일이 오래 "검사 중"이다 · 백엔드 로그에 검사기 오류(세 번째부터 error) | 검사기가 멈췄거나 닿지 않는다 — 판정 없이 받게 되는 일은 없다(닫힌 쪽), 고치면 백엔드가 차례대로 다시 한다(1분부터 두 배씩 1시간까지). `sudo docker compose --env-file .env.production ps clamav clamav-updater`(둘 다 healthy) · `sudo docker logs --tail 30 "${PROJECT_NAME}-clamav"` · 백엔드에서 `sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').connect(3310,'clamav');s.on('connect',()=>s.write('zPING\0'));s.on('data',d=>{console.log(String(d));process.exit(0)});s.on('error',e=>{console.log(e.code);process.exit(1)})"` → `PONG`. `ENOTFOUND` 면 clamav 가 없다(`up -d`), `ECONNREFUSED` 면 서명을 읽는 중이거나 죽었다(로그). clamav 가 `Exited` 면 서명이 없거나(updater 를 먼저 본다) 메모리 한도(`docker inspect … OOMKilled`) — [악성 검사 (ClamAV)](#악성-검사-clamav) |
| 2GB 가까운 파일만 오래 "검사 중"이다 | 검사 한 번이 백엔드의 판정 대기(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분)를 넘는다 — 넘으면 끊고 미뤄서 처음부터 다시 검사한다. 코어 하나로 2GB 에 5분 안팎(개발 PC)이라 보통은 넉넉하다 — 운영 CPU 가 아주 느리거나 zip 밖의 큰 압축(7z · tar.gz — 시간 상한 없음, `MaxScanSize` 4GB 까지 푼다)이면 더 걸린다. 백엔드 로그의 `clamd 가 다 받은 뒤 …초 안에 판정을 보내지 않았습니다` · clamav 로그에 그 판정이 늦게 남는지 본다. 한 번에 둘을 검사하면 `cpus`(2.0)를 나눠 쓴다 — CPU 를 늘리거나 백엔드에 알린다 |
| `clamav-updater` 가 unhealthy · 서명이 오래됐다 (`echo VERSION \| nc localhost 3310` 의 날짜) | updater 의 healthcheck 는 daily 서명이 이틀 넘게 바뀌지 않으면 실패한다. `sudo docker logs --tail 50 "${PROJECT_NAME}-clamav-updater"` — 바깥 HTTPS · DNS(`database.clamav.net` · `current.cvd.clamav.net`)가 막혔거나(NCP ACG 아웃바운드 443 · 53), CDN 이 잦은 요청으로 잠시 막았거나(`You are on cool-down until …` — 그 시각까지 기다린다, 컨테이너를 되풀이해 다시 만들지 않는다), 판이 끝나(EOL) 서명을 주지 않는다(`clamav/clamav` 판을 올린다 — docker 스킬 "이미지 버전 고정"). 검사는 옛 서명으로 이어 간다 |
| `up` 이 `optional dependency "clamav-updater" failed to start` 경고를 내고 `clamav` 가 곧 끝난다 | 처음 기동에서 서명을 받지 못했다(updater 가 unhealthy — clamd 는 서명이 없으면 뜨지 않는다). updater 로그를 보고 고친 뒤 `up -d`. 서명 볼륨을 지웠다면 updater 가 다시 받는 1분 안팎을 기다린다 |
| 대용량 파일을 올릴 수 없다 — 507 `FILE_006` | 서버 총량(`FILE_STORAGE_MAX_TOTAL_GB`) 또는 디스크 여유(`FILE_STORAGE_MIN_FREE_GB`)에 걸렸다 — `df -h /data/docker` · `sudo docker system df -v`(볼륨 `<PROJECT_NAME>_files` 크기). 기간이 지나거나 끊은 링크의 파일은 한 시간마다 지워진다. ClamAV 가 검사하는 동안 `/tmp` 에 잠시 쓰는 몫(최대 12GB)도 같은 디스크다 |
| 받는 사람이 내려받기에서 429 `REQ_001` | 프론트 nginx 의 동시 연결 수 — 같은 주소에서 공개 받기 10개 · 앱 안 받기 30개, 또는 전체 공개 받기 400개 · 앱 안 받기 200개(따로 센다)를 넘었다(접근 로그의 429 — 대소문자를 바꾼 경로도 같은 수로 센다). 백엔드의 요청 수 한도(공개 받기 IP 당 1분 300회)와 같은 코드다 — 백엔드 로그에 없으면 nginx 쪽이다. 사무실 NAT 뒤에서 앱 안 받기가 잦으면 `frontend/nginx.conf` 의 수를 올린다(전체를 올리면 `worker_connections` · `nofile` 그릇도 다시 본다 — 받기 하나가 연결 둘) |
| 내려받기만 502(HTML — NPM 의 오류 화면)이고 다른 화면 · API 는 된다 | 외부 프록시(NPM)의 머리글 버퍼에 받기 응답 머리글이 넘쳤다 — 긴 한글 파일 이름이면 3.5KB 를 넘는다. 두 Proxy Host 의 Advanced 에 `proxy_buffer_size 16k;` · `proxy_busy_buffers_size 16k;` 가 있는지 본다([Nginx Proxy Manager 설정](#nginx-proxy-manager-설정)). NPM 의 오류 로그(NPM 컨테이너 안 `/data/logs/proxy-host-<번호>_error.log` — 2.16.0 템플릿)에 `upstream sent too big header` 가 남는다. 프론트 nginx 는 16k 다 — 프론트 nginx 의 502 면 JSON `SYS_001` 이다 |
| NPM 의 Proxy Host 가 Offline — `"proxy_busy_buffers_size" must be less than …` | Advanced 에 `proxy_buffer_size 16k;` 만 넣었다 — `proxy_busy_buffers_size 16k;` 를 함께 넣고 저장한다 |
| clamav 의 디스크(쓰기 계층)가 줄지 않는다 · `docker system df` 의 clamav 컨테이너가 크다 | 검사 중에 clamd 가 죽으며 남긴 임시 파일이다(`/tmp/clamav-<hex>.tmp` — 받던 스트림 · 푼 내용). compose 는 clamd 를 띄울 때마다 지우므로 다시 뜬 뒤에도 크면 지금 검사 중인 것이다(한 번에 2개 × 최대 6GB). `sudo docker exec "${PROJECT_NAME}-clamav" du -sh /tmp` · `ls -la /tmp`. 자주 죽는다면 까닭을 본다 — `docker inspect -f '{{.State.OOMKilled}} {{.RestartCount}}' "${PROJECT_NAME}-clamav"`(메모리 한도 — [메모리 예산](#메모리-예산)) · `docker logs` |
| frontend 가 뜨지 않는다 — `nginx-env.envsh: SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처…` | `.env.production` 의 `SHARE_PUBLIC_URL` 을 `https://호스트[:포트]` 로(경로 · 끝의 `/` · 끝 점 없이) 고친 뒤 `up -d frontend` |
| `up` 이 `all predefined address pools have been fully subnetted` 로 멈춘다 | 도커 기본 주소 풀(172.17~31.x · 192.168.x — 네트워크 31개)이 다 찼다 — 서브넷을 정하지 않은 네트워크(`clamav` · `clamav-egress` · `mail-dns-egress`)를 만들 자리가 없다. 쓰지 않는 네트워크를 지운다(`docker network prune` — 개발 PC 는 다른 프로젝트 · CI 의 것이 쌓인다) |
### 리졸버 장애 때