OOMKilled exit code 137 해결: kubectl 30초 진단·5분 복구 런북
kubectl describe pod 출력에서 Last State: Terminated, Reason: OOMKilled, Exit Code: 137을 보고 들어오셨다면 잘 찾아오셨습니다. 개념 설명은 뒤로 미루고, 지금 죽은 Pod부터 살립니다.
exit code 137은 128 + 9, 즉 SIGKILL을 받고 강제 종료됐다는 뜻입니다. 누가 죽였느냐가 핵심인데, 범인은 보통 셋 중 하나입니다. 컨테이너가 자기 limit을 넘겼거나(OOMKilled), 노드 전체가 메모리 부족이라 쫓겨났거나(Evicted), 사람이 직접 죽였거나(SIGKILL). 30초 안에 해야 할 단 한 가지는, 내 137이 이 셋 중 어느 분기인지 가르는 것입니다. 분기를 잘못 잡으면 limit만 올리다 노드 압박을 못 보거나, 반대로 멀쩡한 limit을 건드립니다.
30초 진단 분기표: 내 137은 어느 갈래인가
아래 표에서 본인 증상을 찾고, 바로 다음 섹션의 해당 명령으로 가세요.
| 증상 (describe / events에서 보이는 문구) | 진단 | 다음 행동 |
|---|---|---|
Exit Code: 137 + Reason: OOMKilled | 컨테이너 메모리 limit 초과 | 진단 명령 A·B·C → 복구 1·2 |
Reason: Evicted, message: The node was low on resource: memory | 노드 전체 메모리 압박 | 진단 명령 D·E → 복구 3 |
Exit Code: 137인데 OOMKilled 표시 없음 | 수동 kubectl delete --grace-period=0 또는 외부 SIGKILL | events·audit log 확인, 배포/스크립트 점검 |
핵심 구분점: OOMKilled는 컨테이너 단위, Evicted는 Pod 단위(노드 스케줄러가 쫓아냄) 입니다. kubectl get pod에서 STATUS가 OOMKilled로 안 뜨고 정상 Running처럼 보여도, 재시작 카운트가 올라간다면 lastState를 꼭 확인하세요.
복붙 진단 명령 모음
위에서 분기를 잡았으면 그대로 복사해 실행하세요. <pod>만 본인 것으로 바꾸면 됩니다.
# A) Last State 한눈에 보기 — Reason과 Exit Code가 여기 나옵니다
kubectl describe pod <pod> | grep -A5 "Last State"
# B) 스크립트/자동화용: reason + exitCode만 정확히 추출
kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{"\n"}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
# 출력 예: OOMKilled / 137
# C) 죽기 직전 실제 사용량 추세 (metrics-server 필요)
kubectl top pod <pod> --containers
# D) OOMKilling 이벤트를 클러스터 전역에서 수집
kubectl get events --field-selector reason=OOMKilling -A
# E) 노드로 들어가 커널 OOM killer 로그 직접 확인 (가장 확실한 증거)
# nsenter/디버그 컨테이너로 노드 진입 후:
dmesg -T | grep -i "Out of memory: Killed process"
journalctl -k | grep -i oomE의 dmesg에 Killed process ... (java) 같은 줄이 보이면 커널 OOM killer가 그 프로세스를 직접 골라 죽였다는 빼도 박도 못할 증거입니다. cgroup v2가 기본인 2026년 다수 배포판에서는 OOM이 cgroup 단위로 더 정확히 격리되어, 형제 컨테이너 영향 없이 해당 컨테이너만 죽는 경우가 많습니다.
원인별 복구 + 재발 방지
복구 1: limits 재산정
kubectl top으로 본 실사용량 + 여유 30%를 limit으로 잡습니다. requests는 평소 사용량, limit은 피크 기준.
# Before — limit이 실사용보다 빠듯해 피크 때 즉사
resources:
requests: { memory: "256Mi" }
limits: { memory: "512Mi" }
# After — top 측정 피크 700Mi 기준 여유 확보
resources:
requests: { memory: "768Mi" }
limits: { memory: "1Gi" }복구 2: 런타임 힙 vs 컨테이너 limit 불일치 (가장 흔한 함정)
JVM·Node.js에서 런타임 힙 설정이 컨테이너 limit과 같거나 크면 시작하자마자, 혹은 GC 직전에 OOMKilled가 납니다. 힙 외 메타스페이스/스택/네이티브 버퍼도 메모리를 먹기 때문입니다.
# JVM — 절대값 -Xmx 박지 말고 limit 비율로 (limit 인식)
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0" # limit의 75%만 힙에, 나머지는 네이티브 여유
resources:
limits: { memory: "1Gi" } # 힙 ~768Mi + 여유 256Mi
# Node.js — old space를 limit보다 작게
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=768" # limit 1Gi 대비 안전-Xmx1g에 limits.memory: 1Gi처럼 같게 잡는 게 대표적 즉사 패턴입니다. 반드시 힙 < limit으로 두세요.
복구 3: limit 미설정으로 인한 eviction
limit이 없으면 Pod는 BestEffort 또는 Burstable QoS가 되고, 노드 메모리가 부족해지면 이런 Pod가 가장 먼저 쫓겨납니다(Evicted). Karpenter 같은 노드 오토스케일 환경에서는 빈 노드를 줄이려다 압박이 생겨 eviction이 더 자주 보입니다. requests/limits를 명시해 우선순위를 끌어올리세요.
재발 방지: Guaranteed QoS + 모니터링
가장 중요한 워크로드는 requests.memory == limits.memory로 맞춰 Guaranteed QoS를 확보하면 eviction 순위에서 가장 뒤로 빠집니다.
resources:
requests: { cpu: "500m", memory: "1Gi" }
limits: { cpu: "500m", memory: "1Gi" } # 메모리 동일 → Guaranteed추천값 산출은 VPA에 맡기세요. 1.27+에서 in-place resize가 베타화되며 재시작 없이 메모리를 조정하는 흐름이 자리잡고 있습니다.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata: { name: app-vpa }
spec:
targetRef: { apiVersion: apps/v1, kind: Deployment, name: app }
updatePolicy: { updateMode: "Off" } # 우선 추천값만 받아보기HPA는 메모리 기반 스케일이 가능하지만, 메모리는 줄어들지 않는 특성(누수·캐시) 때문에 CPU 기준만큼 안정적이지 않습니다. 메모리는 VPA로 적정값을 잡고, 트래픽 스케일은 HPA(CPU/커스텀 메트릭)로 나누는 편을 권합니다.
마지막으로 죽기 전에 알림을 받으세요. OpenTelemetry로 메모리 메트릭이 표준화되는 추세지만, 룰 자체는 동일합니다.
# Prometheus alert: limit의 90% 도달 시 사전 경고
- alert: PodMemoryNearLimit
expr: container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.9
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.pod }} 메모리 limit 90% 초과 — OOMKilled 임박"실무 한 마디: 현장에서 137 장애의 절반 이상은 limit이 작아서가 아니라 JVM 힙을 limit과 같게 박아둔 설정 미스였습니다. limit부터 무작정 올리기 전에 MaxRAMPercentage부터 확인하면 비용도 아끼고 근본 원인도 잡습니다.
결론: 형제 에러로 오진하지 마세요
137을 다른 종료 코드와 헷갈리면 엉뚱한 곳을 파게 됩니다.
| 코드/상태 | 의미 | 구분 키워드 → 가야 할 글 |
|---|---|---|
| 137 | SIGKILL (OOM 또는 강제종료) | OOMKilled / dmesg OOM → 이 글 |
| 143 | SIGTERM (graceful 종료 실패) | preStop/종료 타임아웃 → graceful shutdown 편 |
| Evicted | 노드 메모리·디스크 압박 | node was low on resource → 본문 복구 3 |
| CrashLoopBackOff | 재시작 루프 (OOM 반복 시 전이) | Back-off restarting → 이전 편 CrashLoopBackOff 글 |
OOM이 반복되면 결국 CrashLoopBackOff로 전이되므로, 반복 재시작이 보이면 lastState reason을 먼저 확인해 진짜 원인이 OOM인지 가르는 게 핵심입니다.
다음 편(K8s_Troubleshooting_Guide 20편)에서는 exit code 143과 SIGTERM, preStop 훅으로 graceful shutdown을 보장하는 런북을 다룹니다.
참고: 공식 문서
이 글에서 다루는 동작·설정·에러의 1차 출처는 다음 공식 문서입니다. 버전별 옵션과 정확한 동작은 여기서 확인하세요.
자주 묻는 질문 (FAQ)
Q. limit을 올렸는데도 또 OOMKilled가 납니다. 왜죠?
A. 메모리 누수이거나 JVM/Node 힙 설정이 limit을 따라 올라가지 않은 경우입니다. kubectl top pod --containers로 사용량이 계속 우상향하면 누수, 시작 직후 즉사하면 힙 설정(MaxRAMPercentage, --max-old-space-size)을 점검하세요.
Q. OOMKilled인데 kubectl get pod엔 Running으로 보입니다.
A. 컨테이너가 죽고 재시작되어 현재는 Running일 수 있습니다. kubectl describe pod의 Last State와 RESTARTS 카운트, jsonpath로 lastState.terminated.reason을 확인하면 과거 OOM 이력이 드러납니다.
Q. exit code 137인데 OOMKilled 표시가 전혀 없습니다.
A. 커널 OOM이 아니라 외부 SIGKILL일 가능성이 큽니다. kubectl delete --grace-period=0, 배포 도구, 노드 종료/드레인, 또는 라이브니스 프로브 실패 후 강제 종료를 의심하고 events와 audit log를 확인하세요.
이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.