Skip to content

필사 모드: Kubernetes OOMKilled(137) 메모리 문제 해결 — 한도를 올리기 전에 확인할 것들

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 137로 죽었는데 로그에는 예외가 없습니다

컨테이너가 반복해서 재시작하는데, 로그의 마지막 줄은 평범한 정보 로그입니다.

kubectl get pod -n search

NAME                      READY   STATUS             RESTARTS       AGE
indexer-6c4d9f7b8-h2klp   0/1     CrashLoopBackOff   4 (94s ago)    22m
kubectl logs indexer-6c4d9f7b8-h2klp -n search --previous | tail -3

2026-07-26T00:38:41.220Z INFO  segment merge started shard=7 docs=1284000
2026-07-26T00:38:44.902Z INFO  segment merge progress shard=7 pct=61

예외도, 종료 메시지도 없습니다. 프로세스가 말할 기회를 얻지 못하고 사라졌다는 뜻이고, 그건 SIGKILL의 특징입니다.

kubectl describe pod indexer-6c4d9f7b8-h2klp -n search | grep -A6 "Last State"

    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Sun, 26 Jul 2026 09:36:02 +0900
      Finished:     Sun, 26 Jul 2026 09:38:45 +0900
    Restart Count:  4

137이 말해 주는 것과 말해 주지 않는 것

137은 128에 9를 더한 값이고, 9번 시그널은 SIGKILL입니다. POSIX 셸과 컨테이너 런타임은 시그널로 종료된 프로세스의 종료 코드를 128 더하기 시그널 번호로 보고합니다.

여기서 정확히 구분해야 할 것이 있습니다. 137은 "누군가 이 프로세스를 강제로 죽였다"는 사실만 말합니다. 메모리 때문인지는 알려 주지 않습니다. SIGKILL을 보내는 주체는 여럿입니다.

  • 커널의 cgroup OOM 킬러 — 컨테이너가 메모리 한계를 넘었을 때
  • 커널의 전역 OOM 킬러 — 노드 전체 메모리가 고갈됐을 때
  • kubelet — SIGTERM 이후 유예 시간 안에 종료하지 않는 컨테이너에게
  • 노드의 다른 관리 도구나 사람

메모리가 원인인지는 종료 코드가 아니라 Reason 필드가 결정합니다. Reason이 OOMKilled면 메모리이고, Error인데 137이면 유예 시간 초과에 의한 강제 종료입니다. 후자에 메모리를 더 주는 것은 아무 효과가 없습니다.

kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,REASON:.status.containerStatuses[*].lastState.terminated.reason,CODE:.status.containerStatuses[*].lastState.terminated.exitCode' \
  | grep -v '<none>' | head -5

NS          POD                            REASON      CODE
search      indexer-6c4d9f7b8-h2klp        OOMKilled   137
payments    ledger-5f8b7c6d9-nm3xt         Error       137
media       transcode-79c5d6f8b-vn4pq      Error       143

두 번째 파드는 137이지만 OOM이 아닙니다. 종료 신호를 무시하다 죽은 것이고, 해결책은 그레이스풀 셧다운 쪽입니다.

가장 확실한 증거는 cgroup이 직접 세는 카운터입니다.

kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- cat /sys/fs/cgroup/memory.events

low 0
high 0
max 3421
oom 2
oom_kill 2

oom_kill이 0보다 크면 이 cgroup 안에서 실제로 프로세스가 OOM으로 죽었다는 뜻입니다. max 카운터도 중요합니다. 이 값은 사용량이 한계에 닿아 커널이 회수를 시도한 횟수이므로, 아직 죽지는 않았지만 매우 아슬아슬한 컨테이너를 미리 찾아내는 데 씁니다.

컨테이너 OOM인가, 노드 전역 OOM인가

증상이 비슷해 보이지만 대응이 완전히 다릅니다.

컨테이너 cgroup OOM은 그 컨테이너만 죽습니다. 한계를 넘은 것은 이 컨테이너의 책임이고, 노드의 다른 파드는 멀쩡합니다.

노드 전역 OOM은 노드 전체 메모리가 고갈되어 커널이 희생자를 고르는 상황입니다. 죽는 프로세스가 원인 제공자가 아닐 수 있고, 여러 파드가 동시에 죽습니다.

kubelet 축출은 그 이전 단계입니다. kubelet은 노드 메모리가 축출 임계값 아래로 떨어지면 파드를 골라 종료하며, 이때 상태는 OOMKilled가 아니라 Evicted입니다.

노드에서 커널 메시지 한 줄이면 판별됩니다.

