Published on

EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝

Authors

서버가 멀쩡해 보이는데 EKS에서 특정 Pod만 OOMKilled로 계속 재시작하는 상황은 흔합니다. 문제는 대개 “메모리가 부족하다”가 아니라 Kubernetes 메모리 제한(limit)과 런타임의 메모리/GC 동작이 충돌하거나, 측정 기준을 잘못 잡아 잘못된 requests/limits로 운영하는 데서 시작합니다.

이 글에서는 OOMKilled정확히 재현/관찰 → 원인 분리 → limit·request·GC·HPA 튜닝 순서로 정리합니다. (중간에 Pod가 종료 단계에서 이상 행동을 보인다면 Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅도 함께 참고하면 좋습니다.)


OOMKilled의 의미부터 정확히 잡기

OOMKilled는 “컨테이너가 메모리를 많이 썼다”는 표현이지만, 실제로는 다음 두 케이스로 나뉩니다.

  1. cgroup limit 초과 (가장 흔함)

    • 컨테이너 메모리 사용량이 resources.limits.memory를 넘으면 커널이 해당 프로세스를 kill
    • Kubernetes 이벤트에 OOMKilled가 찍히고 컨테이너는 재시작
  2. 노드 메모리 압박으로 인한 OOM/eviction

    • 노드 전체 메모리가 부족해져서 시스템 OOM이 발생하거나, kubelet이 eviction을 수행
    • 이 경우는 Pod 이벤트가 Evicted로 찍히기도 하고, 노드 상태/PLEG 문제로 번질 수 있음
    • 노드가 NotReady/PLEG 문제로 이어진다면 EKS kubelet NotReady - PLEG is not healthy 7가지도 같이 점검하세요.

핵심은 **“컨테이너 limit을 넘었는지” vs “노드가 불안정한지”**를 먼저 가르는 것입니다.


1단계: OOMKilled 증거 수집(30분 컷)

Pod 이벤트/종료 사유 확인

kubectl describe pod -n <ns> <pod>

# 종료 사유만 빠르게
kubectl get pod -n <ns> <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{"\n"}'

lastState.terminated.reason=OOMKilled면 컨테이너가 kill 당한 것입니다.

직전 메모리 사용량 확인 (metrics-server 기준)

kubectl top pod -n <ns> <pod> --containers
kubectl top pod -n <ns> -l app=<label>

주의: kubectl top은 샘플링/지연이 있고, OOM 직전 피크를 놓칠 수 있습니다. 가능하면 Prometheus/Grafana에서 container_memory_working_set_bytes(실사용)와 container_memory_rss(RSS) 피크를 확인하세요.

Limit/Request 확인

kubectl get deploy -n <ns> <deploy> -o jsonpath='{.spec.template.spec.containers[0].resources}'

여기서 가장 흔한 실수는:

  • requests.memory가 너무 낮아 노드에 과밀 스케줄링됨
  • limits.memory가 실제 피크보다 낮아 항상 OOMKilled
  • requestslimits를 동일하게 둬서(Guaranteed) 노드 안정성은 좋지만 비용/밀도가 급상승

OOMKilled가 반복되는 대표 원인 6가지

1) Limit이 “피크 + GC 여유”를 못 담는다

애플리케이션 메모리는 보통 다음처럼 움직입니다.

  • 트래픽 증가 → 객체/버퍼 증가 → 메모리 상승
  • GC(또는 메모리 반환)가 늦으면 working set이 계속 상승
  • limit 근처에서 스파이크가 한 번만 떠도 즉시 kill

따라서 limit은 단순 평균이 아니라 피크 + 안전마진을 기준으로 잡아야 합니다.

권장 접근:

  • 최근 7~14일 피크를 기준으로 p95/p99를 확인
  • p99 working set의 1.2~1.5배를 임시 limit으로 올려 “OOM 루프 탈출”
  • 이후 GC/캐시/버퍼 튜닝으로 다시 낮추기

2) requests가 낮아 노드가 과밀해지고, 간접적으로 OOM이 난다

Kubernetes 스케줄러는 requests를 기준으로 배치합니다. requests가 지나치게 낮으면 노드에 많은 Pod가 들어가고, 노드 전체 메모리 압박이 생겨 eviction/OOM 가능성이 커집니다.

