OOMKilled와 exit code 137 완벽 해결: Pod 메모리 limit 진단부터 튜닝까지
K8s_Troubleshooting_Guide 6편
"또 재시작됐네?" — exit code 137을 마주한 순간
새벽에 알림이 울립니다. 특정 Pod의 RESTARTS 카운터가 어느새 두 자리. kubectl describe를 쳐보니 이런 문구가 보입니다.
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입니다.
복붙 가능한 판별 명령어 세트
# 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 wide1번 출력이 OOMKilled 137이면 확정입니다.
노드 OOM(Evicted) vs 컨테이너 OOMKilled
여기서 많이 헷갈리는 게 5편에서 다룬 Pod Evicted (node was low on resource) 와의 차이입니다. 둘 다 "메모리 부족"처럼 보이지만 트리거 주체와 증상이 완전히 다릅니다. (Evicted 자체의 깊은 처리는 5편을 참고하세요.)
| 구분 | 컨테이너 OOMKilled | 노드 OOM / Evicted |
|---|---|---|
| 트리거 주체 | 커널 cgroup OOM Killer | kubelet eviction manager |
| 발동 조건 | 컨테이너가 자기 limit 초과 | 노드 전체 가용 메모리 부족 |
| Pod Status | Running → 재시작 / CrashLoopBackOff | Evicted (Failed) |
| Reason 위치 | lastState.terminated.reason | pod.status.reason |
| Exit Code | 137 | 없음(컨테이너 시작 전 축출) |
| 영향 범위 | 단일 컨테이너 | 노드의 여러 Pod |
| 해결 방향 | limit 튜닝 / 누수 해결 | 노드 증설, requests 재배치 |
요약하면 "내 컨테이너만 죽었고 137이면 OOMKilled, Pod가 Evicted로 사라졌으면 노드 문제" 입니다.
진짜 원인 찾기: 실제 메모리 사용량 측정
limit을 무작정 올리기 전에 실제로 얼마나 쓰는지부터 봐야 합니다.
# metrics-server가 설치되어 있어야 동작
kubectl top pod <pod> -n <ns> --containers더 정밀하게는 Prometheus의 cAdvisor 지표를 봅니다. 핵심 지표는 container_memory_working_set_bytes입니다.
container_memory_working_set_bytes{pod="<pod>", container="<container>"}working set ≠ RSS: working set은 RSS에서 회수 가능한 page cache를 제외한, 커널이 "실제로 필요한" 메모리입니다. OOM Killer의 판단 기준이 바로 이 working set이라 limit 튜닝 시 반드시 이 지표를 봐야 합니다.
피크를 보고 싶다면 p95를 뽑습니다.
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%
# ❌ 잘못된 예: requests와 limit이 같고, 측정 없이 어림짐작
resources:
requests: { memory: "512Mi" }
limits: { memory: "512Mi" } # 살짝만 튀어도 즉사
# ✅ 권장 예: p95 기반 requests + 여유율 포함 limit
resources:
requests: { memory: "640Mi" } # working set p95
limits: { memory: "896Mi" } # p95 × 1.4requests와 limit을 동일하게 두면 QoS가 Guaranteed로 올라가는 장점은 있지만, 메모리는 압축 불가능 자원이라 순간 스파이크에 그대로 OOMKilled됩니다. 약간의 헤드룸을 주는 게 안전합니다.
JVM의 함정: -Xmx와 limit은 같으면 안 된다
가장 흔한 실전 사고가 JVM입니다. -Xmx2g로 두고 컨테이너 limit도 2Gi로 맞췄다가 OOMKilled 나는 경우가 정말 많습니다. JVM의 RSS = heap(-Xmx) + 메타스페이스 + 스레드 스택 + 코드캐시 + off-heap(Direct Buffer) 입니다. heap만 채워도 나머지로 limit을 넘깁니다.
# ❌ 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 누수 해결: 판단 플로우
여기서 의사결정의 갈림길은 메모리 그래프 모양입니다.
working set 그래프를 본다
├─ 톱니파(상승→GC로 하강 반복) → 정상. limit이 빠듯한 것 → limit 상향
└─ 우상향 단조증가(GC 후에도 안 내려감) → 메모리 누수 → 코드 수정핵심: 단조증가형은 limit을 올려도 OOMKilled가 나는 시점만 뒤로 미룰 뿐입니다. 그래프가 우상향이면 힙 덤프(jmap, JFR)부터 떠야지 limit 숫자만 키우면 안 됩니다. 저는 실무에서 "재시작 주기가 limit 증설에 비례해 늘어나면 100% 누수"라는 경험칙을 씁니다. 4Gi→8Gi로 올렸더니 죽는 간격이 정확히 2배가 됐다면, 그건 튜닝이 아니라 디버깅 대상입니다.
재발 방지: VPA와 Prometheus 알림
수동 튜닝을 반복하지 않으려면 자동화가 답입니다.
VPA recommendation 모드 — 실제 사용량 기반 권장값만 산출(자동 적용 X)하므로 안전하게 적정값을 받아볼 수 있습니다.
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가 한 번이라도 발생하면 즉시 포착합니다.
- 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 우선순위는 BestEffort → Burstable → Guaranteed 순으로 죽습니다. 즉 requests=limits인 Guaranteed Pod가 가장 늦게 죽으므로, 중요한 워크로드는 Guaranteed로 두면 노드 OOM에서 보호받습니다.
Q. cgroup v2에서는 뭐가 달라지나요?
A. limit이 memory.max로 관리되고 memory.high(소프트 한도)로 회수 압박을 먼저 받는 등 OOM 동작이 더 예측 가능해집니다. 동작 원리만 알면 진단 명령어 세트는 동일하게 적용됩니다.
다음 편에서는 CrashLoopBackOff의 다양한 원인 분기와 초기화 컨테이너 디버깅을 다룹니다.
이 글은 AI 에이전트가 1차 초안을 작성한 뒤, 사람 편집자가 사실관계·출처·톤과 맥락을 검토하여 발행했습니다. 오류나 부정확한 내용이 확인되면 24시간 이내에 정정합니다.
댓글
불러오는 중...