Docker Compose의 depends_on이 준비 완료를 뜻하지 않는 이유
Docker Compose의 depends_on이 준비 완료를 뜻하지 않는 이유
짧은 형식의 depends_on은 의존 컨테이너를 먼저 시작하지만 그 서비스가 요청을 처리할 준비가 됐다는 뜻은 아니다. 준비 완료를 기다리려면 실제 기능을 검사하는 healthcheck와 condition: service_healthy가 필요하다. 그래도 실행 중 의존 서비스 재시작과 네트워크 장애까지 해결되지는 않는다. 애플리케이션은 제한된 backoff로 연결을 재시도하고, 연결이 끊긴 뒤 복구할 수 있어야 한다.
목차
- #Running과 Ready는 다른 상태다
- #짧은 depends_on이 보장하는 범위
- #Healthcheck로 준비 상태 정의하기
- #service_healthy 조건으로 시작을 연결하기
- #좋은 Healthcheck가 확인해야 하는 것
- #start_period와 interval 계산하기
- #초기화 작업은 일회성 Service로 분리하기
- #애플리케이션 재시도가 여전히 필요한 이유
- #재시도에 Backoff와 Deadline 넣기
- #의존 서비스가 실행 중 재시작될 때
- #Healthcheck와 Readiness를 혼동하지 않기
- #Compose 밖의 배포 환경을 가정하지 않기
- #실패 조건을 자동으로 검증하기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Running과 Ready는 다른 상태다
PostgreSQL 컨테이너 process가 시작됐다고 곧바로 SQL을 받을 수 있는 것은 아니다. Data directory 복구, WAL replay, extension load, schema migration이 남아 있을 수 있다.
stateDiagram-v2
[*] --> Created
Created --> Running: process start
Running --> Ready: initialization complete
Ready --> Unhealthy: dependency failure
Unhealthy --> Ready: recovered
Running --> Exited: startup failureAPI가 db process의 시작 직후 접속하면 다음과 같은 race가 생긴다.
sequenceDiagram
participant C as Compose
participant D as Database
participant A as API
C->>D: start container
D-->>C: process running
C->>A: start container
A->>D: connect
D--xA: not ready
D->>D: recovery completes개발자의 빠른 노트북에서는 우연히 DB가 먼저 준비되고, 느린 CI volume이나 복구할 데이터가 있는 환경에서만 실패할 수 있다. 이런 간헐성 때문에 단순 시작 순서가 readiness처럼 보인다.
짧은 depends_on이 보장하는 범위
다음 설정은 db 컨테이너를 api보다 먼저 생성하고 시작하도록 순서를 표현한다.
services:
api:
image: registry.example.invalid/sample-api:development
depends_on:
- db
db:
image: postgres:17-bookworm
짧은 형식은 db가 healthy가 될 때까지 기다리지 않는다. 의존성은 종료 순서에도 쓰여 api가 db보다 먼저 제거된다.
depends_on을 “API는 DB 없이는 영원히 실행될 수 없다”는 runtime supervision 규칙으로 이해해서도 안 된다. 시작 이후 DB가 죽었다고 API가 자동으로 같이 재시작되는 일반 보장은 아니다.
depends_on은 기본적으로 “누구를 먼저 시작할까”에 답한다. “누가 실제 요청을 받을 수 있나”는 healthcheck가, “연결이 끊기면 어떻게 복구하나”는 application이 답해야 한다.
Healthcheck로 준비 상태 정의하기
Database service에 상태 검사 명령을 둔다.
services:
db:
image: postgres:17-bookworm
environment:
POSTGRES_USER: sample_user
POSTGRES_DB: sample_db
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
test:
- CMD-SHELL
- pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
interval: 5s
timeout: 3s
retries: 10
start_period: 20s
secrets:
db_password:
file: ./secrets/db_password.txt
$${POSTGRES_USER}의 이중 달러는 Compose interpolation을 피하고 container 안의 환경 변수를 shell이 해석하게 한다. 실제 secret 파일은 version control에 넣지 않는다.
검사 명령은 image 안에 존재해야 한다. Minimal image에서 host의 curl을 쓸 수 있다고 가정하면 healthcheck가 실행되지 않는다.
상태를 확인할 수 있다.
docker compose ps
docker inspect sample-db-1 \
--format '{{json .State.Health}}'
service_healthy 조건으로 시작을 연결하기
긴 형식의 조건으로 API 생성을 health 상태에 연결한다.
services:
api:
image: registry.example.invalid/sample-api:development
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL_FILE: /run/secrets/database_url
db:
image: postgres:17-bookworm
healthcheck:
test:
- CMD-SHELL
- pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
interval: 5s
timeout: 3s
retries: 10
start_period: 20s
Compose는 db healthcheck가 성공한 뒤 api를 시작한다. 하지만 다음은 별도 문제다.
- DB port는 열렸지만 필요한 schema가 없음
- API 시작 뒤 DB가 재시작함
- healthcheck는 성공하지만 실제 계정 권한이 잘못됨
- API가 의존하는 replica가 아직 따라잡지 못함
- compose file을 해석하는 도구 버전이 조건을 지원하지 않음
따라서 CI에서 docker compose config로 최종 설정을 보고, 팀이 사용하는 Compose 버전에서 조건을 실제 시험한다.
좋은 Healthcheck가 확인해야 하는 것
검사가 너무 얕으면 false positive가, 너무 깊으면 작은 외부 장애가 연쇄적인 unhealthy를 만든다.
| 검사 | 알 수 있는 것 | 놓치는 것 |
|---|---|---|
| process 존재 | process가 살아 있음 | socket 준비, 실제 요청 |
| TCP connect | port가 열림 | 인증, schema, query |
pg_isready |
PostgreSQL connection 수락 | application 계정 권한과 schema |
| 간단한 SQL | 인증과 query | 전체 business 기능 |
| 외부 의존성 전체 | end-to-end 일부 | 연쇄 unhealthy 위험 |
Application이 시작에 반드시 필요한 최소 계약을 검사한다. Schema version이 필수라면 별도의 startup validation으로 실패 원인을 명확히 보여 줄 수 있다.
async function assertDatabaseContract(db) {
const result = await db.query(
"select version from schema_metadata where component = $1",
["sample-api"],
);
if (result.rows[0]?.version !== 12) {
throw new Error("unsupported database schema version");
}
}
예시는 재구성한 가상 코드다. Healthcheck에 매번 무거운 migration query를 실행하기보다는 시작 시 contract validation과 가벼운 지속 healthcheck를 분리한다.
start_period와 interval 계산하기
Healthcheck parameter는 감으로 정하지 않는다.
healthcheck:
test: ["CMD", "service-ready"]
start_period: 30s
start_interval: 2s
interval: 10s
timeout: 3s
retries: 5
| 항목 | 의미 | 너무 작을 때 |
|---|---|---|
start_period |
초기화 실패를 유예하는 구간 | 정상적인 느린 시작도 unhealthy |
start_interval |
시작 구간 검사 간격 | 지원 버전 확인 필요 |
interval |
일반 상태 검사 간격 | 부하와 log 증가 |
timeout |
한 번의 검사 제한 | 느린 정상 응답을 실패 처리 |
retries |
unhealthy 전 연속 실패 | 일시적 지연에 민감 |
Cold start와 recovery p95/p99를 측정한다. start_period가 무한히 길면 실제 시작 실패를 늦게 발견한다. Healthcheck command 자체가 hang하지 않도록 timeout을 둔다.
start_interval 등 일부 속성은 Compose 버전에 따라 지원 범위가 다르다. 배포 대상의 specification과 CLI 버전을 확인한다.
초기화 작업은 일회성 Service로 분리하기
Migration이 성공해야 API를 시작한다면 장기 실행 DB health와 일회성 migration 완료를 구분한다.
services:
db:
image: postgres:17-bookworm
healthcheck:
test: ["CMD", "pg_isready", "-U", "sample_user"]
migrate:
image: registry.example.invalid/sample-api:development
command: ["node", "dist/migrate.js"]
depends_on:
db:
condition: service_healthy
restart: "no"
api:
image: registry.example.invalid/sample-api:development
depends_on:
migrate:
condition: service_completed_successfully
이제 세 상태가 분리된다.
DB connection ready → migration exit 0 → API start
Migration은 여러 실행에도 안전해야 하고, production에서 replica마다 동시에 실행하지 않도록 배포 도구의 단일 job으로 관리할지 결정한다. Compose local 환경에서 잘 작동하는 구성이 cluster 배포 전략을 대신하지 않는다.
애플리케이션 재시도가 여전히 필요한 이유
service_healthy는 첫 시작의 race를 줄인다. API가 실행된 다음 DB가 재시작하거나 network가 잠시 끊기면 application이 처리해야 한다.
sequenceDiagram
participant A as API
participant D as Database
A->>D: query
D--xA: connection reset
A->>A: discard broken connection
A->>A: bounded backoff
A->>D: reconnect
D-->>A: ready무한 재시도는 configuration error를 숨긴다. Authentication failure, 잘못된 database name, unsupported schema는 빠르게 실패해야 한다. Connection refused나 transient network error처럼 복구 가능한 오류만 재시도한다.
재시도에 Backoff와 Deadline 넣기
고정 간격으로 모든 replica가 동시에 재접속하면 복구 중인 DB에 부하를 몰아준다. Exponential backoff와 jitter를 사용한다.
async function connectWithDeadline(connect, options) {
const startedAt = Date.now();
let attempt = 0;
while (Date.now() - startedAt < options.deadlineMs) {
try {
return await connect();
} catch (error) {
if (!isTransientConnectionError(error)) throw error;
const exponential = Math.min(
options.maxDelayMs,
options.baseDelayMs * 2 ** attempt,
);
const jitter = Math.floor(Math.random() * exponential * 0.3);
await delay(exponential + jitter);
attempt += 1;
}
}
throw new Error("database connection deadline exceeded");
}
실제 library의 pool이 자체 reconnect를 제공한다면 중복 retry layer가 latency를 폭증시키지 않는지 확인한다. 한 요청 내부 재시도와 process startup 재시도의 예산도 분리한다.
Connection이 끊긴 시점에 server가 transaction을 commit했는지 client가 모를 수 있다. 쓰기를 무조건 재시도하지 말고 transaction과 idempotency key를 사용한다.
의존 서비스가 실행 중 재시작될 때
Compose 긴 형식의 restart: true는 명시적인 Compose 작업으로 dependency를 update/restart할 때 dependent를 다시 시작하도록 할 수 있다.
depends_on:
db:
condition: service_healthy
restart: true
Docker 공식 문서가 설명하는 이 동작은 container runtime이 장애로 DB를 자동 재시작한 모든 경우를 의미하지 않는다. 애플리케이션이 stale connection을 폐기하고 새 connection을 얻는 능력은 여전히 필요하다.
API를 함께 재시작하는 것이 항상 좋은 것도 아니다. Connection pool 재연결이 가능하면 API uptime을 유지할 수 있다. 반대로 장기 session이 DB state와 강하게 결합되어 있다면 controlled restart가 단순할 수 있다.
Healthcheck와 Readiness를 혼동하지 않기
Compose health status는 local container orchestration에 쓰인다. Kubernetes의 readiness probe나 외부 load balancer health check와 자동으로 연결되지 않는다.
Application endpoint를 만들 때도 목적을 분리한다.
/live process event loop이 응답하는가
/ready 이 instance가 새 traffic을 받을 수 있는가
/status 운영자가 dependency 상태를 진단할 수 있는가
/live가 DB 일시 장애 때문에 실패하면 모든 API가 함께 재시작되어 DB 복구 부하가 커질 수 있다. /ready는 traffic에서 제외할 수 있지만 process는 살아서 연결을 복구하게 할 수 있다.
민감한 host, credential, stack trace를 public health response에 포함하지 않는다.
Compose 밖의 배포 환경을 가정하지 않기
Compose는 local development와 단일 host 구성에 유용하다. 다른 orchestrator는 depends_on을 해석하지 않는다.
Application이 가져야 할 최소 능력은 환경과 무관하다.
- dependency hostname을 configuration으로 받는다.
- DNS와 connection이 일시 실패할 수 있음을 가정한다.
- bounded startup retry 후 명확한 exit code를 낸다.
- 실행 중 broken connection을 교체한다.
- readiness에 자신의 traffic 수용 상태를 반영한다.
- 종료 signal에 맞춰 connection을 drain한다.
Compose healthcheck는 이 능력 위에 초기 개발 경험을 개선하는 장치로 둔다.
실패 조건을 자동으로 검증하기
docker compose up -d --wait
docker compose ps
docker compose logs --no-color api db
--wait 지원은 Compose 버전에서 확인한다. 다음 scenario를 자동화한다.
| 시나리오 | 기대 결과 |
|---|---|
| 깨끗한 volume의 느린 DB 시작 | API는 healthy 뒤 시작 |
| 잘못된 DB password | retry로 숨기지 않고 명확히 실패 |
| migration 실패 | API가 시작되지 않음 |
| 실행 중 DB restart | API가 재연결하거나 의도대로 재시작 |
| DB 장기 중단 | readiness 실패, 요청은 제한된 오류 |
| DB 복구 | 수동 API 재배포 없이 정상화 |
DB 중단을 재현한다.
docker compose stop db
curl --fail-with-body http://127.0.0.1:8080/ready || true
docker compose start db
단순히 최종 성공만 보지 말고 복구 시간, retry 횟수, error log에 secret이 없는지도 확인한다.
구현 체크리스트
마무리
depends_on은 유용하지만 짧은 형식이 보장하는 것은 시작과 종료 순서다. Process가 running인 순간과 실제 요청을 처리할 수 있는 ready 상태 사이에는 초기화 시간이 있다.
이 간격을 다루려면 의존 서비스에 의미 있는 healthcheck를 정의하고 condition: service_healthy로 시작 조건을 연결한다. Migration 같은 일회성 작업은 성공 종료라는 별도 조건으로 분리한다.
그러나 healthcheck는 시작 순간의 race만 줄인다. 실행 중 network와 dependency는 언제든 끊길 수 있으므로 application은 오류를 분류하고 backoff와 deadline이 있는 재연결을 수행해야 한다.
결국 안정적인 시작은 Compose 설정 한 줄이 아니라 세 층의 계약이다. Compose는 순서와 초기 조건을 조정하고, healthcheck는 준비 상태를 측정하며, application은 runtime 장애와 복구를 책임진다.
관련 노트
- Circuit Breaker로 연쇄 장애 줄이기
- 오프라인 큐에 멱등성이 필요한 이유
- Docker 이미지 레이어와 빌드 캐시 이해하기
- 컨테이너의 PID 1 문제와 시그널 처리
- CI에서 의존성 캐시를 안전하게 사용하는 방법