kubectl debug node/ip-10-0-1-14 -it --image=busybox -- sh

/ # dmesg -T | grep -i "out of memory" | tail -3
[Sun Jul 26 09:38:45 2026] Memory cgroup out of memory: Killed process 21847 (java) total-vm:9421884kB, anon-rss:2088412kB, file-rss:2104kB, shmem-rss:0kB, UID:1000 pgtables:5120kB oom_score_adj:939

Memory cgroup out of memory라는 접두어가 결정적입니다. 이 문구가 있으면 컨테이너 한계 초과이고, 없이 그냥 Out of memory: Killed process만 나오면 노드 전역 OOM입니다.

축출은 이벤트로 확인합니다.

kubectl get events -A --field-selector reason=Evicted --sort-by=.lastTimestamp | tail -3

NAMESPACE   LAST SEEN   TYPE      REASON    OBJECT                        MESSAGE
media       4m12s       Warning   Evicted   pod/transcode-79c5d6f8b-9klm  The node was low on resource: memory. Threshold quantity: 100Mi, available: 84Mi. Container transcode was using 3187Mi, request is 512Mi.

메시지에 사용량과 요청량이 같이 찍힙니다. 3187Mi를 쓰면서 512Mi만 요청한 파드가 노드를 위험에 빠뜨렸다는 뜻이고, 이 경우 고칠 것은 한계가 아니라 요청량의 정직함입니다.

전역 OOM에서 누가 먼저 죽는지는 QoS 클래스가 결정합니다. kubelet은 QoS에 따라 컨테이너의 oom_score_adj를 다르게 설정합니다.

  • Guaranteed — 모든 컨테이너의 requests와 limits가 같음. oom_score_adj는 -997로, 사실상 마지막에 죽습니다
  • BestEffort — requests도 limits도 없음. oom_score_adj는 1000으로, 가장 먼저 죽습니다
  • Burstable — 그 사이. 메모리 요청량이 노드 용량 대비 클수록 점수가 낮아져 살아남을 확률이 높습니다
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass' | grep BestEffort | head -3

NS          POD                             QOS
default     debug-shell                     BestEffort
media       thumbnailer-5c8d7f9b6-p4wnt     BestEffort

런타임이 컨테이너 한계를 오해할 때

컨테이너에 2Gi를 줬는데 애플리케이션은 노드의 64Gi를 자기 몫으로 착각하는 일이 자주 있습니다.

JVM

최신 JVM은 컨테이너 인식을 기본으로 켜므로 한계를 넘겨 힙을 잡는 일은 드뭅니다. 문제는 반대 방향입니다. 기본 MaxRAMPercentage가 25퍼센트라, 2Gi 컨테이너에서 힙 상한이 512Mi로 잡힙니다.

kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- \
  java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "MaxHeapSize|MaxRAMPercentage"

   size_t MaxHeapSize     = 536870912    {product} {ergonomic}
 double MaxRAMPercentage  = 25.000000    {product} {default}

여기서 137이 났다면 원인은 힙이 아닙니다. 힙 상한이 512Mi인데 컨테이너는 2Gi에서 죽었다면 나머지 1.5Gi를 힙 바깥이 쓰고 있다는 뜻입니다. 메타스페이스, 스레드 스택, 코드 캐시, 다이렉트 바이트 버퍼, GC 자료구조, 네이티브 라이브러리가 여기 해당합니다.

kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- jcmd 1 VM.native_memory summary

Total: reserved=4183MB, committed=1971MB
-                 Java Heap (reserved=512MB, committed=512MB)
-                     Class (reserved=1084MB, committed=68MB)
-                    Thread (reserved=418MB, committed=418MB)
-                      Code (reserved=250MB, committed=42MB)
-                        GC (reserved=76MB, committed=76MB)
-                  Internal (reserved=812MB, committed=804MB)

스레드가 418MB를 쓰고 있습니다. 기본 스택 1MB에 스레드 400개를 만든 것입니다. 여기서 힙을 늘리면 상황이 더 나빠집니다.

흔한 오답: 137을 보고 힙 크기를 올리는 것. 힙을 늘리면 GC가 덜 자주 돌아 실제 상주 메모리가 늘고, 오프힙이 원인이었다면 죽는 시점만 앞당깁니다. 먼저 어디가 큰지부터 봐야 합니다.

권장 설정은 절대값이 아니라 비율과 상한의 조합입니다.

env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=70.0
      -XX:MaxMetaspaceSize=256m
      -XX:MaxDirectMemorySize=256m
      -XX:+ExitOnOutOfMemoryError
      -XX:+HeapDumpOnOutOfMemoryError
      -XX:HeapDumpPath=/dumps
