15장. 살아 있음을 증명하기 — HEALTHCHECK·actuator·로그 드라이버
검사를 하나 잘못 걸었더니 컨테이너가 unhealthy로 바뀌었습니다. 그런데 앱은 그 순간에도 200을 돌려주고 있었어요. 그리고 Docker는 아무 조치도 하지 않았습니다.
{/* 삽화 자리: 문 앞에 검사원이 서서 손목시계를 보며 “응답 없음”이라고 서류에 적는다. 문 안쪽에서는 사람이 멀쩡히 일하며 손님에게 물건을 건네고 있다. 검사원은 안을 보지 않고 서류만 본다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
3부 내내 컨테이너 안을 들여다봤습니다. 이미지로 담고, 종료 신호를 듣게 하고, 메모리와 CPU를 나눠 줬어요.
이제 방향을 바꿉니다. 바깥에서 볼 차례예요.
바깥에서 컨테이너를 보면 보이는 건 둘입니다. 상태 한 줄과 로그 한 뭉치요. 로드밸런서가, 오케스트레이터가, 새벽에 알림을 받은 사람이 보는 게 그 둘이에요.
그런데 기본값으로 나오는 상태는 “프로세스가 도는가” 하나뿐이고, 로그는 아무도 크기를 안 재고 있습니다.
14장이 남긴 질문이 앞의 것입니다. 살아 있는 것과 일을 할 수 있는 건 다른데, 그 차이를 바깥에 어떻게 알리나. 뒤의 것은 이 장에서 처음 꺼냅니다.
스토리
먼저 기본 상태부터 봤습니다.
docker run -d --name ch15a -p 18150:8080 container-demo:exec
docker inspect ch15a --format '{{.State.Status}}'
docker inspect ch15a --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}(없음){{end}}'=== 1. Docker 가 기본으로 아는 것은 '프로세스가 도는가' 뿐이다 ===
docker ps : Up 1 second
Status : running
Health 필드 : (없음)
actuator : {"status":"UP"}✅ 실측 (Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Temurin 21.0.12 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch15/health.sh)
네 줄이 서로 다른 이야기를 합니다.
Status는 running입니다. 5장에서 본 그 프로세스가 살아 있다는 뜻이에요. 컨테이너는 프로세스니까, Docker가 아는 건 PID 1이 종료했느냐 아니냐뿐입니다.
Health 필드는 아예 없습니다. 아무도 안 물어봤으니 답도 없어요.
마지막 줄이 흥미롭습니다. 앱 자신에게 물으면 {"status":"UP"}이라고 답합니다.
spring-boot-starter-actuator를 의존성에 넣으면 앱이 /actuator/health 같은 운영용 엔드포인트를 열어 줍니다. 앱이 스스로 자기를 점검해서 UP/DOWN으로 요약해 주는 거예요.
그러니까 답을 할 줄 아는 쪽은 있는데, 묻는 쪽이 없었던 겁니다.
핵심
묻는 쪽을 만들어 주기
HEALTHCHECK는 “이 명령을 주기적으로 실행해서 성공하면 건강한 걸로 치라”는 지시입니다.
FROM container-demo:exec
HEALTHCHECK --interval=2s --timeout=2s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health | grep -q '"status":"UP"' || exit 1성공 판정은 종료 코드로 합니다. 0이면 건강, 0이 아니면 아픔이에요. 판정에는 출력 내용을 안 씁니다(기록은 남아요, 뒤에서 봅니다). 그래서 grep -q로 걸러 종료 코드를 만들어 냅니다.
붙이고 띄워 봤습니다.
=== 2. HEALTHCHECK 를 붙이면 상태가 하나 더 생긴다 ===
기동 후 0초: Status=running Health=starting
기동 후 4초: Status=running Health=starting
기동 후 8초: Status=running Health=healthy
기동 후 12초: Status=running Health=healthy
docker ps : Up 16 seconds (healthy)
첫 검사 성공까지: 7.2초 (start-period 10초를 다 쓰지 않았다)✅ 실측 (같은 환경, 같은 소스)
Status는 내내 running입니다. 변한 건 Health예요. 상태가 하나 더 생긴 겁니다. docker ps에도 (healthy)로 붙어 나오고요 — 실무에서 이걸 제일 먼저 마주치는 자리입니다.
그리고 그 상태는 셋입니다.
| 값 | 뜻 |
|---|---|
starting | start-period 안이거나, 아직 한 번도 성공하지 못했다 |
healthy | 마지막 검사가 성공했다 |
unhealthy | retries 횟수만큼 연속으로 실패했다 |
starting이 따로 있는 이유가 중요합니다. 스프링 앱은 기동에 몇 초 걸리고, 그동안은 당연히 검사가 실패해요. start-period가 없으면 기동 중에 unhealthy 판정을 받고 맙니다.
start-period는 “이 기간 동안의 실패는 횟수로 치지 않는다”는 뜻입니다. 기다려 주는 시간이에요.
그런데 유예를 다 쓰지는 않습니다. 위 실측의 마지막 줄이 그겁니다 — 컨테이너가 뜬 지 7.2초 만에 첫 검사가 성공했고, 10초를 기다리지 않고 바로 healthy로 넘어갔어요. (4초마다 들여다본 표에서는 8초 줄에 잡혔습니다. 표의 숫자는 전이 시각이 아니라 관찰 시각이에요.)
옵션이 하나 더 있습니다. --start-interval을 주면 유예 기간 동안만 더 짧은 주기로 찔러 봅니다. interval을 길게 잡아야 하는데 기동 확인은 빨리 하고 싶을 때 쓰는 거예요.
무엇을 묻느냐가 판정을 가릅니다
여기서 이 장의 사고가 나옵니다.
검사를 일부러 잘못 걸어 봤어요. 5초 걸리는 엔드포인트를 2초 타임아웃으로 두드리게 했습니다.
HEALTHCHECK --interval=3s --timeout=2s --start-period=10s --retries=2 \
CMD wget -qO- http://localhost:8080/slow >/dev/null || exit 1컨테이너에는 --restart=always까지 걸었습니다. 재시작 정책이 이걸 보고 움직이는지 같이 확인하려고요.
=== 3. 프로세스는 멀쩡한데 검사만 실패하면 ===
기동 후 13초: Status=running Health=starting 실패 1회 앱 응답=200
기동 후 17초: Status=running Health=starting 실패 1회 앱 응답=200
기동 후 21초: Status=running Health=unhealthy 실패 2회 앱 응답=200
기동 후 25초: Status=running Health=unhealthy 실패 3회 앱 응답=200
기동 후 29초: Status=running Health=unhealthy 실패 4회 앱 응답=200
기동 후 33초: Status=running Health=unhealthy 실패 5회 앱 응답=200
기동 후 41초: Status=running Health=unhealthy 실패 6회 앱 응답=200
재시작 횟수 : 0 (재시작 정책: always)
검사 기록 (Health.Log 의 한 건):
ExitCode=-1 Output='Health check exceeded timeout (2s)'✅ 실측 (같은 환경, 같은 소스)
가운데 두 열을 나란히 보세요. Health=unhealthy인데 앱 응답=200입니다.
그 순간에도 앱은 요청을 받아 정상 응답을 돌려주고 있었어요. 아픈 건 앱이 아니라 검사입니다. 5초 걸리는 일을 2초 안에 끝내라고 했으니 영원히 실패하죠.
unhealthy는 앱의 상태가 아니라 검사의 결과입니다. 검사가 무엇을 재느냐가 판정을 정해요. 무거운 엔드포인트를 짧은 타임아웃으로 두드리면, 멀쩡한 앱이 아프다고 기록됩니다.
반대 실수도 똑같이 흔합니다. 검사를 /처럼 아무것도 안 하는 경로로 걸어 두면, DB 연결이 끊겨도 계속 healthy가 나와요. 그때는 아픈 앱이 멀쩡하다고 기록됩니다.
/actuator/health가 그걸 막아준다고 생각하기 쉬운데, 조건이 붙습니다. DB 끊김을 잡아주는 건 JPA나 JDBC 의존성이 있어서 스프링이 DB 점검기를 자동 등록했을 때예요. 그런 의존성이 없으면 /actuator/health도 UP만 돌려주는 /와 다를 게 없습니다.
그리고 실패 기록도 봐둘 만합니다. 마지막 두 줄의 ExitCode가 -1이에요. 검사 명령이 -1을 돌려준 게 아니라, 명령이 타임아웃에 걸려 Docker가 직접 끊고 Health check exceeded timeout (2s)를 대신 적어둔 겁니다.
그런데 Docker는 아무것도 하지 않습니다
재시작 횟수 : 0 (재시작 정책: always) 줄이 이 장에서 제일 의외인 부분입니다.
retries=2를 넘겨 연속 6회 실패했고 unhealthy로 20초 넘게 있었습니다. 재시작 정책은 always로 걸려 있고요. 그런데 재시작 횟수는 0입니다.
재시작 정책은 종료 코드에 반응하지 건강 상태에 반응하지 않기 때문이에요. 프로세스가 죽지 않았으니 정책이 볼 일이 없는 겁니다. 17장에서 이 정책을 자세히 봅니다.
Docker Engine이 하는 일이 하나는 있습니다. 이벤트를 쏴요.
=== 4. Docker 가 하는 유일한 조치: 이벤트를 쏜다 ===
docker events --filter event=health_status
health_status: unhealthy✅ 실측 (같은 환경, 같은 소스)
기계 한 대로 운영한다면 이 스트림에 무언가를 붙이는 게 답입니다. unhealthy 컨테이너를 자동으로 재시작해 주는 도구들이 전부 이걸로 동작해요.
그 위의 층에서는 오케스트레이터가 봅니다. Docker Swarm 서비스의 태스크가 unhealthy가 되면 태스크를 죽이고 새로 띄워요.
쿠버네티스는 다릅니다. 이미지의 HEALTHCHECK를 아예 보지 않아요. 검사를 Pod 쪽에 따로 적게 하고(livenessProbe), 그걸 자기가 돌려요. HEALTHCHECK가 박힌 이미지를 쿠버네티스에 올리면 그 검사는 실행조차 안 됩니다. 같은 문제를 완전히 다른 자리에서 푸는 거예요(20장).
그러니까 단일 컨테이너에서 HEALTHCHECK는 “알려주는 기능”이지 “고쳐주는 기능”이 아닙니다.
판정은 어디에 쌓이나
검사 결과는 docker inspect에 남습니다.
docker inspect <컨테이너> --format '{{json .State.Health}}'Status, FailingStreak(연속 실패 횟수), 그리고 Log에 최근 검사 5건의 시각·종료 코드·출력이 들어 있습니다. 위 실측의 “실패 N회”가 그 FailingStreak이고, ExitCode=-1 줄이 그 Log에서 꺼낸 겁니다.
unhealthy인데 왜인지 모르겠다면 그 Log를 먼저 보면 됩니다. 검사 명령이 뭘 출력했는지가 거기 있거든요. 타임아웃으로 끊긴 검사면 명령의 출력 대신 Docker가 적어둔 Health check exceeded timeout 문장이 들어 있고요.
바깥에서 보는 두 번째 것
건강 상태 다음으로 바깥에서 보는 게 로그입니다.
컨테이너의 로그는 파일에 쓰는 게 아니라 표준 출력으로 내보내는 게 원칙입니다. 10장에서 본 이유 때문이에요. 컨테이너 안에 쓴 파일은 컨테이너와 함께 사라지니까요.
내보낸 stdout이 어디로 가는지 따라가 봤습니다.
sudo head -c 140 "$(docker inspect ch15a --format '{{.LogPath}}')"=== 5. 로그는 어디로 가나 ===
로그 드라이버: json-file
크기 제한 : {} (비어 있으면 무제한)
로그 파일 : /var/lib/docker/containers/6348fce9.../6348fce9...-json.log
{"log":"\n","stream":"stdout","time":"2026-09-21T01:24:15.373112385Z"}
{"log":" . ____ _ __ _ _\n","stream":"stdout",✅ 실측 (같은 환경, 같은 소스. macOS의 Docker Desktop은 데몬이 VM 안에 있어서 스크립트에서는 nsenter로 한 겹 들어갑니다)
한 줄씩 JSON으로 감싸져 호스트의 파일에 쌓입니다. stream 필드로 stdout과 stderr를 구분해 두고요. docker logs가 읽는 게 바로 이 파일입니다.
기본 드라이버가 json-file인데, 여기에 함정이 하나 있습니다. 크기 제한 기본값이 “제한 없음”이에요. 위 출력의 크기 제한 : {}가 그거예요. 데몬 설정에 따로 적지 않으면 파일이 계속 자랍니다. 오래 도는 컨테이너가 디스크를 채우는 사고가 이래서 나요.
제한은 컨테이너마다 걸 수 있습니다.
docker run --log-opt max-size=10m --log-opt max-file=3 ...=== 6. 제한을 걸면 ===
제한 건 컨테이너: {"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}}✅ 실측 (같은 환경, 같은 소스)
10MB씩 최대 3개, 그러니까 30MB에서 멈춥니다. 매번 붙이는 건 잊기 쉬우니 데몬 설정에 기본값으로 박아 두는 편이 낫습니다.
// /etc/docker/daemon.json — 고친 뒤 sudo systemctl restart docker
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }아예 드라이버를 바꾸는 방법도 있습니다. local 드라이버는 기본값이 20MB씩 5개라 아무것도 설정 안 해도 디스크가 안 차고, 압축도 하고, docker logs도 그대로 됩니다.
syslog·fluentd 같은 원격 드라이버는 성격이 다릅니다. 로그를 바로 바깥으로 보내는 대신 호스트에 파일이 안 남아서 docker logs가 안 되는 경우가 생겨요. 바꾸기 전에 그걸 감수할 수 있는지 확인해야 합니다.
그래서 어떻게 잡나
| 상황 | 하면 좋은 것 |
|---|---|
| 검사 대상 정하기 | 가볍고, 앱의 핵심 의존성을 실제로 건드리는 경로. 스프링이면 /actuator/health (DB 점검기가 등록돼 있는지 확인하고) |
| 타임아웃 | 그 경로 정상 응답 시간의 2~3배. /actuator/health가 50ms면 3초쯤. 짧으면 멀쩡한 앱이 아프다고 찍힌다 |
| 기동 시간 | start-period를 앱 기동 시간보다 길게. 없으면 기동 중에 unhealthy가 된다 |
interval은 긴데 기동은 빨리 확인하고 싶을 때 | --start-interval로 유예 기간만 짧은 주기로 |
unhealthy인데 원인 모를 때 | docker inspect의 .State.Health.Log |
| 로그 | stdout으로 내보내고, max-size·max-file을 반드시 걸기 |
한 가지 더 심어두겠습니다. 스프링에는 검사가 사실 두 종류 있어요. management.endpoint.health.probes.enabled=true를 켜면 /actuator/health/liveness와 /actuator/health/readiness가 따로 생깁니다. “살아 있나”와 “일을 받을 수 있나”를 나눠 답하는 거예요. 5부에서 쿠버네티스가 정확히 이 두 경로를 찌릅니다(20장).
정리
- Docker가 기본으로 아는 건 “PID 1이 살아 있는가” 하나뿐입니다.
Health필드는HEALTHCHECK를 붙여야 생겨요. - 판정은 검사 명령의 종료 코드로 합니다. 0이면
healthy, 실패가retries만큼 연속되면unhealthy입니다. 타임아웃으로 끊기면 Docker가ExitCode=-1과 함께 사유를 대신 적어둬요. start-period는 기동 중의 실패를 세지 않는 유예 기간입니다. 다 쓰지는 않아요 — 실측에서 10초를 줬는데 7.2초에 첫 검사가 성공하자 바로healthy가 됐습니다.unhealthy는 앱의 상태가 아니라 검사의 결과입니다. 5초짜리 엔드포인트를 2초 타임아웃으로 두드렸더니Health=unhealthy인 채로 앱은 200을 돌려줬습니다.- Docker Engine은
unhealthy에 아무 조치도 하지 않습니다.--restart=always를 걸고 40초 넘게unhealthy로 뒀는데 재시작 횟수가 0이었어요. 재시작 정책은 종료 코드에 반응하지 건강 상태에 반응하지 않습니다. - Engine이 하는 건 이벤트를 쏘는 것뿐입니다.
docker events --filter event=health_status로 받아 쓸 수 있어요. - 쿠버네티스는 이미지의
HEALTHCHECK를 아예 보지 않습니다. Pod에 적은 probe만 돌려요. - 로그는 파일이 아니라 stdout으로 내보냅니다.
json-file드라이버가 한 줄씩 JSON으로 감싸 호스트 파일에 쌓아요. json-file의 크기 제한 기본값은 “제한 없음”입니다.max-size와max-file을 걸지 않으면 디스크가 찹니다.local드라이버는 기본으로 잘라 줍니다.
생각해볼 질문
여기까지가 3부입니다.
11장을 시작할 땐 Dockerfile 한 장이었어요. 지금 그 앱은 빌드가 캐시를 타고, SIGTERM에 응답하고, 512MB 안에서 힙을 잡고, 스로틀링을 숫자로 증명하고, 바깥에 자기 상태를 알립니다. 컨테이너 한 개로 할 수 있는 건 여기까지예요.
그런데 앱 하나로 되는 서비스는 없죠. DB가 필요하고, 캐시가 필요하고, 그것들도 각자 컨테이너예요.
여기서 문제가 하나 생깁니다. 9장에서 컨테이너마다 IP가 따로 붙는 걸 봤죠. 그 주소가 누구 것인지 확인해봤습니다.
=== 7. 컨테이너의 IP 는 누구 것인가 (16장 예고) ===
ipa=172.17.0.2 ipb=172.17.0.3
ipa 를 지우고 새로 띄운 ipc = 172.17.0.2 (지운 ipa 의 주소를 물려받았다)✅ 실측 (같은 환경, 같은 소스)
주소는 컨테이너 것이 아니라 빈자리 것입니다. 먼저 뜨는 순서대로 붙고, 지우면 다음 컨테이너가 물려받아요.
그러면 앱은 DB를 어떻게 찾을까요. 이름으로 불러야 할 텐데, 그 이름은 누가 주소로 바꿔줄까요.