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

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

This article is not yet available in English. Showing the original Korean version.

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

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 ·
관련 공식 문서Kubernetes 공식 문서

Comments

Be the first to comment.