docker compose depends_on은 왜 DB를 안 기다릴까? healthcheck로 connection refused 끝내기
docker compose up 한 줄로 앱과 DB를 같이 띄웠는데, 앱 컨테이너만 connection refused로 죽어버린 경험 있으신가요? "분명히 depends_on: [db] 걸었는데 왜 DB를 안 기다리지?" 이 글은 그 멘탈모델을 바로잡고, Postgres·MySQL·Redis·HTTP별 복붙 가능한 healthcheck + condition 레시피로 문제를 근본부터 끝내드립니다.
"depends_on 걸었는데 왜 죽지?" — 전형적인 실패 시나리오
가장 흔한 형태는 이렇습니다.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
api:
build: .
depends_on:
- db # ← 시작 순서는 보장되지만 "준비 완료"는 아님up 하면 콘솔에 이런 로그가 찍히며 앱이 종료됩니다.
api-1 | could not connect to server: Connection refused
api-1 | Is the server running on host "db" (172.18.0.2) and accepting
api-1 | TCP/IP connections on port 5432?
api-1 | Error: dial tcp 172.18.0.2:5432: connect: connection refused
api-1 exited with code 1db 컨테이너는 분명 떴는데, 그 안의 Postgres 프로세스는 아직 초기화(데이터 디렉터리 생성, WAL 준비)가 안 끝나 5432 포트로 연결을 받지 못하는 상태입니다. 앱은 그 0.5~3초의 공백을 못 견디고 죽는 거죠.
멘탈모델: depends_on은 '시작 순서'만 보장한다
핵심 한 줄입니다. depends_on은 컨테이너 시작(start) 순서만 제어하고, 컨테이너 내부 프로세스의 준비 완료(ready)는 전혀 모릅니다.
[ depends_on의 세계 ]
db 컨테이너 created → started ──┐
├─→ api 컨테이너 started (여기서 끝!)
│
db 내부 Postgres 부팅 중...... ─┘ ← 아직 포트 안 열림 = connection refused즉 "컨테이너가 떴다(started)" ≠ "DB가 연결을 받을 준비가 됐다(healthy)"입니다. 기본 short-syntax depends_on은 started까지만 기다리기 때문에, readiness가 필요한 DB·메시지큐 앞에서는 무용지물입니다. 이걸 해결하려면 (1) 서비스가 "건강한지" 판단하는 healthcheck를 정의하고, (2) depends_on에서 condition: service_healthy로 그 신호를 기다리게 만들어야 합니다.
해결의 핵심 ① — healthcheck 복붙 레시피 4종
healthcheck의 옵션부터 정리합니다.
| 옵션 | 의미 | 권장치(DB 기준) |
|---|---|---|
test | 헬스 판정 명령. 종료코드 0이면 healthy | 서비스별 ping 명령 |
interval | 체크 주기 | 5s |
timeout | 한 번의 체크 제한 시간 | 5s |
retries | 연속 실패 N회면 unhealthy | 5 |
start_period | 부팅 유예 시간(이 안의 실패는 카운트 안 함) | 10s~30s |
팁:
CMD-SHELL은 셸을 통해 실행하므로||, 환경변수 확장이 됩니다.CMD는 셸 없이 exec 합니다. Compose 환경변수와 헷갈리지 않도록 컨테이너 내부에서 평가할 변수는$$로 이스케이프하세요.
Postgres
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
# $$ → compose가 아니라 컨테이너 셸이 변수를 확장하게
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10sMySQL
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: appdb
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s # MySQL은 초기화가 길어 넉넉히Redis
redis:
image: redis:7
healthcheck:
# PONG이 오면 정상
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5HTTP 앱(자체 /health 엔드포인트)
web:
build: .
healthcheck:
# curl 없는 alpine 이미지면 wget -qO- 사용
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s해결의 핵심 ② — depends_on long-syntax + condition
healthcheck를 정의했다면, 이제 의존하는 쪽에서 long-syntax로 조건을 명시합니다.
services:
api:
build: .
depends_on:
db:
condition: service_healthy # db가 healthy 될 때까지 대기
migrate:
condition: service_completed_successfully # 마이그레이션 잡이 0으로 끝날 때까지
redis:
condition: service_started # 그냥 떴으면 OK세 조건의 차이입니다.
| condition | 기다리는 대상 | 쓰는 곳 |
|---|---|---|
service_started | 컨테이너 start | readiness가 필요 없는 보조 서비스 |
service_healthy | healthcheck가 healthy | DB·큐 등 연결 대상 |
service_completed_successfully | 종료코드 0으로 완료 | DB 마이그레이션/시드 잡 |
버전 주의점: condition 문법은 Compose Spec 표준에 정식 포함됐고 Docker Compose v2(docker compose 플러그인)에서 정상 동작합니다. 과거 version: "3.x" 스키마의 short-syntax(depends_on: [db])에서는 condition이 무시됐었죠. 요즘은 version 키 자체가 deprecated이니 빼고, 최신 docker compose로 통일하세요.
healthcheck를 못 쓰는 경우 — 대안 비교
이미지에 ping 도구가 없거나 healthcheck를 못 넣는 상황도 있습니다. 그럴 때의 선택지입니다.
| 방법 | 동작 | 장점 | 단점 / 권장 상황 |
|---|---|---|---|
wait-for-it.sh | TCP 포트 열림까지 폴링 후 명령 실행 | 의존성 없음, 간단 | 포트만 보고 "쿼리 준비"는 모름. 가벼운 대기에 |
dockerize -wait | 포트/HTTP 대기 + 템플릿 | TCP·HTTP 다 지원 | 바이너리 추가 필요. 다중 의존 대기에 |
| 앱 레벨 재시도(백오프) | 앱이 직접 재연결 | 인프라 독립적, 가장 견고 | 코드 작성 필요. 베스트 프랙티스 |
앱 레벨 백오프 예시(Node.js):
async function connectWithRetry(retries = 10, delay = 1000) {
for (let i = 0; i < retries; i++) {
try { return await db.connect(); }
catch (e) {
console.warn(`DB 연결 실패, 재시도 ${i + 1}/${retries}`);
await new Promise(r => setTimeout(r, delay * 2 ** i)); // 지수 백오프
}
}
throw new Error("DB 연결 최종 실패");
}실무 경험상 healthcheck + condition으로 부팅 타이밍 문제는 90% 사라지지만, 운영 중 DB가 잠깐 끊겼다 복구되는 상황(롤링 업데이트, 네트워크 블립)은 healthcheck로 못 막습니다. 그래서 12-factor·클라우드네이티브 관점에서 **healthcheck는 "권장 가속기", 앱 레벨 재시도가 "필수 안전망"**이라고 봅니다. 둘은 경쟁이 아니라 보완 관계예요.
흔한 실수 FAQ
자주 묻는 질문 (FAQ)
Q. condition: service_healthy를 걸었는데도 unhealthy로 빠지면서 앱이 안 떠요.
A. 십중팔구 start_period가 너무 짧습니다. MySQL처럼 초기화가 긴 이미지는 start_period: 30s 이상으로 늘리세요. 이 기간 내 실패는 retries 카운트에 포함되지 않아 부팅 중 일시 실패를 무시해 줍니다.
Q. healthcheck test가 계속 실패해요. 명령은 맞는 것 같은데요.
A. (1) 셸 문법(||, 변수)을 쓰면서 CMD를 쓰면 실패합니다 → CMD-SHELL로 바꾸세요. (2) Compose 변수를 $로 쓰면 compose가 먼저 치환합니다 → 컨테이너에서 평가할 변수는 $$로 이스케이프하세요. (3) alpine 이미지엔 curl이 없을 수 있으니 wget -qO-로 대체하세요.
Q. db는 healthy인데 앱이 여전히 가끔 죽어요.
A. healthcheck는 부팅 타이밍만 해결합니다. 운영 중 순간 단절은 앱 레벨 재시도(지수 백오프)로 막아야 합니다. 그리고 restart: unless-stopped처럼 재시작 정책을 함께 두되, healthcheck가 unhealthy인데 무한 재시작으로 로그가 쌓이지 않는지 확인하세요.
결론 — 실전 체크리스트와 최종 템플릿
- 시작 순서(started) ≠ 준비 완료(healthy)임을 기억한다.
- DB·큐에 healthcheck를 정의한다.
- 의존 서비스에서
condition: service_healthy로 연결한다. - 그래도 앱 레벨 재시도는 깔아둔다(운영 안전망).
바로 복붙해서 쓰는 최종 compose.yml:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
migrate:
build: .
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
api:
build: .
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped이제 connection refused로 새벽에 깨는 일은 끝입니다. depends_on에 condition을 붙이는 순간, "왜 안 기다리지?"가 "알아서 기다리네"로 바뀝니다.
이 글은 AI 에이전트가 1차 초안을 작성한 뒤, 사람 편집자가 사실관계·출처·톤과 맥락을 검토하여 발행했습니다. 오류나 부정확한 내용이 확인되면 24시간 이내에 정정합니다.
댓글
불러오는 중...