Kubernetes: 컨테이너가 OOMKilled(exit code 137)로 종료됨
안녕하세요, BlueByte입니다. Pod가 20분쯤 잘 돌다가 재시작하고, 또 돌다가 또 재시작하는데 kubectl describe pod를 보면 Reason: OOMKilled, Exit Code: 137이 찍혀 있습니다. 애플리케이션 로그는 문장 중간에서 끊깁니다. 앱이 스스로 종료를 결정한 게 아니라 커널이 죽였기 때문입니다. 오늘은 이 상태가 뜻하는 것, 같은 137로 끝나는 서로 다른 세 가지 원인, 그것을 구분하는 명령, 원인별 해결, 그리고 고친 뒤 Pod가 정말 버티는지 확인하는 법까지 하나씩 짚어보겠습니다.
OOMKilled와 137이 말해 주는 것
exit code 137은 128 + 9, 즉 프로세스가 시그널 9(SIGKILL)로 죽었다는 뜻입니다. 여기에 OOMKilled라는 reason이 붙으면 범인은 커널의 out-of-memory killer로 좁혀집니다. 언제 보느냐에 따라 세 가지 모양으로 나타납니다:
NAME READY STATUS RESTARTS AGE
memory-demo-2 0/1 OOMKilled 1 24s Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Ready: False
Restart Count: 5세 번째 모양은 kubectl get pods에는 CrashLoopBackOff로 보이고 위 블록이 Last State 아래에 들어 있는 경우입니다. 셋 다 같은 사건입니다. Kubernetes 문서는 이 동작을 한 문장으로 설명합니다. "memory limits are enforced by the kernel with out of memory (OOM) kills." 그리고 커널이 메모리 압박을 감지했을 때 죽이므로 컨테이너가 limit을 잠깐 넘긴 채 살아 있을 수도 있다고 덧붙입니다. 이 화면을 봤다고 Kubernetes가 고장 난 건 아닙니다. limit이 시킨 대로 했을 뿐입니다.
같은 상태로 끝나는 세 가지 다른 원인
원인이 셋이고, 해결도 셋입니다.
컨테이너 자신의 limit이 너무 작다. limit은 메모리 cgroup의 천장입니다. 실제 working set이 900 MiB인 서비스에 limits.memory: 512Mi를 주면 트래픽이 밀어 올릴 때마다 죽습니다. 문서가 경고하는 오타도 여기에 들어갑니다. memory: 400m은 400 밀리바이트, 즉 0.4바이트라는 뜻인데 작성자는 400Mi를 의도했을 겁니다.
런타임이 limit을 보지 않고 힙 크기를 정했다. 768Mi 컨테이너 안에서 -Xmx1g로 띄운 JVM은 힙이 자라는 순간 죽습니다. 힙에 metaspace와 스레드 스택까지 더하면 cgroup을 넘기 때문입니다. Node의 --max-old-space-size도 같은 모양입니다. 진짜 누수도 여기에 속합니다. 사용량이 몇 시간 동안 올라가다가 천장에 닿는 경우입니다.
컨테이너가 아니라 노드가 바닥났다. limit이 없으면 노드 전체가 모자랄 때만 죽습니다. kubelet은 QoS 클래스별로 oom_score_adj를 매깁니다. Guaranteed는 −997, BestEffort는 1000, Burstable은 request에 따라 그 사이입니다. 그래서 request가 없는 Pod가 커널 oom_killer의 첫 번째 표적이 됩니다.
손대기 전에 어느 경우인지 먼저 가리기
마지막 종료 정보와 리소스를 한 번에 읽습니다:
kubectl get pod api-7d9f -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\t"}{.restartCount}{"\n"}{end}'
kubectl get pod api-7d9f -o jsonpath='{.spec.containers[*].resources}'api OOMKilled 137 5
{"limits":{"memory":"512Mi"},"requests":{"memory":"512Mi"}}다음은 실시간 사용량입니다. kubectl top은 metrics-server가 있어야 하고, Metrics API not available이 나오면 그 애드온이 빠진 것입니다:
kubectl top pod api-7d9f --containersPOD NAME CPU(cores) MEMORY(bytes)
api-7d9f api 120m 498Mi재시작 직전마다 사용량이 limit 쪽으로 기어오르면 첫 번째나 두 번째 원인입니다. 컨테이너에 limit이 아예 없고, 노드에 reason SystemOOM인 Warning 이벤트가 있다면(kubelet이 "System OOM encountered, victim process: java, pid: 1234"처럼 기록합니다) 노드가 바닥난 세 번째 원인입니다:
kubectl get events -A --field-selector reason=SystemOOM해결 1: 측정한 working set에 여유를 더해 limit 잡기
평소 피크 시간 동안 kubectl top을 지켜본 뒤, 가장 높았던 값보다 25–30% 정도 위로 limit을 잡고, request는 Pod가 쉬고 있을 때 필요한 만큼으로 둡니다. request는 스케줄링을, limit은 죽는 선을 정합니다:
resources:
requests:
memory: "768Mi"
limits:
memory: "1Gi"문서에 있는 두 가지를 기억해 두세요. limit만 적으면 Kubernetes가 그 값을 request로 복사하므로 스케줄러가 limit 전체를 예약합니다. 그리고 단위 접미사는 대소문자를 구분합니다. Mi는 메비바이트, M은 메가바이트, m은 밀리바이트입니다.
해결 2: 힙이 limit 안에 들어오게 맞추기
요즘 JVM은 컨테이너를 인식합니다. 기본 최대 힙은 컨테이너 메모리의 25%(-XX:MaxRAMPercentage 기본값 25)이고, 노드 RAM이 아니라 cgroup 천장을 읽습니다. -Xmx를 박아 넣는 대신 비율을 올리면 힙이 limit을 따라갑니다:
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0"Node의 --max-old-space-size는 MiB 단위이고 old generation만 제한하므로 limit 아래에 여유를 남깁니다:
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=768"
resources:
limits:
memory: "1Gi"그래도 사용량이 끝없이 오르면 누수입니다. limit을 키워도 죽는 시점만 미뤄질 뿐이니, 최고점 근처에서 힙 덤프를 떠서 앱을 고치세요.
해결 3: 바닥난 쪽이 노드였을 때
Pod에 request를 줘서 BestEffort에서 벗어나게 합니다. request를 limit과 같게 두면 Guaranteed가 되고 oom_score_adj가 −997이라 커널 목록의 맨 뒤로 갑니다. 혼자서 limit을 넘기면 여전히 죽지만(그건 첫 번째 원인입니다), 이웃 Pod의 누수 값을 대신 치르는 일은 없어집니다.
kubectl get pod api-7d9f -o jsonpath='{.status.qosClass}'고친 뒤에는 Guaranteed가 보여야 합니다. 노드에 BestEffort Pod가 많다면 네임스페이스에 기본 request를 주는 LimitRange 하나로 한꺼번에 고쳐집니다.
실제 사례: limit을 줄인 뒤 40분마다 죽던 Java API
한 팀이 노드당 Pod를 더 채우려고 API의 limits.memory를 2Gi에서 1Gi로 줄였습니다. 그날부터 약 40분마다 OOMKilled로 재시작했습니다. kubectl top pod --containers는 재시작 직전마다 990Mi를 가리켰고, 배포에는 2Gi 시절의 JAVA_TOOL_OPTIONS=-Xmx1536m이 그대로 남아 있었습니다. 새 천장을 넘어 자라도 되는 힙이었던 셈입니다. 이 플래그를 -XX:MaxRAMPercentage=70.0으로 바꾸고, request를 768Mi로 두고, limit 1Gi는 유지했습니다. 다음 롤아웃은 부하 아래에서 약 740Mi에 머물렀고 Restart Count는 한 주 내내 0이었습니다.
끝까지 확인하고 재발 막기
진단할 때 쓴 명령을 고친 뒤 시간이 좀 지나서 다시 돌립니다:
kubectl get pod -l app=api -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].restartCount}{"\n"}{end}'
kubectl top pod -l app=api --containers재시작 횟수가 더 오르지 않고 사용량이 limit 아래에서 평평해지면 증명된 것입니다. 재발을 막으려면 모든 네임스페이스에 LimitRange를 둬서 request 없는 Pod가 뜨지 않게 하고, limit을 줄이기 전에 부하 테스트를 하고, kubelet의 container_oom_events_total 메트릭에 알림을 걸어 50번째가 아니라 첫 번째 kill을 보고, 며칠 동안 계속 오르는 사용량은 올릴 limit이 아니라 고칠 누수로 다루세요.
Evicted와 그냥 137은 어떻게 다른가
Evicted는 커널이 아니라 kubelet입니다. 노드의 memory.available 신호가 eviction 임계값 아래로 떨어지면 kubelet이 직접 Pod를 끝내고, Pod 상태는 Evicted, 메시지는 "The node was low on resource: memory"가 됩니다. 어느 Pod가 먼저 나가는지는 cgroup 천장이 아니라 request와 사용량이 정하므로 해결은 세 번째 원인과 같습니다. request를 주고 노드에 여유를 더하는 것입니다. OOMKilled 없이 137만 있는 경우(Reason: Error)는 다른 곳에서 온 SIGKILL입니다. 대개 liveness probe 실패 뒤 SIGTERM을 무시하고 grace period를 넘긴 컨테이너를 kubelet이 강제 종료한 것입니다. 다음에 137을 만나면 reason부터 읽어 보세요. OOMKilled면 메모리로, Error면 probe와 종료 처리로 가면 됩니다.
관련 질문
kubectl top에서는 사용량이 limit 아래였는데 왜 죽었나요?
kubectl top은 마지막 메트릭 수집 시점의 working set을 보여 줄 뿐 최댓값을 기록하지 않습니다. 두 수집 사이에 limit을 넘긴 순간적인 버스트도 그대로 죽습니다. 노드 자체가 바닥났는지도 확인하세요. 노드에 SystemOOM 이벤트가 있으면 컨테이너가 자기 limit에 닿지 않았어도 커널이 골랐다는 뜻입니다.
limit을 올리면 메모리 누수가 해결되나요?
다음 kill을 미룰 뿐입니다. 사용량이 몇 시간 동안 꾸준히 오르고 평평해지지 않는다면 더 큰 limit은 시간만 벌어 줍니다. limit 근처에서 힙 덤프를 떠서 계속 자라는 할당을 고치세요.
JVM이 limit보다 훨씬 적게 쓰는데도 죽는 이유는요?
힙은 JVM 메모리의 일부일 뿐입니다. metaspace, 스레드 스택, direct buffer, code cache가 모두 cgroup에 계산됩니다. 여유를 두세요. MaxRAMPercentage는 90보다 70–75 정도가 안전하고, 차이가 크면 -XX:NativeMemoryTracking 플래그로 네이티브 메모리를 확인하세요.
OOMKilled와 Evicted는 같은 건가요?
아닙니다. OOMKilled는 cgroup limit을 넘긴 컨테이너 하나를, 또는 노드 전체 OOM 때 고른 컨테이너를 커널이 죽이는 것입니다. Evicted는 노드의 memory.available 신호가 eviction 임계값을 넘었을 때 kubelet이 Pod를 끝내는 것이고, Pod 상태가 Evicted, 메시지가 the node was low on resource: memory로 나옵니다.
limits.memory만 적었더니 Pod가 Insufficient memory로 Pending이 됐습니다.
request 없이 limit만 있으면 Kubernetes가 limit 값을 request로 복사하므로 스케줄러가 노드에서 limit 전체를 예약합니다. 평소 사용량이 limit보다 한참 낮다면 request를 더 작게 명시하세요.
참고 자료
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
같은 카테고리 다른 글
Git: fatal: detected dubious ownership in repository
Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.
Kubernetes: Internal error occurred: failed calling webhook
admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.
Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)
V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.
Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트
컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.