generated from ttipo/project-base
d57e6bfe6c
1만 통 측정에서 색인 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이었고 캐시가 비면 3~8배 느렸다(사용자 결정 2026-11-05). 8GB 서버의 한도 합은 6.65GB · 백업 때 7.65GB 다. 복원 연습 때는 8.15GB 로 8GB 를 넘는다 — 사실만 적고 다룰 방법은 따로 정한다. mail/README 에 운영에 올리는 차례(db 만 먼저 up · 한도 · shared_buffers 확인)를 적었다. docker 스킬의 연습 주기를 기준 문서(README — 한 달에 한 번)에 맞췄다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
571 lines
35 KiB
YAML
571 lines
35 KiB
YAML
# ----------------------------------------
|
||
# 컨테이너 로그 보존 (13단계 — mail/README.md "로그 보존") — 모든 서비스에 명시한다
|
||
#
|
||
# 도커 기본(json-file · 회전 없음)이면 로그 파일이 디스크를 끝없이 채운다. 크기로 회전하고(max-size), 정한 개수(max-file)만 남기며,
|
||
# 회전한 파일은 gzip 으로 줄인다(compress — docker logs 는 그대로 읽는다). json-file 은 줄마다 JSON 껍데기(시각 · stream)가 붙어 글의 1.4배 안팎이다.
|
||
# 기본(logging-default) 10m × 5 (컨테이너마다 글 35MB 안팎) — db · redis · mail-resolver · clamav · clamav-updater · (개발) mailpit
|
||
# 길게(logging-long) 20m × 10 (글 140MB 안팎) — mail · frontend · backend
|
||
# 메일 엔진 인증 · 거절 · 배달 기록. 직원 50명 · 하루 받는 메일 3,000통 · 보내는 메일 750통이면 하루 10MB 안팎 → 2주쯤
|
||
# frontend nginx 접근 로그(쿼리 · 토큰 없음). 한 사람이 하루 1,500 요청이면 50명에 하루 20MB 안팎 → 1주쯤
|
||
# backend 경고 · 오류 · 주요 일만(요청마다 남기지 않는다) — 몇 주
|
||
# (줄 길이는 개발 엔진 151바이트 · backend 176바이트 — 2026-10-31. 직원 수 · 메일 양은 가정이다 — 운영에서 docker logs --since 24h <컨테이너> | wc -c 로 다시 잰다)
|
||
# 디스크는 모두 차도 0.75GB 안팎(회전한 파일은 압축돼 실제로는 그 몇 분의 1). 더 오래 둬야 하는 기록은 로그 수집기로 — 지금은 두지 않았다
|
||
# 바꾼 값은 컨테이너를 다시 만들어야 걸린다(up -d 가 다시 만든다)
|
||
# ----------------------------------------
|
||
x-logging: &logging-default
|
||
driver: json-file
|
||
options:
|
||
max-size: "10m"
|
||
max-file: "5"
|
||
compress: "true"
|
||
x-logging-long: &logging-long
|
||
driver: json-file
|
||
options:
|
||
max-size: "20m"
|
||
max-file: "10"
|
||
compress: "true"
|
||
|
||
services:
|
||
# ----------------------------------------
|
||
# 1. Frontend (Nginx — 화면 정적 호스팅 + /api 프록시) — 운영의 유일한 입구
|
||
#
|
||
# 같은 주소(origin) 배포: 브라우저는 화면과 같은 주소의 /api 를 부르고, 이 nginx 가 백엔드로 넘긴다
|
||
# (docs/mail/api-contract.md 머리말 — CORS 없이 쿠키·CSP connect-src 'self' 로 동작한다).
|
||
# 외부 리버스 프록시는 proxy 네트워크로 이 컨테이너(<PROJECT_NAME>-frontend:8080)에만 붙는다.
|
||
#
|
||
# 네트워크 — 인터넷을 향한 컨테이너를 메일 엔진·DB 와 떼어 놓는다:
|
||
# proxy 외부 리버스 프록시 ↔ frontend (다른 프로젝트와 함께 쓸 수 있다)
|
||
# edge frontend ↔ backend 전용
|
||
# 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
|
||
build:
|
||
context: ./${FRONTEND_DIR}
|
||
dockerfile: Dockerfile
|
||
args:
|
||
- BUILD_MODE=${BUILD_MODE}
|
||
# 같은 주소 배포라 상대경로로 고정한다 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP 가 맞지 않는다
|
||
- VITE_API_BASE_URL=/api
|
||
expose:
|
||
- "8080"
|
||
environment:
|
||
- NODE_ENV=${APP_ENV}
|
||
# nginx 템플릿 값 (frontend/nginx-env.envsh) — 본문 한도를 백엔드와 같은 env 에서 만든다 (각 한도 + 1MB)
|
||
- MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20}
|
||
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
|
||
# 서비스 이름(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:-}
|
||
# 메일 프로그램 자동 설정(13단계) — autoconfig. · autodiscover.<MAIL_DOMAIN> 에 IMAP · SMTP 호스트 MAIL_HOSTNAME 을 고정 XML 로 낸다.
|
||
# 둘 중 하나라도 비면 끈다 (nginx-env.envsh · mail/README.md "메일 프로그램 자동 설정")
|
||
- MAIL_DOMAIN=${MAIL_DOMAIN:-}
|
||
- MAIL_HOSTNAME=${MAIL_HOSTNAME:-}
|
||
restart: unless-stopped
|
||
logging: *logging-long
|
||
# 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다
|
||
ulimits:
|
||
nofile:
|
||
soft: 8192
|
||
hard: 8192
|
||
networks:
|
||
- edge
|
||
- proxy
|
||
depends_on:
|
||
- backend
|
||
healthcheck:
|
||
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 nginx 는 IPv4 만 바인딩하므로 127.0.0.1 을 명시한다
|
||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ || exit 1"]
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 10s
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
cpus: '0.5'
|
||
memory: 512m
|
||
|
||
# ----------------------------------------
|
||
# 2. Backend (HTTP API & WebSocket 통합)
|
||
# ----------------------------------------
|
||
backend:
|
||
container_name: ${PROJECT_NAME}-backend
|
||
build:
|
||
context: ./${BACKEND_DIR}
|
||
dockerfile: Dockerfile
|
||
expose:
|
||
- "${BACKEND_PORT}"
|
||
- "${WEBSOCKET_PORT}"
|
||
# PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown)
|
||
init: true
|
||
# 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초)
|
||
stop_grace_period: 30s
|
||
env_file:
|
||
- ./${BACKEND_DIR}/.env.${APP_ENV}
|
||
environment:
|
||
- NODE_ENV=${APP_ENV}
|
||
- PORT=${BACKEND_PORT}
|
||
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
|
||
# 화면의 공개 주소(origin, 콤마로 여러 개) — 상태 변경 요청의 출처 검사에 쓴다. 운영 필수
|
||
- CORS_ORIGIN=${CORS_ORIGIN}
|
||
# 백엔드 앞의 프록시 홉 수(또는 주소 목록) — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
|
||
# 외부 리버스 프록시 → 프론트 nginx → 백엔드 = 2. proxy 네트워크를 다른 프로젝트와 함께 쓰면
|
||
# 주소 목록(<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>)을 권장한다 (루트 env 예시 참조)
|
||
- TRUST_PROXY=${TRUST_PROXY:-}
|
||
# 앱이 직접 쓰지는 않는다. 로컬 주소가 남은 채 운영에 배포되는 것을
|
||
# 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts)
|
||
- BACKEND_API_URL=${BACKEND_API_URL}
|
||
# 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true
|
||
- ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-}
|
||
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000}
|
||
- THROTTLE_LIMIT=${THROTTLE_LIMIT:-100}
|
||
- DB_HOST=db
|
||
- DB_PORT=5432
|
||
- DB_USER=${DB_USER}
|
||
- DB_PASSWORD=${DB_PASSWORD}
|
||
- DB_NAME=${DB_NAME}
|
||
- DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false}
|
||
- UPLOAD_DIR=/app/uploads
|
||
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
|
||
- REDIS_HOST=redis
|
||
- REDIS_PORT=6379
|
||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||
# 메일 엔진(Stalwart) — 백엔드 전용 내부 JMAP. 직원 메일함은 게이트웨이 계정의
|
||
# 대리 접근(<직원 주소>%<게이트웨이 주소>)으로 읽는다 (mail/README.md)
|
||
- MAIL_JMAP_URL=${MAIL_JMAP_URL:-http://mail:8080}
|
||
- MAIL_DOMAIN=${MAIL_DOMAIN}
|
||
- MAIL_GATEWAY_USER=${MAIL_GATEWAY_USER}
|
||
- MAIL_GATEWAY_PASSWORD=${MAIL_GATEWAY_PASSWORD}
|
||
# 메일 엔진 영구 관리자 아이디 — mail setup 이 같은 값으로 관리자를 만들고, 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다.
|
||
# bootstrap 전에만 정할 수 있다 (루트 env 예시 · mail/README.md)
|
||
- MAIL_ADMIN_LOCAL_PART=${MAIL_ADMIN_LOCAL_PART:-admin}
|
||
# 관리자 외에 웹메일 로그인·대리 접근을 막을 계정 (주소 전체, 콤마). 비어 있어도 위 관리자는 항상 막는다
|
||
- MAIL_BLOCKED_ACCOUNTS=${MAIL_BLOCKED_ACCOUNTS:-}
|
||
# 사내 목록 계정 아이디 — mail setup 이 만든 비밀번호 없는 계정(<이 값>@MAIL_DOMAIN). 백엔드가 게이트웨이 대리 접근으로
|
||
# 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는다 (docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md)
|
||
- MAIL_DIRECTORY_LOCAL_PART=${MAIL_DIRECTORY_LOCAL_PART:-mail-directory}
|
||
# 엔진 웹훅(12단계 — 메일 프로그램 로그인 기록)의 서명 키 — 엔진이 보내는 묶음(POST /api/internal/mail-engine-events)의 HMAC-SHA256(X-Signature)을 이 값으로 본다.
|
||
# mail setup 이 같은 값으로 엔진 웹훅을 만든다. 운영 필수(shared/config/env.validation.ts). 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다
|
||
# (docs/mail/api-contract.md 5절 "엔진 웹훅 받기" · mail/README.md "엔진 웹훅")
|
||
- MAIL_WEBHOOK_SECRET=${MAIL_WEBHOOK_SECRET:-}
|
||
# 앱 네트워크 대역 — 웹훅 받는 쪽이 접속 주소(소켓 — X-Forwarded-For 가 아니다)가 이 대역인지 본다. 아래 networks 의 app-network 서브넷과 같은 값이어야 한다
|
||
- APP_NETWORK_SUBNET=${APP_NETWORK_SUBNET:-172.16.71.0/24}
|
||
# 첨부 1개 최대 크기(MB). 앞단 리버스 프록시의 요청 본문 한도는 이보다 커야 한다
|
||
# (작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 — .claude/skills/docker/SKILL.md)
|
||
- 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
|
||
logging: *logging-long
|
||
# 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다.
|
||
# clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번)
|
||
networks:
|
||
- app-network
|
||
- edge
|
||
- clamav
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
# redis 는 profile 로 비활성화될 수 있으므로 선택적 의존으로 둔다
|
||
redis:
|
||
condition: service_healthy
|
||
required: false
|
||
# mail 도 profile 뒤에 있다. 켜져 있으면 메일 엔진이 준비된 뒤 백엔드가 뜬다
|
||
mail:
|
||
condition: service_healthy
|
||
required: false
|
||
healthcheck:
|
||
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다
|
||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 30s
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
cpus: '1.0'
|
||
memory: 1g
|
||
|
||
# ----------------------------------------
|
||
# 3. Database (PostgreSQL) — 내부 네트워크 전용
|
||
# ----------------------------------------
|
||
db:
|
||
container_name: ${PROJECT_NAME}-db
|
||
# 판 · digest 까지 고정한다(루트 env 의 DB_IMAGE — docker 스킬 "이미지 버전 고정"). 메이저(15)는 데이터 형식이라 그대로 둔다 —
|
||
# 올리려면 pg_dump → 새 메이저에 복원(mail/README.md "백업"의 복원 차례). 아래 watchtower 표시는 13단계에 뺐다(판을 사람이 올린다)
|
||
image: ${DB_IMAGE}
|
||
# 공유 버퍼(shared_buffers) 256MB — 기본 128MB 의 두 배 · 메모리 한도(아래 deploy — 1g)의 1/4. 14단계 메일 검색 색인 때문이다(아래 deploy 의 "메모리").
|
||
# 공식 이미지의 꼴이다 — entrypoint(docker-entrypoint.sh)가 처음 기동의 initdb 를 하고 이 인자를 postgres 에 그대로 넘긴다.
|
||
# 명령줄 값은 데이터 폴더의 postgresql.conf · ALTER SYSTEM 보다 이긴다 — 값은 이 한 곳에 둔다(개발도 같다 — docker-compose.dev.yml 은 덮어쓰지 않는다)
|
||
command: ["postgres", "-c", "shared_buffers=256MB"]
|
||
expose:
|
||
- "5432"
|
||
environment:
|
||
POSTGRES_USER: ${DB_USER}
|
||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||
POSTGRES_DB: ${DB_NAME}
|
||
volumes:
|
||
- ${DB_DATA_DIR}:/var/lib/postgresql/data
|
||
restart: always
|
||
logging: *logging-default
|
||
networks:
|
||
- app-network
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 10s
|
||
# 메모리 — 1g (14단계에 512m 에서 올렸다 — 2026-11-05 사용자 결정). 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이다 —
|
||
# 2026-11-05 개발(1만 통 · 색인 121MB)에서 캐시가 비면 3~8배 느렸다. 512m 로도 모자라지는 않았다(채우기 0.16GB 안팎 · 찾기 시험 최대 236MiB) — 늘린 몫은 색인을 메모리에 두는 자리다.
|
||
# 공유 버퍼(위 command) 밖은 연결마다의 작업 메모리와 표 · 색인의 페이지 캐시다 — 페이지 캐시도 이 한도에 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다).
|
||
# 8GB 서버의 합은 mail/README.md "메모리 예산". 값을 바꾸면 다음 up 이 db 를 다시 만든다 — 수 초 멈추고 backend 는 다시 붙는다(같은 절의 차례)
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
cpus: '0.5'
|
||
memory: 1g
|
||
|
||
# ----------------------------------------
|
||
# 4. Redis — 내부 네트워크 전용 · 기본 비활성
|
||
#
|
||
# 기본적으로 기동하지 않는다. 캐시/세션 공유가 실제로 필요해질 때 켠다.
|
||
# 루트 .env.{APP_ENV} 에서 COMPOSE_PROFILES=redis
|
||
# 또는 일회성으로 docker compose --profile redis up -d
|
||
# ----------------------------------------
|
||
redis:
|
||
profiles: ["redis"]
|
||
container_name: ${PROJECT_NAME}-redis
|
||
image: ${REDIS_IMAGE}
|
||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||
expose:
|
||
- "6379"
|
||
volumes:
|
||
- ${REDIS_DATA_DIR}:/data
|
||
restart: always
|
||
logging: *logging-default
|
||
networks:
|
||
- app-network
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 5s
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
cpus: '0.25'
|
||
memory: 256m
|
||
|
||
# ----------------------------------------
|
||
# 5. Mail (Stalwart) — 사내 메일 엔진 · profile `mail`
|
||
#
|
||
# 직원 로그인 = 메일 계정. 백엔드가 내부 JMAP(http://mail:8080)으로 중계한다.
|
||
# 켜기: 루트 .env.{APP_ENV} 에 COMPOSE_PROFILES=mail
|
||
# 초기화: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs (여러 번 실행해도 안전)
|
||
# 자세한 내용: mail/README.md
|
||
#
|
||
# 운영 기본 가정 = 메일 전용 호스트 (mailcow 와 같은 방식). 아래 포트를 호스트에 직접 연다.
|
||
# 25 SMTP 수신 · 465 SMTPS 제출 · 993 IMAPS · 443 HTTPS (내장 ACME 인증서 발급에도 쓴다)
|
||
# 같은 호스트의 리버스 프록시가 443 을 이미 쓰고 있다면 mail/README.md 의 대안을 따른다.
|
||
#
|
||
# ⚠️ 8080(HTTP JMAP·관리 API)은 백엔드 전용 내부 포트다 — 호스트에 절대 공개하지 않는다.
|
||
# ⚠️ 0.0.0.0(IPv4)으로만 바인딩한다. 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가
|
||
# 중계해 컨테이너에는 도커 게이트웨이 주소로 보인다. 그러면 외부 접속이 신뢰 대역(MAIL_TRUSTED_NETWORKS)
|
||
# 으로 취급되어 자동 차단 예외·게이트웨이 IP 제한이 무력화된다.
|
||
# ----------------------------------------
|
||
mail:
|
||
profiles: ["mail"]
|
||
container_name: ${PROJECT_NAME}-mail
|
||
image: ${MAIL_IMAGE}
|
||
# 이미지 기본 사용자 stalwart(UID 2000)로 구동한다 (비-root). healthcheck 는 이미지에 내장되어 있다
|
||
# (https 443 → 실패 시 http 8080 /healthz/live — bootstrap 모드에서도 동작).
|
||
expose:
|
||
- "8080"
|
||
ports:
|
||
- "0.0.0.0:25:25"
|
||
- "0.0.0.0:465:465"
|
||
- "0.0.0.0:993:993"
|
||
- "0.0.0.0:443:443"
|
||
environment:
|
||
# JMAP·OAuth 디스커버리 URL 의 기준 주소 (공개 주소)
|
||
- STALWART_PUBLIC_URL=${MAIL_PUBLIC_URL}
|
||
# 최초 기동(bootstrap) 전용 임시 관리자 "아이디:비밀번호". bootstrap/recovery 모드에서만 통한다.
|
||
# 운영은 설정을 마치면 값을 비우고 컨테이너를 다시 만든다 (mail/README.md)
|
||
- STALWART_RECOVERY_ADMIN=${MAIL_RECOVERY_ADMIN:-}
|
||
volumes:
|
||
- mail-config:/etc/stalwart
|
||
- mail-data:/var/lib/stalwart
|
||
restart: unless-stopped
|
||
logging: *logging-long
|
||
# 컨테이너의 DNS — 도커 DNS(127.0.0.11)가 도커 내부 이름(mailpit 등)은 직접 풀고 바깥 이름은 메일 전용 리졸버에 묻는다.
|
||
# 엔진 자신의 DNS(x:DnsResolver)는 mail setup 이 맞춘다 — 운영은 이 리졸버에 바로(도커 DNS 를 거치지 않는다), 개발은 이 길(System)
|
||
dns:
|
||
- ${MAIL_RESOLVER_IP:-172.16.73.254}
|
||
networks:
|
||
- app-network
|
||
- mail-dns
|
||
# 리졸버가 답한 뒤에 뜬다 — 엔진은 기동할 때 리졸버가 DNSSEC 을 검증하는지 보고, 못 하면 DANE 을 끈다.
|
||
# required: false — 평소에는 healthy 를 기다리고, 리졸버가 unhealthy 거나 떠 있지 못하면(설정 오류로 바로 끝남 · 재시작을 되풀이함)
|
||
# compose 가 경고("optional dependency … failed to start")만 내고 mail 을 띄운다. 없으면 리졸버 오류 하나로 `up -d mail` 이 옛 mail 을 지운 뒤
|
||
# 새 mail 을 띄우지 않고 끝나 메일 전체가 선다 — 바깥 DNS 만 안 되는 편이 낫다 (2026-10-30 개발 Compose 2.40 에서 넷 다 확인, mail/README.md "리졸버 장애 때")
|
||
depends_on:
|
||
mail-resolver:
|
||
condition: service_healthy
|
||
required: false
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
cpus: '1.0'
|
||
memory: 1g
|
||
|
||
# ----------------------------------------
|
||
# 6. Mail resolver (unbound) — 메일 엔진 전용 재귀 DNS · profile `mail`
|
||
#
|
||
# 엔진의 DNS 조회(스팸 차단 목록 DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버에서 직접 푼다.
|
||
# Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다(127.255.255.254). DNSSEC 을 검증해 엔진의 DANE 이 켜진다.
|
||
# 설정 mail/resolver/unbound.conf · mail setup 이 엔진의 x:DnsResolver 를 이 주소로 맞춘다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").
|
||
#
|
||
# 네트워크 — 메일 엔진만 닿게:
|
||
# mail-dns mail ↔ 리졸버 전용. internal(바깥으로 나가는 길 없음). 리졸버 주소는 MAIL_RESOLVER_IP 로 고정한다 —
|
||
# 엔진 설정 · mail 의 dns 가 주소(IP)만 받는다. 서브넷의 끝 쪽에 둔다(도커는 mail 에 앞쪽부터 차례로 준다)
|
||
# mail-dns-egress 리졸버의 바깥 조회 길 — 리졸버 혼자 붙는다
|
||
# app-network(메일 엔진의 신뢰 대역)에는 붙이지 않는다 — mail setup 이 두 네트워크의 구성원을 확인한다 (운영은 그 밖이 있으면 중단)
|
||
# 이미지는 판까지 고정한다 (docker 스킬 "이미지 버전 고정"). 1.26.2 는 NLnet Labs 의 보안 릴리스(2026-10-21)라 2주를 기다리지 않았다
|
||
# ----------------------------------------
|
||
mail-resolver:
|
||
profiles: ["mail"]
|
||
container_name: ${PROJECT_NAME}-mail-resolver
|
||
# 공식이 아닌(커뮤니티) 이미지라 판에 digest 까지 묶는다 — 같은 태그를 누가 다시 올려도 바뀌지 않게 (2026-10-30 manifest list digest)
|
||
image: klutchell/unbound:1.26.2@sha256:50cbeb32b5838a829a7226b19cb151f9535bd7f9f3190f950780f452fe807478
|
||
# 이미지는 root 로 떠서 53 에 붙은 뒤 내려가는 꼴이다 — 처음부터 비-root(이미지의 unbound 사용자 101:102)로 띄운다.
|
||
# 53 에 붙는 것은 도커가 컨테이너마다 net.ipv4.ip_unprivileged_port_start=0 으로 두기 때문이다 — 권한(capability)은 모두 뺀다
|
||
user: "101:102"
|
||
cap_drop:
|
||
- ALL
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
volumes:
|
||
# 이미지의 기본 설정을 통째로 바꾼다. DNSSEC 신뢰 앵커(/var/unbound/root.key)는 이미지의 것을 쓰고 컨테이너 안에서 고쳐 쓴다
|
||
- ./mail/resolver/unbound.conf:/etc/unbound/unbound.conf:ro
|
||
restart: unless-stopped
|
||
logging: *logging-default
|
||
networks:
|
||
mail-dns:
|
||
ipv4_address: ${MAIL_RESOLVER_IP:-172.16.73.254}
|
||
mail-dns-egress: {}
|
||
healthcheck:
|
||
# 데몬이 답하는지만 본다(자기 안의 localhost) — 바깥 DNS 가 잠깐 막혀도 mail 이 뜨지 못하는 일이 없게.
|
||
# 바깥 조회 · DNSSEC 은 mail setup 이 메일 컨테이너에서 확인한다. drill-hc 는 이미지에 든 drill 의 종료 코드를 0 · 1 로 맞춘 것
|
||
test: ["CMD", "drill-hc", "localhost", "@127.0.0.1", "A"]
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 3
|
||
start_period: 10s
|
||
deploy:
|
||
resources:
|
||
limits:
|
||
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
|
||
logging: *logging-default
|
||
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
|
||
logging: *logging-default
|
||
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:
|
||
# 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외
|
||
# (mail/scripts/setup.mjs 가 APP_NETWORK_SUBNET 으로 맞추고, 더 넓은 신뢰 대역은 거부한다).
|
||
# 그래서 backend·db·redis·mail(개발은 mailpit 도)만 붙인다 — frontend 등 다른 컨테이너를 붙이지 않는다 (setup 이 확인한다).
|
||
# 기본값은 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖이라 자동 배정 네트워크와 겹치지 않는다.
|
||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → APP_NETWORK_SUBNET 을 바꾼다.
|
||
name: ${PROJECT_NAME}_app-network
|
||
driver: bridge
|
||
ipam:
|
||
config:
|
||
- subnet: ${APP_NETWORK_SUBNET:-172.16.71.0/24}
|
||
edge:
|
||
# frontend(nginx) ↔ backend 전용. 서브넷을 고정해 두면 백엔드 TRUST_PROXY 를 주소 목록으로 쓸 수 있다
|
||
# (backend 가 보는 frontend 주소가 늘 이 대역 안이다). 겹치면 EDGE_NETWORK_SUBNET 을 바꾼다.
|
||
name: ${PROJECT_NAME}_edge
|
||
driver: bridge
|
||
ipam:
|
||
config:
|
||
- subnet: ${EDGE_NETWORK_SUBNET:-172.16.72.0/24}
|
||
mail-dns:
|
||
# mail ↔ mail-resolver 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(mail 의 바깥 길은 app-network 그대로).
|
||
# 리졸버 주소(MAIL_RESOLVER_IP)를 고정하려고 서브넷을 고정한다. 겹치면 MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP 를 함께 바꾼다
|
||
name: ${PROJECT_NAME}_mail-dns
|
||
driver: bridge
|
||
internal: true
|
||
ipam:
|
||
config:
|
||
- subnet: ${MAIL_RESOLVER_SUBNET:-172.16.73.0/24}
|
||
mail-dns-egress:
|
||
# 리졸버가 루트 · 각 도메인의 DNS 서버에 묻는 길 — 리졸버 혼자 붙는다. 믿는 대역이 아니라 서브넷을 고정하지 않는다 —
|
||
# 도커가 기본 주소 풀(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 이다. 악성 검사 서명은 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
|
||
# 대용량 첨부 파일 (11단계) — 기록은 DB 에 있다. 백업하지 않는다(30일짜리 전송물 — mail/README.md "백업")
|
||
files:
|
||
name: ${PROJECT_NAME}_files
|
||
# 악성 검사 서명 (110MB 안팎 — 2026-10) — 지워도 clamav-updater 가 다시 받는다. 백업하지 않는다
|
||
clamav-db:
|
||
name: ${PROJECT_NAME}_clamav-db |