/인프라/OOMKilled exit code 137 해결: Pod 메모리 limit 진단부터 튜닝까지
인프라OOMKilledexit code 137

OOMKilled exit code 137 해결: Pod 메모리 limit 진단부터 튜닝까지

Pod가 exit code 137 OOMKilled로 계속 재시작되나요? 노드 Evicted와의 차이, kubectl 진단, working set 측정, requests/limits 튜닝, JVM -Xmx 함정, VPA·알림 자동화까지 복붙 명령어로 정리했습니다.

OOMKilled exit code 137 해결: Pod 메모리 limit 진단부터 튜닝까지

OOMKilled와 exit code 137 완벽 해결: Pod 메모리 limit 진단부터 튜닝까지

K8s_Troubleshooting_Guide 6편

"또 재시작됐네?" — exit code 137을 마주한 순간

새벽에 알림이 울립니다. 특정 Pod의 RESTARTS 카운터가 어느새 두 자리. kubectl describe를 쳐보니 이런 문구가 보입니다.

CODE
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137

이건 애플리케이션 버그도, 헬스체크 실패도 아닙니다. 컨테이너가 자기 메모리 limit을 초과해서 커널이 강제로 죽인 것입니다. 여기서 핵심은 "왜 죽었는가"를 막연히 추측하는 게 아니라, 정확히 판별하고 → 실측하고 → 튜닝하고 → 재발을 막는 순서로 끊어내는 것입니다. 이번 편은 그 실전 플레이북입니다.

5분 진단: exit code 137 판별과 노드 OOM(Evicted)과의 차이

exit code 137 = 128 + 9 (SIGKILL)

리눅스에서 시그널로 종료된 프로세스의 exit code는 128 + 시그널번호입니다. 9번은 SIGKILL이므로 128 + 9 = 137. 컨테이너가 cgroup의 memory.limit(cgroup v2에서는 memory.max)을 넘어서면 커널의 OOM Killer가 해당 cgroup 내 프로세스를 SIGKILL로 즉시 종료합니다. graceful이 없습니다. 그래서 137입니다.

복붙 가능한 판별 명령어 세트

Bash
# 1) Last State를 한 줄로 — reason과 exitCode만 추출
kubectl get pod <pod> -n <ns> \
  -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}{"  "}{.status.containerStatuses[*].lastState.terminated.exitCode}{"\n"}'

# 2) 사람이 읽기 좋은 전체 상태
kubectl describe pod <pod> -n <ns> | grep -A5 "Last State"

# 3) 커널 OOM Killer 이벤트 직접 확인
kubectl get events -n <ns> --field-selector reason=OOMKilling

# 4) 재시작 횟수와 상태 한눈에
kubectl get pod <pod> -n <ns> -o wide

1번 출력이 OOMKilled 137이면 확정입니다.

노드 OOM(Evicted) vs 컨테이너 OOMKilled

여기서 많이 헷갈리는 게 5편에서 다룬 Pod Evicted (node was low on resource) 와의 차이입니다. 둘 다 "메모리 부족"처럼 보이지만 트리거 주체와 증상이 완전히 다릅니다. (Evicted 자체의 깊은 처리는 5편을 참고하세요.)

구분컨테이너 OOMKilled노드 OOM / Evicted
트리거 주체커널 cgroup OOM Killerkubelet eviction manager
발동 조건컨테이너가 자기 limit 초과노드 전체 가용 메모리 부족
Pod StatusRunning → 재시작 / CrashLoopBackOffEvicted (Failed)
Reason 위치lastState.terminated.reasonpod.status.reason
Exit Code137없음(컨테이너 시작 전 축출)
영향 범위단일 컨테이너노드의 여러 Pod
해결 방향limit 튜닝 / 누수 해결노드 증설, requests 재배치

요약하면 "내 컨테이너만 죽었고 137이면 OOMKilled, Pod가 Evicted로 사라졌으면 노드 문제" 입니다.

진짜 원인 찾기: 실제 메모리 사용량 측정

limit을 무작정 올리기 전에 실제로 얼마나 쓰는지부터 봐야 합니다.