resources:
  requests:
    memory: 2Gi
  limits:
    memory: 2Gi

-XX:+ExitOnOutOfMemoryError가 중요합니다. 이 옵션이 없으면 JVM은 힙 부족 상황에서도 죽지 않고 GC에 시간을 다 쓰면서 살아 있습니다. 겉보기에는 Running인데 응답하지 않는, 진단이 가장 어려운 상태가 됩니다. 차라리 깨끗하게 죽고 재시작하는 편이 낫습니다.

Node.js

V8의 오래된 세대 힙 상한은 프로세스가 인식한 시스템 메모리를 기준으로 정해지는데, 컨테이너 한계를 정확히 반영하지 못하는 조합이 여전히 존재합니다. 명시하는 것이 안전합니다.

kubectl exec -it api-gateway-7d8f9c6b5-x2mnp -n edge -- \
  node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)"

4144.0

컨테이너 한계는 1Gi인데 V8은 4GB까지 늘릴 수 있다고 믿고 있습니다. 이 상태에서는 V8이 GC를 서두르지 않다가 컨테이너가 먼저 죽습니다. 자바스크립트 힙 부족 예외조차 나오지 않고 그냥 137입니다.

env:
  - name: NODE_OPTIONS
    value: '--max-old-space-size=768'
resources:
  requests:
    memory: 1Gi
  limits:
    memory: 1Gi

한계의 75퍼센트 정도를 힙에 주고 나머지를 버퍼, 네이티브 애드온, 스택에 남깁니다. Buffer를 많이 쓰는 애플리케이션이라면 더 낮춰야 합니다. Buffer는 힙 바깥에 할당되므로 max-old-space-size에 포함되지 않습니다.

파이썬

파이썬에는 힙 상한 개념이 없습니다. 대신 두 가지가 자주 문제를 만듭니다.

첫째, glibc의 스레드별 메모리 아레나입니다. 스레드가 많으면 아레나가 늘어나 RSS가 실사용량보다 훨씬 커집니다.

env:
  - name: MALLOC_ARENA_MAX
    value: '2'

둘째, 워커 프로세스가 누수를 축적합니다. 근본 수정 전까지는 주기적 재활용이 실용적인 완충 장치입니다.

command:
  - gunicorn
  - --workers=4
  - --max-requests=2000
  - --max-requests-jitter=200
  - app:application

--max-requests-jitter가 없으면 워커가 동시에 재시작해 요청이 한꺼번에 끊깁니다.

컨테이너는 살아 있는데 기능이 죽는 경우

cgroup OOM 킬러는 컨테이너를 죽이는 것이 아니라 cgroup 안에서 가장 점수가 높은 프로세스를 죽입니다. PID 1이 아닌 자식 워커가 희생되면 컨테이너는 계속 Running으로 보이고, RESTARTS도 늘지 않으며, 기능 일부만 조용히 멈춥니다.

kubectl exec -it api-gateway-7d8f9c6b5-x2mnp -n edge -- cat /sys/fs/cgroup/memory.events | grep oom_kill

oom_kill 3

RESTARTS는 0인데 oom_kill이 3입니다. 이 조합이 보이면 자식 프로세스가 죽고 있다는 뜻입니다. 해결은 감독 프로세스가 자식 사망을 감지해 스스로 종료하도록 만드는 것입니다. 그래야 쿠버네티스가 개입할 수 있습니다.

working set과 RSS, 그리고 페이지 캐시

메모리 지표는 이름이 비슷한데 의미가 다릅니다. 잘못된 지표에 알림을 걸면 매일 오탐에 시달립니다.

  • memory.current (cgroup v2) — 이 cgroup이 차지한 전체. 익명 메모리와 페이지 캐시를 모두 포함합니다. 한계 판정의 기준이 되는 값입니다
  • working set — memory.current에서 비활성 파일 캐시를 뺀 값. kubectl top이 보여 주는 값이고 kubelet 축출 판정의 기준입니다
  • RSS — 프로세스가 실제로 물리 메모리에 올려 둔 페이지. 공유 라이브러리가 중복 계산되고 페이지 캐시는 빠집니다

여기서 함정이 나옵니다. 파일을 많이 읽는 애플리케이션은 페이지 캐시가 쌓여 memory.current가 한계에 붙습니다. 그래프만 보면 곧 죽을 것 같지만, 캐시는 회수 가능하므로 실제로는 죽지 않습니다.

