- Published on
K8s CrashLoopBackOff - liveness probe 오탐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 죽지 않았는데 Pod가 계속 재시작되는 상황은 운영에서 가장 피로한 케이스 중 하나입니다. 특히 CrashLoopBackOff가 떴지만 애플리케이션 로그를 보면 치명적인 에러가 없고, 부하가 조금만 올라가도 재시작이 반복된다면 liveness probe 오탐(false positive) 을 가장 먼저 의심해야 합니다.
이 글에서는 liveness/readiness/startup probe의 역할 차이부터, 오탐을 증명하는 방법, 그리고 “죽은 프로세스만 확실히 죽이고(=liveness)”, “트래픽만 안전하게 막는(=readiness)”, “느린 부팅을 허용하는(=startup)” 형태로 프로브를 재설계하는 과정을 정리합니다.
CrashLoopBackOff에서 liveness 오탐을 의심해야 하는 신호
다음 징후가 함께 보이면, 실제 크래시가 아니라 kubelet이 liveness 실패로 강제 재시작시키는 패턴일 가능성이 큽니다.
- 컨테이너 종료 코드가
137(SIGKILL) 또는143(SIGTERM)처럼 “외부에서 죽임”에 가까움 kubectl describe pod의 Events에Liveness probe failed가 반복- 애플리케이션 로그에는 치명적 예외가 없고, 재시작 직전까지 정상 요청도 처리
- CPU 스파이크/GC/DB 지연 등으로 특정 구간에서 응답이 느려질 때만 재현
> 참고로, 네트워크/iptables 계열 문제로 특정 컴포넌트가 CrashLoopBackOff가 나는 케이스도 있습니다. 예를 들어 kube-proxy가 iptables 오류로 재시작되는 유형은 접근법이 다르니, 유사 증상이면 EKS kube-proxy CrashLoopBackOff iptables 오류 해결도 같이 확인해보세요.
1) 먼저 “진짜 크래시”인지 “프로브가 죽였는지” 증명하기
Pod 이벤트 확인
kubectl describe pod <pod-name> -n <ns>
Events에 아래가 반복되면 거의 확정입니다.
Liveness probe failed: ...Killing container with id ...
종료 원인/코드 확인
kubectl get pod <pod-name> -n <ns> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{" "}{.status.containerStatuses[0].lastState.terminated.exitCode}{" "}{.status.containerStatuses[0].lastState.terminated.finishedAt}{"\n"}'
reason=Error라도 exitCode가 137/143이면 “프로세스 내부 크래시”보다 “외부 종료”일 가능성이 큼OOMKilled면 probe가 아니라 메모리 문제이므로 limit/누수/GC를 봐야 함
kubelet 관점에서 probe 실패 메시지 확인
클러스터 접근 권한이 있다면 노드에서 kubelet 로그를 확인하면 가장 명확합니다.
# systemd 기반 노드
sudo journalctl -u kubelet -f
노드에서 서비스가 반복 재시작하는 경우의 추적 기법은 Kubernetes에 국한되지 않습니다. 원인 추적 흐름이 필요하면 systemd 서비스가 반복 재시작될 때 원인 추적법도 도움이 됩니다.
2) liveness/readiness/startup probe 역할을 헷갈리면 오탐이 필연
프로브 설계의 핵심은 “각 probe가 무엇을 책임지는지”를 분리하는 것입니다.
- livenessProbe: 프로세스가 “회복 불가능하게” 멈췄는지 확인 → 실패 시 컨테이너 재시작
- readinessProbe: 지금 트래픽을 받을 준비가 되었는지 확인 → 실패해도 재시작하지 않고 Endpoints에서 제외
- startupProbe: 부팅이 느린 앱이 초기화 동안 liveness에 맞아 죽지 않게 보호 → 성공할 때까지 liveness/readiness를 유예
운영에서 가장 흔한 실수는 아래 둘입니다.
- liveness에 “외부 의존성(DB, Redis, 외부 API)” 체크를 넣는다
- DB가 잠깐 느려져도 앱은 살아있는데, liveness가 실패해서 컨테이너를 죽임
- readiness와 liveness를 동일 엔드포인트로 둔다
- 트래픽을 잠깐 끊으면 될 상황을 재시작으로 악화시킴
3) 오탐을 만드는 전형적인 패턴과 해결 전략
패턴 A: /health가 너무 무겁다 (DB/캐시/외부 API 포함)
증상
- DB 커넥션 풀 고갈, 네트워크 지연, 외부 API rate limit 때마다 liveness 실패
해결
- liveness는 “프로세스 내부 상태”로 최소화
- readiness에서만 의존성 체크(또는 별도
/_ready)
권장 엔드포인트 설계 예:
/_live: 이벤트 루프/스레드가 살아있고, 치명적 데드락/패닉 상태가 아님/_ready: DB 연결 가능, 큐 소비 가능, 필수 의존성 OK
패턴 B: timeout/period/failureThreshold가 너무 공격적
예: timeoutSeconds: 1, failureThreshold: 3, periodSeconds: 5
- 순간적인 GC Stop-the-world, CPU 스파이크에도 15초 안에 재시작
해결
- timeout을 현실적으로(예: 2~5초)
- failureThreshold를 늘려 “짧은 흔들림”을 흡수
- periodSeconds를 늘려 probe 자체 부하도 줄임
패턴 C: 초기 부팅이 느린데 startupProbe가 없다
증상
- 기동 시 마이그레이션/캐시 워밍업/모델 로딩 때문에 30~120초 걸리는데
- liveness가 10초 내 응답을 기대 → 뜨자마자 죽음
해결
startupProbe로 “기동 완료 전까지는 죽이지 않기”
4) 실전 YAML: 오탐을 줄이는 프로브 구성
아래 예시는 흔한 웹 API(HTTP) 기준이며, 핵심은 liveness는 가볍게, readiness는 의존성 포함 가능, startup으로 초기 구간 보호입니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: myrepo/api:1.2.3
ports:
- containerPort: 8080
# 1) 느린 부팅 보호: 이 probe가 성공하기 전까지 liveness/readiness는 사실상 유예됨
startupProbe:
httpGet:
path: /_startup
port: 8080
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 30 # 최대 150초(5*30)까지 부팅 허용
# 2) 프로세스 생존만 확인: 외부 의존성 체크 금지
livenessProbe:
httpGet:
path: /_live
port: 8080
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6 # 최대 60초 흔들림 허용
# 3) 트래픽 수신 가능 여부: 여기에는 DB/캐시 등 의존성 체크가 들어가도 됨
readinessProbe:
httpGet:
path: /_ready
port: 8080
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
엔드포인트 구현 팁(개념)
/_live: 단순히 200 OK를 반환하되, 앱 내부적으로 “치명 상태” 플래그(예: 이벤트 루프 정지 감지, 메인 워커가 죽음, panic 상태)를 감지하면 500 반환/_ready: DB ping, 필수 큐/캐시 접근 등 “트래픽을 받으면 실패할 조건”을 포함/_startup: 마이그레이션/워밍업 완료 후 200 반환
Node.js/Java/Spring/Go 모두 동일한 원칙으로 적용 가능합니다.
5) 디버깅 체크리스트: 오탐인지 빠르게 가르는 방법
(1) probe 실패 시각과 앱 로그 시각을 맞춰보기
- probe 실패 직전 GC 로그, 스레드 덤프, DB 타임아웃이 있는지
- 애플리케이션이 “죽기 직전”까지 정상 처리했는지
(2) probe 자체를 Pod 내부에서 재현
kubectl exec -n <ns> -it <pod-name> -- sh
# 컨테이너 내부에서 동일 경로 호출
wget -qO- http://127.0.0.1:8080/_live
wget -qO- http://127.0.0.1:8080/_ready
컨테이너 내부에서 빠른데 kubelet에서만 실패하면, 다음을 의심합니다.
- 애플리케이션이
0.0.0.0이 아니라127.0.0.1에만 바인딩(또는 그 반대) - 네트워크 정책/사이드카 프록시/iptables 영향
- TLS 리다이렉트(HTTP→HTTPS)로 probe가 301/302를 받고 실패
(3) HTTP 상태 코드/리다이렉트/인증 요구 확인
- probe는 기본적으로 단순 HTTP GET이며, 인증 헤더를 넣기 어렵습니다.
/health가 인증을 요구하거나 302로 튕기면 실패합니다.
6) 운영에서 자주 쓰는 “안전한” 튜닝 가이드
정답은 서비스 특성마다 다르지만, 오탐을 줄이는 방향성은 비교적 일정합니다.
- timeoutSeconds: 1초는 너무 짧은 경우가 많음(특히 JVM/파이썬/부하 시). 2~5초부터 시작
- failureThreshold: liveness는 3보다 5~10이 운영 친화적(짧은 스파이크 흡수)
- periodSeconds: 5초는 민감, 10초는 무난. 너무 짧으면 probe 트래픽 자체가 부하가 됨
- startupProbe: “최악의 콜드 스타트 시간”을 기준으로 넉넉히
추가로, readiness 실패 시 트래픽이 빠지는지(Endpoints에서 제외되는지) 확인해야 합니다.
kubectl get endpointslice -n <ns> -l app=api
7) 결론: liveness는 ‘죽었을 때만’, readiness는 ‘못 받을 때만’
CrashLoopBackOff가 보이면 대부분은 애플리케이션 크래시를 먼저 떠올리지만, 실제 현장에서는 liveness probe 오탐이 매우 흔합니다. 해결의 핵심은 다음 3가지입니다.
- Events에서
Liveness probe failed를 확인해 “kubelet이 죽였는지”부터 증명 - liveness를 가볍게(외부 의존성 제거), readiness로 트래픽 제어
- 느린 기동은 startupProbe로 보호하고, timeout/threshold를 현실적으로 튜닝
이렇게 바꾸면 “살아있는 프로세스를 계속 죽이는” 악순환을 끊고, 장애를 재시작이 아닌 트래픽 제어(readiness) 로 완화하는 방향으로 운영 안정성을 끌어올릴 수 있습니다.