- Published on
Kubernetes OOMKilled 메모리 튜닝 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Pod가 갑자기 재시작되고 이벤트에 OOMKilled가 찍히면, 대부분은 limits.memory를 올리면 끝날 것 같지만 실제로는 그렇지 않은 경우가 많습니다. 메모리 사용량의 성격(힙, 네이티브, 페이지 캐시, 누수, 버퍼 폭증)을 구분하지 않으면, 한 번 올린 limit은 계속 올라가고 노드는 점점 불안정해집니다.
이 글은 OOMKilled를 “재현 가능한 데이터”로 바꾸고, requests/limits를 합리적으로 재설계하며, 애플리케이션 런타임까지 포함해 메모리 튜닝을 마무리하는 실전 흐름을 다룹니다.
관련해서 크래시 루프/OOMKilled의 원인 추적 흐름을 더 넓게 보고 싶다면 아래 글도 함께 보면 좋습니다.
1) OOMKilled의 “종류”부터 구분하기
Kubernetes에서 OOM은 크게 두 가지 레벨에서 발생합니다.
1-1. 컨테이너 OOMKilled (cgroup limit 초과)
- Pod 이벤트에
OOMKilled가 명확히 남습니다. - 해당 컨테이너가
limits.memory를 넘는 순간 커널이 프로세스를 종료합니다. - 가장 흔한 케이스이며, 이 글의 주 대상입니다.
1-2. 노드 OOM (노드 전체 메모리 고갈)
- 노드가 메모리 압박을 받으면 커널 OOM killer가 “누구를 죽일지” 결정합니다.
- Pod가 limit을 안 넘었는데도 죽을 수 있습니다(특히 BestEffort/낮은 QoS).
- 이 경우는 노드의
MemoryPressure, 과도한 overcommit, 페이지 캐시/버퍼, DaemonSet 메모리 폭증 등도 함께 봐야 합니다.
2) 가장 먼저 확인할 5가지: 커맨드 모음
2-1. Pod 이벤트와 종료 사유 확인
kubectl describe pod -n <namespace> <pod-name>
출력에서 다음을 확인합니다.
Last State: TerminatedReason: OOMKilledExit Code: 137(자주 함께 보임)Events에Killing메시지
<namespace> 같은 부등호 표기는 MDX에서 빌드 에러가 날 수 있으니, 위처럼 반드시 인라인 코드 또는 코드 블록으로 처리하는 습관을 추천합니다.
2-2. 리소스 설정값(요청/제한) 확인
kubectl get pod -n <namespace> <pod-name> -o jsonpath='{.spec.containers[*].resources}'
여기서 requests.memory와 limits.memory가 비어 있거나 지나치게 낮은지 확인합니다.
2-3. 실시간 사용량(메트릭 서버 기준)
kubectl top pod -n <namespace>
kubectl top pod -n <namespace> --containers
주의할 점:
kubectl top은 “현재값” 중심이라 스파이크를 놓치기 쉽습니다.- OOM은 대개 스파이크로 발생합니다. 따라서 Prometheus 같은 시계열이 있으면 반드시 최대값(p95/p99/peak)을 봐야 합니다.
2-4. cgroup 메모리 관점으로 확인(컨테이너 내부)
컨테이너에 접속할 수 있다면 다음이 매우 유용합니다.
# cgroup v2 환경에서 자주 유효
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events
memory.events에 oom 또는 oom_kill 카운터가 증가했는지 확인합니다.
2-5. 애플리케이션 로그에서 “직전 징후” 찾기
OOM 직전에는 흔히 아래 패턴이 있습니다.
- 대량 요청/배치 시작
- 특정 엔드포인트 호출 후 급증
- 캐시 워밍/리빌드
- 큰 파일 다운로드/압축 해제
- 메시지 큐 backlog 처리
애플리케이션 레벨 누수 진단이 필요하다면, 메모리 폭증을 “누수” 관점으로 접근한 글도 참고가 됩니다.
3) requests/limits를 “안전하게” 다시 잡는 공식
OOM을 막는 가장 단순한 방법은 limits.memory를 올리는 것입니다. 하지만 운영에서는 다음 3가지를 동시에 만족해야 합니다.
- OOM이 없어야 함
- 노드가 과밀해지지 않아야 함
- HPA/스케줄링이 안정적이어야 함
3-1. 기본 원칙
requests.memory: **평균이 아니라 “평시 상한(p50~p75)”**에 맞춥니다.limits.memory: **스파이크 상한(p95~p99 + 여유분)**에 맞춥니다.- 여유분은 보통
10%~30%를 잡되, GC 언어(JVM 등)나 네이티브 메모리가 큰 런타임은 더 크게 잡습니다.
3-2. QoS 클래스도 함께 고려
requests와limits가 모두 설정되고 동일하면Guaranteed- 둘 다 설정되지만 다르면
Burstable - 둘 다 없으면
BestEffort
노드 OOM 상황에서 BestEffort가 먼저 희생됩니다. 즉, 중요한 워크로드일수록 최소한 requests.memory는 반드시 지정하는 것이 안전합니다.
3-3. 예시: Burstable로 시작하는 현실적인 템플릿
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
template:
spec:
containers:
- name: api
image: myrepo/api:1.0.0
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "1024Mi"
실전에서는 위처럼 Burstable로 시작한 뒤, 관측 데이터를 기반으로 requests를 올리거나, limits를 내리거나, 혹은 둘을 같게 맞춰 Guaranteed로 전환합니다.
4) “limit만 올렸는데도” OOM이 나는 흔한 이유
4-1. 힙 외 메모리(네이티브, 스레드 스택, mmap, direct buffer)
특히 JVM, Node.js, Python(네이티브 라이브러리 사용), Go(CGo)에서 자주 발생합니다.
- JVM:
-Xmx만 보고 limit을 잡으면 실패합니다. Metaspace, CodeCache, Thread stack, Direct buffer가 추가로 먹습니다. - Node.js: V8 heap 상한이 기본적으로 컨테이너 메모리를 제대로 반영하지 않거나(환경/버전/옵션에 따라), native addon이 별도 메모리를 씁니다.
4-2. 동시성 폭증(큐 backlog, 스레드/코루틴 무제한)
“요청이 밀리면 더 많은 작업을 띄워서 처리”하는 구조는 메모리 상한이 없으면 OOM으로 이어집니다.
- 워커 수 제한
- 큐 consumer concurrency 제한
- 업로드/다운로드 스트림 처리(버퍼 상한)
4-3. 캐시 전략 실패(무제한 캐시)
- LRU 없이
Map에 계속 쌓임 - 요청 파라미터 다양화로 캐시 키 폭발
- 이미지/바이너리 캐시를 메모리에 적재
5) 런타임별 튜닝 포인트(바로 적용 가능한 값)
5-1. JVM: -Xmx를 limit의 50%~70%로 시작
컨테이너 limit이 1024Mi인데 -Xmx1024m로 주면, 힙 외 메모리까지 합쳐서 OOM이 나기 쉽습니다.
권장 시작점(보수적):
-Xmx를 limit의60%전후- Direct buffer를 쓰면
-XX:MaxDirectMemorySize도 명시 - 스레드가 많으면
-Xss(스택) 고려
예시:
JAVA_TOOL_OPTIONS="-Xms512m -Xmx640m -XX:MaxDirectMemorySize=128m -XX:MaxMetaspaceSize=192m"
Kubernetes 매니페스트 예시:
env:
- name: JAVA_TOOL_OPTIONS
value: "-Xms512m -Xmx640m -XX:MaxDirectMemorySize=128m -XX:MaxMetaspaceSize=192m"
resources:
limits:
memory: "1024Mi"
5-2. Node.js: --max-old-space-size로 힙 상한 고정
Node는 힙이 limit을 침범하지 않도록 상한을 명시하는 편이 안전합니다.
NODE_OPTIONS="--max-old-space-size=512"
위 값은 MB 단위입니다. 예를 들어 limit이 1024Mi라면 512~700 정도로 시작하고, 네이티브 사용량(이미지 처리, 암호화, grpc 등)에 따라 조정합니다.
5-3. Python: 워커/스레드 수가 곧 메모리
Gunicorn, uWSGI 같은 멀티프로세스 모델은 워커 수가 메모리 사용량을 선형으로 늘립니다.
- 워커 수를 CPU 기준으로만 잡지 말고, 워커당 RSS를 측정해 limit에 맞추기
- 프리로드(
--preload)는 메모리 공유에 유리할 수 있지만, 라이브러리/할당 패턴에 따라 오히려 역효과가 날 수 있어 A/B로 확인
Gunicorn 예시:
gunicorn app:app -w 2 --threads 4 --max-requests 2000 --max-requests-jitter 200
--max-requests는 누수 의심 상황에서 “주기적 리사이클”로 OOM을 완화하는 실전 옵션입니다(근본 해결 전 임시방편으로 유효).
5-4. Go: GOMEMLIMIT로 컨테이너 친화적으로
Go는 최근 버전에서 GOMEMLIMIT로 목표 힙 상한을 설정할 수 있습니다.
GOMEMLIMIT=700MiB
GOGC=100
limit이 1024Mi라면 GOMEMLIMIT를 650~800MiB 사이로 두고, 나머지를 스택/네이티브/여유분으로 남깁니다.
6) HPA와 메모리: “스케일 아웃”이 OOM을 해결하지 못하는 경우
HPA는 기본적으로 CPU 기반이 많고, 메모리 기반은 다음 함정이 있습니다.
- 메모리 사용량이 “누적형”이면 스케일 아웃으로도 내려가지 않습니다(각 Pod가 계속 증가).
- 스파이크가 짧으면 HPA가 반응하기 전에 이미 OOM이 납니다.
따라서 OOM 대응은 보통 아래 순서가 안정적입니다.
- Pod 당 메모리 상한을 런타임 튜닝으로 먼저 안정화
- 그 다음 HPA로 트래픽 대응
메모리 기반 HPA 예시(메트릭 파이프라인이 준비된 경우):
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70
7) VPA(Vertical Pod Autoscaler)를 “권장값 생성기”로 쓰는 방법
VPA를 켜면 자동으로 Pod를 재시작하며 리소스를 바꾸기 때문에, 운영 환경에서는 곧바로 Auto를 적용하기 부담스럽습니다.
실전 팁:
- VPA를
Off또는Initial로 두고 recommendation만 받아서 사람이 검토 후 반영
VPA 예시:
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: api-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api
updatePolicy:
updateMode: "Off"
이후 추천값 확인:
kubectl describe vpa api-vpa
8) OOM 재발 방지 체크리스트(운영 기준)
8-1. 관측 지표를 “최대값” 중심으로 바꾸기
container_memory_working_set_bytes의 p95/p99- OOM 이벤트 카운트
- 재시작 횟수
- 요청량과 메모리 사용량의 상관
8-2. 배포 전후 비교를 자동화
- 동일 트래픽 구간에서 메모리 상한이 내려갔는지
- GC 횟수/시간이 늘지 않았는지(JVM)
- 레이턴시가 악화되지 않았는지
8-3. “메모리 절약”보다 “상한 확정”이 먼저
메모리 최적화는 시간이 걸립니다. 당장 중요한 건 OOM이 안 나는 상한을 확정하는 것입니다.
- 런타임 힙 상한 고정
- 동시성 상한 고정
- 버퍼 크기 상한 고정
그 다음에 캐시/알고리즘 최적화를 진행하는 편이 안전합니다.
9) 실전 시나리오: 1Gi limit에서 OOM이 나는 API 튜닝 예시
가정:
limits.memory가1024Mi- Node.js API가 트래픽 스파이크 때
OOMKilled - 이미지 처리 라이브러리(네이티브)가 메모리를 추가로 사용
대응 순서:
kubectl describe pod로 OOMKilled 확인- Prometheus(또는 로그)로 OOM 직전 peak가
900Mi이상인지 확인 NODE_OPTIONS로 V8 힙 상한을512~640MB로 제한- 이미지 처리 동시성 제한(예: 큐 worker 2개로 제한)
requests.memory를512Mi로 올려 안정적인 스케줄링 유도- 여전히 스파이크가 크면
limits.memory를1280Mi로 올리되, 원인(동시성/버퍼)을 먼저 고정
매니페스트 예시:
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "1280Mi"
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=640"
여기서 핵심은 “limit을 올렸다”가 아니라, 힙 상한과 동시성 상한을 같이 고정했다는 점입니다. 이 두 가지가 없으면 limit을 올려도 언젠가 다시 OOM이 납니다.
10) 마무리: OOMKilled를 끝내는 한 줄 요약
OOMKilled는 단순히 메모리가 부족해서가 아니라, **상한이 정의되지 않은 메모리 소비(힙/네이티브/동시성/버퍼/캐시)**가 컨테이너 limit을 넘기면서 발생합니다.- 따라서 해결책도
limits.memory증설 하나가 아니라,- 관측(peak)
requests/limits재설계(QoS 포함)- 런타임 힙 상한 설정
- 동시성/버퍼 상한 고정
- HPA/VPA의 역할 분리 로 완성됩니다.
운영에서 자주 겪는 EKS 환경 이슈를 함께 다루는 글이 필요하다면 아래도 참고해 보세요.