Docker Compose healthcheck와 depends_on 설정법
Docker Compose healthcheck가 종료 코드로 상태를 판단하는 방식과 depends_on만으로 준비 상태를 보장할 수 없는 이유를 설명합니다. Postgres와 앱에 맞는 readiness 검사 작성법도 확인합니다.
Docker Compose healthcheck가 실제로 수행하는 작업
Docker Compose healthcheck는 Docker가 타이머에 따라 컨테이너 내부에서 실행하는 하나의 명령입니다. Docker는 로그를 읽거나 포트를 모니터링하거나 프로세스 목록을 검사하지 않습니다. 명령을 실행하고 종료 코드를 읽은 다음 컨테이너에 단일 상태를 저장합니다: starting, healthy 또는 unhealthy. 종료 코드 0은 정상 상태를 의미합니다. 그 외의 종료 코드는 비정상 상태를 의미합니다. 종료 코드 2는 Docker가 예약한 값이므로 의도적으로 반환하면 안 됩니다.
이것이 전체 동작 방식입니다. 거의 모든 healthcheck 문제는 같은 원인에서 발생합니다. 작성한 명령이 실제로 확인하려는 내용과 다른 질문에 답하기 때문입니다. 이 가이드에서는 이미 VPS에서 compose 파일을 작성하는 방법을 알고 있다고 가정하고, 스택이 잘못된 순서로 시작하는 지점부터 설명합니다.
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30stest 값은 두 가지 유용한 형식으로 사용할 수 있습니다. CMD으로 시작하는 목록은 셸을 사용하지 않고 명령을 직접 실행하므로 파이프, && 및 변수 확장이 작동하지 않습니다. CMD-SHELL으로 시작하는 목록은 나머지 내용을 하나의 문자열로 전달하여 컨테이너 내부의 /bin/sh -c에서 실행합니다. 검사에 셸 문법이 필요할 때 이 형식을 사용해야 합니다. 일반 문자열은 CMD-SHELL으로 처리됩니다. ["NONE"]만 포함된 목록은 Dockerfile을 통해 이미지에 내장된 healthcheck를 제거합니다.
검사는 컨테이너 내부에서 실행되므로 명령에 지정한 모든 바이너리가 해당 이미지에 존재해야 합니다. 먼저 이를 확인해야 합니다. curl이 없는 slim 이미지에서는 애플리케이션 로그에 전혀 나타나지 않는 이유로 컨테이너가 영구적으로 비정상 상태가 될 수 있습니다. 다음과 같이 직접 테스트합니다.
docker compose exec api curl --version바이너리가 없으면 OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown으로 응답합니다. Alpine 기반 이미지는 일반적으로 대신 BusyBox wget을 포함하므로 검사는 ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"]이 됩니다.
interval, retries 및 start_period의 결합 방식
5개의 설정이 타이밍을 제어합니다. 기본값은 Compose가 아니라 Docker Engine에서 제공됩니다.
interval: 컨테이너가 start period를 지난 후 두 검사 사이의 시간입니다. 기본값은 30s입니다.timeout: Docker가 해당 실행을 중지하고 실패로 처리하기 전까지 검사 1회에 허용되는 시간입니다. 기본값은 30s입니다.retries: 상태가unhealthy으로 변경되기 전에 필요한 연속 실패 횟수입니다. 기본값은 3입니다.start_period: 컨테이너가 시작된 후의 유예 시간입니다. 기본값은 0s입니다.start_interval: start period 동안 검사를 실행하는 간격입니다. 기본값은 5s이며 Docker Engine 25.0 이상이 필요합니다.
중요한 규칙은 다음과 같습니다. start period 동안 실패한 검사는 retries에 포함되지 않으며 컨테이너는 starting 상태를 유지합니다. 검사가 처음 성공하면 컨테이너는 healthy 상태가 되고 start period는 남은 시간이 있더라도 즉시 종료됩니다. 검사 실패 상태로 start period가 끝나면 일반 카운트다운이 시작됩니다. 컨테이너가 unhealthy으로 표시되려면 retries회 연속으로 실패해야 합니다.
따라서 컨테이너가 시작된 후 unhealthy에 도달하기까지 걸리는 최악의 시간은 start_period에 retries과 interval의 곱을 더한 뒤 timeout을 더한 값입니다. 위 파일의 값으로 계산하면 30 더하기 5 곱하기 13이며, 결과는 95초입니다. deploy timeout을 설정하기 전에 이 숫자를 기록해 두어야 합니다. 60초 후 포기하는 rollout은 이 컨테이너가 최종 상태에 도달하는 것을 확인할 수 없기 때문입니다.
여기서 흔히 발생하는 실수는 느린 시작 시간을 감당하기 위해 retries을 늘리는 것입니다. 이 방법은 한 번은 작동하지만 이후에는 계속 문제를 일으킵니다. 부팅에 8회의 재시도가 필요했던 서비스가 이제 운영 환경에서 문제가 감지되기 전에 8회의 연속 실패를 허용하게 됩니다. 대신 start_period을 사용해야 합니다. start_period은 첫 성공 이전에만 적용됩니다.
depends_on만으로는 아무것도 보장되지 않는 이유
depends_on의 짧은 형식이 대부분의 혼동을 일으킵니다.
api:
depends_on:
- db이는 한 가지를 의미합니다. db 컨테이너를 api 컨테이너보다 먼저 시작합니다. Compose는 컨테이너가 생성되고 시작될 때까지 기다립니다. PostgreSQL의 최초 초기화가 완료될 때까지 기다리지 않습니다. port 5432가 연결을 수락할 때까지도 기다리지 않습니다. 애플리케이션은 약 1초 후에 시작되고, 아직 아무것도 listening하지 않는 port에 연결을 시도한 뒤 종료됩니다. 로그에는 Connection refused가 표시됩니다. 서버가 실행 중이지만 아직 복구 중이면 FATAL: the database system is starting up가 표시됩니다.
실제로 필요한 것은 긴 형식입니다.
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition에는 3개의 값이 있습니다. service_started은 짧은 형식과 같습니다. service_healthy은 dependency가 healthy 상태를 보고할 때까지 dependent service의 시작을 보류합니다. 이 기능은 dependency가 compose file 또는 해당 image에서 healthcheck를 정의한 경우에만 의미가 있습니다. service_completed_successfully은 database migration과 같은 one-shot container가 status 0으로 종료될 때까지 기다립니다.
condition 옆에는 2개의 추가 필드가 있습니다. restart: true는 dependency service를 업데이트한 후 이 service를 다시 시작하도록 Compose에 지시합니다. required: false는 dependency가 없을 때 오류 대신 경고로 처리합니다.
이제 많은 사용자가 놓치는 제한 사항을 살펴보겠습니다. 이러한 조건은 stack이 시작될 때 평가됩니다. 이는 시작 순서를 제어하는 규칙이지 supervision 규칙이 아닙니다. database가 새벽 3시에 다시 시작되어도 service_healthy를 다시 평가하지 않으며, 이 조건을 다시 충족하기 위해 애플리케이션을 다시 시작하지도 않습니다. 애플리케이션 코드는 자체적으로 다시 연결해야 합니다. docker compose up --no-deps api는 설계상 전체 메커니즘을 건너뜁니다. docker start로 container를 직접 시작하는 경우도 마찬가지입니다.
프로세스 존재 여부가 아니라 준비 상태를 확인하는 검사를 작성합니다
pgrep nginx과 같은 검사는 프로세스 테이블 항목이 존재한다는 사실만 확인합니다. 서비스가 요청에 응답할 수 있는지는 확인하지 않습니다. 웹 애플리케이션은 데이터베이스 풀이 중단된 후에도 수신 대기 소켓을 오랫동안 열어 둘 수 있습니다. 그러면 장애가 지속되는 동안에도 프로세스 검사는 계속 정상으로 표시됩니다.
컨테이너가 존재하는 목적에 맞는 작업을 수행하도록 합니다.
- HTTP 서비스의 경우 실제 엔드포인트를 요청합니다.
curl -fsS은-f때문에 400 이상인 모든 상태 코드에서 0이 아닌 상태로 종료합니다. 따라서 문제가 있는 애플리케이션이 반환하는 500도 검사 실패로 처리됩니다. - PostgreSQL의 경우
pg_isready을 사용합니다. 서버가 연결을 수락하면 0, 연결을 거부하면 1, 전혀 응답하지 않으면 2, 전달한 매개변수가 잘못되었으면 3으로 종료합니다. - Redis의 경우
redis-cli ping을 사용합니다.PONG을 출력하고 0으로 종료합니다. - MariaDB의 경우 공식 이미지에
healthcheck.sh스크립트가 포함되어 있으며,healthcheck.sh --connect --innodb_initialized가 유지 관리자가 문서화한 형식입니다.
pg_isready에는 알아 두어야 할 함정이 하나 있습니다. 빈 데이터 디렉터리에서 처음 시작할 때 공식 postgres 이미지는 TCP 포트가 아니라 Unix 소켓에서만 수신 대기하는 임시 서버를 대상으로 초기화를 수행합니다. 호스트 인수 없이 pg_isready을 실행하면 해당 소켓을 사용합니다. 따라서 애플리케이션에서 사용할 TCP 포트 5432가 아직 닫혀 있어도 "연결을 수락함"으로 응답할 수 있습니다. 검사가 TCP를 명시적으로 대상으로 지정하도록 하면 이 문제가 해결됩니다. 임시 서버는 TCP로 응답하지 않기 때문입니다.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s달러 기호를 2개 사용한 것은 오타가 아닙니다. Compose는 파일을 읽는 동안 $VAR 자체를 확장합니다. 그러면 호스트 환경의 값이 검사에 삽입됩니다. $$은 이를 단일 $로 이스케이프합니다. 따라서 컨테이너 내부의 셸이 컨테이너 자체 환경을 기준으로 확장합니다.
올바른 순서로 시작하는 postgres 및 app 스택
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:스택을 시작하고 상태 변화를 확인합니다.
docker compose up -d
docker compose psSTATUS 열에는 대괄호 안에 상태가 표시됩니다. 정상적인 두 서비스의 상태는 두 행 모두 Up 41 seconds (healthy)로 표시됩니다. 데이터베이스가 아직 초기화 중이면 db은 Up 4 seconds (health: starting)로 표시되고, api는 목록에 없습니다. Compose가 아직 생성하지 않았기 때문입니다.
검사가 성공하거나 실패한 이유를 확인하려면 health log를 읽습니다.
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker는 최근 몇 개의 결과를 저장합니다. 각 결과에는 시작 시간, 종료 시간, ExitCode, 그리고 명령의 Output가 포함됩니다. 저장되는 출력은 잘립니다. 따라서 검사에서 큰 페이지 본문을 출력하면 로그 항목을 유용하게 사용할 수 없습니다. 검사 출력은 간결하게 유지합니다.
컨테이너가 비정상 상태가 될 때 Docker가 수행하는 작업
아무 작업도 수행하지 않습니다. 대부분의 사용자가 가장 놀라는 답입니다.
단일 호스트에서 실행되는 Docker Engine은 비정상 상태인 컨테이너를 다시 시작하지 않습니다. restart: unless-stopped 정책은 주 프로세스가 종료될 때 작동하지만, 비정상 상태인 컨테이너는 종료되지 않은 상태입니다. Compose가 그대로 두기 때문에 컨테이너가 unhealthy 상태로 일주일 동안 실행될 수도 있습니다. Swarm mode는 비정상 태스크를 교체하지만, 한 서버에서 실행되는 일반 Compose 스택은 그렇게 하지 않습니다.
따라서 확실한 방법은 2가지입니다. 프로세스가 손상된 상태를 감지하면 종료하도록 만들어 재시작 정책이 작동할 대상을 생성합니다. 또는 외부에서 상태를 모니터링하고 비정상 상태를 알립니다. Uptime Kuma 모니터가 healthcheck와 동일한 엔드포인트를 확인하도록 설정하면, 의존성이 손상된 상태가 두 곳에 모두 표시됩니다. 사용자가 알리기 전에 모니터를 통해 문제를 확인할 수 있습니다. 트래픽이 Traefik reverse proxy를 통해 애플리케이션에 도달한다면, proxy가 백엔드를 판단하는 방식은 Docker health 상태와 별개라는 점을 기억해야 합니다. 따라서 한쪽만으로는 다른 쪽을 대체할 수 없습니다.
끝까지 healthy 상태가 되지 않는 check 디버깅
동일한 container에서 정확히 같은 command를 직접 실행하고 exit code를 확인합니다.
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"container가 계속 unhealthy 상태를 보고하는 동안 exit=0이라면 compose test가 방금 입력한 내용과 다르다는 뜻입니다. 일반적으로 shell 문법이 필요한 위치에 CMD를 사용했기 때문입니다.
나머지 문제의 대부분은 2가지 실수로 발생합니다. 첫 번째는 잘못된 port입니다. healthcheck는 container 내부에서 실행되므로 published host port가 아니라 container port를 사용해야 합니다. ports: - "8080:3000"에서는 application이 3000에서 listening하므로 http://localhost:8080를 확인하는 check는 영원히 실패합니다. 이때도 browser에서는 site가 정상적으로 작동할 수 있습니다. 두 번째는 잘못된 host입니다. check 내부에서 localhost는 동일한 container를 의미합니다. 자체 상태를 확인할 때는 올바르지만, 다른 container를 확인할 때는 잘못된 값입니다. 이 경우에는 service name이 필요하며, 예를 들면 db입니다.
마지막으로 별도로 구분해야 할 경우가 있습니다. healthcheck는 통과하지만 사용자는 오류를 보는 경우입니다. 이는 endpoint가 실제 작업을 수행하지 않고 정적인 200만 반환할 때 발생합니다. database를 전혀 조회하지 않는 readiness endpoint로는 database가 중단되었는지 확인할 수 없습니다. 실제로 실행되는 간단한 query를 1개 수행하도록 구성합니다.
FAQ
depends_on에서 데이터베이스가 healthy라고 표시되는데도 애플리케이션이 계속 연결에 실패하는 이유는 무엇입니까?
condition: service_healthy은 스택이 시작될 때 한 번 평가되기 때문입니다. 그 이후에는 아무것도 감시하지 않습니다. 나중에 데이터베이스 컨테이너가 재시작되어도 Compose는 조건을 다시 충족하기 위해 애플리케이션을 재시작하지 않습니다. 따라서 애플리케이션 코드에 자체 재연결 및 재시도 로직이 필요합니다. docker start 또는 docker compose up --no-deps로 단일 컨테이너를 시작할 때도 이 조건은 아무런 동작을 하지 않습니다.
이미지에 이미 healthcheck가 정의되어 있다면 별도의 healthcheck가 필요합니까?
일반적으로 필요하지 않습니다. 이를 재정의하는 것은 오히려 후퇴인 경우가 많습니다. 해당 소프트웨어에서 준비 상태가 무엇을 의미하는지 이미지 유지 관리자가 알고 있기 때문입니다. 이미지의 검사가 현재 설정에 맞지 않을 때만 자체 검사를 추가합니다. 예를 들어 이미지의 검사가 변경한 포트를 확인하는 경우입니다. 이미지의 healthcheck를 비활성화하려면 서비스에 test: ["NONE"] 또는 disable: true을 설정합니다.
healthcheck에 curl과 wget 중 무엇을 사용해야 합니까?
이미지에 이미 있는 것을 사용하고, 사용하기 전에 docker compose exec <service> curl --version로 존재 여부를 확인합니다. Debian 기반 이미지에는 둘 다 없는 경우가 많습니다. Alpine 기반 이미지에는 BusyBox wget이 있습니다. 소프트웨어에 pg_isready 또는 redis-cli와 같은 자체 클라이언트가 있는데 healthcheck만 실행하려고 이미지를 추가 패키지로 확장하지 마십시오.
비정상 상태의 컨테이너는 자동으로 재시작됩니까?
단일 호스트에서 Docker Engine이 자동으로 재시작하지는 않습니다. 재시작 정책은 health 상태가 아니라 프로세스 종료에 반응합니다. 따라서 다른 구성 요소가 조치할 때까지 비정상 컨테이너는 실행 상태를 유지하며 계속 고장 난 상태로 남습니다. 장애를 감지하면 프로세스가 종료되도록 구성하거나, 상태를 감시하고 경고를 보내는 외부 모니터를 실행해야 합니다.
start_period는 얼마나 길게 설정해야 합니까?
측정한 정상적인 최초 시작 시간 중 가장 긴 시간에 여유 시간을 더한 길이로 설정합니다. 빈 볼륨을 대상으로 docker compose up을 사용해 시간을 측정해야 합니다. 데이터베이스의 최초 시작은 이후의 모든 시작보다 훨씬 느리기 때문입니다. start period가 너무 길면 최초 unhealthy 판정만 지연됩니다. retries가 너무 높으면 컨테이너 전체 수명 동안 검사의 엄격성이 약해집니다. 이는 더 심각한 장애입니다.