- Published on
K8s CrashLoopBackOff에서 OOMKilled 원인 추적
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 죽었다가 살아나기를 반복하는 CrashLoopBackOff는 증상일 뿐이고, 그 뒤에 숨어 있는 원인이 OOMKilled(Out Of Memory kill)라면 문제는 더 까다로워집니다. 단순히 메모리 limit을 올리는 것으로 “해결된 것처럼 보이게” 만들 수는 있지만, 실제로는 메모리 누수/버퍼 폭증/캐시 정책/동시성 과다/노드 메모리 압박 같은 구조적 원인이 남아 재발합니다.
이 글은 K8s에서 OOMKilled로 인한 CrashLoopBackOff를 재현 가능하게 관측하고, 컨테이너 레벨(리소스 설정) → 애플리케이션 레벨(메모리 사용 패턴) → 노드 레벨(Pressure/eviction) 순서로 원인을 좁혀 가는 체크리스트입니다.
1) CrashLoopBackOff와 OOMKilled의 관계를 정확히 이해하기
CrashLoopBackOff: 컨테이너가 반복적으로 종료되어 kubelet이 재시작을 백오프(backoff)하는 상태OOMKilled: 컨테이너가 메모리 한도를 초과하거나(대부분), 노드 메모리 압박으로 커널 OOM-killer가 프로세스를 죽인 결과
Kubernetes에서 흔한 흐름은 다음입니다.
- 프로세스가 메모리를 계속 사용
- 컨테이너의 cgroup 메모리 limit 초과
- 커널이 해당 cgroup의 프로세스를 종료(OOM kill)
- 컨테이너 exit code
137(SIGKILL) - kubelet이 재시작 → 반복되면 CrashLoopBackOff
핵심은 “누가 죽였는지” 입니다.
- 컨테이너 limit 초과로 죽음: 보통
OOMKilled: true로 찍힘 - 노드 전체 메모리 압박으로 죽음: 이벤트에
MemoryPressure,Evicted가 나타날 수 있고, 여러 파드가 연쇄적으로 영향을 받음
2) 1차 확인: kubectl describe로 사실관계 확정
가장 먼저 “정말 OOMKilled가 맞는지”를 확정합니다.
# 재시작이 많은 파드 확인
kubectl get pods -n <ns> -o wide
# 원인 단서가 가장 많이 나오는 명령
kubectl describe pod <pod> -n <ns>
describe에서 집중해서 볼 포인트:
State: Terminated/Reason: OOMKilledExit Code: 137Last State에 직전 종료 사유가 남아 있음Events섹션에Back-off restarting failed container,Killing등의 메시지
컨테이너가 너무 빨리 죽어서 로그가 안 남는다면 직전 로그를 확인합니다.
# 직전 크래시의 로그
kubectl logs -n <ns> <pod> -c <container> --previous
여기서 로그가 깔끔하게 끊기거나, 특정 요청 처리 중에 끊긴다면 트래픽/특정 엔드포인트/특정 배치 작업과 연관이 있을 가능성이 큽니다.
3) 2차 확인: requests/limits 설정이 “현실적인가”
OOMKilled의 절반은 애플리케이션 버그가 아니라 리소스 설정의 불일치에서 시작합니다.
requests.memory가 너무 낮으면: 스케줄링은 되지만 노드에 과밀 배치되어 노드 메모리 압박이 자주 발생limits.memory가 너무 낮으면: 정상 피크(캐시 warm-up, JVM/Node 힙 확장, 대용량 응답 생성)에도 컨테이너가 즉시 OOMKilled
현재 설정을 확인합니다.
kubectl get pod <pod> -n <ns> -o jsonpath='{range .spec.containers[*]}{.name}{"\n req: "}{.resources.requests.memory}{"\n lim: "}{.resources.limits.memory}{"\n"}{end}'
(중요) QoS 클래스 확인
QoS 클래스는 eviction 우선순위에 영향을 줍니다.
- Guaranteed: requests == limits (CPU/Memory 모두) → 가장 보호받음
- Burstable: requests < limits → 중간
- BestEffort: requests/limits 없음 → 가장 먼저 축출
확인:
kubectl get pod <pod> -n <ns> -o jsonpath='{.status.qosClass}{"\n"}'
Burstable/BestEffort인 상태에서 노드 메모리가 빡빡하면, “내 앱이 문제 없어도” 죽을 수 있습니다.
4) 3차 확인: 노드 메모리 압박(MemoryPressure)인지 분리
컨테이너 limit을 넘겨 죽은 것인지, 노드가 부족해서 죽은 것인지 분리해야 대응이 달라집니다.
# 파드가 올라간 노드 확인
NODE=$(kubectl get pod <pod> -n <ns> -o jsonpath='{.spec.nodeName}')
echo $NODE
kubectl describe node $NODE
describe node에서 확인할 것:
Conditions에MemoryPressure=True가 있었는지Allocated resources가 과도하게 잡혀 있는지- 이벤트에 eviction 관련 메시지가 있는지
노드 단에서 메모리 압박이 반복된다면, 단순히 특정 파드 limit을 올리는 것보다 노드 타입/오토스케일/파드 밀도/requests 재조정이 먼저일 수 있습니다.
EKS 환경에서 네트워크 이슈가 동반되어 연결이 끊기거나 재시도가 폭증하면 메모리 사용이 급증하는 패턴도 있습니다. 노드 레벨에서 이상 징후가 보이면 함께 점검해두면 좋습니다: EKS conntrack 테이블 포화로 연결 끊김 해결법
5) “메모리가 왜 늘었는지”를 메트릭으로 고정하기 (필수)
OOMKilled 원인 추적의 핵심은 죽기 직전 메모리 곡선을 보는 것입니다.
5.1 metrics-server로 빠르게 보기(정밀하진 않음)
kubectl top pod -n <ns>
kubectl top pod <pod> -n <ns> --containers
top은 스냅샷이라 “죽기 직전”을 놓치기 쉽습니다. 따라서 Prometheus/Grafana가 있다면 아래 지표를 대시보드로 고정하는 게 좋습니다.
5.2 Prometheus에서 자주 쓰는 쿼리
- 컨테이너 메모리 사용량(working set)
container_memory_working_set_bytes{namespace="<ns>", pod="<pod>", container!="POD"}
- limit 대비 사용률
container_memory_working_set_bytes{namespace="<ns>", pod="<pod>", container!="POD"}
/
container_spec_memory_limit_bytes{namespace="<ns>", pod="<pod>", container!="POD"}
- 재시작 횟수 증가 추이
increase(kube_pod_container_status_restarts_total{namespace="<ns>", pod="<pod>"}[15m])
여기서 확인해야 할 전형적인 패턴:
- 계단식 증가: 캐시/배치/큐 적재
- 선형 증가: 누수(leak) 의심
- 스파이크 후 즉시 OOM: 특정 요청(대용량 payload/압축/이미지 처리/대규모 JSON) 의심
6) 애플리케이션 레벨 원인: 언어/런타임별 체크리스트
OOMKilled는 “K8s 문제”가 아니라, 대부분 프로세스 메모리 관리 문제입니다. 런타임별로 자주 터지는 지점을 빠르게 점검합니다.
6.1 JVM (Spring Boot 등)
- 컨테이너 메모리 limit 대비
-Xmx가 너무 큼 - Metaspace/DirectMemory/Thread stack을 고려하지 않음
- GC 로그 없이 추정으로만 튜닝
컨테이너 환경에서는 Xmx를 명시하거나, 적어도 컨테이너 인식 옵션을 확인합니다.
# 예: JAVA_TOOL_OPTIONS로 힙 상한을 limit보다 여유 있게
export JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:+ExitOnOutOfMemoryError"
스레드가 과도하게 늘어나면 스택 메모리로 OOM이 나기도 합니다. DB 커넥션/스레드 폭증을 막는 설계 관점은 가상 스레드 도입 사례가 도움이 됩니다: Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기
6.2 Node.js
- 기본 힙 한도(대략 1.5~2GB 근처)가 컨테이너 limit과 엇갈림
- 대용량 JSON 파싱/버퍼 생성으로 순간 메모리 피크
- 요청 동시성 폭발(큐잉 없이 무한 처리)
컨테이너 limit이 작다면 --max-old-space-size로 힙 상한을 명시해 OOMKilled 대신 앱 레벨에서 제어 가능한 실패로 바꾸는 것도 방법입니다.
node --max-old-space-size=512 server.js
6.3 Python (Gunicorn/Uvicorn)
- 워커 수 과다(프로세스 메모리 * 워커 수)
- preload/app 초기화 시점에 큰 객체를 들고 시작
- worker 재시작 정책이 없어 누수가 누적
특히 워커 모델은 메모리와 직결됩니다. 타임아웃/워커 설정을 재현 기반으로 잡는 방식은 다음 글의 접근이 유사합니다(원인은 다르지만 “재현→관측→설정” 흐름이 동일): Gunicorn Uvicorn Worker timeout 재현과 해결
7) 흔한 실수: limit만 올리기 전에 “피크 메모리”의 정체를 잡기
다음 중 하나라도 해당하면, limit 상향은 임시방편일 가능성이 큽니다.
- 특정 시간대(배치/크론)만 터짐
- 특정 API 호출에서만 터짐(대용량 응답/리포트 생성)
- 재시작 간격이 점점 짧아짐(누수)
- 트래픽 증가와 함께 비례해서 터짐(동시성 제어 부재)
동시성/재시도 폭증으로 메모리 피크가 생기는 케이스
외부 API 장애나 레이트리밋이 걸렸을 때, 무제한 재시도는 요청 큐/버퍼/컨텍스트가 쌓이면서 메모리 피크를 만들 수 있습니다. 백오프/큐잉 설계로 “메모리로 버티지 않게” 만드는 접근은 다음 글이 참고됩니다: OpenAI API 429 Rate Limit 재시도·백오프 설계
8) 실전 절차: OOMKilled 원인 추적 플레이북
아래 순서대로 하면 “추측”이 아니라 “증거” 기반으로 좁힐 수 있습니다.
8.1 파드 단서 수집
kubectl get pod <pod> -n <ns> -o wide
kubectl describe pod <pod> -n <ns>
kubectl logs <pod> -n <ns> --previous
- OOMKilled 여부/exit code 확인
- 마지막 로그 위치(특정 요청/작업) 확인
8.2 리소스 설정 확인 및 QoS 확인
kubectl get pod <pod> -n <ns> -o jsonpath='{.status.qosClass}{"\n"}'
kubectl get pod <pod> -n <ns> -o jsonpath='{range .spec.containers[*]}{.name}{" req="}{.resources.requests.memory}{" lim="}{.resources.limits.memory}{"\n"}{end}'
- limit이 터무니없이 낮은지
- requests가 너무 낮아 과밀 배치되는지
8.3 노드 압박 여부 분리
NODE=$(kubectl get pod <pod> -n <ns> -o jsonpath='{.spec.nodeName}')
kubectl describe node $NODE | sed -n '/Conditions:/,/Addresses:/p'
- MemoryPressure가 있었는지
- 동일 노드의 다른 파드도 재시작/eviction이 있는지
8.4 메트릭으로 “죽기 직전”을 고정
- working set이 limit에 닿는지
- 증가 패턴(선형/계단/스파이크)
- 재시작 증가와 상관관계
8.5 애플리케이션 내부 계측 추가(가능하면)
- JVM: GC 로그/heap dump(운영은 신중)
- Node: heap snapshot, clinic, allocation profiling
- Python: tracemalloc, objgraph, 프로세스 RSS 관측
K8s 관점에서는 liveness/readiness가 너무 공격적이면 메모리 문제와 별개로 CrashLoopBackOff를 악화시킬 수 있으니 함께 점검합니다.
9) 대응 전략: 재발 방지까지 포함한 처방전
9.1 단기(서비스 복구)
- limit을 소폭 상향(무작정 2배가 아니라, 관측된 피크 + 여유)
- 워커/동시성 제한(즉시 효과 큼)
- 문제 엔드포인트 임시 차단/샘플링
9.2 중기(원인 제거)
- 누수 제거(캐시 무한 성장, 전역 컬렉션, 세션/버퍼 누적)
- 스트리밍 전환(대용량 응답을 메모리에 올리지 않기)
- 백오프/큐잉으로 폭주 제어
9.3 장기(플랫폼 안정화)
- requests/limits 재설계 및 QoS 개선(중요 워크로드는 Guaranteed 고려)
- HPA/VPA(가능하면) + 노드 오토스케일 조합
- OOMKilled 알림:
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}기반
10) 마무리: “OOMKilled”는 결론이 아니라 출발점
OOMKilled는 메모리가 부족하다는 결과이고, 실제 원인은 크게 세 갈래로 나뉩니다.
- 리소스 설정 문제(limit/requests/QoS)
- 노드 압박(과밀 배치, 노드 타입/스케일링)
- 애플리케이션 메모리 패턴 문제(누수, 동시성, 대용량 처리)
kubectl describe로 사실을 확정하고, 메트릭으로 죽기 직전 곡선을 고정한 뒤, 런타임별로 “왜 늘었는지”를 파고들면 재발을 막을 수 있습니다. 다음에 CrashLoopBackOff가 떠도, 이제는 단순 재시작이 아니라 증거 기반의 원인 추적으로 접근할 수 있을 겁니다.