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
+149 -4
View File
@@ -12,6 +12,10 @@ services:
# app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다
# (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다)
# mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조)
# clamav backend ↔ clamav(악성 검사기) 전용 — 바깥 길 없음. 서명 갱신(clamav-updater)은 clamav-egress 로만 (7번 참조)
#
# 호스트 이름 둘 — 포털(CORS_ORIGIN)과 대용량 첨부의 공개 내려받기(SHARE_PUBLIC_URL, 11단계)를 server 하나씩으로 가른다
# (frontend/nginx.conf · nginx-env.envsh). 외부 리버스 프록시는 두 호스트를 모두 이 컨테이너의 8080 으로 넘기고 Host 를 그대로 넘긴다
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
@@ -32,7 +36,16 @@ services:
# 서비스 이름(backend)이 아니라 고유 컨테이너 이름 — proxy 네트워크를 다른 프로젝트와 함께 쓰면
# 그쪽의 backend 라는 이름으로 풀릴 수 있다 (nginx 는 붙은 모든 네트워크에서 이름을 찾는다)
- NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT}
# 대용량 첨부의 공개 내려받기 호스트(11단계) — 백엔드와 같은 두 값으로 가른다: SHARE_PUBLIC_URL 의 호스트 이름이 포털(CORS_ORIGIN)과 다르면
# 그 이름의 server 가 공개 화면(/s/)· 공개 API 만 열고 포털은 그 둘을 404 로 닫는다. 비었거나 같으면 포털이 함께 연다 (nginx-env.envsh)
- SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-}
- CORS_ORIGIN=${CORS_ORIGIN:-}
restart: unless-stopped
# 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다
ulimits:
nofile:
soft: 8192
hard: 8192
networks:
- edge
- proxy
@@ -115,19 +128,41 @@ services:
- MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20}
# 메일 한 통의 첨부 합계(MB) — 파일당 한도 이상이어야 한다
- MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=${MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB:-25}
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절 "환경 변수"). 비운 값은 백엔드 기본값이다 (검사: shared/config/env.validation.ts)
# 공개 내려받기 화면의 출처 — 운영 필수(https, 포털과 다른 호스트 권장 · 예 https://files.arasystem.it.kr). 비우면(개발) CORS_ORIGIN 의 첫 주소
- SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-}
- SHARE_REPORT_ADDRESS=${SHARE_REPORT_ADDRESS:-}
- SHARE_DOWNLOAD_LIMIT=${SHARE_DOWNLOAD_LIMIT:-}
- MAIL_LARGE_FILE_MAX_SIZE_MB=${MAIL_LARGE_FILE_MAX_SIZE_MB:-}
- MAIL_LARGE_FILE_MAX_COUNT=${MAIL_LARGE_FILE_MAX_COUNT:-}
- MAIL_LARGE_FILE_USER_QUOTA_GB=${MAIL_LARGE_FILE_USER_QUOTA_GB:-}
- MAIL_LARGE_FILE_RETENTION_DAYS=${MAIL_LARGE_FILE_RETENTION_DAYS:-}
- FILE_STORAGE_MAX_TOTAL_GB=${FILE_STORAGE_MAX_TOTAL_GB:-}
- FILE_STORAGE_MIN_FREE_GB=${FILE_STORAGE_MIN_FREE_GB:-}
# 파일을 두는 곳 — 아래 named volume <PROJECT_NAME>_files (루트 env 로 바꾸지 않는다)
- FILE_STORAGE_DIR=/app/files
# 악성 검사기(clamd) — 운영 필수: 아래 clamav 서비스(호스트 이름 clamav, 포트 3310). 비우면(개발 기본) 검사하지 않고 다 올리자마자 "검사하지 못함"
- CLAMAV_HOST=${CLAMAV_HOST:-}
- CLAMAV_PORT=${CLAMAV_PORT:-}
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 — named volume <PROJECT_NAME>_uploads.
# 호스트 디렉터리(./data/uploads)를 쓰지 않는 이유: sudo docker compose 가 없는 디렉터리를 root 소유로 만들어
# backend(node)가 쓰지 못한다. 새 named volume 은 이미지의 /app/uploads(node 소유) 소유자를 물려받는다 (backend/Dockerfile).
# (개발은 docker-compose.dev.yml 이 ./data/uploads 바인드 마운트로 바꾼다)
# (메일 첨부는 업로드 중 /tmp/arasystem-mail-uploads 에 잠시 받는다 — 컨테이너 쓰기 계층 디스크.
# read_only 로 바꾸면 그 경로에 tmpfs 가 아닌 볼륨을 붙인다: tmpfs 는 메모리 한도에 잡힌다)
# 대용량 첨부 파일(11단계)은 named volume <PROJECT_NAME>_files — 같은 이유로 이미지의 /app/files(node 소유)를 물려받는다.
# 한 파일 2GB 까지 · 직원마다 10GB 까지 쌓인다 — 디스크 여유는 백엔드가 만들 때 본다(FILE_STORAGE_MIN_FREE_GB). 30일짜리 전송물이라 백업하지 않는다
# (개발도 같은 named volume 이다 — 업로드와 달리 호스트 디렉터리로 바꾸지 않는다, docker-compose.dev.yml)
volumes:
- uploads:/app/uploads
- files:/app/files
restart: unless-stopped
# 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다
# 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다.
# clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번)
networks:
- app-network
- edge
- clamav
depends_on:
db:
condition: service_healthy
@@ -325,6 +360,99 @@ services:
cpus: '0.25'
memory: 128m
# ----------------------------------------
# 7. 악성 검사 (ClamAV) — 대용량 첨부(11단계)의 검사기 · 운영은 늘 뜬다 (개발은 profile `clamav` — docker-compose.dev.yml)
#
# 백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (docs/mail/api-contract.md 4절 "악성 검사",
# backend modules/file-storage/clamav-client.ts). 설정 mail/clamav/ · 자세한 것 mail/README.md "악성 검사 (ClamAV)".
#
# 둘로 나눈다 — 바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다:
# clamav clamd(검사기). 백엔드와만 닿는 clamav 네트워크(internal) 하나에 붙는다 — 바깥 길이 없다(파일 해석기에 구멍이 나도
# 바깥 · 다른 컨테이너에 닿지 못하게). 서명 볼륨은 읽기 전용으로 붙이고 바뀐 서명을 스스로 다시 읽는다(clamd.conf 의 SelfCheck)
# clamav-updater freshclam(서명 갱신). 서명을 받아 볼륨에 쓴다 — 자기 바깥 길 clamav-egress 에만 붙고 백엔드 · 검사기와 잇지 않는다
# 이미지는 공식 clamav/clamav 의 LTS(1.4) 판을 digest 까지 고정한다(docker 스킬 "이미지 버전 고정") — _base 는 서명이 들지 않은 이미지다(서명은 볼륨에).
# 두 컨테이너 모두 이미지의 clamav 사용자(100:101) · 권한(capability) 없음 · no-new-privileges. 호스트 포트는 어떤 환경에서도 열지 않는다
# ----------------------------------------
clamav:
container_name: ${PROJECT_NAME}-clamav
# 1.4 는 ClamAV 의 LTS(2024-08 부터 3년). 1.4.6 은 보안 릴리스(2026-08-07). digest 는 2026-10-30 의 manifest list (이미지는 linux/amd64 만)
image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f
user: "100:101"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# 이미지의 /init 은 root 로 소유자를 바꾸고 freshclam 까지 함께 띄운다 — 검사기만 바로 띄운다. PID 1 은 docker 의 tini(sh 는 exec 로 clamd 가 된다).
# 띄우기 전에 clamd 의 임시 파일(clamd.conf 의 TemporaryDirectory /tmp — clamav-<hex>.tmp)을 지운다: 검사 중에 죽으면(OOM 등) 받던 스트림 ·
# 푼 내용이 남고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다(2026-10-30 — 흘려보내는 중에 죽일 때마다 수백 MB 씩 남았다).
# 지우지 못해도(소유자가 다르면) clamd 는 띄운다 — 그 줄이 docker logs 에 남는다
init: true
entrypoint: ["/bin/sh", "-c", "rm -rf /tmp/clamav-*; exec clamd --foreground --config-file=/etc/clamav/clamd.conf"]
expose:
- "3310"
volumes:
- clamav-db:/var/lib/clamav:ro
- ./mail/clamav/clamd.conf:/etc/clamav/clamd.conf:ro
restart: unless-stopped
networks:
- clamav
# 서명이 볼륨에 생긴 뒤에 뜬다 — 처음 기동은 clamav-updater 가 서명을 받는 몇 분을 기다린다(없으면 clamd 가 바로 끝난다).
# required: false — 서명이 오래됐다고(아래 updater 의 healthcheck) 검사기를 띄우지 않으면 검사가 통째로 서므로 경고만 내고 띄운다
depends_on:
clamav-updater:
condition: service_healthy
required: false
healthcheck:
# 이미지에 든 clamdcheck.sh — 3310 에 PING 을 보내 PONG 을 받는지. 서명을 다시 읽는 동안(30초 안팎)은 답하지 않는다 — 세 번까지 기다린다
test: ["CMD", "clamdcheck.sh"]
interval: 60s
timeout: 10s
retries: 3
start_period: 5m
# CPU — 검사 하나는 코어 하나만 쓴다(2GB 에 5분 안팎 — 2026-10-30 개발 PC). 백엔드가 한 번에 2개까지 보내므로 2개까지 쓰게 두고(서버가 2 vCPU 보다 적으면 낮춘다),
# 다른 컨테이너와 다툴 때는 절반의 몫(cpu_shares 512 — 기본 1024)만 받는다 — 검사가 웹메일 · 메일 엔진을 늦추지 않게
cpu_shares: 512
# 메모리 — 서명을 읽으면 1GB 안팎(2026-10 서명 363만 개). 다시 읽을 때 두 벌을 들지 않는다(clamd.conf 의 ConcurrentDatabaseReload no — 1GB 그대로).
# 검사하는 동안 받은 파일을 /tmp 에 써 그 페이지 캐시가 이 한도에 함께 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다). 예산은 mail/README.md "메모리 예산"
deploy:
resources:
limits:
cpus: '2.0'
memory: 2g
clamav-updater:
container_name: ${PROJECT_NAME}-clamav-updater
image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f
user: "100:101"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
init: true
# freshclam 데몬 — 기동 때 한 번 받고 Checks(하루 12번)마다 묻는다. 멈추면 컨테이너가 끝나 재시작 정책을 탄다
entrypoint: ["freshclam", "--daemon", "--foreground", "--stdout", "--config-file=/etc/clamav/freshclam.conf"]
volumes:
- clamav-db:/var/lib/clamav
- ./mail/clamav/freshclam.conf:/etc/clamav/freshclam.conf:ro
restart: unless-stopped
networks:
- clamav-egress
healthcheck:
# 서명(main · daily)이 있고 daily 가 이틀 안에 바뀌었으면 healthy — 갱신이 이틀 넘게 멈추면 unhealthy(docker ps 로 보인다. 검사는 옛 서명으로 이어 간다).
# 처음 기동은 서명을 받는 몇 분 동안 실패해도 세지 않는다(start_period)
test: ["CMD-SHELL", "ls /var/lib/clamav/main.c[lv]d >/dev/null 2>&1 && find /var/lib/clamav -maxdepth 1 -name 'daily.c[lv]d' -mmin -2880 | grep -q ."]
interval: 60s
timeout: 10s
retries: 3
start_period: 10m
# 메모리 — 평소 수십 MB. 받은 서명을 바꿔 넣기 전에 읽어 보는 동안(freshclam.conf 의 TestDatabases) 잠시 커진다 —
# 처음 기동(서명 전체)이 가장 커서 0.7GB 남짓이었다(2026-10-30). 평소 갱신은 daily 만이라 그보다 작다
deploy:
resources:
limits:
cpus: '0.5'
memory: 1g
networks:
app-network:
# 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외
@@ -359,17 +487,34 @@ networks:
# 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md)
name: ${PROJECT_NAME}_mail-dns-egress
driver: bridge
clamav:
# backend ↔ clamav(검사기) 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(검사기는 바깥 길이 없다).
# clamd 는 암호화하지 않은 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 도커가 정한다
name: ${PROJECT_NAME}_clamav
driver: bridge
internal: true
clamav-egress:
# 서명 갱신(clamav-updater)이 database.clamav.net 에 묻는 길 — updater 혼자 붙는다. 서브넷은 도커 기본 주소 풀(172.17~31.x · 192.168.x)에서 —
# 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail-dns-egress 와 같다)
name: ${PROJECT_NAME}_clamav-egress
driver: bridge
proxy:
external: true
# 메일 설정(config.json)·저장소(RocksDB). 컨테이너 사용자(UID 2000)가 써야 하므로
# 바인드 마운트 대신 named volume 을 쓴다. 이름을 고정해 백업 명령이 디렉터리 이름에 좌우되지 않게 한다.
# 업로드도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다.
# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 파일이 사라진다.
# 업로드 · 대용량 첨부 파일도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. 악성 검사 서명은 clamav 사용자(100)가 쓴다.
# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 · 대용량 첨부 파일이 사라진다.
volumes:
mail-config:
name: ${PROJECT_NAME}_mail-config
mail-data:
name: ${PROJECT_NAME}_mail-data
uploads:
name: ${PROJECT_NAME}_uploads
name: ${PROJECT_NAME}_uploads
# 대용량 첨부 파일 (11단계) — 기록은 DB 에 있다. 백업하지 않는다(30일짜리 전송물 — mail/README.md "백업")
files:
name: ${PROJECT_NAME}_files
# 악성 검사 서명 (110MB 안팎 — 2026-10) — 지워도 clamav-updater 가 다시 받는다. 백업하지 않는다
clamav-db:
name: ${PROJECT_NAME}_clamav-db