실전 팁:

  • requests.memory는 “평균”이 아니라 트래픽 정상 상태의 p50~p70 정도로 잡는 게 운영상 안정적입니다.
  • 노드 메모리 여유(daemonset, kubelet reserved 포함)를 감안하세요.

3) JVM: 컨테이너 메모리 인식/Heap 비율 미스매치

Java는 컨테이너 환경에서 다음이 자주 문제입니다.

  • Heap만 잡고 끝이 아니라, Metaspace / Thread stack / Direct buffer / Code cache가 추가로 필요
  • -Xmx를 limit에 너무 가깝게 잡으면, heap 밖 영역이 조금만 늘어도 OOMKilled

JVM 권장 설정(예시)

limit이 1024Mi라면 heap을 60~70%로 시작해보는 게 안전합니다.

resources:
  requests:
    memory: "768Mi"
  limits:
    memory: "1024Mi"

env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=65
      -XX:InitialRAMPercentage=65
      -XX:+UseG1GC
      -XX:+ExitOnOutOfMemoryError
  • MaxRAMPercentage는 컨테이너 limit 기준으로 heap 상한을 잡습니다.
  • ExitOnOutOfMemoryError는 Java 내부 OOM 시 프로세스를 종료시켜, 애매하게 살아남아 더 큰 장애를 만드는 상황을 줄입니다.

추가로 Direct memory를 많이 쓰는 Netty/HTTP 클라이언트가 있다면 -XX:MaxDirectMemorySize도 고려하세요.


4) Go: GC 목표치(GOGC)가 높아 피크가 커진다

Go는 기본적으로 GC가 “힙이 얼마나 커지면 돌지”를 GOGC로 제어합니다. GOGC=100이면 힙이 2배가 되면 GC가 도는 식이라, 트래픽 스파이크에서 힙이 급격히 커질 수 있습니다.

Go 컨테이너에서 추천: GOMEMLIMIT 사용

Go 1.19+라면 GOMEMLIMIT로 상한을 주는 게 효과적입니다.

env:
  - name: GOMEMLIMIT
    value: "700MiB"
  - name: GOGC
    value: "80"
  • GOMEMLIMIT는 컨테이너 limit보다 여유를 두고(예: limit 1Gi면 650~800MiB) 설정합니다.
  • 너무 낮추면 CPU 사용량(GC 빈도)이 늘 수 있으니 p95 latency와 함께 봐야 합니다.

5) Node.js: old space가 limit을 넘기거나, 네이티브 메모리가 누적된다

Node는 V8 heap(Old space) 외에도 native addon, Buffer, mmap 등이 있습니다. --max-old-space-size만 올리면 해결될 것 같지만, limit과의 관계를 잘못 잡으면 OOMKilled가 더 빨라집니다.

Node 튜닝 예시

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=512"
resources:
  limits:
    memory: "1024Mi"
  • old space를 limit의 50~60% 정도로 두고 시작
  • Buffer가 큰 워크로드(파일/이미지/프록시)는 RSS가 힙보다 더 커질 수 있어 working set을 기준으로 관측

6) Python: 캐시/워커 모델/메모리 누수

Python은 런타임 특성상 메모리 반환이 즉시 OS로 내려가지 않거나(특히 glibc allocator), 워커 수에 따라 선형으로 늘어납니다.

  • Gunicorn/Uvicorn 워커를 CPU 기준으로만 늘리면 메모리 폭발
  • 이미지/데이터 처리 라이브러리(Pillow, numpy) 사용 시 피크가 큼

대응:

  • 워커 수를 메모리 기준으로 산정(예: 워커 1개당 200Mi면 limit 1Gi에서 4~5개가 상한)
  • --max-requests/--max-requests-jitter로 장기 누적 완화
gunicorn app:app -w 4 -k uvicorn.workers.UvicornWorker \
  --max-requests 2000 --max-requests-jitter 200

requests/limits 실전 템플릿(안전한 출발점)

아래는 “일단 OOM 루프를 멈추고 관측 가능한 상태로 만들기”에 좋은 기본값 패턴입니다.

