- Published on
EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡해 보이는데 EKS에서 특정 Pod만 OOMKilled로 계속 재시작하는 상황은 흔합니다. 문제는 대개 “메모리가 부족하다”가 아니라 Kubernetes 메모리 제한(limit)과 런타임의 메모리/GC 동작이 충돌하거나, 측정 기준을 잘못 잡아 잘못된 requests/limits로 운영하는 데서 시작합니다.
이 글에서는 OOMKilled를 정확히 재현/관찰 → 원인 분리 → limit·request·GC·HPA 튜닝 순서로 정리합니다. (중간에 Pod가 종료 단계에서 이상 행동을 보인다면 Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅도 함께 참고하면 좋습니다.)
OOMKilled의 의미부터 정확히 잡기
OOMKilled는 “컨테이너가 메모리를 많이 썼다”는 표현이지만, 실제로는 다음 두 케이스로 나뉩니다.
cgroup limit 초과 (가장 흔함)
- 컨테이너 메모리 사용량이
resources.limits.memory를 넘으면 커널이 해당 프로세스를 kill - Kubernetes 이벤트에
OOMKilled가 찍히고 컨테이너는 재시작
- 컨테이너 메모리 사용량이
노드 메모리 압박으로 인한 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가 실제 피크보다 낮아 항상 OOMKilledrequests와limits를 동일하게 둬서(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_bytesp95/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>
Evicted,MemoryPressure이벤트 확인- 노드 불안정이 반복되면, eviction 루프 관점도 함께 점검하세요: EKS Pod Eviction Loop - PDB·우선순위·Spot 정리
3) 종료/재시작이 꼬여서 더 큰 메모리 문제를 만들고 있나?
- SIGTERM 처리 중 리소스 정리가 안 되어 종료가 지연
- grace period 동안 메모리 사용이 더 증가
이 경우는 종료 시나리오를 함께 봐야 합니다: Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅
단계별 처방: “지금 당장”부터 “근본 해결”까지
1) 지금 당장(서비스 살리기)
limits.memory를 p99 working set * 1.3 정도로 임시 상향requests.memory도 함께 상향(과밀 방지)- 런타임별 상한 설정(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 파라미터를 더 구체적으로 산정하는 방법까지 함께 정리해드릴 수 있습니다.