Bash
# metrics-server가 설치되어 있어야 동작
kubectl top pod <pod> -n <ns> --containers

더 정밀하게는 Prometheus의 cAdvisor 지표를 봅니다. 핵심 지표는 container_memory_working_set_bytes입니다.

PROMQL
container_memory_working_set_bytes{pod="<pod>", container="<container>"}

working set ≠ RSS: working set은 RSS에서 회수 가능한 page cache를 제외한, 커널이 "실제로 필요한" 메모리입니다. OOM Killer의 판단 기준이 바로 이 working set이라 limit 튜닝 시 반드시 이 지표를 봐야 합니다.

피크를 보고 싶다면 p95를 뽑습니다.

PROMQL
quantile_over_time(0.95,
  container_memory_working_set_bytes{pod=~"<deploy>-.*", container="<c>"}[7d])

requests/limits 산정 공식과 YAML

산정 원칙은 단순합니다.

  • requests = working set p95 (스케줄러가 자리를 보장)
  • limit = requests × (1 + buffer). buffer는 워크로드 변동성에 따라 25~50%
YAML
# ❌ 잘못된 예: requests와 limit이 같고, 측정 없이 어림짐작
resources:
  requests: { memory: "512Mi" }
  limits:   { memory: "512Mi" }   # 살짝만 튀어도 즉사

# ✅ 권장 예: p95 기반 requests + 여유율 포함 limit
resources:
  requests: { memory: "640Mi" }   # working set p95
  limits:   { memory: "896Mi" }   # p95 × 1.4

requests와 limit을 동일하게 두면 QoS가 Guaranteed로 올라가는 장점은 있지만, 메모리는 압축 불가능 자원이라 순간 스파이크에 그대로 OOMKilled됩니다. 약간의 헤드룸을 주는 게 안전합니다.

JVM의 함정: -Xmx와 limit은 같으면 안 된다

가장 흔한 실전 사고가 JVM입니다. -Xmx2g로 두고 컨테이너 limit도 2Gi로 맞췄다가 OOMKilled 나는 경우가 정말 많습니다. JVM의 RSS = heap(-Xmx) + 메타스페이스 + 스레드 스택 + 코드캐시 + off-heap(Direct Buffer) 입니다. heap만 채워도 나머지로 limit을 넘깁니다.

YAML
# ❌ heap과 limit이 동일 → heap 외 영역이 limit 초과
env:
  - { name: JAVA_OPTS, value: "-Xmx2g" }
resources:
  limits: { memory: "2Gi" }

# ✅ limit 대비 heap을 70~75%로
env:
  - { name: JAVA_OPTS, value: "-XX:MaxRAMPercentage=75.0" }
resources:
  limits: { memory: "2Gi" }   # heap ≈ 1.5Gi, 나머지 0.5Gi가 off-heap 여유

JDK 10+ 컨테이너 인식 옵션인 -XX:MaxRAMPercentage를 쓰면 limit이 바뀌어도 heap이 자동으로 비례 조정돼 유지보수가 편합니다.

limit 상향 vs 누수 해결: 판단 플로우

여기서 의사결정의 갈림길은 메모리 그래프 모양입니다.

CODE
working set 그래프를 본다
  ├─ 톱니파(상승→GC로 하강 반복) → 정상. limit이 빠듯한 것 → limit 상향
  └─ 우상향 단조증가(GC 후에도 안 내려감) → 메모리 누수 → 코드 수정

핵심: 단조증가형은 limit을 올려도 OOMKilled가 나는 시점만 뒤로 미룰 뿐입니다. 그래프가 우상향이면 힙 덤프(jmap, JFR)부터 떠야지 limit 숫자만 키우면 안 됩니다. 저는 실무에서 "재시작 주기가 limit 증설에 비례해 늘어나면 100% 누수"라는 경험칙을 씁니다. 4Gi→8Gi로 올렸더니 죽는 간격이 정확히 2배가 됐다면, 그건 튜닝이 아니라 디버깅 대상입니다.

재발 방지: VPA와 Prometheus 알림

수동 튜닝을 반복하지 않으려면 자동화가 답입니다.