kubectl exec -it indexer-6c4d9f7b8-h2klp -n search -- sh -c \
  'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/memory.current; grep -E "^(anon|file|slab) " /sys/fs/cgroup/memory.stat'

2147483648
2091483136
anon 1183842304
file 874512384
slab 33124352

전체 2091MB 중 익명 메모리는 1183MB, 파일 캐시가 874MB입니다. 익명 메모리만 보면 여유가 있습니다.

kubectl top pod indexer-6c4d9f7b8-h2klp -n search

NAME                      CPU(cores)   MEMORY(bytes)
indexer-6c4d9f7b8-h2klp   410m         1216Mi

kubectl top이 1216Mi를 보고하는 이유가 이것입니다. 비활성 파일 캐시를 뺀 값입니다.

흔한 오답: 알림을 container_memory_usage_bytes에 거는 것. 이 지표는 페이지 캐시를 포함하므로 정상적인 파일 I/O에도 한계 근처까지 올라갑니다. 알림은 container_memory_working_set_bytes로 걸어야 합니다.

다만 한 가지 단서가 있습니다. 캐시가 회수되지 않는 종류일 때, 예를 들어 tmpfs나 공유 메모리에 올린 데이터는 회수 대상이 아니라 그대로 한계를 밀어붙입니다. emptyDir에 medium을 Memory로 준 볼륨이 대표적입니다. 이 볼륨에 쓴 데이터는 전부 컨테이너 메모리 한계에 계산됩니다.

volumes:
  - name: scratch
    emptyDir:
      medium: Memory
      sizeLimit: 256Mi

sizeLimit을 반드시 지정하십시오. 없으면 노드 메모리 절반까지 쓸 수 있습니다.

requests와 limits를 어떻게 정할 것인가

원인대표 신호진단해결
컨테이너 한계 초과Reason OOMKilled, Exit Code 137describe의 Last State, memory.events의 oom_kill한계 상향 또는 사용량 축소
자식 프로세스만 사망RESTARTS는 0인데 기능 일부 정지oom_kill 카운터와 RESTARTS 비교감독 프로세스가 자식 사망 시 종료
JVM 오프힙 초과힙은 여유, 컨테이너는 한계jcmd VM.native_memory summary메타스페이스와 다이렉트 상한 지정
Node.js 힙 상한 미설정heap_size_limit이 컨테이너 한계보다 큼v8 힙 통계 조회max-old-space-size 명시
페이지 캐시 누적usage는 한계 근처, working set은 여유memory.stat의 file 항목알림 지표를 working set으로 교체
메모리 기반 emptyDir볼륨 쓰기와 함께 사용량 증가볼륨 스펙의 medium 확인sizeLimit 지정 또는 디스크 사용
노드 전역 OOM여러 파드 동시 사망, dmesg에 cgroup 문구 없음노드 dmesg오버커밋 축소, requests 정합성
kubelet 축출STATUS Evictedreason=Evicted 이벤트요청량 상향, 축출 임계값 재검토
메모리 누수working set이 톱니 없이 단조 증가시계열 그래프, 프로파일링코드 수정, 워커 재활용

메모리에 관해서는 requests와 limits를 같게 두는 것이 대체로 옳습니다. 이유는 메모리가 압축 불가능한 자원이기 때문입니다. CPU는 부족하면 느려지지만 메모리는 부족하면 죽습니다. requests보다 많이 쓰는 파드는 스케줄러의 계산을 배신하는 것이고, 그 대가는 같은 노드의 다른 파드가 치릅니다.

resources:
  requests:
    cpu: 500m
    memory: 2Gi
  limits:
    memory: 2Gi

여기서 흔한 논쟁이 있습니다. requests와 limits를 전부 같게 맞춰 Guaranteed QoS를 얻어야 하는가입니다. 정리하면 이렇습니다.

  • 메모리는 같게 — 노드 전역 압박에서 마지막에 희생되고, 오버커밋으로 인한 연쇄 사고를 막습니다
  • CPU requests는 지정, CPU limits는 신중하게 — CPU 한계는 cgroup 대역폭 스로틀링을 유발합니다. 여유가 있는 노드에서도 한계에 걸려 지연이 튀는 현상이 나오므로, 지연에 민감한 서비스에서는 CPU limits를 생략하고 requests로 몫만 확보하는 선택이 흔합니다
  • 다만 CPU limits를 생략하면 Guaranteed QoS는 얻을 수 없습니다. 노드 전역 OOM 방어와 지연 안정성 중 무엇이 더 중요한지의 문제이지, 한쪽이 항상 옳은 것이 아닙니다