resources:
  requests:
    cpu: "200m"
    memory: "512Mi"
  limits:
    cpu: "1"
    memory: "1024Mi"
  • requests를 너무 낮게 두지 마세요(과밀 스케줄링 방지)
  • limit은 피크를 담되, 런타임별 오버헤드(JVM non-heap 등)까지 고려

운영 중에는 다음 지표를 같이 봅니다.

  • container_memory_working_set_bytes p95/p99
  • OOMKilled 발생 시각의 트래픽/큐 길이/latency
  • 노드 메모리 사용률, eviction 이벤트

HPA/오토스케일과 OOMKilled의 관계

OOMKilled가 반복될 때 흔히 “Pod를 더 늘리면 되지 않나?”라고 생각하지만, 스케일 아웃이 메모리 피크를 낮추지 못하는 구조도 많습니다.

  • 요청당 메모리가 큰 작업(대용량 변환, LLM 프록시에서 큰 컨텍스트 처리)
  • 캐시가 과도하게 커지는 구조(인메모리 캐시)
  • 워커 모델이 Pod 수와 무관하게 Pod당 피크가 고정

HPA를 쓰는 경우:

  • CPU 기반 HPA만으로는 메모리 OOM을 막기 어렵습니다.
  • 가능하면 메모리 기반 HPA 또는 custom metric(큐 길이 등)을 고려하세요.

디버깅 체크리스트(원인 분리용)

1) 컨테이너 limit 초과가 확실한가?

  • OOMKilled + 해당 컨테이너 limits.memory가 낮음
  • working set이 limit 근처까지 치솟음

2) 노드 메모리 압박/eviction이 섞여 있나?

kubectl get events -A --sort-by=.lastTimestamp | tail -n 50
kubectl describe node <node>

3) 종료/재시작이 꼬여서 더 큰 메모리 문제를 만들고 있나?

  • SIGTERM 처리 중 리소스 정리가 안 되어 종료가 지연
  • grace period 동안 메모리 사용이 더 증가

이 경우는 종료 시나리오를 함께 봐야 합니다: Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅


단계별 처방: “지금 당장”부터 “근본 해결”까지

1) 지금 당장(서비스 살리기)

  1. limits.memory를 p99 working set * 1.3 정도로 임시 상향
  2. requests.memory도 함께 상향(과밀 방지)
  3. 런타임별 상한 설정(JVM MaxRAMPercentage / Go GOMEMLIMIT / Node old space)

예: Java 서비스의 빠른 응급조치

env:
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:MaxRAMPercentage=65 -XX:+UseG1GC"
resources:
  requests:
    memory: "1Gi"
  limits:
    memory: "1536Mi"

2) 1~2일 내(재발 방지)

  • 메모리 프로파일링/heap dump/pprof 등으로 “무엇이 커지는지” 확인
  • 캐시 상한(예: Caffeine maximumSize/maximumWeight), 버퍼 풀 상한 설정
  • 워커 수/큐 동시성 제한(특히 Python/Node)

3) 1~2주 내(구조 개선)

  • 요청당 메모리 큰 작업 분리(별도 워커/큐)
  • 배치/스트리밍 처리로 피크 완화
  • 큰 객체를 오래 잡는 코드 경로 개선

마무리: OOMKilled는 ‘측정 기준’부터 바꿔야 빨리 끝난다

EKS에서 OOMKilled 루프를 끝내는 핵심은 단순히 limit을 올리는 게 아니라:

  • 컨테이너 limit 기준으로 메모리를 관측하고
  • requests로 과밀 스케줄링을 막고
  • 런타임(JVM/Go/Node/Python)의 GC/힙 상한을 limit에 맞춰 동기화하는 것입니다.

이 3가지만 맞춰도 “원인 모를 OOMKilled 반복”의 대부분은 재현 가능하고, 튜닝 가능한 문제로 바뀝니다.

필요하면 다음 정보를 주면(런타임/limit/피크 메모리/트래픽 패턴) 상황에 맞는 requests·limits와 GC 파라미터를 더 구체적으로 산정하는 방법까지 함께 정리해드릴 수 있습니다.