- Published on
K8s CrashLoopBackOff - OOMKilled·Probe 5분 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡한데 Pod가 계속 재시작되면 대부분 CrashLoopBackOff로 보입니다. 하지만 CrashLoopBackOff는 원인이 아니라 결과입니다. 핵심은 “컨테이너가 왜 종료(exit)했는가”와 “왜 kubelet이 죽였는가(OOM, Probe 실패 등)”를 5분 안에 갈라내는 것입니다.
이 글은 현장에서 가장 빈도가 높은 두 축인 OOMKilled와 Liveness/Readiness Probe 실패를 중심으로, 최소 명령어로 빠르게 결론에 도달하는 진단 루틴을 제공합니다.
0. 5분 진단 로드맵(먼저 이대로 따라하기)
아래 순서대로 보면 대부분 5분 내에 원인이 좁혀집니다.
- 이벤트/상태로 “누가 죽였는지” 확인
kubectl -n <ns> get pod <pod> -o wide
kubectl -n <ns> describe pod <pod>
Last State: Terminated의Reason이 OOMKilled인지Events에Liveness probe failed,Readiness probe failed,Back-off restarting failed container가 있는지
- 직전 크래시 로그 확보(재시작 루프에서 가장 중요)
kubectl -n <ns> logs <pod> -c <container> --previous --tail=200
- 컨테이너 종료 코드/시그널 확인
kubectl -n <ns> get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason} {.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
- OOMKilled면 대개
exitCode: 137(SIGKILL) - 애플리케이션이 스스로 죽으면
exitCode: 1등 다양
- OOM이면 “limit/실사용/스파이크”를 바로 확인
kubectl -n <ns> top pod <pod>
kubectl -n <ns> get pod <pod> -o jsonpath='{.spec.containers[0].resources}'
- Probe면 “엔드포인트/타임아웃/초기 지연/포트”를 바로 확인
kubectl -n <ns> get pod <pod> -o yaml | yq '.spec.containers[].livenessProbe, .spec.containers[].readinessProbe'
이제부터는 OOMKilled와 Probe를 각각 더 깊게 파고듭니다.
1. CrashLoopBackOff의 정체: ‘재시작 백오프’일 뿐
CrashLoopBackOff는 kubelet이 컨테이너를 재시작하려고 시도했지만, 짧은 시간에 반복 실패해서 재시작 간격(backoff)을 늘리는 상태입니다.
- 컨테이너가 즉시 종료(잘못된 커맨드, 설정 오류, 예외로 프로세스 종료)
- kubelet이 강제 종료(OOMKilled, liveness probe 실패)
둘 중 하나가 대부분입니다. 따라서 “왜 종료했는지”를 먼저 분류해야 합니다.
2. OOMKilled 2분 진단: ‘누가 메모리를 죽였나’부터
OOMKilled는 크게 두 종류가 있습니다.
- 컨테이너 OOMKilled: cgroup memory limit 초과 → kubelet이 SIGKILL
- 노드 OOM(시스템 OOM): 노드 메모리 고갈 → 커널 OOM killer가 희생자 선택
describe pod에서 Reason: OOMKilled가 보이면 우선 컨테이너 limit 초과를 의심합니다.
2.1 빠른 체크리스트
(1) requests/limits 확인
kubectl -n <ns> get deploy <deploy> -o jsonpath='{.spec.template.spec.containers[0].resources}{"\n"}'
limits.memory가 너무 낮거나requests.memory가 너무 낮아 노드에 과밀 스케줄링되었거나
(2) 현재 사용량(스파이크는 놓칠 수 있음)
kubectl -n <ns> top pod <pod> --containers
top은 “현재” 값이라, 순간 스파이크로 죽는 OOM은 안 보일 수 있습니다. 이 경우 Prometheus/Grafana의 container_memory_working_set_bytes 같은 시계열이 필요합니다.
(3) 종료 코드 137 확인
kubectl -n <ns> get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
보통 OOMKilled는 137입니다.
2.2 OOMKilled의 흔한 원인 6가지
- JVM/Node/Python 런타임의 기본 메모리 정책
- Java: 컨테이너 메모리 인식 옵션/JDK 버전 차이
- Node.js: 기본 old space 제한(버전에 따라 다름)
- 캐시/버퍼가 무한정 커짐
- in-memory cache, LRU 설정 누락
대용량 요청 처리(압축/파싱/이미지 변환)로 순간 피크
로그/메트릭 버퍼 폭증
- stdout 폭주 + sidecar/agent 버퍼
- 메모리 누수
- 특정 엔드포인트 호출 패턴에서만 증가
- requests가 너무 낮아 노드가 과밀(노드 OOM 유발)
2.3 즉시 적용 가능한 대응(안전한 순서)
(A) limit 상향 + request 동반 조정
limit만 올리면 노드가 과밀해져 다른 Pod까지 연쇄 장애가 날 수 있습니다. request도 현실에 맞게 올려 스케줄링을 안정화하세요.
resources:
requests:
cpu: "200m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
(B) 애플리케이션 메모리 상한 설정
예: Node.js
node --max-old-space-size=768 server.js
예: JVM(컨테이너 환경에 맞춰)
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50" \
java -jar app.jar
(C) HPA/부하 분산으로 ‘피크’를 쪼개기
메모리 피크가 트래픽과 연동되면 스케일아웃이 더 근본적인 해결일 수 있습니다. 다만 HPA가 기대처럼 줄지 않는 이슈(예: PDB, stabilization window 등)가 있으면 스케일 정책부터 점검해야 합니다.
관련 글: Kubernetes HPA가 0으로 안 줄 때 - PDB·윈도우·종료
3. Probe 실패 3분 진단: ‘정상인데 죽는다’의 전형
Probe는 크게 두 가지 목적이 있습니다.
- Readiness: 트래픽을 받을 준비가 됐는가(로드밸런싱 포함 여부)
- Liveness: 프로세스가 비정상 상태로 굳었는가(재시작 필요 여부)
문제는 liveness를 readiness처럼 쓰면, 초기 구동/일시적 지연에도 컨테이너를 계속 죽여서 CrashLoopBackOff가 됩니다.
3.1 describe pod 이벤트로 먼저 분류
kubectl -n <ns> describe pod <pod> | sed -n '/Events/,$p'
자주 보이는 패턴:
Liveness probe failed: HTTP probe failed with statuscode: 500Readiness probe failed: dial tcp ...: connect: connection refusedcontext deadline exceeded(timeout)
3.2 가장 흔한 원인 7가지
- 포트/경로 불일치
- 앱은 8080인데 probe는 80
/health가 아니라/actuator/health
- 초기화 시간이 긴데 initialDelaySeconds가 짧음
- DB 마이그레이션, 캐시 워밍업, 모델 로딩
- timeoutSeconds가 너무 짧음
- GC/IO로 1~2초 멈추는 순간에 실패
failureThreshold가 낮아 ‘스파이크’에 취약
liveness가 외부 의존성(DB, Redis)까지 확인
- 외부 장애 시 앱을 계속 재시작 → 더 악화
- HTTP 500을 liveness로 바로 죽임
- 일시적 500은 재시작보다 트래픽 차단(readiness)이 적절
- startupProbe 없이 liveness를 바로 켬
- 시작 중인 컨테이너를 liveness가 죽임
3.3 권장 Probe 설계(실전 템플릿)
초기 구동이 길 수 있는 서비스라면 startupProbe를 적극 권장합니다.
containers:
- name: app
image: your/app:1.0.0
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: 8080
# 최대 60초까지 시작을 기다림 (10 * 6)
failureThreshold: 10
periodSeconds: 6
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 0
timeoutSeconds: 2
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /livez
port: 8080
# startupProbe 통과 후에만 의미가 생김
timeoutSeconds: 2
periodSeconds: 10
failureThreshold: 3
설계 원칙:
startupProbe: “부팅 완료”만 확인(길게)readinessProbe: “트래픽 받을 준비” 확인(외부 의존성 포함 가능)livenessProbe: “프로세스가 살아있고 응답 가능한가”만 확인(외부 의존성은 가급적 제외)
3.4 로컬에서 probe를 그대로 재현하기
Pod 안에서 probe 엔드포인트를 직접 호출해 “진짜로 느린지/응답이 없는지” 확인합니다.
kubectl -n <ns> exec -it <pod> -c <container> -- sh -lc 'wget -qO- -S http://127.0.0.1:8080/healthz'
또는 임시 디버그 Pod로 네트워크 관점에서 확인:
kubectl -n <ns> run netshoot --rm -it --image=nicolaka/netshoot -- bash
# inside
curl -v http://<pod-ip>:8080/healthz
4. OOMKilled + Probe가 같이 보일 때(가장 헷갈리는 케이스)
현장에서는 다음 조합이 자주 나옵니다.
- 메모리가 빡빡함 → GC/스왑 없는 환경에서 지연 증가 → readiness timeout
- 지연 중에 liveness 실패 → 재시작
- 재시작 직후 트래픽 재유입 → 메모리 피크 → OOMKilled
이 경우 먼저 OOM을 해결(limit/request/상한)하고, 그 다음 **probe를 완화(startupProbe, timeoutSeconds 조정)**하는 순서가 안정적입니다.
5. 운영 팁: 재시작 루프를 멈추고 ‘증거’를 남기는 방법
CrashLoopBackOff는 로그를 놓치기 쉽습니다. 아래를 습관화하면 재현 없이도 원인 파악이 빨라집니다.
5.1 terminationMessage 활용
앱 종료 직전에 핵심 정보를 /dev/termination-log로 남기면 describe pod에서 바로 볼 수 있습니다.
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: FallbackToLogsOnError
5.2 preStop + graceful shutdown
종료가 급하면 요청 처리 중인 워커가 비정상 종료하고 다음 부팅에서 더 오래 걸릴 수 있습니다.
lifecycle:
preStop:
exec:
command: ["sh", "-lc", "sleep 10"]
terminationGracePeriodSeconds: 30
6. 결론: 5분 안에 결론내는 질문 3개
CrashLoopBackOff를 보면 아래 3가지만 순서대로 답하면 됩니다.
- 누가 죽였나? (OOMKilled / liveness / 앱 자체 종료)
- 죽기 직전 로그는 무엇인가? (
--previous) - 즉시 완화책은 무엇인가? (메모리 상향·상한 설정 / startupProbe 도입·timeout 조정)
추가로, 인그레스/로드밸런서 레벨에서 499/500이 같이 튀는 경우에는 “Pod가 readiness를 제대로 유지하는지”까지 연결해서 봐야 합니다. 트래픽 리셋/클라이언트 중단이 함께 보이면 아래 글도 참고할 만합니다.
이제 describe pod에서 OOMKilled인지 probe 실패인지부터 갈라내고, 위 템플릿대로 조정하면 CrashLoopBackOff의 80%는 빠르게 정리됩니다.