Published on

K8s CrashLoopBackOff에서 OOMKilled 원인 추적

Authors

서버가 죽었다가 살아나기를 반복하는 CrashLoopBackOff는 증상일 뿐이고, 그 뒤에 숨어 있는 원인이 OOMKilled(Out Of Memory kill)라면 문제는 더 까다로워집니다. 단순히 메모리 limit을 올리는 것으로 “해결된 것처럼 보이게” 만들 수는 있지만, 실제로는 메모리 누수/버퍼 폭증/캐시 정책/동시성 과다/노드 메모리 압박 같은 구조적 원인이 남아 재발합니다.

이 글은 K8s에서 OOMKilled로 인한 CrashLoopBackOff를 재현 가능하게 관측하고, 컨테이너 레벨(리소스 설정) → 애플리케이션 레벨(메모리 사용 패턴) → 노드 레벨(Pressure/eviction) 순서로 원인을 좁혀 가는 체크리스트입니다.

1) CrashLoopBackOff와 OOMKilled의 관계를 정확히 이해하기

  • CrashLoopBackOff: 컨테이너가 반복적으로 종료되어 kubelet이 재시작을 백오프(backoff)하는 상태
  • OOMKilled: 컨테이너가 메모리 한도를 초과하거나(대부분), 노드 메모리 압박으로 커널 OOM-killer가 프로세스를 죽인 결과

Kubernetes에서 흔한 흐름은 다음입니다.

  1. 프로세스가 메모리를 계속 사용
  2. 컨테이너의 cgroup 메모리 limit 초과
  3. 커널이 해당 cgroup의 프로세스를 종료(OOM kill)
  4. 컨테이너 exit code 137 (SIGKILL)
  5. 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: OOMKilled
  • Exit Code: 137
  • Last 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에서 확인할 것:

  • ConditionsMemoryPressure=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는 메모리가 부족하다는 결과이고, 실제 원인은 크게 세 갈래로 나뉩니다.

  1. 리소스 설정 문제(limit/requests/QoS)
  2. 노드 압박(과밀 배치, 노드 타입/스케일링)
  3. 애플리케이션 메모리 패턴 문제(누수, 동시성, 대용량 처리)

kubectl describe로 사실을 확정하고, 메트릭으로 죽기 직전 곡선을 고정한 뒤, 런타임별로 “왜 늘었는지”를 파고들면 재발을 막을 수 있습니다. 다음에 CrashLoopBackOff가 떠도, 이제는 단순 재시작이 아니라 증거 기반의 원인 추적으로 접근할 수 있을 겁니다.