도커 중급 강좌 #4 compose 심화 — depends_on, healthcheck, profiles
#3 까지 web + db를 한 파일에 넣고 실행했습니다. 이번 글은 그 위에 운영 감각을 더하는 단계입니다. healthcheck, depends_on의 condition, profiles, override 파일까지 정리합니다.
도커 중급 강좌 시리즈에서 이번 글의 위치:
- #1 멀티스테이지 빌드와 이미지 슬리밍
- #2 빌드 캐시 — 레이어 순서 최적화
- #3 docker compose 기초 — web + db
- #4 compose 심화 — depends_on, healthcheck, profiles ← 이번 글
- #5 환경변수와 secrets 관리
- #6 로깅과 디버깅
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 한지를 도커가 판단할 수 있게 합니다.
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: ["CMD", "pg_isready", "-U", "app"] # exec form (직접 실행)
test: ["CMD-SHELL", "pg_isready -U app"] # shell 통해 실행 (파이프, $ 변수 가능)
test: ["NONE"] # healthcheck 비활성화CMD-SHELL이 더 유연해서 자주 씁니다.
자주 쓰는 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가 됩니다.
상태 보기 #
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": [...]
# }Status는 starting → healthy 또는 unhealthy로 바뀝니다. 컨테이너 안에서 healthcheck 명령이 종료 코드 0을 반환하면 healthy, 0이 아니면 그 카운트가 retries까지 쌓여 unhealthy.
depends_on의 condition — 의미 있는 의존
#
healthcheck가 있으면, depends_on도 더 엄격하게 정의할 수 있습니다.
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_healthy | healthcheck가 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:rocollectstatic 컨테이너가 정적 파일을 모은 뒤 종료되고, 그 결과를 named volume으로 web이 읽어 가는 흐름입니다. 운영 / 스테이징 환경에서 자주 보는 패턴.
restart — 죽으면 다시 띄우기
#
운영용 서비스에는 자동 재시작 정책을 붙입니다.
| 값 | 의미 |
|---|---|
no (기본) | 재시작 안 함 |
always | 항상 — 도커 재시작 시에도 |
on-failure[:N] | 종료 코드 0이 아닐 때만, 최대 N 번 |
unless-stopped | 사용자가 명시적으로 stop 한 게 아니면 항상 |
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**가 이걸 깔끔하게 풀어줍니다.
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:
- testdocker 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 어드민 UItest프로파일 — 부하 도구debug프로파일 — 디버그 프록시, jaeger 같은 traces
같은 compose.yaml 한 파일 안에 모두 들어가고, 평소엔 영향이 없습니다.
Override 파일 — compose.dev.yaml
#
profile만으로 풀리지 않는 경우도 있습니다. 예를 들어 dev에선 volumes에 코드를 bind mount 하고, prod에선 mount 하지 않는 경우. 같은 서비스 정의를 환경별로 다르게 가져가야 할 때입니다.
이럴 땐 override 파일을 둡니다.
services:
web:
image: ghcr.io/curtis/myapp:1.0
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}services:
web:
build: .
image: myapp:dev
volumes:
- ./:/app
environment:
DEBUG: "1"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 — 서비스 정의 재사용
#
비슷한 서비스가 여러 개일 때, 한 정의를 베이스로 두고 나머지가 상속받는 패턴.
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 — 더 가벼운 재사용 #
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 priorityx-로 시작하는 키는 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 시크릿으로 다루는 패턴을 정리합니다.