도커 중급 강좌 #4 compose 심화 — depends_on, healthcheck, profiles

7 분 소요

#3 까지 web + db를 한 파일에 넣고 실행했습니다. 이번 글은 그 위에 운영 감각을 더하는 단계입니다. healthcheck, depends_on의 condition, profiles, override 파일까지 정리합니다.

도커 중급 강좌 시리즈에서 이번 글의 위치:

depends_on만으로는 부족한 경우 #

기초 예제에서 본 단순한 형태:

단순 depends_on
services:
  web:
    depends_on:
      - pg
  pg:
    image: postgres:16

이 정의는 pg 컨테이너가 시작된 다음에 web을 시작 한다는 의미입니다. 그 이상은 아닙니다. pg가 시작됐다고 해서 PostgreSQL 데몬이 listen을 시작한 건 아닙니다. 첫 부팅에는 데이터 디렉터리 초기화에 수 초가 걸리는데, 그동안 web이 먼저 떠서 connection을 시도하면 — connection refused로 죽어버립니다.

이걸 한 줄로 정리하면: 컨테이너 시작 ≠ 서비스 준비.

이 간격을 메우는 도구가 healthcheck 입니다.

healthcheck — 정말 준비되었는가 #

healthcheck는 컨테이너 안에서 주기적으로 명령을 돌려, 컨테이너가 healthy 한지 unhealthy 한지를 도커가 판단할 수 있게 합니다.

postgres healthcheck
services:
  pg:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

각 필드의 의미:

필드의미
test어떤 명령으로 검사할지
interval검사 주기
timeout검사 명령의 타임아웃
retries몇 번 연속 실패해야 unhealthy로 보는지
start_period부팅 그레이스 — 이 시간 동안의 실패는 retries에 안 셈

test의 형식은 두 가지:

test 형태들
test: ["CMD", "pg_isready", "-U", "app"]      # exec form (직접 실행)
test: ["CMD-SHELL", "pg_isready -U app"]      # shell 통해 실행 (파이프, $ 변수 가능)
test: ["NONE"]                                # healthcheck 비활성화

CMD-SHELL이 더 유연해서 자주 씁니다.

자주 쓰는 healthcheck 패턴 #

다양한 서비스의 healthcheck
# Postgres
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]

# MySQL
healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]

# Redis
healthcheck:
  test: ["CMD", "redis-cli", "ping"]

# 일반 HTTP 서비스 (curl이 있을 때)
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]

# wget이 있는 alpine 베이스
healthcheck:
  test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8000/health"]

Postgres healthcheck의 $$ 표기 주의: compose.yaml의 $VAR는 호스트의 환경변수로 치환됩니다. 컨테이너 안의 환경변수를 그대로 두려면 $$로 이스케이프 해야 합니다. 위 예에서 $$POSTGRES_USER는 컨테이너 안의 $POSTGRES_USER가 됩니다.

상태 보기 #

healthcheck 상태 확인
docker compose ps
# NAME       STATUS
# myapp-pg-1   Up 12 seconds (healthy)
# myapp-web-1  Up 5 seconds

docker inspect myapp-pg-1 --format '{{json .State.Health}}' | jq
# {
#   "Status": "healthy",
#   "FailingStreak": 0,
#   "Log": [...]
# }

Statusstartinghealthy 또는 unhealthy로 바뀝니다. 컨테이너 안에서 healthcheck 명령이 종료 코드 0을 반환하면 healthy, 0이 아니면 그 카운트가 retries까지 쌓여 unhealthy.

depends_on의 condition — 의미 있는 의존 #

healthcheck가 있으면, depends_on도 더 엄격하게 정의할 수 있습니다.

condition 사용
services:
  web:
    depends_on:
      pg:
        condition: service_healthy
      redis:
        condition: service_started
      migrate:
        condition: service_completed_successfully

  pg:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

  migrate:
    image: myapp:latest
    command: python manage.py migrate
    depends_on:
      pg:
        condition: service_healthy
    restart: "no"

  redis:
    image: redis:7-alpine

세 가지 condition:

condition의미
service_started컨테이너가 시작되면 OK (기본)
service_healthyhealthcheck가 healthy가 되면 OK
service_completed_successfully컨테이너가 종료 코드 0으로 끝나면 OK

위 예에서 web은 — pg가 healthy 하고, migrate가 성공으로 끝났고, redis가 시작되었을 때만 뜹니다. 첫 부팅의 마이그레이션 → 앱 시작 흐름이 자연스럽게 정리됩니다.

