- Published on
K8s CrashLoopBackOff - OOMKilled·Probe·Exit 137 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡해 보이는데 Pod가 계속 재시작되며 CrashLoopBackOff로 빠지는 상황은 운영에서 가장 흔한 장애 패턴 중 하나입니다. 특히 OOMKilled, Readiness/Liveness probe failed, Exit Code 137이 함께 보이면 “메모리 문제인지, 프로브 설정 문제인지, 앱이 진짜 크래시 난 건지”가 뒤섞여 진단이 어려워집니다.
이 글은 증상(BackOff) 과 원인(OOM, probe, SIGKILL) 을 분리해서 관찰하는 순서로, 빠르게 결론에 도달하는 체크리스트를 제공합니다.
CrashLoopBackOff를 정확히 이해하기
CrashLoopBackOff는 “컨테이너가 반복적으로 종료되며, kubelet이 재시작 간격을 점점 늘리는(backoff) 상태”를 의미합니다. 즉 CrashLoopBackOff 자체는 원인이 아니라 결과입니다.
원인은 크게 다음 범주로 나뉩니다.
- 애플리케이션 프로세스가 오류로 종료(예: exit 1, uncaught exception)
- OOMKilled(메모리 부족으로 커널이 종료)
- Liveness probe 실패로 kubelet이 컨테이너를 강제 재시작
- 노드/런타임 이슈(디스크 압박, cgroup 문제, containerd 문제 등)
여기서 운영에서 가장 많이 섞이는 조합이 OOMKilled + Exit 137 + probe 실패입니다.
1단계: kubectl로 “무슨 이유로 죽었는지” 확정하기
Pod 상태/이벤트 확인
가장 먼저 해야 할 일은 describe로 이벤트와 마지막 종료 사유를 보는 것입니다.
kubectl -n <ns> describe pod <pod>
아래 항목을 집중적으로 봅니다.
Last State: Terminated의Reason,Exit Code,FinishedState: Waiting의Reason: CrashLoopBackOff- 하단
Events의Killing,Back-off restarting failed container,Unhealthy메시지
종종 다음처럼 결정적인 힌트가 바로 나옵니다.
Reason: OOMKilledExit Code: 137Liveness probe failed: HTTP probe failed with statuscode: 500
종료 코드 137의 의미 (중요)
Exit Code 137은 보통 128 + 9(SIGKILL) 입니다. 즉 프로세스가 SIGKILL로 강제 종료되었다는 뜻이고, 대표 원인은:
- OOMKilled: 메모리 초과로 커널 OOM killer가 SIGKILL
- kubelet이 강제 kill: 종료(graceful termination) 시간 초과, 혹은 런타임/노드 이슈
- 운영자가
kubectl delete pod --force등으로 강제 종료
따라서 137을 봤다면 곧바로 “OOM이네”라고 단정하지 말고, Reason이 OOMKilled인지를 함께 확인해야 합니다.
이전 컨테이너 로그 확인 (-p)
CrashLoop에서 현재 컨테이너는 이미 재시작된 상태일 수 있으므로 이전 인스턴스 로그가 핵심입니다.
kubectl -n <ns> logs <pod> -c <container> --previous
- 앱이 시작 직후 예외로 죽는지
- 특정 요청/초기화 단계에서 메모리를 급격히 쓰는지
- 프로브 엔드포인트가 500을 내는지
를 확인합니다.
2단계: OOMKilled 진단 — “Limit 초과”인지 “노드 메모리 압박”인지
OOMKilled는 크게 두 가지로 나뉩니다.
- 컨테이너 메모리 limit 초과(OOMKilled): 가장 흔함
- 노드 전체 메모리 부족(Node OOM): 다른 Pod도 연쇄적으로 불안정
컨테이너 limit/requests 확인
kubectl -n <ns> get pod <pod> -o jsonpath='{range .spec.containers[*]}{.name}{"\n"}{.resources}{"\n\n"}{end}'
메모리 limit가 너무 작으면, 앱이 정상이어도 특정 트래픽/캐시/초기 로딩에서 쉽게 OOM이 납니다.
실사용량 확인 (metrics-server)
kubectl -n <ns> top pod <pod>
kubectl top node
- Pod 메모리가 limit에 계속 닿는지
- 노드 메모리가 전체적으로 부족한지
를 봅니다.
OOMKilled가 났을 때 자주 하는 실수
requests만 올리고limits는 그대로 둠 → OOMKilled는 계속 발생- limit을 과도하게 올림 → 노드 밀도 저하, 다른 워크로드에 영향
- JVM/Node/Python 런타임의 힙 설정을 무시함 → limit보다 큰 힙을 잡아 OOM
JVM 예시: limit과 Xmx 불일치
컨테이너 limit이 512Mi인데 -Xmx1g면 거의 확정적으로 OOMKilled입니다.
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0"
MaxRAMPercentage 기반으로 컨테이너 메모리 limit에 맞춰 힙을 자동 조정하는 방식이 안전합니다.
Node.js 예시: old space 제한
node --max-old-space-size=384 server.js
컨테이너 limit이 512Mi라면, old space를 384MB 수준으로 제한하고 네이티브/버퍼 메모리 여유를 남기는 식으로 튜닝합니다.
3단계: Probe 실패 진단 — “앱이 죽어서 실패” vs “프로브가 죽인다”
프로브는 상태를 관찰하기도 하지만, 특히 liveness는 실패 시 컨테이너를 죽이는 트리거가 됩니다. 따라서 잘못된 프로브 설정은 정상 앱도 반복 재시작시키는 원인이 됩니다.
흔한 패턴 1) 스타트업이 느린데 liveness가 너무 빠름
앱이 초기화에 40초 걸리는데 liveness가 10초부터 때리면, 앱은 준비되기 전에 계속 죽습니다.
해결책은 startupProbe를 도입하거나, initialDelaySeconds를 충분히 주는 것입니다.
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 20 # 최대 100초까지 스타트업 허용
흔한 패턴 2) readiness 실패를 liveness로 착각
- readiness 실패: 트래픽에서 제외(재시작은 안 함)
- liveness 실패: 컨테이너 재시작
외부 의존성(DB, Redis, 외부 API)이 잠깐 불안정할 때 readiness는 실패해도 되지만, liveness까지 실패하게 만들면 불필요한 재시작 폭풍이 생깁니다.
권장:
liveness: “프로세스가 살아있는가/데드락인가” 같은 최소 조건readiness: “지금 트래픽을 받을 준비가 되었는가” (의존성 포함 가능)
흔한 패턴 3) 프로브 타임아웃이 너무 짧음
GC/CPU 스파이크가 있을 때 1초 타임아웃은 과격합니다.
timeoutSeconds: 3
failureThreshold: 3
periodSeconds: 10
처럼 “일시적 지연”을 흡수할 완충을 둡니다.
4단계: Exit 137인데 OOMKilled가 아닐 때
Exit Code 137이지만 Reason: OOMKilled가 아니라면 다음을 의심합니다.
1) 종료 유예 시간 초과 (terminationGracePeriodSeconds)
Pod가 종료 SIGTERM을 받고도 지정 시간 내 종료하지 못하면 SIGKILL(=137)로 강제 종료됩니다.
- 긴 요청 처리/flush/shutdown 훅이 있는 서버
- preStop 훅이 오래 걸리는 경우
terminationGracePeriodSeconds: 60
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
그리고 애플리케이션도 SIGTERM을 받아 정상 종료하도록 구현되어야 합니다.
2) 노드 압박/퇴거(Eviction)와 혼동
노드가 MemoryPressure, DiskPressure 상태이면 Pod가 퇴거되거나 강제 종료될 수 있습니다.
kubectl describe node <node>
Events에 Evicted 또는 pressure 관련 메시지가 있는지 확인합니다.
5단계: 재발 방지 — 리소스/프로브/관측성의 3종 세트
리소스 설정 가이드라인
requests: “평균/기본 사용량” 기반limits: “피크 + 안전마진” 기반- OOM이 반복되면 limit 상향 + 메모리 사용 원인 추적을 같이 진행
예시:
resources:
requests:
cpu: "200m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
프로브 설계 가이드라인
startupProbe로 초기 부팅 보호- readiness는 의존성 포함 가능, liveness는 최소화
- 타임아웃/임계치로 순간 스파이크 흡수
관측성: “왜 메모리가 늘었는지”를 남겨라
OOMKilled는 결과일 뿐, 원인은 메모리 누수/캐시 폭주/대량 로딩/버퍼 증가 등 다양합니다.
- 애플리케이션 메트릭(힙, RSS, GC)
- 요청당 메모리 증가
- 특정 엔드포인트/배치 실행 시 급증
을 대시보드로 남기면 다음 CrashLoop에서 진단 시간이 급격히 줄어듭니다.
MSA 환경에서 타임아웃/데드라인이 연쇄 장애를 만들듯, K8s에서도 probe/리소스 설정이 연쇄 재시작을 만들 수 있습니다. 관련해서는 gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결처럼 “작은 설정 누락이 장애를 증폭”시키는 패턴을 함께 참고하면 좋습니다.
실전 체크리스트 (요약)
아래 순서대로 보면 대부분 10분 내에 원인 범주가 좁혀집니다.
kubectl describe pod로Reason/Exit Code/Events확인kubectl logs --previous로 “죽기 직전” 로그 확인Reason: OOMKilled면- limit/requests 확인 →
kubectl top으로 실제 사용량 확인 - 런타임 힙/버퍼 설정이 limit을 넘지 않는지 점검
- limit/requests 확인 →
Unhealthy이벤트가 많으면- startupProbe 도입 여부
- readiness/liveness 역할 분리
- timeout/threshold 완충
- 137인데 OOMKilled가 아니면
- terminationGracePeriodSeconds, SIGTERM 핸들링
- 노드 pressure/eviction 확인
마무리
CrashLoopBackOff는 “앱이 죽었다”가 아니라 “쿠버네티스가 계속 재시작하고 있다”는 신호입니다. 따라서 (1) 종료 사유(OOMKilled/137) 확정 → (2) 프로브가 죽이는지 여부 분리 → (3) 리소스·프로브·종료 시그널을 함께 조정하는 방식으로 접근해야 가장 빠르게 해결됩니다.
특히 Exit 137은 OOM의 전형적인 신호이지만, 언제든지 “강제 SIGKILL”의 결과일 수 있으므로 Reason과 이벤트를 반드시 함께 보세요.