docker compose 서버 배포, Kubernetes 없이도 충분한 이유와 실전 구성

목차

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.ymlhealthcheckstart_period를 넣어 헬스체크부터 정상화하기. 둘째, .env에서 비밀값을 빼서 secrets 블록으로 옮기기. 셋째, 위에 적은 30줄짜리 롤링 배포 스크립트를 한 번 굴려보기. 이 세 가지만 해도 운영 안정성이 눈에 띄게 달라진다.

관련 글