Docker Compose의 depends_on이 준비 완료를 뜻하지 않는 이유

Docker Compose의 depends_on이 준비 완료를 뜻하지 않는 이유

한눈에 보기

짧은 형식의 depends_on은 의존 컨테이너를 먼저 시작하지만 그 서비스가 요청을 처리할 준비가 됐다는 뜻은 아니다. 준비 완료를 기다리려면 실제 기능을 검사하는 healthcheckcondition: service_healthy가 필요하다. 그래도 실행 중 의존 서비스 재시작과 네트워크 장애까지 해결되지는 않는다. 애플리케이션은 제한된 backoff로 연결을 재시도하고, 연결이 끊긴 뒤 복구할 수 있어야 한다.

목차

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 failure

API가 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가 될 때까지 기다리지 않는다. 의존성은 종료 순서에도 쓰여 apidb보다 먼저 제거된다.

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를 시작한다. 하지만 다음은 별도 문제다.

따라서 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이 가져야 할 최소 능력은 환경과 무관하다.

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이 없는지도 확인한다.

구현 체크리스트

Compose

Application

검증

마무리

depends_on은 유용하지만 짧은 형식이 보장하는 것은 시작과 종료 순서다. Process가 running인 순간과 실제 요청을 처리할 수 있는 ready 상태 사이에는 초기화 시간이 있다.

이 간격을 다루려면 의존 서비스에 의미 있는 healthcheck를 정의하고 condition: service_healthy로 시작 조건을 연결한다. Migration 같은 일회성 작업은 성공 종료라는 별도 조건으로 분리한다.

그러나 healthcheck는 시작 순간의 race만 줄인다. 실행 중 network와 dependency는 언제든 끊길 수 있으므로 application은 오류를 분류하고 backoff와 deadline이 있는 재연결을 수행해야 한다.

결국 안정적인 시작은 Compose 설정 한 줄이 아니라 세 층의 계약이다. Compose는 순서와 초기 조건을 조정하고, healthcheck는 준비 상태를 측정하며, application은 runtime 장애와 복구를 책임진다.

관련 노트

참고 자료