service_completed_successfully 패턴 #

마이그레이션 / 시드 / 정적 파일 빌드 같은 일회성 컨테이너를 정의하는 패턴이 자주 쓰입니다.

일회성 작업 컨테이너
services:
  collectstatic:
    image: myapp:latest
    command: python manage.py collectstatic --noinput
    volumes:
      - static:/app/static
    restart: "no"

  web:
    image: myapp:latest
    depends_on:
      collectstatic:
        condition: service_completed_successfully
    volumes:
      - static:/app/static:ro

collectstatic 컨테이너가 정적 파일을 모은 뒤 종료되고, 그 결과를 named volume으로 web이 읽어 가는 흐름입니다. 운영 / 스테이징 환경에서 자주 보는 패턴.

restart — 죽으면 다시 띄우기 #

운영용 서비스에는 자동 재시작 정책을 붙입니다.

의미
no (기본)재시작 안 함
always항상 — 도커 재시작 시에도
on-failure[:N]종료 코드 0이 아닐 때만, 최대 N 번
unless-stopped사용자가 명시적으로 stop 한 게 아니면 항상
restart 정책
services:
  web:
    image: myapp:latest
    restart: unless-stopped

  migrate:
    image: myapp:latest
    command: migrate
    restart: "no"      # 일회성이라 재시작하면 곤란

운영에선 보통 **unless-stopped**가 안전한 기본입니다. always는 사용자가 의도적으로 멈춰도 다시 떠서 가끔 거슬리고, on-failure는 정상 종료(예: PID 1이 의도적으로 exit) 까지는 살리지 못합니다.

profiles — 한 파일 안에서 환경 분기 #

같은 compose.yaml 인데 dev에선 mailhog를 띄우고, prod에선 안 띄우고 싶다거나, 테스트할 때만 추가 서비스를 켜고 싶을 때 — 옛날엔 파일을 여러 개로 나눠 관리했습니다. 요즘은 **profiles**가 이걸 깔끔하게 풀어줍니다.

profiles 사용
services:
  web:
    image: myapp:latest
    # profile 안 붙은 건 항상 켜짐

  pg:
    image: postgres:16
    # 항상 켜짐

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"
    profiles:
      - dev

  pgadmin:
    image: dpage/pgadmin4
    profiles:
      - dev
      - debug

  load-test:
    image: locust
    profiles:
      - test
프로파일 활성화
docker compose up                        # 기본만 (web, pg)
docker compose --profile dev up          # web, pg, mailhog, pgadmin
docker compose --profile test up         # web, pg, load-test
COMPOSE_PROFILES=dev,debug docker compose up

프로파일이 안 붙은 서비스는 항상 켜지고, 프로파일이 있는 서비스는 그 프로파일이 명시될 때만 켜집니다. 한 서비스에 여러 프로파일을 적으면 OR로 동작합니다.

이 패턴 덕분에:

  • dev 프로파일 — 메일 가짜 SMTP, DB 어드민 UI
  • test 프로파일 — 부하 도구
  • debug 프로파일 — 디버그 프록시, jaeger 같은 traces

같은 compose.yaml 한 파일 안에 모두 들어가고, 평소엔 영향이 없습니다.

Override 파일 — compose.dev.yaml #

profile만으로 풀리지 않는 경우도 있습니다. 예를 들어 dev에선 volumes에 코드를 bind mount 하고, prod에선 mount 하지 않는 경우. 같은 서비스 정의를 환경별로 다르게 가져가야 할 때입니다.

이럴 땐 override 파일을 둡니다.

compose.yaml — 운영 베이스
services:
  web:
    image: ghcr.io/curtis/myapp:1.0
    restart: unless-stopped
    environment:
      DATABASE_URL: ${DATABASE_URL}
compose.dev.yaml — dev 오버라이드
services:
  web:
    build: .
    image: myapp:dev
    volumes:
      - ./:/app
    environment:
      DEBUG: "1"
dev 셋업
docker compose -f compose.yaml -f compose.dev.yaml up

뒤쪽 파일이 앞쪽 파일을 deep merge 하는 식으로 합쳐집니다. 같은 키는 뒤쪽이 이김 (스칼라 / 객체), 리스트는 보통 합쳐짐. 운영용 베이스를 그대로 두고 dev 시점의 차이만 얹는 흐름입니다.

자동 인식 — compose.override.yaml #

파일 이름을 compose.override.yaml (또는 docker-compose.override.yml)로 두면 명시 없이도 자동 인식 됩니다.

