aba274e56f
템플릿이 나중에 반드시 필요해질 공용 기능을 미리 갖춘다. multer 취약점을 해소한 김에, 안전한 업로드 기준도 함께 세운다. - modules/upload — 단일 파일 업로드 엔드포인트(POST /api/files)와 재사용 가능한 검증 설정 - 확장자와 MIME 을 **둘 다** 검사하는 허용목록 방식. 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. .html/.svg/.js 등 브라우저가 실행할 수 있는 형식은 허용 목록에서 제외. - 저장 파일명은 서버가 UUID 로 생성한다. 클라이언트 파일명을 경로에 쓰면 경로 탈출이 가능하다. - 크기 한도(UPLOAD_MAX_SIZE_MB, 기본 5MB) 초과 시 FILE_002(413). - 도메인 에러 코드 FILE_001/FILE_002 추가 후 세 플랫폼 사전 동기화. 함께 고친 것: - 전역 필터가 프레임워크의 영문 메시지를 그대로 노출하고 있었다. 크기 초과 시 multer 의 "File too large" 가 사용자에게 보였다 — 이제 코드에 대응하는 레지스트리 문구만 사용하며, 재정의는 BusinessException 으로만 가능하다. - sync:error-codes 가 컨테이너 안에서 실행되면 형제 디렉터리를 찾지 못해 cryptic 한 mkdir 오류를 냈다. 원인과 해결 방법을 알리도록 보강. - 업로드 볼륨 경로에 compose 기본값을 주어 기존 .env 에 키가 없어도 동작하게 함. 실제 multipart 요청으로 정상 업로드·형식 거부·MIME 위장·경로 탈출 시도·크기 초과· 파일 누락 시나리오를 모두 검증했다. 알려진 한계(확장자·MIME 검사는 파일 내용을 보지 않는다)는 security.md 에 명시하고, 업로드 디렉터리를 정적 서빙하지 말 것을 규칙으로 남겼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
4.8 KiB
YAML
166 lines
4.8 KiB
YAML
services:
|
|
# ----------------------------------------
|
|
# 1. Frontend (Nginx 정적 호스팅)
|
|
# ----------------------------------------
|
|
frontend:
|
|
container_name: ${PROJECT_NAME}-frontend
|
|
build:
|
|
context: ./${FRONTEND_DIR}
|
|
dockerfile: Dockerfile
|
|
args:
|
|
- BUILD_MODE=${BUILD_MODE}
|
|
- VITE_API_BASE_URL=${BACKEND_API_URL}
|
|
expose:
|
|
- "8080"
|
|
environment:
|
|
- NODE_ENV=${APP_ENV}
|
|
restart: unless-stopped
|
|
networks:
|
|
- app-network
|
|
- 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}
|
|
- CORS_ORIGIN=${CORS_ORIGIN}
|
|
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS}
|
|
- THROTTLE_LIMIT=${THROTTLE_LIMIT}
|
|
- DB_HOST=db
|
|
- DB_PORT=5432
|
|
- DB_USER=${DB_USER}
|
|
- DB_PASSWORD=${DB_PASSWORD}
|
|
- DB_NAME=${DB_NAME}
|
|
- DB_SYNCHRONIZE=${DB_SYNCHRONIZE}
|
|
- UPLOAD_DIR=/app/uploads
|
|
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
|
|
- REDIS_HOST=redis
|
|
- REDIS_PORT=6379
|
|
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
|
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다
|
|
volumes:
|
|
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
|
|
restart: unless-stopped
|
|
networks:
|
|
- app-network
|
|
- proxy
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
# redis 는 profile 로 비활성화될 수 있으므로 선택적 의존으로 둔다
|
|
redis:
|
|
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
|
|
image: ${DB_IMAGE}
|
|
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
|
|
networks:
|
|
- app-network
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 10s
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: '0.5'
|
|
memory: 512m
|
|
labels:
|
|
- "com.centurylinklabs.watchtower.enable=true"
|
|
|
|
# ----------------------------------------
|
|
# 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
|
|
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
|
|
labels:
|
|
- "com.centurylinklabs.watchtower.enable=true"
|
|
|
|
networks:
|
|
app-network:
|
|
driver: bridge
|
|
proxy:
|
|
external: true |