Health check
K8s가 컨테이너의 살아 있음과 트래픽을 받을 준비됨을 어떻게 판단하는지의 모델을 다룹니다. liveness · readiness · startup 세 probe의 역할 분리, httpGet · tcpSocket · exec 검사 방식, initialDelaySeconds · periodSeconds · failureThreshold 같은 매개변수 튜닝, liveness에 외부 의존성을 넣었을 때의 cascading failure, terminationGracePeriodSeconds와 preStop 훅의 graceful shutdown까지 한 사이클로 정리합니다.
11장 resources.requests / limits까지 우리가 잡은 것은 Pod에게 얼마만큼의 자원을 줄 것인가의 모델이었습니다. CPU · 메모리의 requests와 limits로 스케줄러와 cgroup이 그 Pod를 어떤 조건에 두는지가 정해집니다. 그러나 자원이 충분하다고 해서 그 컨테이너가 정말로 일을 하고 있는지는 별개의 이야기입니다. 프로세스는 떠 있는데 안에서 데드락이 걸려 있을 수 있고, 컨테이너는 막 시작했지만 DB 커넥션 풀이 아직 안 차서 트래픽을 받으면 안 되는 상황도 있습니다. 이번 챕터에서는 K8s가 이 두 질문 — **“살아 있는가”**와 “트래픽을 받을 준비가 됐는가” — 를 어떻게 판단하는지, 그리고 그 판단의 근거가 되는 세 종류의 probe를 한 사이클로 정리합니다.
이번 챕터의 끝에서는 Pod 매니페스트에 박힌 세 probe 블록과 terminationGracePeriodSeconds, preStop이 어떤 운영 시나리오를 막고 있는지를 한 줄로 읽어 낼 수 있는 상태가 됩니다.
왜 세 probe로 갈라야 하는가 #
probe를 처음 보면 자연스러운 의문이 하나 생깁니다 — “컨테이너가 살아 있는지 한 가지만 보면 되지 않나?” 운영 시각에서 이 질문이 단순하지 않은 이유는, “살아 있다"라는 한 단어 안에 서로 다른 두 의미가 섞여 있기 때문입니다. 프로세스는 떠 있고 OS 차원에서는 멀쩡하지만, 그 안에서 캐시를 다 못 채워 트래픽을 받으면 즉시 502를 뱉는 상태가 있습니다. 그 컨테이너에게 “재시작해야 하는가"의 답은 “아니"이고, “트래픽을 보내도 되는가"의 답은 “아직"입니다. 두 답이 다릅니다.
K8s는 이 두 답을 다른 객체로 분리했습니다 — liveness와 readiness입니다. 그리고 시작이 느린 앱을 위한 보호자 한 층을 더 두었습니다 — startup입니다. 세 probe의 역할을 한 표로 정리하면 다음과 같습니다.
| probe | 묻는 질문 | 실패 시 K8s의 행동 | 영향 범위 |
|---|---|---|---|
| liveness | 이 컨테이너가 살아 있는가 | 그 컨테이너를 재시작 | 그 컨테이너 한 개 |
| readiness | 이 Pod가 트래픽을 받을 준비가 됐는가 | Service Endpoints에서 그 Pod 제외 | 트래픽 라우팅 |
| startup | 이 컨테이너가 시작을 끝냈는가 | 그 컨테이너를 종료 (그리고 restartPolicy에 따라 재시작) | 컨테이너 기동 단계 |
세 probe의 결정적 차이는 실패가 어떤 결과로 이어지는가입니다. liveness 실패는 컨테이너의 재시작을, readiness 실패는 트래픽의 차단을, startup 실패는 기동 단계의 종료를 일으킵니다. 이 결과의 차이를 모르고 매니페스트를 적으면, “컨테이너가 살아 있긴 한데 502가 뜬다” 거나 “잘 굴러가던 앱이 무한 재시작에 빠진다” 같은 사고로 직결됩니다.
컨테이너 재시작과 Pod 재생성은 다른 일 #
자주 헷갈리는 부분 하나를 미리 짚습니다. liveness 실패의 결과는 컨테이너 재시작 이지 Pod 재생성이 아닙니다. Pod는 그대로 살아 있고, 그 안의 컨테이너만 종료된 뒤 같은 Pod에서 다시 시작됩니다. kubectl get pods의 RESTARTS 컬럼이 1, 2, 3 식으로 올라가는 게 그 신호입니다. Pod 자체가 다른 노드로 옮겨 가거나 새 IP를 받지는 않습니다. 한편 readiness 실패는 컨테이너를 건드리지 않습니다 — 살아 있는 컨테이너 그대로, 다만 5장 Service의 Endpoints 목록에서 제외되어 트래픽이 안 들어올 뿐입니다.
세 가지 검사 방식 — httpGet / tcpSocket / exec #
세 probe 모두 같은 세 가지 검사 방식 중 하나를 고를 수 있습니다. 각자 적합한 시나리오와 비용이 다릅니다.
| 방식 | 동작 | 적합한 워크로드 | 비용 |
|---|---|---|---|
| httpGet | 지정된 path / port로 HTTP GET. 200~399 응답이면 성공 | HTTP 서버 (대부분의 웹 · API) | 낮음 |
| tcpSocket | 지정된 포트로 TCP 연결 시도. 연결되면 성공 | 비-HTTP 서버 (DB, gRPC 일부, Redis) | 매우 낮음 |
| exec | 컨테이너 안에서 명령을 실행. exit 0 이면 성공 | 임의 스크립트로 검사가 필요한 워크로드 | 높음 (새 프로세스 fork) |
httpGet — 가장 흔한 선택 #
대부분의 웹 · API 서버에는 httpGet이 첫 번째 후보입니다.
livenessProbe:
httpGet:
path: /healthz
port: 8080
httpHeaders:
- name: X-Probe
value: kubelet
initialDelaySeconds: 10
periodSeconds: 10/healthz라는 경로는 K8s 생태계의 컨벤션입니다 — 프로젝트 코드에서 /health, /healthz, /ping, /-/healthy 같은 이름을 자주 보게 됩니다. 응답 코드가 200~399 범위이면 성공으로 판정하고, 4xx · 5xx 면 실패입니다. 응답 본문은 보지 않습니다.
httpGet의 장점은 앱 코드가 자기 상태를 직접 표현할 수 있다는 점입니다. 단순히 “프로세스가 떠 있다"가 아니라 “DB 커넥션 풀이 정상이다”, “캐시가 채워졌다” 같은 의미를 200 / 503으로 갈라 응답할 수 있습니다.
tcpSocket — 포트만 열려 있으면 됨 #
HTTP가 아닌 서버에는 tcpSocket이 자연스러운 선택입니다.
readinessProbe:
tcpSocket:
port: 5432
initialDelaySeconds: 5
periodSeconds: 10PostgreSQL, MySQL, Redis 같은 비-HTTP 서버가 흔한 적용 대상입니다. K8s가 그 포트로 TCP 3-way handshake를 시도해 성공하면 OK, 실패하면 NG입니다. 다만 TCP 연결이 된다고 해서 그 서버가 진짜로 쿼리를 처리할 수 있다는 뜻은 아닙니다 — Postgres가 막 시작해 listen은 하는데 아직 startup이 안 끝난 상태에서도 TCP 연결은 됩니다. 그래서 데이터베이스 워크로드의 readiness에는 tcpSocket보다 exec으로 pg_isready 같은 명령을 도는 편이 더 정확합니다.
exec — 임의 명령으로 검사 #
특정 명령으로만 표현되는 검사는 exec을 씁니다.
readinessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -h 127.0.0.1 -p 5432
initialDelaySeconds: 5
periodSeconds: 10exec은 컨테이너 안에서 새 프로세스를 fork 해 명령을 실행하고, 그 exit code가 0 이면 성공입니다. 가장 유연하지만 비용이 가장 비쌉니다. fork 자체가 작지 않은 작업이고, 그 명령이 sh를 거쳐 다시 클라이언트 바이너리를 띄우는 식이면 매번의 검사가 무겁습니다. 1분에 한 번씩 도는 검사라도 컨테이너가 수백 개라면 부하가 누적됩니다. 가능하면 httpGet을 우선 검토하고, 그 길이 막혔을 때 tcpSocket이나 exec을 고르는 순서가 운영의 표준입니다.
공통 매개변수 — 시간과 임계치 #
세 probe는 같은 시간 매개변수를 공유합니다. 한 표로 정리합니다.
| 필드 | 의미 | 기본값 |
|---|---|---|
initialDelaySeconds | 컨테이너가 시작된 후 첫 검사까지 기다릴 시간 | 0 |
periodSeconds | 검사 주기 | 10 |
timeoutSeconds | 한 번의 검사가 응답을 기다리는 시간 상한 | 1 |
failureThreshold | 연속 실패 몇 번이면 최종 실패로 볼지 | 3 |
successThreshold | 연속 성공 몇 번이면 최종 성공으로 볼지 (liveness / startup은 1로 고정) | 1 |
이 다섯 값이 한 probe의 동작을 완전히 결정합니다. 예를 들어 periodSeconds: 10, failureThreshold: 3 이면 최대 30초 동안 연속 실패가 이어져야 K8s가 그 probe를 진짜 실패로 본다는 뜻입니다. timeoutSeconds: 1은 한 번의 검사가 1초 안에 응답하지 않으면 그 회차를 실패로 처리한다는 뜻입니다.
기본값이 운영에서 그대로 쓰기에는 너무 공격적 인 경우가 흔합니다. 특히 timeoutSeconds: 1은 GC가 살짝 길어지거나 노드의 부하가 잠깐 올라간 순간에도 실패로 떨어집니다. liveness에 그 기본값이 그대로 들어가 있으면 일시적인 응답 지연이 컨테이너 재시작으로 이어지는 사고로 직결됩니다. 11장에서 본 CPU throttling도 같은 시점에 응답 지연을 키우는 원인이라, 자원 모델과 probe 매개변수가 서로 엮여 사고를 만듭니다. 운영 매니페스트에서는 거의 항상 timeoutSeconds를 3~5초로 키우고, failureThreshold도 3~5 정도로 두는 편이 안전합니다.
liveness probe — 살아 있는가 #
liveness probe의 역할은 죽었지만 죽지 않은 척하는 컨테이너를 찾아내는 것입니다. 프로세스는 떠 있는데 데드락에 빠져 어떤 요청에도 응답하지 못하는 상태, 메모리 누수로 응답 시간이 무한대로 늘어진 상태 같은 것이 그 대상입니다. liveness가 실패하면 K8s는 그 컨테이너를 SIGTERM → 시간 초과 시 SIGKILL로 종료하고, Pod의 restartPolicy에 따라 다시 띄웁니다. Deployment의 restartPolicy 기본값은 Always 이므로 거의 모든 워크로드에서 자동 재시작이 따라옵니다.
spec:
template:
spec:
containers:
- name: web
image: myapp:1.4.0
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3이 매니페스트의 의미는 다음과 같습니다.
- 컨테이너가 시작된 뒤 30초 동안은 검사하지 않습니다 (
initialDelaySeconds). - 그 이후로 10초마다
/healthz를 부릅니다 (periodSeconds). - 한 번의 호출이 3초 안에 응답하지 않으면 그 회차를 실패로 처리합니다 (
timeoutSeconds). - 연속 3회 실패하면 (
failureThreshold) liveness 실패로 보고, 컨테이너를 재시작합니다.
검사가 진짜로 실패해서 컨테이너가 재시작되면 kubectl describe pod의 이벤트와 kubectl get pods의 RESTARTS 카운트에 흔적이 남습니다.
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 2m kubelet Liveness probe failed: HTTP probe failed with statuscode: 503
Normal Killing 2m kubelet Container web failed liveness probe, will be restarted
Normal Pulled 2m kubelet Container image "myapp:1.4.0" already present on machine
Normal Created 2m kubelet Created container web
Normal Started 2m kubelet Started container webLiveness probe failed라는 이벤트와 그 직후의 Container ... will be restarted가 한 묶음으로 찍히는 모양이 표준입니다. 이 흔적이 자주 보이는 Pod는 liveness probe를 의심해야 합니다 — 진짜로 컨테이너가 자주 죽는지, 아니면 probe가 너무 공격적이라 멀쩡한 컨테이너를 죽이고 있는지를 가려야 합니다. 진단 트리의 완성된 버전은 27장 kubectl 디버깅 패턴에서 정리합니다.
liveness에 무엇을 넣어야 하는가 #
이 부분이 운영에서 가장 사고가 많이 나는 지점입니다. 결론부터 적으면 — liveness probe는 자기 프로세스의 상태만 봐야 합니다. 외부 의존성 (DB, 캐시, 다른 마이크로서비스)을 liveness에 넣으면 안 됩니다.
이유는 cascading failure 때문입니다. DB가 잠시 다운되었을 때 모든 앱 컨테이너의 liveness가 동시에 실패해 동시에 재시작에 들어가면, DB가 복구되어도 앱들은 한참 동안 다시 떠오르지 못합니다. 더 심한 경우, 재시작된 앱이 또다시 DB에 닿지 못해 또 liveness가 실패하고, 다시 재시작되는 무한 루프에 빠집니다. liveness는 자기 안의 상태만, 외부 의존성은 readiness 로 — 이 분리를 처음부터 굳히고 가는 편이 안전합니다.
/healthz 엔드포인트는 보통 다음 정도만 봅니다.
- 앱 프로세스가 응답을 만들 수 있다 (HTTP 핸들러까지 도달했다).
- 자기 안의 데드락 감지가 OK입니다.
DB 핑이나 외부 서비스 호출은 이 엔드포인트에 절대 넣지 않는 것이 운영의 표준입니다.
readiness probe — 트래픽을 받을 준비가 됐는가 #
readiness probe의 역할은 트래픽 라우팅의 게이트입니다. liveness와 달리 readiness는 컨테이너를 죽이지 않습니다 — 대신 그 Pod를 Service의 Endpoints 목록에서 제외합니다. 결과적으로 그 Pod로 새 요청이 들어오지 않게 됩니다.
spec:
template:
spec:
containers:
- name: web
image: myapp:1.4.0
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
successThreshold: 1/readyz는 /healthz와 별개의 엔드포인트로 두는 패턴이 흔합니다. 두 엔드포인트가 보는 것이 다르기 때문입니다.
/healthz(liveness) — 자기 프로세스의 상태만/readyz(readiness) — 자기 프로세스 + DB 핑 + 캐시 연결 + 의존하는 외부 서비스의 상태
readiness가 실패한 Pod는 죽지 않고 살아 있되, 트래픽만 잠시 끊어집니다. DB 연결이 일시적으로 안 되는 동안 readiness가 false가 되어 트래픽이 차단되고, DB가 복구되면 readiness가 다시 true로 돌아와 트래픽이 다시 흘러 들어옵니다. 컨테이너의 재시작 없이 일시적 장애를 흡수하는 모델입니다.
Endpoints에서 빠지는 모양 확인 #
readiness 실패가 일어났을 때 Endpoints (또는 그 후속 객체인 EndpointSlice)가 어떻게 변하는지 짧게 봅니다.
kubectl get svc web
kubectl get endpoints webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web ClusterIP 10.96.123.45 <none> 80/TCP 1d
NAME ENDPOINTS AGE
web 10.244.1.10:8080,10.244.1.11:8080,10.244.2.5:8080 1d세 Pod의 IP가 모두 Endpoints에 들어 있는 게 정상 상태입니다. 한 Pod의 readiness가 실패로 떨어지면 그 IP만 Endpoints에서 빠집니다.
NAME ENDPOINTS AGE
web 10.244.1.10:8080,10.244.2.5:8080 1dService가 그 Pod로 트래픽을 보내지 않습니다. kubectl get pods에서는 그 Pod가 READY 0/1 상태로 보이고, 컨테이너는 여전히 Running입니다.
NAME READY STATUS RESTARTS AGE
web-7c4d-aa1 1/1 Running 0 1d
web-7c4d-bb2 0/1 Running 0 1d
web-7c4d-cc3 1/1 Running 0 1dREADY 컬럼의 0/1이 핵심입니다. 컨테이너는 한 개 떠 있는데 그중 0개가 ready라는 뜻이고, 이 상태에서 RESTARTS는 증가하지 않습니다.
Pod 안에 컨테이너가 여러 개라면 #
한 Pod에 컨테이너가 여러 개 있고 그중 하나의 readiness가 false 면, Pod 전체의 ready가 false가 되어 Endpoints에서 빠집니다. 두 컨테이너가 멀쩡해도 한 컨테이너만 readiness가 안 떨어지면 Pod 전체에 트래픽이 안 들어오는 모양입니다. 의도된 동작입니다 — Pod는 K8s의 라우팅 단위이고, 그 안의 한 부속이 준비가 안 됐다면 그 Pod로 트래픽을 보내지 않는 편이 안전하기 때문입니다.
startup probe — 시작이 느린 앱의 보호자 #
세 번째 probe 인 startup은 1.16에서 beta, 1.18에서 stable이 된 비교적 새로운 객체입니다. 풀어 주는 문제가 분명합니다 — 시작이 느린 앱입니다.
Java / Spring Boot, Rails, 큰 ML 모델을 메모리에 올리는 워크로드는 시작에 60초 이상 걸리는 일이 흔합니다. 이런 앱에 startup probe 없이 liveness만 두면 어떤 일이 벌어지는지 따라가 봅니다. 앱이 시작에 60초가 걸리는데 liveness의 initialDelaySeconds: 10이라면 — 시작 10초 시점부터 K8s가 /healthz를 부르고, 앱은 아직 응답할 수 없으니 실패가 누적되어 결국 컨테이너가 죽습니다. K8s가 다시 띄워도 같은 일이 반복되어 무한 재시작에 빠집니다.
회피책은 initialDelaySeconds를 90초나 120초처럼 크게 잡는 것이지만, 그러면 새 문제가 생깁니다 — 앱이 진짜로 죽었을 때도 그만큼 늦게 감지 됩니다. 운영 중에 데드락이 걸려도 첫 90초 동안은 무방비입니다. 시작 시간에 맞춰 initialDelaySeconds를 키운 대가가 평소 운영의 감도 저하로 돌아옵니다.
startup probe가 이 분리를 깨끗이 풉니다. startup이 성공하기 전까지 liveness와 readiness는 비활성이고, startup이 한 번 성공하면 그 이후로는 startup이 다시 동작하지 않고 liveness / readiness가 정상 주기로 돕니다.
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 30위 매니페스트는 최대 5분 (10초 × 30회)을 시작에 허용한다는 뜻입니다. 5분 안에 한 번이라도 /healthz가 200을 응답하면 startup이 성공으로 판정되고, 그 이후로 startup probe는 더 동작하지 않습니다. 그 다음부터는 liveness / readiness가 평소 주기로 돕니다. 5분이 지나도 한 번도 성공하지 못하면 startup 실패로 판정되어 컨테이너를 종료시키고 restartPolicy에 따라 다시 띄웁니다.
핵심 공식은 단순합니다. failureThreshold × periodSeconds가 시작에 허용되는 최대 시간입니다. Spring Boot가 평균 60초 걸리고 가끔 90초가 걸린다면 failureThreshold: 12 × periodSeconds: 10 = 120초로 두는 식의 계산이 일반적입니다.
흔한 운영 사고 시나리오 셋 #
세 probe의 모델을 안 상태에서, 운영에서 자주 마주치는 사고 셋을 짚어 둡니다. 이 셋만 피해도 health check 관련 사고의 큰 부분이 사라집니다.
사고 1 — liveness만 있고 readiness가 없음 #
가장 흔한 첫 번째 사고입니다. 매니페스트에 liveness만 적고 readiness를 안 적으면, K8s는 컨테이너가 시작되자마자 그 Pod를 ready로 판정 합니다. Service의 Endpoints에 즉시 그 Pod가 추가되고 트래픽이 들어옵니다.
문제는 컨테이너가 막 시작했을 때입니다. 프로세스는 떠 있고 listen도 시작했지만, DB 커넥션 풀이 아직 채워지지 않았거나 캐시가 미리 로드되지 않은 상태입니다. 그 시점에 들어온 트래픽이 502를 뱉고, 사용자가 영향을 받습니다. 롤링 업데이트 중에 매번 짧은 502 burst가 보이면 readiness 누락을 가장 먼저 의심해야 합니다.
해결은 단순합니다 — readiness probe를 추가하고, 그 안의 /readyz 엔드포인트가 DB 핑 · 캐시 상태를 보고 200 / 503으로 갈라 응답하게 만듭니다. 그러면 그 Pod가 진짜로 트래픽을 받을 준비가 될 때까지 Endpoints에 들어가지 않습니다.
사고 2 — liveness가 너무 공격적 #
두 번째 사고는 liveness 매개변수 자체에 있습니다. 기본값 timeoutSeconds: 1로 두고 운영을 시작하면, DB가 잠시 느려지거나 GC가 길어진 순간에 health 체크가 1초 안에 응답하지 못해 실패합니다. 연속 3회 실패하면 컨테이너 재시작이 발동되고, 재시작 직후의 컨테이너는 또 GC를 돌고 또 응답이 늦어 또 실패합니다.
이 사이클이 한번 시작되면 멈추기 어렵습니다. 운영자가 매니페스트에서 timeoutSeconds를 키울 때까지 같은 패턴이 반복됩니다. 운영 매니페스트의 liveness는 timeoutSeconds: 3 ~ 5, failureThreshold: 3 ~ 5 정도로 두고 시작하는 편이 안전합니다.
사고 3 — DB 핑을 liveness에 넣음 #
세 번째 사고는 모델의 분리를 모르고 매니페스트를 적었을 때 일어납니다. /healthz가 DB 핑까지 보고 false를 응답하게 만들어 두면, DB가 잠깐만 다운되어도 모든 앱 컨테이너의 liveness가 동시에 실패 해 동시에 재시작에 들어갑니다.
DB가 30초 만에 복구되어도 앱들은 한참 동안 다시 떠오르지 못합니다 — 자기 시작 시간이 30초 이상 걸리는 앱이라면 그 시간만큼 503이 더 길어집니다. 더 심하게는 앱이 다시 떠도 그 사이 DB가 또 흔들리면 또 죽고, 또 다시 뜨는 cascading failure에 빠집니다.
규칙은 한 줄입니다. liveness는 자기 프로세스, readiness는 외부 의존성. DB · 캐시 · 다른 마이크로서비스 같은 외부 의존성은 어디에 들어가야 합니까? readiness입니다. DB가 다운되면 readiness가 false가 되어 트래픽이 차단되고, DB가 복구되면 readiness가 true로 돌아와 트래픽이 다시 흐릅니다. 컨테이너는 죽지 않으므로 cascading failure도 일어나지 않습니다.
probe와 graceful shutdown #
probe의 모델 위에 한 층 더 얹히는 주제가 graceful shutdown입니다. Pod가 종료될 때 진행 중이던 요청이 502가 되지 않게 하려면, 트래픽을 먼저 끊고 그 다음에 컨테이너를 죽여야 합니다. K8s는 이 흐름을 다음 단계로 진행합니다.
- Pod가
Terminating상태로 들어갑니다. - K8s가 그 Pod의 IP를 Endpoints에서 제거합니다 (트래픽 차단 시작).
- 동시에 컨테이너에
SIGTERM을 보냅니다. terminationGracePeriodSeconds(기본 30초) 안에 컨테이너가 종료되기를 기다립니다.- 시간이 지나도 안 죽으면
SIGKILL로 강제 종료합니다.
여기서 미묘한 부분은 2단계와 3단계가 거의 동시에 일어난다는 점입니다. Endpoints 갱신은 K8s 컨트롤플레인을 거쳐 각 노드의 kube-proxy에 전파되는 동안 시간이 걸리지만, SIGTERM은 즉시 도달합니다. 결과적으로 SIGTERM을 받은 컨테이너가 막 종료를 시작했는데, 아직 Endpoints 갱신이 다 안 퍼져서 그 Pod로 마지막 요청들이 몇 개 더 들어오는 시간 창 (window)이 생깁니다. 그 요청들이 종료 중인 컨테이너에 닿아 502가 됩니다.
PreStop 훅으로 시간 창 메우기 #
이 빈 부분을 메우는 도구가 lifecycle.preStop 훅입니다. SIGTERM을 받기 전 K8s가 먼저 실행해 주는 명령으로, 보통 짧은 sleep을 둬서 Endpoints 갱신이 다 퍼질 시간을 벌어 줍니다.
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: web
image: myapp:1.4.0
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]위 매니페스트의 흐름은 다음과 같습니다.
- Pod가 종료 시작 → K8s가 Endpoints에서 제거.
- K8s가
preStop훅을 실행 → 10초 동안 sleep. - 그 10초 사이에 Endpoints 갱신이 클러스터 전체로 다 퍼집니다 — 새 트래픽이 안 들어옵니다.
- preStop이 끝나면 K8s가 컨테이너에
SIGTERM을 보냅니다. - 컨테이너가 자기 안에서 인플라이트 요청들을 처리하고 깔끔하게 종료합니다.
- 종료가 안 끝나면
terminationGracePeriodSeconds(60초) 후에SIGKILL.
terminationGracePeriodSeconds는 preStop의 시간까지 포함 합니다. 즉 위 예시에서 60초 중 10초가 preStop에, 나머지 50초가 SIGTERM 후 종료에 쓰입니다. preStop을 20초로 두면 SIGTERM 이후 시간이 40초로 줄어드므로, 두 값을 같이 조정해야 합니다.
PodDisruptionBudget과 함께 노드 업그레이드 시 안전한 종료 흐름의 본격적인 운영 매뉴얼은 30장 업그레이드 전략에서 다룹니다.
SIGTERM을 앱이 직접 처리하기 #
다른 길로 같은 효과를 내는 방법도 있습니다. 앱이 SIGTERM을 받으면 자기 readiness 엔드포인트가 false를 응답하도록 코드에 적어 두는 패턴입니다. SIGTERM이 들어오자마자 /readyz가 503을 응답하기 시작하고, 곧 K8s가 다음 readiness 검사에서 그 Pod를 Endpoints에서 빼냅니다. 그 사이 인플라이트 요청들을 마저 처리하고 종료합니다.
이 방식은 PreStop 훅 없이도 깔끔한 graceful shutdown을 만듭니다. 다만 앱 코드 차원의 SIGTERM 핸들러가 정확히 동작해야 한다는 전제가 있습니다 — 컨테이너의 PID 1이 SIGTERM을 무시하면 readiness false 처리도 일어나지 않습니다. 컨테이너 이미지의 ENTRYPOINT가 tini나 dumb-init 같은 init 도구를 거치는 패턴이 이 문제를 막아 줍니다.
종합 매니페스트 #
세 probe와 graceful shutdown을 한 매니페스트에 모은 예시입니다. Java Spring Boot 앱을 가정했습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
spec:
replicas: 3
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
terminationGracePeriodSeconds: 60
containers:
- name: order-api
image: myorg/order-api:2.3.0
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
memory: "1Gi"
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 18
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
successThreshold: 1
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]매니페스트에 적혀 있는 의도를 한 줄씩 짚으면 다음과 같습니다.
- startup probe: 시작에 최대 180초 (10초 × 18회) 허용. Spring Boot의 평균 기동 시간 + 여유.
- liveness probe: startup이 성공한 뒤부터 동작.
/actuator/health/liveness는 자기 프로세스의 상태만 봅니다 (DB 핑 없음). - readiness probe:
/actuator/health/readiness가 DB 커넥션 풀과 외부 의존성을 봅니다. DB가 잠시 다운되면 readiness가 false가 되어 트래픽이 차단되고, 컨테이너는 살아 있습니다. - preStop sleep 10초 + terminationGracePeriodSeconds 60초: graceful shutdown의 시간 창을 충분히 확보.
Spring Boot 2.3+ 의 actuator가 liveness와 readiness 엔드포인트를 표준으로 분리해 제공해 주므로, 이런 설정을 비교적 쉽게 적용할 수 있습니다. 다른 프레임워크에서도 같은 분리 (자기 상태 / 외부 의존성)를 두 엔드포인트로 코드 차원에 만들어 두는 패턴이 운영의 표준입니다.
Docker HEALTHCHECK와 K8s probe #
Docker의 HEALTHCHECK 인스트럭션과 K8s probe의 관계를 한 번 정리해 둡니다.
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8080/healthz || exit 1이 인스트럭션은 docker run으로 컨테이너를 직접 띄울 때 도커 데몬이 그 검사를 도는 모델입니다. docker ps의 STATUS 컬럼에 (healthy) / (unhealthy) 표시가 찍히고, Docker Compose의 depends_on.condition: service_healthy 같은 곳에서도 이 값을 봅니다.
K8s는 이 HEALTHCHECK 값을 무시합니다. K8s가 보는 것은 Pod 매니페스트의 livenessProbe / readinessProbe / startupProbe 뿐입니다. 같은 이미지를 K8s에 올리면 Dockerfile의 HEALTHCHECK는 그냥 무시되고, 매니페스트에 probe를 따로 적어야 합니다. 두 모델이 비슷해 보이지만 다른 층에서 동작한다고 보면 됩니다 — 한 컨테이너의 검사는 Docker가, K8s 워크로드의 검사는 K8s가 책임집니다.
이미지 자체가 둘 다에서 쓰일 수 있다면 두 곳에 같은 의도의 검사가 적혀 있어도 괜찮습니다 — 다만 K8s 매니페스트의 probe가 진짜 동작하는 검사라는 점은 분명히 해야 합니다. docker-compose의 healthcheck와 K8s probe의 더 자세한 매핑은 부록 A docker-compose에서 k8s로에서 정리합니다.
연습문제 #
- 위 본문의
full-deployment.yaml을 가정한 상태에서, 일부러/actuator/health/liveness가 503을 응답하게 만드는 시나리오를 메모로 정리해 보세요. K8s가 어떤 단계 (probe 실패 카운트 → Killing → Pulled → Created → Started)를 거치는지 시간 순서대로 적고, 같은 시점에RESTARTS카운트가 어떻게 변하는지 §“liveness probe — 살아 있는가"의 모델과 맞춰 정리합니다. - DB 핑을 일부러 liveness에 넣은 매니페스트와, 같은 DB 핑을 readiness에만 넣은 매니페스트 두 개를 가정하고 비교합니다. DB가 30초 동안 다운되었다가 복구되는 시나리오에서 두 모양이 각각 어떻게 동작할지 (cascading failure vs 트래픽만 일시 차단)를 한 단락으로 비교 정리하고, §“흔한 운영 사고 시나리오 셋"의 사고 3과 어떻게 연결되는지 메모합니다.
- 본 챕터의
terminationGracePeriodSeconds: 60+preStop: sleep 10조합을 가정한 상태에서, preStop을sleep 70으로 바꿨을 때 어떤 일이 벌어지는지 §“PreStop 훅으로 시간 창 메우기"의 시간 모델로 추론합니다. SIGTERM 이후 컨테이너가 자기 작업을 마무리할 시간이 얼마나 남는지, 그리고terminationGracePeriodSeconds와 어떻게 조정해야 하는지를 한 단락으로 정리합니다.
한 줄 요약: liveness는 “살아 있는가” 라 실패 시 컨테이너 재시작, readiness는 “트래픽 준비됐는가” 라 실패 시 Endpoints에서 제외, startup은 시작이 느린 앱의 보호자다. liveness에는 자기 프로세스만, 외부 의존성은 readiness에 — 이 분리가 cascading failure를 막는다.
terminationGracePeriodSeconds와preStop훅이 graceful shutdown의 시간 창을 만들고, Docker의 HEALTHCHECK는 K8s가 무시한다.
다음 챕터 #
지금까지 우리가 다룬 것은 한 Pod의 자원 모델 (11장)과 그 Pod의 건강 판정 (본 챕터)이었습니다. replicas: 3 같은 숫자는 사람이 매니페스트에 직접 적었습니다. 그러나 운영 클러스터의 트래픽은 시간대와 요일에 따라 크게 출렁이고, 그때마다 사람이 replicas를 손으로 조정하는 모양은 지속 가능하지 않습니다.
13장 오토스케일링에서는 그 빈 부분을 메우는 세 객체를 한 사이클로 정리합니다. HPA (Horizontal Pod Autoscaler)는 CPU · 메모리 · 커스텀 메트릭에 따라 replicas를 자동으로 늘리고 줄이는 컨트롤러입니다. VPA (Vertical Pod Autoscaler)는 한 Pod의 requests / limits 자체를 자동으로 조정하는 다른 축의 모델입니다. Cluster Autoscaler는 Pod가 스케줄될 노드가 모자랄 때 노드 자체를 자동으로 추가하는 한 단계 더 위의 객체입니다. 그리고 HPA의 입력 메트릭은 결국 readiness가 true 인 Pod 들에서만 모인다는 점에서, 본 챕터에서 잡은 readiness가 다음 챕터의 출발점에 다시 등장합니다.