VPA recommendation 모드 — 실제 사용량 기반 권장값만 산출(자동 적용 X)하므로 안전하게 적정값을 받아볼 수 있습니다.

YAML
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata: { name: my-app-vpa }
spec:
  targetRef: { apiVersion: apps/v1, kind: Deployment, name: my-app }
  updatePolicy: { updateMode: "Off" }   # 권장값만, 적용은 수동

Prometheus 알림 룰 — OOMKilled가 한 번이라도 발생하면 즉시 포착합니다.

YAML
- alert: PodOOMKilled
  expr: kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} > 0
  for: 1m
  labels: { severity: warning }
  annotations:
    summary: "{{ $labels.pod }} OOMKilled 발생"

최신 동향도 챙겨두면 좋습니다. KEP 기반 in-place Pod resize가 정식화되면서 Pod를 재시작하지 않고 메모리 limit을 무중단 조정할 수 있고, VPA도 in-place 업데이트로 진화 중입니다. cgroup v2 환경에서는 memory.max 기반으로 OOM 동작이 더 예측 가능해졌고, OpenTelemetry/Prometheus 기반 컨테이너 메모리 관측이 표준으로 자리잡고 있습니다.

진단 매트릭스: 증상 → 원인 → 액션

증상추정 원인다음 액션
exit 137 + 톱니파 그래프limit이 빠듯함limit을 p95×1.4로 상향
exit 137 + 단조증가 그래프메모리 누수힙 덤프/JFR 분석, 코드 수정
JVM에서 137, heap은 여유off-heap/메타스페이스 초과MaxRAMPercentage=75 적용
Status: Evicted, 137 없음노드 자원부족5편(노드 OOM) 참고, 노드 증설
137인데 Reason 빈칸graceful 종료와 혼동events·dmesg로 OOMKilling 재확인

자주 묻는 질문 (FAQ)

Q. limit을 늘렸는데 또 죽어요. A. 십중팔구 메모리 누수입니다. working set 그래프가 GC 후에도 안 내려가는 단조증가형이면 limit 상향은 시간 끌기일 뿐입니다. 힙 덤프를 분석해 원인을 잡으세요.

Q. requests만 있고 limit이 없으면? A. 컨테이너 단위 OOMKilled는 안 나지만, 노드 메모리가 부족해지면 kubelet이 Pod를 Evict합니다. QoS도 Burstable이라 우선 축출 대상이 될 수 있습니다.

Q. exit code 137인데 Reason이 안 보여요. A. SIGKILL로 죽은 다른 케이스(노드 graceful 종료, 수동 kill)와 혼동된 경우입니다. kubectl get events --field-selector reason=OOMKilling과 노드의 dmesg | grep -i oom으로 커널 OOM 여부를 교차 확인하세요.

Q. QoS 클래스와 OOM 우선순위는 무슨 관계인가요? A. 노드 메모리 압박 시 커널 OOM 우선순위는 BestEffortBurstableGuaranteed 순으로 죽습니다. 즉 requests=limits인 Guaranteed Pod가 가장 늦게 죽으므로, 중요한 워크로드는 Guaranteed로 두면 노드 OOM에서 보호받습니다.

Q. cgroup v2에서는 뭐가 달라지나요? A. limit이 memory.max로 관리되고 memory.high(소프트 한도)로 회수 압박을 먼저 받는 등 OOM 동작이 더 예측 가능해집니다. 동작 원리만 알면 진단 명령어 세트는 동일하게 적용됩니다.

다음 편에서는 CrashLoopBackOff의 다양한 원인 분기와 초기화 컨테이너 디버깅을 다룹니다.

✦ ✦ ✦
편집 검토 · Editorial Review

이 글은 AI 에이전트가 1차 초안을 작성한 뒤, 사람 편집자가 사실관계·출처·톤과 맥락을 검토하여 발행했습니다. 오류나 부정확한 내용이 확인되면 24시간 이내에 정정합니다.

작성 · Content Reviewer·검토 · 사람 편집자·발행 · 2026년 6월 12일

댓글

불러오는 중...