Docker Compose healthcheck 설정 및 활용 방법
Docker Compose healthcheck의 동작 원리와 depends_on의 한계를 설명합니다. Postgres와 애플리케이션을 위한 올바른 readiness check 작성법과 interval, retries, start_period 설정 최적화 가이드를 제공합니다.
Docker Compose healthcheck의 실제 동작 방식
Docker Compose healthcheck는 Docker가 컨테이너 내부에서 타이머에 맞춰 실행하는 하나의 명령입니다. Docker는 로그를 읽거나, 포트를 감시하거나, 프로세스 목록을 검사하지 않습니다. 오직 명령을 실행하고 종료 코드를 읽은 뒤, 컨테이너에 starting, healthy, 또는 unhealthy 중 하나의 상태를 저장할 뿐입니다. 종료 코드 0은 정상(healthy)을 의미합니다. 그 외의 종료 코드는 비정상(unhealthy)을 의미하며, 종료 코드 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가 없는 슬림 이미지는 애플리케이션 로그에 나타나지 않는 이유로 인해 영구적으로 비정상 상태가 될 수 있습니다. 다음 명령으로 직접 테스트해 보십시오.
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초 후에 중단되는 롤아웃 설정으로는 이 컨테이너가 최종 상태에 도달하는 것을 확인할 수 없기 때문입니다.
여기서 흔히 하는 실수는 느린 시작을 보완하기 위해 retries를 높이는 것입니다. 이는 한 번은 효과가 있을지 몰라도 장기적으로는 문제가 됩니다. 부팅에 8번의 재시도가 필요했던 서비스가 이제는 운영 환경에서 8번의 연속 실패가 발생할 때까지 아무런 조치도 취해지지 않게 되기 때문입니다. 첫 성공 이전 단계에만 적용되는 start_period을 대신 사용하십시오.
depends_on만으로는 아무것도 보장되지 않는 이유
depends_on의 단축형은 혼란을 야기하는 주된 원인입니다.
api:
depends_on:
- db이 설정은 단 한 가지, 즉 api 컨테이너를 시작하기 전에 db 컨테이너를 먼저 시작한다는 의미입니다. Compose는 컨테이너가 생성되고 시작될 때까지 기다릴 뿐입니다. PostgreSQL이 최초 초기화를 완료할 때까지 기다리지 않으며, 5432 포트가 연결을 수락할 준비가 될 때까지도 기다리지 않습니다. 애플리케이션은 약 1초 뒤에 시작되어 아직 아무것도 수신 대기하지 않는 포트에 연결을 시도하다가 종료됩니다. 로그에는 Connection refused가 나타나거나, 서버가 실행 중이지만 여전히 복구 중인 경우 FATAL: the database system is starting up가 표시됩니다.
사람들이 실제로 원하는 것은 긴 형식의 설정입니다.
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition에는 세 가지 값이 있습니다. service_started은 단축형과 동일합니다. service_healthy은 의존 대상 서비스가 정상(healthy) 상태라고 보고할 때까지 해당 서비스를 대기시킵니다. 이는 의존 대상 서비스가 compose 파일이나 이미지 내에 healthcheck를 정의했을 때만 의미가 있습니다. service_completed_successfully은 데이터베이스 마이그레이션과 같은 일회성 컨테이너가 상태 코드 0으로 종료될 때까지 기다립니다.
condition 옆에는 두 개의 추가 필드가 있습니다. restart: true는 의존 대상 서비스가 업데이트된 후 해당 서비스를 재시작하도록 Compose에 지시합니다. required: false은 누락된 의존성을 오류가 아닌 경고 수준으로 낮춥니다.
이제 사용자들이 자주 겪는 한계점에 대해 설명하겠습니다. 이러한 조건들은 스택이 올라올 때 평가됩니다. 이는 시작 순서를 제어하는 것이지, 상시 감시 규칙이 아닙니다. 만약 새벽 3시에 데이터베이스가 재시작된다면, 그 누구도 service_healthy를 재평가하지 않으며 이를 충족하기 위해 애플리케이션을 재시작하지도 않습니다. 애플리케이션 코드는 여전히 스스로 재연결을 시도해야 합니다. docker compose up --no-deps api는 설계상 이 메커니즘 전체를 건너뛰며, docker start으로 컨테이너를 직접 시작하는 경우도 마찬가지입니다.
프로세스 존재 여부가 아닌 서비스 준비 상태를 확인하는 검사 작성
pgrep nginx와 같은 검사는 프로세스 테이블에 항목이 존재하는지만 증명할 뿐입니다. 해당 서비스가 요청을 처리할 수 있는지에 대해서는 아무것도 보장하지 않습니다. 웹 애플리케이션은 데이터베이스 풀이 죽은 후에도 리스닝 소켓을 계속 열어둘 수 있으며, 이 경우 프로세스 검사는 장애가 발생한 동안에도 계속 정상(green)으로 표시됩니다.
컨테이너가 수행해야 할 본연의 작업을 직접 요청하십시오.
- HTTP 서비스의 경우, 실제 엔드포인트를 요청하십시오.
curl -fsS은-f옵션으로 인해 상태 코드가 400 이상일 경우 0이 아닌 값을 반환하므로, 고장 난 애플리케이션이 반환하는 500 오류는 실패한 검사로 처리됩니다. - PostgreSQL의 경우, 서버가 연결을 수락하면 0, 거부하면 1, 응답이 없으면 2, 전달된 매개변수가 잘못되었으면 3을 반환하는
pg_isready을 사용하십시오. - Redis의 경우,
PONG를 출력하고 0을 반환하는redis-cli ping을 사용하십시오. - MariaDB의 경우, 공식 이미지에서
healthcheck.sh스크립트를 제공하며, 유지보수 담당자가 문서화한 형식은healthcheck.sh --connect --innodb_initialized입니다.
pg_isready에는 알아두어야 할 함정이 하나 있습니다. 데이터 디렉터리가 비어 있는 상태에서 처음 시작할 때, 공식 postgres 이미지는 Unix 소켓에서만 수신 대기하는 임시 서버를 대상으로 초기화를 수행합니다. 호스트 인자 없이 실행된 pg_isready은 해당 소켓을 사용하므로, TCP 포트 5432가 애플리케이션에 닫혀 있는 동안에도 "연결 수락 중"이라고 응답할 수 있습니다. 검사 대상에 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달러 기호가 두 번 쓰인 것은 오타가 아닙니다. Compose는 파일을 읽는 동안 $VAR을 자체적으로 확장하므로, 호스트 환경의 값이 검사 항목에 고정될 수 있습니다. $$를 사용하면 이를 단일 $으로 이스케이프하여, 컨테이너 내부의 셸이 컨테이너 자신의 환경 변수를 기준으로 확장하도록 만들 수 있습니다.
올바른 순서로 시작하는 PostgreSQL 및 애플리케이션 스택
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)로 표시되며, Compose가 아직 컨테이너를 생성하지 않았으므로 api는 목록에서 보이지 않습니다.
상태 확인이 성공했거나 실패한 이유를 확인하려면 상태 로그를 읽어보십시오:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker는 마지막 몇 개의 결과와 함께 시작 시간, 종료 시간, ExitCode, 그리고 명령의 Output을 저장합니다. 저장된 출력은 잘려서 표시되므로, 긴 페이지 본문을 출력하는 확인 명령은 유용한 로그를 제공하지 못합니다. 상태 확인 명령은 간결하게 작성하십시오.
컨테이너가 비정상(unhealthy) 상태가 되었을 때 Docker가 수행하는 작업
아무 작업도 수행하지 않습니다. 이는 사람들을 가장 놀라게 하는 답변입니다.
단일 호스트에서 실행되는 Docker Engine은 비정상 상태인 컨테이너를 재시작하지 않습니다. restart: unless-stopped 정책은 메인 프로세스가 종료될 때 반응하는데, 비정상 상태의 컨테이너는 아직 종료되지 않았기 때문입니다. Compose는 해당 컨테이너를 그대로 두며, 컨테이너는 일주일 내내 unhealthy 상태로 머물 수 있습니다. Swarm 모드는 비정상 태스크를 교체하지만, 단일 서버의 일반적인 Compose 스택은 그렇지 않습니다.
따라서 두 가지 현실적인 선택지가 있습니다. 프로세스가 스스로 고장 났음을 인지했을 때 종료되도록 만들어 재시작 정책이 작동하게 하는 방법입니다. 또는 외부에서 상태를 감시하다가 알림을 받는 방법입니다. Uptime Kuma 모니터를 헬스체크가 호출하는 동일한 엔드포인트로 지정하면, 의존성 문제가 발생했을 때 양쪽 모두에서 이를 확인할 수 있으며 사용자보다 먼저 모니터를 통해 문제를 인지할 수 있습니다. Traefik 리버스 프록시를 통해 애플리케이션으로 트래픽이 전달되는 경우, 프록시가 인식하는 백엔드 상태는 Docker의 헬스 상태와는 별개이므로 한쪽이 다른 쪽을 대신하지 않는다는 점을 유의해야 합니다.
상태가 정상으로 돌아오지 않는 헬스체크 디버깅
동일한 컨테이너 내부에서 정확한 명령어를 직접 실행하고 종료 코드를 확인하십시오.
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"컨테이너는 여전히 비정상 상태를 보고하는데 exit=0가 정상으로 나온다면, 작성한 compose test가 방금 입력한 명령어와 다르다는 의미입니다. 이는 보통 셸 문법이 필요한 곳에 CMD를 사용했기 때문입니다.
대부분의 문제는 두 가지 실수에서 비롯됩니다. 첫 번째는 잘못된 포트입니다. 헬스체크는 컨테이너 내부에서 실행되므로, 호스트에 공개된 포트가 아닌 컨테이너 내부 포트를 사용해야 합니다. ports: - "8080:3000" 설정에서 애플리케이션이 3000번 포트에서 대기 중일 때, http://localhost:8080를 대상으로 체크하면 브라우저에서는 사이트가 잘 작동하더라도 헬스체크는 계속 실패합니다. 두 번째는 잘못된 호스트입니다. 체크 내부에서 localhost는 자기 자신인 해당 컨테이너를 의미합니다. 이는 자기 자신을 검사할 때는 맞지만, 다른 컨테이너를 검사할 때는 틀린 설정입니다. 다른 컨테이너를 검사하려면 db과 같이 서비스 이름을 사용해야 합니다.
마지막으로 한 가지 더 고려할 점은 헬스체크는 통과하는데 사용자는 오류를 겪는 경우입니다. 이는 엔드포인트가 실제 동작을 수행하지 않고 단순히 200 상태 코드만 반환할 때 발생합니다. 데이터베이스를 조회하지 않는 준비 상태(readiness) 엔드포인트는 데이터베이스 연결이 끊겼음을 알려줄 수 없습니다. 가벼운 실제 쿼리를 하나 실행하도록 구성하십시오.
FAQ
depends_on이 데이터베이스가 정상이라고 하는데 왜 애플리케이션 연결이 실패합니까?
condition: service_healthy은 스택이 시작될 때 단 한 번만 평가되기 때문입니다. 그 이후에는 아무것도 감시하지 않습니다. 나중에 데이터베이스 컨테이너가 재시작되더라도 Compose는 조건을 다시 충족하기 위해 애플리케이션을 재시작하지 않습니다. 따라서 애플리케이션 코드 자체에 재연결 및 재시도 로직이 있어야 합니다. 또한 docker start이나 docker compose up --no-deps로 단일 컨테이너를 시작할 때는 이 조건이 아무런 역할을 하지 않습니다.
이미지가 이미 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은 그렇게 하지 않습니다. 재시작 정책은 프로세스 종료에 반응할 뿐 상태에는 반응하지 않으므로, 비정상 컨테이너는 다른 조치가 취해지기 전까지는 계속 켜진 채로 고장 난 상태를 유지합니다. 프로세스가 실패를 감지하면 스스로 종료되게 만들거나, 상태를 감시하여 알림을 보내는 외부 모니터를 실행하십시오.
start_period는 어느 정도로 길어야 합니까?
측정된 가장 느린 정상적인 첫 시작 시간보다 충분히 길어야 하며, 여유 시간을 더해야 합니다. 데이터베이스의 첫 시작은 이후의 모든 시작보다 훨씬 느리므로, 빈 볼륨을 대상으로 docker compose up을 사용하여 시간을 측정하십시오. start_period가 너무 길면 첫 번째 unhealthy 판정만 지연될 뿐입니다. 재시도 횟수가 너무 높으면 컨테이너 수명 전체에 걸쳐 체크 기능이 약화되는데, 이것이 더 나쁜 결과입니다.