Split View: Kubernetes 프로브 3종 제대로 쓰기 — liveness, readiness, startup의 정확한 경계
Kubernetes 프로브 3종 제대로 쓰기 — liveness, readiness, startup의 정확한 경계
들어가며 — 멀쩡한 파드가 몇 분마다 재시작합니다
애플리케이션은 정상 동작하는데 RESTARTS만 조용히 올라갑니다.
kubectl get pod -n payments
NAME READY STATUS RESTARTS AGE
checkout-7d9f6c5b4d-2xk9p 1/1 Running 11 (4m2s ago) 3h18m
checkout-7d9f6c5b4d-lm4vz 1/1 Running 9 (12m ago) 3h18m
로그에는 종료 직전까지 정상 요청 처리 기록만 있습니다. 답은 이벤트에 있습니다.
kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | tail -5
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 4m12s (x33 over 3h17m) kubelet Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Normal Killing 4m2s (x11 over 3h17m) kubelet Container checkout failed liveness probe, will be restarted
쿠버네티스가 이 컨테이너를 죽이고 있습니다. 정확히는 우리가 그렇게 하라고 지시해 둔 것입니다.
세 프로브는 서로 다른 질문에 답합니다
세 프로브의 문법은 똑같아서 헷갈리지만, 실패했을 때 하는 일이 완전히 다릅니다.
- startupProbe — "기동이 끝났는가". 성공할 때까지 liveness와 readiness를 완전히 정지시킵니다. 한 번 성공하면 그 컨테이너의 생애 동안 다시 실행되지 않습니다. failureThreshold를 다 소진하면 컨테이너를 죽입니다
- readinessProbe — "지금 트래픽을 받아도 되는가". 실패하면 서비스 엔드포인트에서 파드 주소가 빠집니다. 컨테이너를 재시작하지 않습니다. 성공하면 다시 들어옵니다
- livenessProbe — "이 프로세스를 재시작해야 하는가". 실패하면 kubelet이 컨테이너를 죽이고 restartPolicy에 따라 다시 띄웁니다
여기서 가장 자주 나오는 오해를 먼저 정리합니다. readiness 실패는 절대 재시작을 유발하지 않습니다. 파드가 계속 0/1로 남아 있는데 재시작이 안 된다고 당황하는 경우가 많은데, 그건 설계대로 동작하는 것입니다. 재시작은 오직 liveness와 startup의 권한입니다.
readiness 실패는 엔드포인트에서 확인합니다.
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 10.42.3.17,10.42.1.9 3h20m
세 개의 파드 중 두 개만 들어가 있습니다. 나머지 하나가 readiness에 실패한 파드입니다.
kubectl get pod -n payments -l app=checkout \
-o custom-columns='POD:.metadata.name,IP:.status.podIP,READY:.status.containerStatuses[*].ready'
POD IP READY
checkout-7d9f6c5b4d-2xk9p 10.42.3.17 true
checkout-7d9f6c5b4d-lm4vz 10.42.1.9 true
checkout-7d9f6c5b4d-q8w2n 10.42.2.31 false
잘못 쓰면 나는 세 가지 사고
| 증상 | 원인 | 진단 | 해결 |
|---|---|---|---|
| 정상 파드가 주기적으로 재시작 | liveness가 과민, 특히 timeoutSeconds 기본값 1초 | Events의 Unhealthy와 Killing | timeoutSeconds 상향, 헬스 엔드포인트 경량화 |
| 배포 직후 502와 커넥션 리셋 | readiness 없음 또는 실제 준비 상태를 반영 못 함 | EndpointSlice에 파드가 언제 들어오는지 | readiness 추가, maxUnavailable 0 |
| 느린 기동 앱이 Ready에 도달 못 함 | startup 없이 liveness가 기동 중 컨테이너를 죽임 | Started와 Killing 이벤트의 간격 | startupProbe 추가 |
| 데이터베이스 장애 시 전 파드 동시 재시작 | liveness에 의존성 검사 포함 | 헬스 핸들러가 무엇을 호출하는지 | liveness에서 의존성 제거 |
| 엔드포인트 0개로 서비스 전면 중단 | readiness에 모든 파드가 공유하는 의존성 포함 | 엔드포인트 수 시계열 | 부분 저하 허용, 의존성 분리 |
| 종료 중 요청 실패 | 엔드포인트 전파 전에 SIGTERM 도착 | 파드 종료 시각과 실패 시각 대조 | preStop 지연 삽입 |
| 종료 시 Exit Code 137 | 유예 시간 내에 종료하지 못함 | Reason Error에 137 | 드레인 시간 단축, 유예 기간 상향 |
| kubelet CPU 상승 | exec 프로브 과다 | 프로브 종류 집계 | httpGet 또는 grpc로 교체 |
사고 1 — 과민한 liveness가 만드는 자기 파괴
가장 위험한 조합입니다. 부하가 몰리면 애플리케이션 응답이 느려집니다. 프로브도 함께 느려집니다. 타임아웃이 나면 쿠버네티스가 컨테이너를 죽입니다. 남은 파드에 부하가 더 몰립니다. 그 파드들도 타임아웃이 납니다. 부하가 높을수록 더 많이 죽는 구조가 되어, 시스템이 스스로를 무너뜨립니다.
이 시나리오에서 liveness는 안정성 장치가 아니라 증폭기입니다. 진단은 재시작 시각과 트래픽 그래프를 겹쳐 보면 즉시 드러납니다. 피크 시간에만 재시작이 몰린다면 확정입니다.
사고 2 — readiness 없이 배포하기
readiness가 없으면 컨테이너 프로세스가 뜬 순간 파드는 Ready로 간주되고 곧바로 엔드포인트에 들어갑니다. 애플리케이션이 아직 커넥션 풀을 만들고 캐시를 채우는 중이어도 요청이 들어옵니다.
kubectl get events -n payments --field-selector reason=Started --sort-by=.lastTimestamp | tail -2
배포 직후 몇 초 동안만 5xx가 튀고 곧 정상으로 돌아온다면 거의 항상 이 문제입니다. 롤링 업데이트마다 반복되므로 하루에 여러 번 배포하는 팀에서는 상시 오류율로 굳어집니다.
사고 3 — 느린 기동을 liveness가 학살하는 경우
기동에 90초가 걸리는 애플리케이션에 initialDelaySeconds 30짜리 liveness를 걸면, 30초 시점부터 프로브가 실패하기 시작해 컨테이너가 죽고, 다시 뜨고, 또 죽습니다. 영원히 Ready에 도달하지 못합니다.
kubectl describe pod ledger-0 -n payments | grep -E "Started|Killing"
Normal Started 2m11s (x5 over 7m32s) kubelet Started container ledger
Normal Killing 91s (x4 over 6m52s) kubelet Container ledger failed liveness probe, will be restarted
Started와 Killing의 간격이 약 40초로 일정합니다. 기동 완료 전에 반복적으로 죽고 있다는 명백한 신호입니다.
흔한 오답: initialDelaySeconds를 200으로 올리는 것. 두 가지 대가가 따릅니다. 첫째, 진짜로 죽은 컨테이너도 첫 200초 동안은 감지되지 않습니다. 둘째, 그 값은 가장 느린 기동에 맞춰야 하므로 노드가 바쁜 날에는 여전히 부족합니다. 정답은 startupProbe입니다.
파라미터 계산 — 실제로 몇 초 뒤에 죽는가
다섯 개 파라미터의 기본값과 의미를 먼저 확정합니다.
- initialDelaySeconds (기본 0) — 컨테이너 시작 후 첫 프로브까지의 대기
- periodSeconds (기본 10) — 프로브 실행 주기
- timeoutSeconds (기본 1) — 한 번의 프로브가 응답을 기다리는 시간
- failureThreshold (기본 3) — 연속 실패 몇 번을 실패로 볼 것인가
- successThreshold (기본 1) — 연속 성공 몇 번을 성공으로 볼 것인가. liveness와 startup에서는 1만 허용됩니다
timeoutSeconds 기본값 1초가 사고의 가장 흔한 씨앗입니다. GC 일시 정지, 순간적인 스레드 풀 포화, 디스크 지연 어느 것이든 1초를 넘기게 만들 수 있습니다. 헬스 엔드포인트가 아무리 가벼워도 프로세스 전체가 멈추면 응답이 늦습니다.
이제 계산합니다. 아래 설정에서 컨테이너가 t초에 완전히 멈췄다고 가정합니다.
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
- 최악의 경우 첫 실패 프로브는 t 직후가 아니라 다음 주기, 즉 최대 t 더하기 10초 시점에 시작합니다
- 세 번 연속 실패해야 하므로 10초 간격으로 세 번, 마지막 실패는 t 더하기 30초 근처입니다
- 각 프로브는 타임아웃 3초를 소진하므로 실제 판정은 그보다 몇 초 늦습니다
즉 감지 지연의 최악값은 대략 periodSeconds 곱하기 failureThreshold 더하기 timeoutSeconds입니다. 위 설정은 약 33초입니다. 이 값을 SLO와 맞춰야 합니다. 30초 안에 복구되어야 하는 서비스라면 periodSeconds를 5로 줄여야 합니다.
startupProbe의 예산은 곱셈으로 정합니다.
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
5초 곱하기 60회로 최대 300초의 기동을 허용합니다. 이 300초 동안 liveness는 아예 실행되지 않습니다. 기동이 끝나면 startup은 영구히 비활성화되고 liveness가 짧은 주기로 넘겨받습니다. 느슨한 기동과 민첩한 감지를 동시에 얻는 유일한 방법이 이것입니다.
세 프로브를 함께 쓰는 완성된 형태는 이렇습니다.
containers:
- name: checkout
image: registry.example.com/checkout:1.14.2
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
successThreshold: 1
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
세 가지 설계 의도가 들어 있습니다. readiness는 빠르게 빠지고 빠르게 돌아오도록 주기와 임계값을 작게 잡았습니다. liveness는 반대로 관대하게 잡아 60초 이상 확실히 죽어 있어야만 재시작합니다. 경로도 다릅니다. liveness는 /healthz, readiness는 /readyz로 분리했습니다.
liveness에 의존성을 넣으면 장애가 전면화됩니다
헬스체크를 처음 만들 때 가장 자연스러워 보이는 구현이 이것입니다.
GET /healthz
→ 데이터베이스에 SELECT 1
→ 레디스에 PING
→ 결제 게이트웨이에 HEAD 요청
→ 전부 성공하면 200
정직해 보이지만 프로덕션에서는 재난입니다. 데이터베이스가 3분간 흔들리면 무슨 일이 벌어지는지 순서대로 보겠습니다.
- 모든 파드의 liveness가 동시에 실패합니다. 같은 의존성을 보고 있으므로 예외 없이 전부입니다
- kubelet이 모든 컨테이너를 죽입니다
- 파드들이 동시에 재시작하며 커넥션 풀을 새로 만들고, 캐시가 전부 비워집니다
- 데이터베이스가 회복되는 순간 모든 파드가 동시에 연결과 쿼리를 쏟아붓습니다
- 데이터베이스가 다시 넘어갑니다
- 반복됩니다
3분짜리 데이터베이스 장애가 30분짜리 서비스 장애로 확대됩니다. 게다가 재시작 때문에 진행 중이던 요청, 인메모리 큐, 워밍업된 캐시가 전부 사라져 회복이 더 느려집니다.
liveness가 답해야 하는 질문은 오직 하나입니다. "이 프로세스를 재시작하면 상황이 나아지는가." 데이터베이스가 죽었을 때 프로세스를 재시작하면 나아지는 것이 없습니다. 그러므로 데이터베이스는 liveness에 들어가면 안 됩니다.
liveness에 넣어도 되는 것은 재시작으로 실제 해결되는 상태뿐입니다. 이벤트 루프 정지, 데드락, 복구 불가능한 내부 상태 손상 정도입니다.
GET /healthz
→ 프로세스가 요청을 받아 응답을 만들 수 있는가만 확인
→ 항상 200, 다만 이벤트 루프가 막혀 있으면 응답 자체가 나가지 않는다
이 단순한 핸들러가 대부분의 경우 최선입니다. 그리고 여기서 한 걸음 더 나아간 결론도 가능합니다. 재시작으로 고쳐질 실패 모드가 없다면 liveness 프로브를 아예 두지 않는 것이 더 안전합니다. 프로브가 없으면 컨테이너는 프로세스가 죽을 때만 재시작되고, 그건 대개 옳은 동작입니다.
readiness에는 어디까지 넣어도 되는가
readiness는 재시작을 유발하지 않으므로 의존성 검사를 넣을 여지가 있습니다. 기준은 이것입니다. 그 의존성 없이 이 파드가 처리할 수 있는 요청이 하나라도 있는가.
- 모든 요청이 데이터베이스를 필요로 한다면 readiness에 넣는 것이 합리적입니다. 어차피 실패할 요청을 받지 않는 편이 낫습니다
- 일부만 필요하다면 넣지 않아야 합니다. 정적 응답이나 캐시로 처리 가능한 요청까지 차단됩니다
그리고 넣기로 했다면 반드시 한 가지를 확인해야 합니다. 모든 파드가 동시에 NotReady가 되면 서비스의 엔드포인트가 0개가 되고, 클라이언트는 연결 자체를 거부당합니다. 이건 5xx보다 나쁜 경험일 수 있고, 회복 시점에 헬스 신호가 전혀 없어 진단도 어려워집니다.
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout -o wide
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 <unset> 3h44m
ENDPOINTS가 비어 있으면 이 상태입니다. 의존성 검사를 readiness에 넣을 때는 최소 한 대는 남기는 정책이나, 의존성 실패를 부분 저하로 처리하는 설계를 함께 검토해야 합니다.
프로브 종류 선택과 exec의 비용
네 가지 방식이 있고 비용과 신뢰도가 다릅니다.
httpGet — 기본 선택지입니다. kubelet이 파드 IP로 직접 요청합니다. 200부터 399까지를 성공으로 봅니다. 여기에 함정이 하나 있습니다. 인증 프록시가 헬스 경로를 로그인 페이지로 302 리다이렉트하면 프로브는 성공으로 판정됩니다. 애플리케이션이 죽어 있어도 성공합니다. 헬스 경로는 반드시 인증에서 제외하고 200을 직접 반환해야 합니다.
tcpSocket — 포트가 열려 있는지만 봅니다. 리스너 소켓은 커널이 유지하므로, 애플리케이션 스레드가 전부 멈춰도 성공합니다. 신뢰도가 가장 낮아 다른 선택지가 없을 때만 씁니다.
grpc — gRPC 서비스라면 표준 헬스 프로토콜을 kubelet이 직접 호출합니다. 예전처럼 헬스 체크 바이너리를 이미지에 넣어 exec로 부를 필요가 없습니다.
readinessProbe:
grpc:
port: 9000
service: readiness
periodSeconds: 5
exec — 가장 비쌉니다. 프로브가 돌 때마다 컨테이너 안에서 프로세스를 새로 만듭니다. 컨테이너 런타임이 태스크를 생성하고, 네임스페이스를 설정하고, 프로세스를 회수하는 비용이 매번 듭니다. 주기 10초짜리 exec 프로브를 파드 500개에 걸면 클러스터 전체에서 초당 50회의 프로세스 생성이 상시 발생합니다.
비용은 두 곳에서 나타납니다. 노드의 kubelet과 런타임 CPU가 올라가고, 프로브 프로세스가 컨테이너의 cgroup에 속하므로 애플리케이션의 CPU와 메모리 예산을 잠식합니다. 셸 스크립트를 부르는 exec 프로브라면 셸 자체의 메모리도 여기에 포함됩니다. 메모리 한계가 빠듯한 컨테이너에서는 프로브가 OOM의 방아쇠가 되기도 합니다.
현황을 세어 보면 대개 놀랍습니다.
kubectl get pods -A -o json | \
jq -r '.items[].spec.containers[].livenessProbe | select(.!=null) | keys[]' | \
grep -v Seconds | grep -v Threshold | sort | uniq -c | sort -rn
412 httpGet
87 exec
23 tcpSocket
exec 87개 중 상당수는 컬을 호출하는 스크립트이고, 그건 httpGet으로 바꾸면 그대로 사라지는 비용입니다.
서비스 메시를 쓴다면 한 가지 더 있습니다. kubelet은 파드 IP로 직접 요청하므로 프로브는 사이드카의 mTLS 정책을 우회합니다. 메시가 엄격한 상호 TLS를 요구하면 프로브가 실패하므로, 대부분의 메시는 프로브 경로를 사이드카가 대신 받도록 재작성하는 옵션을 제공합니다. 프로브가 갑자기 전부 실패하기 시작했는데 애플리케이션은 멀쩡하다면 이쪽을 의심합니다.
종료 경로 — 엔드포인트 제거와 SIGTERM의 경쟁 조건
프로브를 아무리 잘 잡아도 롤링 업데이트 때마다 오류가 튄다면 원인은 종료 경로에 있습니다.
파드가 삭제되면 두 가지 일이 동시에, 서로 조율 없이 시작됩니다.
- kubelet 경로 — preStop 훅을 실행하고, 컨테이너 주 프로세스에 SIGTERM을 보내고, 유예 시간이 끝나면 SIGKILL을 보냅니다
- 엔드포인트 경로 — 컨트롤러가 EndpointSlice에서 파드를 제거하고, 그 변경이 모든 노드의 kube-proxy와 인그레스 컨트롤러, 서비스 메시 사이드카로 전파됩니다
문제는 두 번째가 느리다는 것입니다. 컨트롤러가 감지하고, EndpointSlice를 갱신하고, 모든 노드가 그것을 받아 규칙을 다시 쓰기까지 수백 밀리초에서 수 초가 걸립니다. 큰 클러스터나 외부 로드밸런서가 끼면 더 걸립니다.
그동안 컨테이너는 이미 SIGTERM을 받고 리스너를 닫았습니다. 아직 이 파드로 라우팅되는 요청은 커넥션 거부를 만납니다. 배포 중 소량의 502와 커넥션 리셋이 나오는 이유가 정확히 이것입니다.
해결은 SIGTERM을 일부러 늦추는 것입니다. preStop 훅은 SIGTERM보다 먼저 실행되고 완료될 때까지 SIGTERM이 보류되므로, 여기에 전파를 기다리는 지연을 넣습니다.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: checkout
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 15']
최신 버전에서는 셸을 부르지 않는 전용 핸들러를 쓸 수 있습니다. 이미지에 셸이 없는 distroless 환경에서 특히 유용합니다.
lifecycle:
preStop:
sleep:
seconds: 15
여기서 유예 시간 예산을 반드시 계산해야 합니다. terminationGracePeriodSeconds는 preStop 실행 시간까지 포함한 전체 예산입니다.
- preStop 지연 15초
- 진행 중 요청 드레인 최대 30초
- 여유 15초
- 합계 60초
이 계산을 안 하면 preStop이 끝나자마자 유예 시간이 소진되어 SIGKILL이 날아갑니다. 종료 코드 137에 Reason이 Error로 찍히는 상황이 그것이고, OOMKilled 편에서 다룬 메모리 문제와 종료 코드가 같아 오진하기 쉽습니다.
애플리케이션 쪽도 SIGTERM을 제대로 처리해야 합니다. 새 연결은 거부하되 진행 중인 요청은 끝내고, 백그라운드 워커는 현재 작업을 마무리한 뒤 종료합니다. SIGTERM 핸들러가 없는 애플리케이션은 기본 동작으로 즉시 죽으므로, preStop을 아무리 길게 잡아도 진행 중 요청이 잘립니다.
kubectl exec -it checkout-7d9f6c5b4d-2xk9p -n payments -- \
sh -c 'grep SigCgt /proc/1/status'
SigCgt: 0000000000014002
이 비트마스크에 15번 비트가 서 있는지로 SIGTERM 핸들러 등록 여부를 확인할 수 있습니다. 셸을 통해 실행되는 컨테이너에서는 셸이 PID 1이 되어 시그널을 자식에게 전달하지 않는 고전적인 문제도 함께 확인해야 합니다.
표준 템플릿과 점검 목록
새 서비스를 만들 때 아래를 출발점으로 쓰고, 값만 서비스 특성에 맞게 조정합니다.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
sleep:
seconds: 15
startupProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
배포 전에 다섯 가지를 확인합니다.
- liveness 핸들러가 외부 의존성을 호출하지 않는가
- timeoutSeconds가 1로 남아 있지 않은가
- 기동 시간의 최악값이 startupProbe 예산 안에 들어가는가
- terminationGracePeriodSeconds가 preStop 지연과 드레인 시간의 합보다 큰가
- 애플리케이션이 SIGTERM을 받아 드레인을 시작하는가
재발 방지 지표로는 프로브 실패 자체를 추적하는 것이 유용합니다. 재시작으로 이어지기 전 단계에서 잡히기 때문입니다.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: probe-alerts
namespace: monitoring
spec:
groups:
- name: probes
rules:
- alert: LivenessProbeFailing
expr: increase(prober_probe_total{probe_type="Liveness",result="failure"}[15m]) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} liveness failing without restart yet"
- alert: ServiceHasNoEndpoints
expr: kube_endpoint_address_available == 0
for: 3m
labels:
severity: critical
마치며 — 프로브는 안전장치가 아니라 권한 위임입니다
프로브를 설정한다는 것은 쿠버네티스에게 트래픽을 끊을 권한과 프로세스를 죽일 권한을 넘기는 일입니다. 그 권한을 어떤 근거로 행사할지를 우리가 정합니다. 근거가 부실하면 아무 문제 없는 시스템이 자기 방어 장치에 의해 무너집니다.
기억할 한 문장은 이것입니다. liveness는 오직 "재시작하면 나아지는가"에만 답해야 하고, 그 답이 아니오인 모든 조건은 readiness로 보내거나 아예 프로브에서 빼야 합니다.
Using the Three Kubernetes Probes Properly — The Exact Boundaries of liveness, readiness, and startup
Introduction — A Perfectly Fine Pod Restarts Every Few Minutes
The application works fine, but the RESTARTS column quietly climbs.
kubectl get pod -n payments
NAME READY STATUS RESTARTS AGE
checkout-7d9f6c5b4d-2xk9p 1/1 Running 11 (4m2s ago) 3h18m
checkout-7d9f6c5b4d-lm4vz 1/1 Running 9 (12m ago) 3h18m
The logs contain nothing but records of normal request handling right up to the moment of termination. The answer is in the events.
kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | tail -5
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 4m12s (x33 over 3h17m) kubelet Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Normal Killing 4m2s (x11 over 3h17m) kubelet Container checkout failed liveness probe, will be restarted
Kubernetes is killing this container. More precisely, we are the ones who told it to.
The Three Probes Answer Different Questions
The syntax of the three probes is identical, which is what makes them confusing, but what they do on failure is completely different.
- startupProbe — "Has startup finished". It completely halts liveness and readiness until it succeeds. Once it succeeds, it never runs again for the lifetime of that container. If it burns through failureThreshold, it kills the container
- readinessProbe — "May this take traffic right now". On failure, the pod address is dropped from the service endpoint. It does not restart the container. When it succeeds, the address comes back in
- livenessProbe — "Does this process need to be restarted". On failure, the kubelet kills the container and brings it back up according to restartPolicy
Let us clear up the most common misunderstanding first. A readiness failure never triggers a restart. People often panic that a pod stays at 0/1 and never restarts, but that is the design working as intended. Restarting is the exclusive authority of liveness and startup.
You confirm a readiness failure at the endpoint.
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 10.42.3.17,10.42.1.9 3h20m
Only two of the three pods are in there. The remaining one is the pod that failed readiness.
kubectl get pod -n payments -l app=checkout \
-o custom-columns='POD:.metadata.name,IP:.status.podIP,READY:.status.containerStatuses[*].ready'
POD IP READY
checkout-7d9f6c5b4d-2xk9p 10.42.3.17 true
checkout-7d9f6c5b4d-lm4vz 10.42.1.9 true
checkout-7d9f6c5b4d-q8w2n 10.42.2.31 false
Three Incidents That Come from Using Them Wrong
| Symptom | Cause | Diagnosis | Fix |
|---|---|---|---|
| A healthy pod restarts on a schedule | liveness is too sensitive, especially the timeoutSeconds default of 1 second | Unhealthy and Killing in Events | Raise timeoutSeconds, lighten the health endpoint |
| 502s and connection resets right after a deploy | No readiness, or it does not reflect real readiness | When the pod enters the EndpointSlice | Add readiness, maxUnavailable 0 |
| A slow-starting app never reaches Ready | Without startup, liveness kills the container mid-boot | The gap between the Started and Killing events | Add startupProbe |
| Every pod restarts at once during a database outage | liveness includes a dependency check | What the health handler calls | Remove the dependency from liveness |
| A full service outage with zero endpoints | readiness includes a dependency every pod shares | Time series of the endpoint count | Allow partial degradation, separate the dependency |
| Requests fail during termination | SIGTERM arrives before endpoint propagation | Compare the pod termination time with the failure time | Insert a preStop delay |
| Exit Code 137 on termination | Fails to shut down within the grace period | 137 with Reason Error | Shorten the drain time, raise the grace period |
| kubelet CPU climbs | Too many exec probes | Count probes by type | Replace with httpGet or grpc |
Incident 1 — The Self-Destruction a Hypersensitive liveness Creates
This is the most dangerous combination. When load piles up, the application responds more slowly. The probe slows down along with it. When it times out, Kubernetes kills the container. More load piles onto the remaining pods. Those pods time out too. It becomes a structure where the higher the load the more things die, and the system tears itself down.
In this scenario liveness is not a stability device but an amplifier. The diagnosis reveals itself immediately when you overlay the restart timestamps on the traffic graph. If restarts cluster only at peak hours, it is confirmed.
Incident 2 — Deploying Without readiness
Without readiness, the pod is treated as Ready the moment the container process comes up, and it enters the endpoint right away. Requests arrive even while the application is still building its connection pool and filling its cache.
kubectl get events -n payments --field-selector reason=Started --sort-by=.lastTimestamp | tail -2
If 5xx spikes for only a few seconds right after a deploy and then returns to normal, it is almost always this problem. Because it repeats on every rolling update, it hardens into a permanent error rate for teams that deploy several times a day.
Incident 3 — When liveness Massacres a Slow Startup
If you attach a liveness probe with initialDelaySeconds 30 to an application that takes 90 seconds to start, the probe begins failing at the 30 second mark, the container dies, comes back up, and dies again. It never reaches Ready.
kubectl describe pod ledger-0 -n payments | grep -E "Started|Killing"
Normal Started 2m11s (x5 over 7m32s) kubelet Started container ledger
Normal Killing 91s (x4 over 6m52s) kubelet Container ledger failed liveness probe, will be restarted
The gap between Started and Killing holds steady at about 40 seconds. That is an unmistakable signal that it is being killed over and over before startup completes.
A common wrong answer: raising initialDelaySeconds to 200. Two costs follow. First, a container that is genuinely dead also goes undetected for the first 200 seconds. Second, that value has to be sized for the slowest startup, so on days when the node is busy it is still not enough. The right answer is startupProbe.
Parameter Math — How Many Seconds Until It Actually Dies
Let us pin down the defaults and the meaning of the five parameters first.
- initialDelaySeconds (default 0) — the wait from container start to the first probe
- periodSeconds (default 10) — how often the probe runs
- timeoutSeconds (default 1) — how long a single probe waits for a response
- failureThreshold (default 3) — how many consecutive failures count as a failure
- successThreshold (default 1) — how many consecutive successes count as a success. Only 1 is allowed for liveness and startup
The timeoutSeconds default of 1 second is the most common seed of an incident. A GC pause, a momentary thread pool saturation, disk latency — any of them can push past 1 second. No matter how light the health endpoint is, if the whole process stalls, the response is late.
Now we calculate. Assume that with the settings below the container comes to a complete stop at second t.
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
- In the worst case the first failing probe starts not right after t but on the next cycle, that is at most t plus 10 seconds
- Because three consecutive failures are required, that means three probes at 10 second intervals, and the last failure lands near t plus 30 seconds
- Each probe burns its 3 second timeout, so the actual verdict comes a few seconds later than that
In other words, the worst case detection delay is roughly periodSeconds times failureThreshold plus timeoutSeconds. The settings above come to about 33 seconds. You have to line this value up with your SLO. For a service that must recover within 30 seconds, you need to drop periodSeconds to 5.
The budget for startupProbe is set by multiplication.
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
5 seconds times 60 attempts allows a startup of up to 300 seconds. During those 300 seconds liveness does not run at all. Once startup finishes, startup is permanently disabled and liveness takes over on a short period. This is the only way to get a generous startup and agile detection at the same time.
Here is the finished form that uses all three probes together.
containers:
- name: checkout
image: registry.example.com/checkout:1.14.2
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
successThreshold: 1
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
Three design intentions are baked in. readiness got a small period and threshold so it drops out fast and comes back fast. liveness is the opposite, set generously so it restarts only when the container has definitively been dead for 60 seconds or more. The paths differ too. liveness uses /healthz and readiness uses /readyz.
Putting Dependencies in liveness Turns a Failure into a Total Outage
This is the implementation that looks most natural when you first build a health check.
GET /healthz
→ SELECT 1 against the database
→ PING to Redis
→ HEAD request to the payment gateway
→ 200 if everything succeeds
It looks honest, but in production it is a disaster. Let us walk through, in order, what happens when the database wobbles for 3 minutes.
- Every pod fails liveness at the same time. They are all looking at the same dependency, so it is all of them without exception
- The kubelet kills every container
- The pods restart simultaneously, build new connection pools, and every cache is emptied
- The instant the database recovers, every pod dumps connections and queries onto it at once
- The database goes over again
- It repeats
A 3 minute database failure escalates into a 30 minute service outage. On top of that, the restarts wipe out the in-flight requests, the in-memory queues, and the warmed caches, which makes recovery even slower.
There is exactly one question liveness has to answer: does restarting this process make things better. When the database is down, restarting the process improves nothing. So the database must not go into liveness.
The only things that belong in liveness are the states a restart actually fixes. Roughly: a stalled event loop, a deadlock, and unrecoverable corruption of internal state.
GET /healthz
→ check only whether the process can accept a request and produce a response
→ always 200, except that if the event loop is blocked the response never goes out at all
This simple handler is the best option in most cases. And a conclusion one step beyond it is also possible. If there is no failure mode that a restart would fix, it is safer to have no liveness probe at all. Without a probe, the container restarts only when the process dies, and that is usually the right behavior.
How Far Can You Go with readiness
Because readiness does not trigger a restart, there is room to put dependency checks in it. The criterion is this. Is there even a single request this pod can serve without that dependency.
- If every request needs the database, putting it in readiness is reasonable. It is better not to accept requests that would fail anyway
- If only some of them need it, you should not put it in. You would also block the requests that could be served from a static response or a cache
And once you decide to put it in, there is one thing you absolutely must check. If every pod goes NotReady at the same time, the service ends up with zero endpoints and clients get their connections refused outright. That can be a worse experience than a 5xx, and because there is no health signal at all at the moment of recovery, diagnosis gets harder too.
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout -o wide
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 <unset> 3h44m
If ENDPOINTS is empty, this is the state you are in. When you put a dependency check in readiness, you also have to consider a policy that keeps at least one instance in, or a design that treats a dependency failure as partial degradation.
Choosing a Probe Type and the Cost of exec
There are four methods, and they differ in cost and reliability.
httpGet — the default choice. The kubelet sends the request directly to the pod IP. It treats 200 through 399 as success. There is one trap here. If an auth proxy sends a 302 redirect from the health path to a login page, the probe is judged a success. It succeeds even when the application is dead. The health path must be excluded from authentication and must return 200 directly.
tcpSocket — it only looks at whether the port is open. The kernel maintains the listener socket, so it succeeds even when every application thread has stalled. It is the least reliable, so use it only when there is no other option.
grpc — for a gRPC service, the kubelet calls the standard health protocol directly. There is no need to bake a health check binary into the image and call it through exec the way you used to.
readinessProbe:
grpc:
port: 9000
service: readiness
periodSeconds: 5
exec — the most expensive. Every time the probe runs, it creates a new process inside the container. The container runtime pays the cost of creating a task, setting up namespaces, and reaping the process every single time. Put an exec probe with a 10 second period on 500 pods and the cluster as a whole sees 50 process creations per second, continuously.
The cost shows up in two places. The kubelet and runtime CPU on the node climb, and because the probe process belongs to the cgroup of the container, it eats into the CPU and memory budget of the application. For an exec probe that invokes a shell script, the memory of the shell itself is included in that. In a container with a tight memory limit, the probe can even become the trigger for an OOM.
Counting what you actually have is usually a surprise.
kubectl get pods -A -o json | \
jq -r '.items[].spec.containers[].livenessProbe | select(.!=null) | keys[]' | \
grep -v Seconds | grep -v Threshold | sort | uniq -c | sort -rn
412 httpGet
87 exec
23 tcpSocket
A good share of those 87 exec probes are scripts that call curl, and that is a cost that disappears outright if you switch to httpGet.
If you use a service mesh, there is one more thing. Because the kubelet sends the request directly to the pod IP, the probe bypasses the mTLS policy of the sidecar. If the mesh requires strict mutual TLS the probe fails, so most meshes offer an option to rewrite the probe path so the sidecar receives it instead. If every probe suddenly starts failing while the application is perfectly fine, suspect this.
The Termination Path — The Race Condition Between Endpoint Removal and SIGTERM
No matter how well you tune the probes, if errors spike on every rolling update, the cause is in the termination path.
When a pod is deleted, two things start simultaneously and with no coordination between them.
- The kubelet path — it runs the preStop hook, sends SIGTERM to the main container process, and sends SIGKILL once the grace period ends
- The endpoint path — the controller removes the pod from the EndpointSlice, and that change propagates to the kube-proxy on every node, to the ingress controllers, and to the service mesh sidecars
The problem is that the second one is slow. It takes hundreds of milliseconds to several seconds for the controller to notice, to update the EndpointSlice, and for every node to receive it and rewrite its rules. With a large cluster or an external load balancer in the mix, it takes longer.
Meanwhile the container has already received SIGTERM and closed its listener. Requests that are still routed to this pod meet a connection refusal. This is exactly why a small number of 502s and connection resets show up during a deploy.
The fix is to delay SIGTERM on purpose. The preStop hook runs before SIGTERM and SIGTERM is held back until it completes, so you put a delay here that waits for propagation.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: checkout
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 15']
Recent versions let you use a dedicated handler that does not invoke a shell. It is especially useful in a distroless environment where the image has no shell.
lifecycle:
preStop:
sleep:
seconds: 15
Here you absolutely have to calculate the grace period budget. terminationGracePeriodSeconds is the entire budget, including the preStop execution time.
- 15 seconds of preStop delay
- up to 30 seconds to drain in-flight requests
- 15 seconds of slack
- 60 seconds in total
If you skip this calculation, the grace period is exhausted the moment preStop ends and SIGKILL flies. That is the situation where exit code 137 gets stamped with Reason Error, and because it shares an exit code with the memory problem covered in the OOMKilled article, it is easy to misdiagnose.
The application side has to handle SIGTERM properly too. Refuse new connections but finish the in-flight requests, and let background workers wrap up their current job before exiting. An application with no SIGTERM handler dies instantly under the default behavior, so no matter how long you make preStop, in-flight requests get cut off.
kubectl exec -it checkout-7d9f6c5b4d-2xk9p -n payments -- \
sh -c 'grep SigCgt /proc/1/status'
SigCgt: 0000000000014002
Whether bit 15 is set in this bitmask tells you if a SIGTERM handler is registered. For containers launched through a shell, you also have to check the classic problem where the shell becomes PID 1 and does not forward signals to its children.
A Standard Template and a Checklist
When you build a new service, use the below as a starting point and adjust only the values to fit the characteristics of the service.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
sleep:
seconds: 15
startupProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
Check five things before you deploy.
- Does the liveness handler stay clear of calling external dependencies
- Has timeoutSeconds been left at 1
- Does the worst case startup time fit inside the startupProbe budget
- Is terminationGracePeriodSeconds larger than the sum of the preStop delay and the drain time
- Does the application receive SIGTERM and start draining
As a metric for preventing recurrence, tracking probe failures themselves is useful. They get caught at the stage before they lead to a restart.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: probe-alerts
namespace: monitoring
spec:
groups:
- name: probes
rules:
- alert: LivenessProbeFailing
expr: increase(prober_probe_total{probe_type="Liveness",result="failure"}[15m]) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} liveness failing without restart yet"
- alert: ServiceHasNoEndpoints
expr: kube_endpoint_address_available == 0
for: 3m
labels:
severity: critical
Closing — A Probe Is Not a Safety Device but a Delegation of Authority
Configuring a probe means handing Kubernetes the authority to cut traffic and the authority to kill a process. We are the ones who decide on what grounds that authority gets exercised. If the grounds are weak, a system with nothing wrong with it collapses under its own defense mechanism.
Here is the one sentence to remember. liveness must answer only "does restarting make it better", and every condition whose answer is no has to be sent to readiness or taken out of the probes entirely.