흔한 오답: 137이 뜰 때마다 한계를 두 배로 올리는 것. 누수라면 죽는 주기만 길어질 뿐이고, 그동안 노드 하나가 더 필요해집니다. 시계열을 먼저 보십시오. 톱니 모양으로 오르내리면 정상적인 GC 패턴이고 한계가 진짜 부족한 것입니다. 단조 증가하다가 절벽처럼 떨어지면 누수입니다.

재발 방지 — 근거를 만들고 미리 잡습니다

VPA로 실제 사용량에 근거를 둡니다

권고 모드로만 돌려도 충분한 가치가 있습니다. 자동 적용은 파드 재생성을 동반하므로 신중해야 합니다.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: indexer
  namespace: search
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: indexer
  updatePolicy:
    updateMode: 'Off'
kubectl get vpa indexer -n search \
  -o jsonpath='{.status.recommendation.containerRecommendations[0]}'

{"containerName":"indexer","lowerBound":{"cpu":"320m","memory":"1300Mi"},"target":{"cpu":"480m","memory":"1730Mi"},"uncappedTarget":{"cpu":"480m","memory":"1730Mi"},"upperBound":{"cpu":"1100m","memory":"2600Mi"}}

target은 관측된 사용량 분포를 기반으로 한 권장치이고, upperBound는 관측 기간이 짧을수록 넉넉하게 나옵니다. 최소 일주일, 가능하면 트래픽 피크를 포함한 기간의 값을 봐야 의미가 있습니다.

죽기 전에 알림을 받습니다

죽은 뒤에 오는 알림은 사후 통보입니다. 한계에 접근하는 단계에서 잡아야 합니다.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: memory-alerts
  namespace: monitoring
spec:
  groups:
    - name: memory
      rules:
        - alert: ContainerOOMKilled
          expr: increase(kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}[10m]) > 0
          labels:
            severity: critical
          annotations:
            summary: "{{ $labels.namespace }}/{{ $labels.pod }} was OOMKilled"
        - alert: ContainerMemoryNearLimit
          expr: |
            container_memory_working_set_bytes{container!="",container!="POD"}
              / on(namespace,pod,container) container_spec_memory_limit_bytes{container!=""} > 0.9
          for: 15m
          labels:
            severity: warning
        - alert: ContainerMemoryGrowsMonotonically
          expr: |
            deriv(container_memory_working_set_bytes{container!="",container!="POD"}[6h]) > 0
              and increase(container_memory_working_set_bytes{container!="",container!="POD"}[6h]) > 200e6
          for: 1h
          labels:
            severity: warning

세 번째 규칙이 누수 조기 경보입니다. 6시간 동안 단조 증가하면서 200MB 이상 늘었다면 정상적인 캐시 워밍업으로 보기 어렵습니다.

사후 분석 자료를 미리 확보합니다

OOM으로 죽은 컨테이너는 아무것도 남기지 못합니다. 죽기 전에 덤프가 나가도록 볼륨을 붙여 둡니다.

volumeMounts:
  - name: dumps
    mountPath: /dumps
volumes:
  - name: dumps
    persistentVolumeClaim:
      claimName: heap-dumps

JVM은 앞서 본 HeapDumpOnOutOfMemoryError로, Node.js는 시그널 기반 스냅샷으로, 파이썬은 memray나 tracemalloc으로 확보합니다. 단, 힙 덤프를 쓰는 동안 메모리가 추가로 필요하므로 한계에 여유가 없으면 덤프 자체가 실패할 수 있다는 점은 감안해야 합니다.

마치며 — Reason을 먼저 보고, 한계는 마지막에 올립니다

OOMKilled 대응의 순서는 정해져 있습니다. Reason이 정말 OOMKilled인지 확인하고, cgroup의 oom_kill 카운터로 뒷받침하고, dmesg로 컨테이너 OOM인지 노드 전역 OOM인지 가르고, 런타임이 한계를 제대로 인식하는지 검사하고, working set 시계열이 톱니인지 단조 증가인지 봅니다. 여기까지 온 다음에야 한계 조정이 근거 있는 결정이 됩니다.

기억할 한 문장은 이것입니다. 137은 진단이 아니라 사망 원인 미상이며, 메모리라고 확정해 주는 것은 오직 Reason 필드와 oom_kill 카운터입니다.

현재 단락 (1/230)

컨테이너가 반복해서 재시작하는데, 로그의 마지막 줄은 평범한 정보 로그입니다.

작성 글자: 0원문 글자: 10,673작성 단락: 0/230