목차
- 통념 1: "Compose는 개발용이지 프로덕션엔 못 쓴다"
- 통념 2: "무중단 배포는 K8s 없이는 불가능하다"
- 통념 3: "Nginx는 별도 호스트에 두는 게 정석이다"
- .env 관리: 한 파일에 다 넣지 마라
- 통념 4: "헬스체크는 그냥 200만 떨어뜨리면 된다"
- 로그와 볼륨: 잊기 쉬운 두 가지
- 언제 Compose를 떠나야 하나
Docker Compose는 여러 컨테이너를 YAML 한 파일로 정의하고 한 번에 띄우는 오케스트레이션 도구다. 흔히 "로컬 개발용"으로 알려져 있지만, docker compose 서버 배포는 트래픽 일 100만 미만의 서비스에서는 충분히 실용적인 선택지다. 프론트엔드를 2년 하다가 백엔드로 넘어온 뒤 처음 맡은 일이 배포였는데, 주변에서 던지는 조언이 거의 비슷했다. "그 정도면 ECS 쓰세요." "쿠버 안 쓰면 나중에 후회합니다."
실제로 1년을 굴려보니 다르게 생각하게 됐다. 작은 서비스에서 Kubernetes로 가는 건 대부분 오버킬이고, Compose만 잘 짜도 무중단 배포, 환경변수 분리, 헬스체크, 리버스 프록시까지 다 된다. 이 글은 흔히 들리는 통념 몇 가지를 뒤집으면서, 실제로 운영 중인 구성을 단계별로 풀어본다.
통념 1: "Compose는 개발용이지 프로덕션엔 못 쓴다"
가장 자주 듣는 말이다. 프론트 시절에 nginx 컨테이너 하나 띄울 때만 써봤던 경험으로는 이 말이 맞는 것 같았다. 그런데 백엔드로 넘어와서 실제 운영 환경을 들여다보니, Compose가 못 쓸 이유가 의외로 적었다.
Docker 공식 문서도 Production with Docker Compose 페이지를 따로 두고 있다. 2024년 1월에 docker-compose (v1, Python)가 EOL 처리되고 docker compose (v2, Go)로 단일화된 이후, 프로덕션 사용을 명시적으로 가이드한다. v2.20 이후로 --wait, --wait-timeout, pull_policy 같은 배포용 옵션이 안정화됐다 (Release Notes 2023-07 기준).
핵심은 "Compose가 못 한다"가 아니라 "Compose가 안 하는 일을 누가 대신해주냐"다. 그 일이 뭔지 정리하면 이렇다.
| 기능 | Kubernetes | Docker Compose |
|---|---|---|
| 다중 노드 스케줄링 | 기본 제공 | 불가 (단일 호스트) |
| 자동 스케일링 | HPA | 수동 (--scale) |
| 롤링 업데이트 | 기본 제공 | 수동 구성 필요 |
| 서비스 디스커버리 | DNS + Service | 컨테이너 이름 기반 |
| 헬스체크 + 자동 재시작 | 기본 제공 | 기본 제공 |
| 시크릿 관리 | Secret 오브젝트 | .env + secrets 블록 |
| 학습 곡선 | 가파름 | 완만함 |
단일 호스트로 충분한 서비스라면 위 표에서 굵게 들어오는 차이는 "다중 노드"와 "자동 스케일링"뿐이다. 일 동시 접속 1000명 이하 서비스에서 이게 필요한 경우는 드물다. 필자가 운영 중인 사이드 프로젝트는 EC2 c6i.large 한 대에 Compose로 API 2벌, Postgres, Redis, Nginx까지 묶어 돌리는데, CPU 사용률이 평상시 15%를 넘지 않는다.
통념 2: "무중단 배포는 K8s 없이는 불가능하다"
이건 반은 맞고 반은 틀린 말이다. 정확히 말하면 "Compose의 기본 up 명령은 무중단이 아니다"가 맞다. 컨테이너를 stop → remove → create → start 순서로 가기 때문에 중간에 다운타임이 생긴다.
그런데 v2.20부터 추가된 --no-recreate, --scale, --wait 조합과 Nginx의 upstream 재로드를 묶으면 사실상 무중단이 된다. 핵심 아이디어는 단순하다.
새 컨테이너를 먼저 띄우고, 헬스체크 통과를 기다린 뒤, 옛 컨테이너를 내린다
기존 서비스가 api_1로 떠 있다고 가정하자. 배포 시 api_2를 새 이미지로 띄우고, 이게 헬스체크를 통과하면 Nginx upstream에서 트래픽을 둘 다 받게 한다. 그다음 api_1을 내리고, 새 버전 api_3을 띄운다. 이렇게 한 칸씩 굴리는 게 롤링 업데이트의 본질이다.
물론, 실제 운영 중인 compose.prod.yml의 핵심 부분이다.
services:
api:
image: ${REGISTRY}/myapp:${TAG}
env_file: .env.prod
healthcheck:
# 5초마다 /health 호출, 3회 실패 시 unhealthy 처리
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 3
start_period: 15s
deploy:
replicas: 2
restart: unless-stopped
networks:
- backend
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/letsencrypt:ro
depends_on:
api:
condition: service_healthy
networks:
- backend
물론, 여기서 중요한 건 start_period: 15s다. Django나 FastAPI 같은 앱은 부팅에 5~10초가 걸리는 게 보통인데, 이걸 짧게 잡으면 헬스체크가 너무 빨리 실패해서 컨테이너가 무한 재시작에 빠진다. 이걸 처음에 몰라서 새벽에 배포 굴리다 알람이 30분간 울린 적이 있다.
배포 스크립트는 30줄이면 충분하다
한편, K8s 매니페스트 작성하는 시간보다 짧다.
#!/bin/bash
set -euo pipefail
NEW_TAG=$1
OLD_COUNT=$(docker compose -f compose.prod.yml ps -q api | wc -l)
NEW_COUNT=$((OLD_COUNT + 2))
# 새 이미지로 추가 컨테이너 띄우기 (기존은 유지)
TAG=$NEW_TAG docker compose -f compose.prod.yml up -d \
--no-deps --scale api=$NEW_COUNT --no-recreate api
# 모든 인스턴스 healthy 대기 (최대 60초)
TAG=$NEW_TAG docker compose -f compose.prod.yml up -d \
--wait --wait-timeout 60 api
# Nginx upstream 재로드
docker compose -f compose.prod.yml exec nginx nginx -s reload
# 이전 컨테이너 정리
docker compose -f compose.prod.yml up -d \
--scale api=2 --no-recreate api
이게 전부다. 헬스체크 실패 시 --wait가 non-zero로 떨어지면서 스크립트가 멈춘다. CI/CD 파이프라인에서 이걸 받아 자동 롤백 시키면 된다.
통념 3: "Nginx는 별도 호스트에 두는 게 정석이다"
예를 들어, L7 로드밸런서를 따로 두는 게 대규모 트래픽에선 맞다. 하지만 단일 호스트 + Compose 환경에서 Nginx를 같은 호스트의 컨테이너로 두는 건 충분히 합리적인 선택이다. 같은 Docker 네트워크 안에서 컨테이너 이름으로 통신하기 때문에, upstream을 api:8000처럼 서비스명으로 적으면 끝난다.
그런데, 다만 한 가지 함정이 있다. Compose는 컨테이너가 재시작되면 IP가 바뀔 수 있는데, Nginx는 시작 시점에 DNS를 한 번 캐싱한다. 이걸 해결하려면 resolver를 명시하고 set 변수로 upstream을 잡아야 한다.
upstream api_backend {
# Docker 내장 DNS는 127.0.0.11
server api:8000 max_fails=2 fail_timeout=10s;
keepalive 32;
}
server {
listen 80;
server_name myapp.example.com;
location /health {
access_log off;
return 200 "ok\n";
}
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 5초 안에 응답 없으면 다음 upstream으로
proxy_connect_timeout 5s;
proxy_next_upstream error timeout http_502 http_503;
}
}
keepalive 32는 upstream과 32개 커넥션을 풀로 유지한다는 뜻이다. 이걸 안 넣었더니 매 요청마다 TCP 핸드셰이크가 일어나서 p99 응답 시간이 80ms → 35ms로 떨어졌다. 체감상 가장 효과적이었던 단일 옵션이다.
.env 관리: 한 파일에 다 넣지 마라
프론트엔드 시절 .env는 그냥 키 몇 개 적어두는 파일이었다. 백엔드로 넘어와 보니 환경변수는 보안 경계 그 자체였다. 처음 한동안은 .env 하나에 다 적어두고 git에서만 제외했는데, 이건 좋은 방식이 아니다.
지금은 세 단계로 분리한다.
| 파일 | 용도 | git 관리 | 누가 만지나 |
|---|---|---|---|
.env.example |
키 목록 + 더미값 | 커밋 | 모두 |
.env.prod |
실제 운영값 | 제외 | 서버 owner |
secrets/*.txt |
진짜 민감한 값 | 제외 | Docker secrets |
Compose는 secrets 블록을 지원한다. v3.1부터 들어왔고, Swarm 모드가 아니어도 파일 기반 secret은 작동한다.
services:
api:
image: myapp:${TAG}
env_file: .env.prod
secrets:
- db_password
- jwt_signing_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
JWT_KEY_FILE: /run/secrets/jwt_signing_key
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_signing_key:
file: ./secrets/jwt_signing_key.txt
이렇게 하면 docker compose config로 설정 덤프를 떠도 실제 값이 노출되지 않는다. env_file은 일반 설정 (포트, URL, 피처 플래그) 용도로만 쓰고, 진짜 비밀은 secrets로 빼는 게 안전하다.
게다가, 여담으로 프론트에서는 NEXT_PUBLIC_ 접두사 붙으면 클라이언트에 박힌다는 사실이 가장 큰 걱정거리였다. 백엔드로 와보니 환경변수가 노출되는 경로가 훨씬 다양했다. /proc/<pid>/environ, 컨테이너 inspect, 잘못 설정한 로그까지. 시각이 달라지는 지점이다.
통념 4: "헬스체크는 그냥 200만 떨어뜨리면 된다"
실제로, 이건 한참 잘못 알고 있었다. 단순히 /health가 200을 리턴해도, DB 커넥션이 죽었거나 외부 API가 안 잡히면 그 컨테이너는 사실상 죽은 상태다. 그런데 헬스체크는 통과하니까 트래픽이 계속 들어온다.
결국, 지금은 헬스체크를 두 가지로 나눈다.
# FastAPI 예시
@app.get("/health/live")
async def liveness():
# 프로세스가 살아있는지만 본다 (재시작 트리거)
return {"status": "alive"}
@app.get("/health/ready")
async def readiness(db: Session = Depends(get_db)):
# 트래픽 받을 준비가 됐는지 본다 (LB 등록 트리거)
try:
db.execute(text("SELECT 1"))
return {"status": "ready"}
except Exception as e:
raise HTTPException(503, f"db unreachable: {e}")
Compose의 healthcheck는 /health/live를 쓰고, Nginx upstream의 max_fails는 /health/ready를 보게 한다. 이렇게 분리하면 DB가 잠깐 끊겼을 때 컨테이너는 살려두되 트래픽만 빼는 식의 제어가 가능하다. 이 패턴은 K8s의 liveness/readiness probe와 동일한 발상인데, Compose에서도 똑같이 흉내 낼 수 있다.
로그와 볼륨: 잊기 쉬운 두 가지
그러나, 배포 자체보다 운영하면서 더 많이 만나는 문제가 디스크 풀이다. Docker 기본 로그 드라이버는 json-file이고, 별다른 설정 없이 두면 컨테이너 로그가 무한히 쌓인다. 2GB짜리 인스턴스가 어느 날 새벽에 no space left on device를 뱉는 경험을 한 번 하면 까먹지 않는다.
services:
api:
image: myapp:${TAG}
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
실제로, 이 네 줄이면 컨테이너당 로그가 최대 30MB로 제한된다. 더 정밀하게 관리하고 싶으면 loki 드라이버나 외부 로그 수집기로 빼는 게 정석이지만, 일단 위 설정만 들어가도 디스크 사고는 거의 막을 수 있다.
볼륨은 더 헷갈린다. docker compose down -v를 무심코 치면 데이터 볼륨이 같이 날아간다. 운영 환경에서는 down -v를 금지어로 정해두고, 데이터 볼륨은 외부 마운트(EBS, NFS)로 빼두는 게 안전하다.
volumes:
postgres_data:
driver: local
driver_opts:
type: none
device: /mnt/ebs/pg_data
o: bind
언제 Compose를 떠나야 하나
이 글이 "Compose가 만능"이라는 주장은 아니다. 다음 신호가 보이면 그때 K8s나 ECS로 옮기는 게 맞다.
- 단일 호스트로 트래픽을 못 받아낼 때 (CPU 사용률이 평상시 60% 이상)
- 호스트 장애 시 다운타임이 비즈니스에 치명적일 때 (SLA 99.9% 이상)
- 팀 인원이 5명을 넘어 누군가는 인프라 전담이 가능할 때
- 서비스 메시, 카나리, 트래픽 분할 같은 고급 배포 패턴이 필요할 때
또한, 이 중 두 개 이상에 해당되면 이전 시점이다. 그 전까지는 Compose로 버티면서 다른 데 시간 쓰는 게 ROI가 높다.
지금 당장 해볼 만한 것 세 가지를 적어두면 충분할 것 같다. 첫째, 기존 compose.yml에 healthcheck와 start_period를 넣어 헬스체크부터 정상화하기. 둘째, .env에서 비밀값을 빼서 secrets 블록으로 옮기기. 셋째, 위에 적은 30줄짜리 롤링 배포 스크립트를 한 번 굴려보기. 이 세 가지만 해도 운영 안정성이 눈에 띄게 달라진다.
관련 글
- GitHub Actions 캐시 최적화 완전 가이드: 빌드 시간 절반으로 줄이기 – GitHub Actions 캐시 최적화는 CI 비용을 가장 빠르게 깎는 수단이다. actions/cache 동작 원리부터 Docker la…
- Trivy로 컨테이너 이미지 취약점 스캔을 CI/CD에 붙인 3개월 회고 – 프론트엔드에서 백엔드로 넘어오고 나서 처음 맡은 컨테이너 보안 스캔 자동화. Trivy를 GitHub Actions에 붙이면서 겪은 시행착…
- 쿠버네티스 Ingress Nginx 설정 완전판: TLS·경로 라우팅·Rate Limit·Canary 실전 – 쿠버네티스 Ingress Nginx 설정을 단순 설치만 하고 끝내면 운영 들어가서 반드시 한 번은 터진다. TLS, 라우팅, Rate Li…