자동 합쳐짐
ls
# compose.yaml
# compose.override.yaml

docker compose up
# 두 파일이 자동으로 합쳐짐

로컬 개발 머신마다 다른 설정(예: 호스트 포트 충돌 회피)을 compose.override.yaml에 두고 .gitignore에 추가하는 패턴도 있습니다.

extends — 서비스 정의 재사용 #

비슷한 서비스가 여러 개일 때, 한 정의를 베이스로 두고 나머지가 상속받는 패턴.

extends 패턴
services:
  worker-base:
    image: myapp:latest
    environment:
      QUEUE_URL: redis://redis:6379/0
    depends_on:
      redis:
        condition: service_started

  worker-default:
    extends: worker-base
    command: python -m worker --queue default

  worker-priority:
    extends: worker-base
    command: python -m worker --queue priority
    deploy:
      replicas: 3

여러 worker 컨테이너의 공통 설정을 한 번만 적게 됩니다. 다만 — 같은 효과를 YAML anchor (& / *)로도 낼 수 있고, 도구의 가독성 면에서는 anchor가 더 흔하게 쓰입니다.

YAML anchor — 더 가벼운 재사용 #

anchor / alias
x-worker-base: &worker-base
  image: myapp:latest
  environment:
    QUEUE_URL: redis://redis:6379/0
  depends_on:
    - redis

services:
  worker-default:
    <<: *worker-base
    command: python -m worker --queue default

  worker-priority:
    <<: *worker-base
    command: python -m worker --queue priority

x-로 시작하는 키는 Compose가 무시(extension) 하므로 임시 정의에 적합합니다. &worker-base로 anchor를 정하고, <<: *worker-base로 풀어 쓰면 같은 정의가 여러 서비스에 깔끔히 들어갑니다.

deploy:와 Swarm — 한 단락만 #

deploy: 라는 키를 본 적이 있을 거입니다. 이건 Docker Swarm 모드에서만 의미가 있고, 일반 docker compose up에선 대부분 무시됩니다(replicas: 3 같은 건 무시되고 한 개만 뜸).

쿠버네티스 / 클라우드 네이티브 환경이 일반화되면서 Swarm은 잘 안 쓰입니다. Compose는 로컬 개발 + 작은 단일 호스트 운영 까지가 sweet spot이고, 그 너머는 다른 도구로 이전하는 게 자연스럽습니다.

자주 만나는 함정 #

  • healthcheck 명령에 외부 도구가 없음 — slim/alpine 이미지엔 curl이 없습니다. wget --spider로 대체하거나, 베이스에 apk add curl / apt-get install curl.
  • healthcheck가 너무 빠름interval: 1s 같은 값은 컨테이너에 부담을 줍니다. 보통 5s ~ 30s가 적당합니다.
  • depends_on의 transitive가 안 동작한다고 느낌condition은 직접 의존만 봅니다. 두 칸 건너 의존성은 명시해야 합니다.
  • override 합치기에서 list가 의도와 다르게 합쳐짐 — list는 항상 단순 concat이 아닐 때가 있습니다. 결과가 의심되면 docker compose config로 합쳐진 결과를 출력해 봅니다.
합쳐진 최종 정의 보기
docker compose -f compose.yaml -f compose.dev.yaml config
# 두 파일이 합쳐진 결과를 그대로 출력 — 디버깅에 매우 유용

docker compose config는 합쳐진 형태가 의도와 같은지 확인할 때 자주 쓰는 디버깅 도구입니다.

정리 #

이번 글에서 잡은 그림:

  • healthcheck로 컨테이너의 “준비 여부” 를 도커가 알 수 있게 한다 (pg_isready, redis-cli ping, HTTP /health)
  • **depends_on: condition**으로 의미 있는 시작 순서 — service_healthy, service_completed_successfully
  • **restart: unless-stopped**가 운영용 서비스의 안전한 기본
  • **profiles**로 한 파일 안에서 dev / test / debug 환경을 분기
  • override 파일 (compose.override.yaml, compose.prod.yaml)로 환경별 차이만 얹는다
  • **docker compose config**로 합쳐진 최종 정의를 검증

다음 글(#5 환경변수와 secrets 관리)에서는 비밀 값 — DB 비밀번호, API 키 같은 — 을 이미지에 박지 않고 컨테이너에 주입하는 방법, 그리고 그 값을 .env와 compose의 secrets: / BuildKit 시크릿으로 다루는 패턴을 정리합니다.

X