목차
- 시작은 이 에러 로그 한 줄이었다
- depends_on만으로 부족한 이유
- Docker Compose 헬스체크 문법 뜯어보기
- 서비스별 헬스체크 설정 실전 예제
- condition: service_healthy 연동하기
- 자주 놓치는 함정
- 짧게 덧붙이는 디버깅 팁
- 언제 쓰고 언제 피할까
시작은 이 에러 로그 한 줄이었다
api-1 | 2026/04/21 09:42:11 dial tcp 172.18.0.2:5432: connect: connection refused
api-1 | 2026/04/21 09:42:13 dial tcp 172.18.0.2:5432: connect: connection refused
api-1 | 2026/04/21 09:42:16 panic: failed to connect to database after 3 retries
api-1 exited with code 1
postgres-1 | 2026-04-21 09:42:17 UTC LOG: database system is ready to accept connections
즉, Docker Compose 헬스체크 설정을 걸어두지 않으면 개발 환경에서도 이런 로그를 자주 본다. API 컨테이너가 connection refused를 세 번 찍고 죽은 뒤, 그제서야 Postgres가 ready to accept connections를 뱉는 순서가 눈에 들어온다. 컨테이너는 순서대로 기동됐는데 정작 "준비 완료" 타이밍이 어긋난 것이다.
프론트엔드만 만지다가 백엔드로 넘어온 뒤 가장 낯설었던 부분이 바로 이 초기화 경쟁이었다. 브라우저에서는 렌더 트리가 조금 늦게 그려져도 페이지가 죽지는 않는다. 반면 컨테이너 스택은 한 녀석이 1초 먼저 깨면 다른 녀석이 exit 1로 사라진다. depends_on만 써두고 끝났다고 생각했던 첫 주, 개발 DB만 docker compose up -d 해놓고 잠깐 자리 비웠다가 돌아와보니 API 컨테이너 두 개가 재시작 루프를 돌고 있었다. 3시간쯤 헤맨 뒤 알게 된 건 depends_on이 보장하는 건 "시작 순서"지 "가용 상태"가 아니라는 사실이다.
depends_on만으로 부족한 이유
depends_on: [postgres]는 Compose가 postgres 컨테이너를 "먼저 실행"한 뒤 api를 실행하도록 만든다. 끝. Postgres 프로세스가 소켓을 열었는지, WAL 복구가 끝났는지, 초기 스크립트가 다 돌았는지는 확인하지 않는다. 공식 문서에도 depends_on does not wait for services to be "ready"라고 명시되어 있다 (출처: Docker Compose 공식 문서, Services top-level element, 2026-04 기준).
또한, 예전엔 이 간극을 wait-for-it.sh, dockerize, 혹은 앱 쪽에 재시도 루프를 박아 메웠다. 다 유효한 해법이지만 서비스마다 스크립트를 구해 복사하고, Dockerfile에 RUN chmod +x를 또 추가하고, ENTRYPOINT를 바꾸는 식이라 관리 포인트가 늘어난다. Compose v2.1부터는 condition: service_healthy가 생기면서 이 작업을 compose 파일 한 곳에 몰아넣을 수 있게 됐다.
다음 표는 세 가지 대기 방식의 차이를 거칠게 정리한 것이다.
| 방식 | 구현 위치 | 상태 판단 기준 | 팀 공유 난이도 |
|---|---|---|---|
| wait-for-it.sh | 앱 이미지 내부 | TCP 포트 오픈 여부 | 스크립트 배포 필요 |
| 앱 재시도 루프 | 앱 코드 | 쿼리/핑 응답 | 언어마다 재구현 |
| healthcheck + condition | docker-compose.yml | 커스텀 커맨드 결과 | compose 파일 하나로 끝 |
특히, TCP 포트만 확인하는 방식은 "듣고 있지만 아직 쿼리를 못 받는" 상태를 걸러내지 못한다. Postgres 초기화 중에도 5432 포트는 열려 있다 (개인적으로 이 차이를 모르면 한참 헤매게 된다).
Docker Compose 헬스체크 문법 뜯어보기
핵심 필드는 다섯 개다. 전부 외울 필요는 없고, 평소 세팅할 땐 네 개만 쓴다.
services:
postgres:
image: postgres:16.2
environment:
POSTGRES_PASSWORD: dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s # 체크 주기
timeout: 3s # 한 번의 체크가 넘기면 실패로 간주할 한계
retries: 5 # 연속 실패 시 unhealthy로 전환되는 기준
start_period: 10s # 기동 직후 실패를 카운트하지 않는 유예 구간
test 배열의 첫 요소는 CMD 또는 CMD-SHELL이다. CMD는 exec 형태로 바로 실행하고, CMD-SHELL은 /bin/sh -c에 문자열을 넘긴다. 파이프나 &&를 써야 하면 CMD-SHELL을 골라야 한다. 단일 바이너리는 CMD가 낫다.
start_period가 가장 중요한데 초기에 자주 놓치는 옵션이다. Postgres 같은 서비스는 콜드 스타트가 7~12초 걸린다. start_period를 0으로 두면 기동 초반의 실패가 그대로 retries 카운터에 쌓여, 이미지가 완전히 뜨기도 전에 unhealthy로 찍혀버린다. 체감상 로컬 M2 Mac에서 Postgres 16은 10초, Kafka 3.6은 30초, Elasticsearch 8은 60초 정도 잡아야 안정적이었다.
로그로 상태 확인하기
# 특정 컨테이너의 헬스체크 이력 보기
docker inspect --format='{{json .State.Health}}' myproject-postgres-1 | jq
# 간단히 최근 상태만
docker ps --format "table {{.Names}}\t{{.Status}}"
docker inspect 결과에는 최근 5회 체크 로그가 Log 배열에 들어 있다. FailingStreak 값이 retries 기준에 도달하면 컨테이너 상태가 unhealthy로 바뀐다. 이걸 안 보고 이슈를 재현하려 하면 막막해진다.
서비스별 헬스체크 설정 실전 예제
PostgreSQL
pg_isready는 postgres 이미지 안에 포함돼 있어 추가 설치가 필요 없다.
postgres:
image: postgres:16.2
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
그래서, 주의할 점은 -U 플래그를 환경변수와 맞추는 것이다. 기본값인 postgres 유저로 돌리면 DB가 없을 때 FATAL: database "postgres" does not exist 로그가 pg 컨테이너에 계속 찍힌다. 동작은 되지만 로그가 더러워진다.
Redis
redis:
image: redis:7.2-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 2s
retries: 5
즉, Redis는 빠르게 뜨니까 start_period를 생략해도 큰 문제는 없어 보인다. 비밀번호를 쓴다면 redis-cli -a $REDIS_PASSWORD ping으로 바꿔야 한다. 패스워드 인증이 필요한 인스턴스에서 ping만 쏘면 NOAUTH 에러가 떨어지고 healthcheck가 실패로 처리된다.
앱 서버 (FastAPI, Node 등)
앱 쪽은 /health 엔드포인트를 만들어두고 curl 혹은 wget으로 찌르는 방식이 무난하다. alpine 베이스 이미지에는 curl이 없어서 wget을 쓰거나 이미지에 curl을 설치해야 한다.
api:
build: .
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
/health 엔드포인트는 단순히 200만 뱉지 말고 DB 연결, Redis 연결, 외부 의존성 ping까지 돌려본 뒤 상태를 반환하도록 해두면 유용하다. 다만 너무 무거운 체크를 3초마다 돌리면 그 자체가 부하가 되니 적당한 선에서 끊어야 한다.
condition: service_healthy 연동하기
여기가 실제로 "순서 문제"를 푸는 지점이다. depends_on을 키-밸류 매핑으로 바꾸고, 각 의존 서비스마다 condition을 지정한다.
api:
build: .
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migration:
condition: service_completed_successfully
세 가지 condition을 알아두면 충분하다.
service_started: 기본값. 시작하자마자 진행 (기존 depends_on과 같다)service_healthy: 의존 서비스가 healthy일 때까지 대기service_completed_successfully: 의존 서비스가 exit 0으로 끝날 때까지 대기. 일회성 마이그레이션에 쓴다
즉, 마이그레이션 컨테이너를 별도로 띄우고, API는 마이그레이션이 성공 종료한 뒤에만 기동하도록 엮는 패턴이 실무에서 잘 먹힌다. Alembic, Prisma, Flyway 같은 도구를 쓸 때 특히 깔끔해진다.
자주 놓치는 함정
이미지에 체크 도구가 없을 때
distroless나 scratch 기반 이미지는 pg_isready, curl, wget이 아예 없다. 이 경우 healthcheck 명령이 executable file not found로 떨어진다. 해결 방법은 두 가지로 갈린다.
그러나, 하나. 앱 바이너리 자체에 --health 같은 서브커맨드를 만들어 내부에서 DB/Redis 연결을 찔러본 뒤 exit code로 돌려주는 방식. Go 프로젝트에서 많이 쓴다.
한편, 둘. 멀티스테이지 빌드로 최종 이미지에 busybox 정도만 추가. 이미지 크기가 1~2MB 늘지만 wget이 생긴다.
start_period 이후에도 초기화가 끝나지 않는 서비스
그런데, Elasticsearch, Kafka, 대용량 DB 복원이 있는 Postgres는 start_period를 60초로 잡아도 부족할 수 있다. 이럴 땐 start_interval(Compose v2.24+ 추가)로 초기 구간의 체크 주기를 짧게 가져가면 도움이 된다. start_period 안에서는 start_interval 간격으로 찌르다가, 한 번 성공하면 정상 interval로 전환된다.
healthcheck와 재시작 정책은 별개다
물론, 컨테이너가 unhealthy 상태가 됐다고 해서 Compose가 자동으로 재시작해주지는 않는다. restart: unless-stopped나 restart: on-failure를 함께 걸어야 한다. Docker Swarm 쪽은 --update-health 정책이 따로 있지만 로컬 compose에서는 무관하다.
Healthcheck가 자체로 리소스를 먹는다
3초마다 pg_isready를 돌리면 미미하지만 CPU가 올라간다. 실제로 M2 Mac에서 Postgres + Kafka + ES + App 네 개를 동시에 띄워보니 healthcheck 로그만으로 docker stats가 꾸준히 0.3% CPU를 잡고 있더라. 노트북이 발열로 힘들어한다면 interval을 10초로 늘려도 괜찮다.
짧게 덧붙이는 디버깅 팁
docker compose events를 터미널 한쪽에 띄워두면 healthcheck 상태 전이가 실시간으로 찍힌다. health_status: healthy, health_status: unhealthy 이벤트가 뜰 때마다 눈으로 확인할 수 있다. 처음 세팅할 때 이걸 같이 띄워두면 "왜 안 떠?"라며 허공에 대고 화내지 않아도 된다.
언제 쓰고 언제 피할까
판단 기준을 체크리스트로 정리하면 이렇다.
- 스택 안에 DB, 캐시, 큐 같은 상태성 서비스가 하나라도 있다면 healthcheck를 걸어라. 앱 재시도 루프만 믿지 마라.
- 일회성 스크립트 컨테이너(DB 마이그레이션, 시드 데이터 주입 등)는 healthcheck 대신
service_completed_successfully를 써라. - 프로덕션에서 Kubernetes로 올릴 계획이라면 livenessProbe/readinessProbe와 같은 헬스 엔드포인트를 공유하도록 설계해라. Compose healthcheck를 K8s probe와 같은 커맨드로 맞춰두면 이관이 부드럽다.
- CI에서 컨테이너 기동 후 테스트를 돌린다면
docker compose up -d --wait가 정답이다. sleep으로 버티는 스크립트는 이제 버려도 된다. - 반대로 단일 서비스 + 외부 관리형 DB 조합이라면 compose 쪽 healthcheck는 굳이 쓸 필요 없다. 앱 쪽 재시도 로직만으로 충분하다.
프론트에서는 거의 접할 일 없던 영역이지만, 한 번 세팅해두고 나면 docker compose up이 성공과 실패를 말끔히 갈라준다. 다음번 스택을 새로 짤 땐 새로운 서비스를 추가할 때마다 healthcheck 블록부터 쓰고 시작할 생각이다.
관련 글
- Prometheus Grafana 알람 Slack 연동 설정 — 웹훅·임계값 튜닝·온콜 라우팅 – Prometheus alerting rules 작성부터 Alertmanager Slack 웹훅 연동, group_wait·repeat_in…
- PostgreSQL 인덱스 최적화 실무 — 인덱스 걸었는데 왜 느린지 모르겠다면 – 인덱스 걸면 빨라진다고들 하는데, 실제로는 그렇지 않은 경우가 많다. EXPLAIN ANALYZE 해석부터 복합 인덱스 컬럼 순서, par…
- 캐시 TTL jitter 설정으로 Cache Stampede 막는 실전 패턴 – TTL 만료 직후 DB에 요청이 몰리는 Thundering Herd를 막는 제일 싼 방법은 jitter 한 줄이다. Redis와 CDN 양…