- Published on
K8s OOMKilled 반복? 메모리 리밋·GC 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Pod가 OOMKilled로 죽고 재시작을 반복하면, 증상은 같아도 원인은 크게 두 갈래로 나뉩니다.
- 리소스 설정 문제:
resources.limits.memory가 실제 워크로드에 비해 낮거나,requests와limits의 조합 때문에 노드 압박이 커지는 경우 - 애플리케이션 문제: 메모리 누수, 캐시 폭주, 배치 처리 피크, GC 튜닝 미스(특히 JVM/Node/Python) 등
이 글은 “무턱대고 메모리 리밋만 올리기” 전에, 컨테이너 메모리 리밋과 GC 동작을 함께 보면서 원인을 좁히는 절차를 제공합니다.
관련해서 재시작 원인 추적 관점은 systemd 글과도 결이 비슷합니다. Pod 재시작이 반복될 때도 결국 “왜 죽었는지”를 이벤트와 로그로 좁혀야 합니다: systemd 서비스가 계속 재시작될 때 원인 추적
1) OOMKilled의 종류부터 구분하기
Kubernetes에서 OOM은 보통 두 유형으로 나타납니다.
1-1. 컨테이너 OOMKilled (cgroup limit 초과)
- Pod 이벤트에
OOMKilled가 찍히고 kubectl describe pod에서 마지막 종료 사유가OOMKilled- 컨테이너가 자기 리밋을 넘어서 커널에 의해 즉시 종료
이 경우는 “리밋이 낮다” 또는 “애플리케이션이 순간 피크로 리밋을 넘는다” 둘 중 하나입니다.
1-2. 노드 메모리 압박에 의한 축출(Eviction)
- Pod 상태가
Evicted이거나 - 이벤트에
The node was low on resource: memory같은 문구
이 경우는 해당 Pod만의 문제가 아니라, 노드 전체 메모리 압박과 QoS, 우선순위, 다른 Pod 사용량이 얽힙니다.
2) 5분 안에 확인하는 1차 증거 수집
아래 커맨드는 “리밋 초과로 죽었는지”, “노드 압박인지”, “얼마나 자주/언제부터인지”를 빠르게 보여줍니다.
2-1. Pod 이벤트와 종료 사유 확인
kubectl -n myns describe pod mypod-xxxxx
확인 포인트
Last State: TerminatedReason: OOMKilledExit Code: 137(SIGKILL)Events섹션에Killing또는Evicted메시지
2-2. 이전 컨테이너 로그 확인
재시작이 반복되면 현재 로그만 보면 원인이 증발합니다. 반드시 --previous를 봅니다.
kubectl -n myns logs mypod-xxxxx -c app --previous
애플리케이션이 OOM 직전에 남긴 힌트(캐시 리빌드, 배치 시작, 대용량 응답 생성 등)가 자주 나옵니다.
2-3. 현재 메모리 사용량과 리밋 비교
kubectl -n myns top pod mypod-xxxxx
top은 순간값이라 피크를 놓칠 수 있습니다. 그래도 “리밋의 80~95%를 상시 물고 있나” 정도는 감이 옵니다.
3) requests/limits와 QoS가 OOM 양상을 바꾼다
Kubernetes는 requests와 limits 조합으로 QoS 클래스를 정합니다.
Guaranteed: 모든 컨테이너에 대해 CPU/메모리requests == limitsBurstable:requests가 있으나limits와 다르거나 일부만 설정BestEffort: requests/limits 미설정
노드 메모리 압박 상황에서 축출 우선순위는 대체로 BestEffort가 먼저, 그다음 Burstable, 마지막이 Guaranteed입니다.
즉, OOM 증상이 Evicted라면 단순히 해당 Pod의 리밋을 올리는 것보다:
- 노드에 과밀 배치가 아닌지
requests가 너무 낮아 과도한 overcommit이 발생하는지- 중요한 워크로드를
Guaranteed로 올릴지
를 같이 봐야 합니다.
4) “리밋을 올리면 해결”이 아닌 경우를 가르는 체크리스트
리밋을 올려도 다시 OOM이 나는 패턴은 보통 아래 중 하나입니다.
- 메모리 누수: 시간이 갈수록 RSS가 계속 우상향
- 캐시/버퍼 무제한: 트래픽이 늘수록 캐시가 계속 커짐
- 배치/리포트 피크: 특정 시간대에만 급격히 증가
- GC 튜닝 미스: 힙은 안정적이지만 네이티브 메모리/오버헤드가 커짐
- 리밋 대비 런타임 기본값 문제: Node의 기본 힙 상한, JVM 컨테이너 인식 옵션 등
이걸 구분하려면 “컨테이너 메모리”를 구성하는 요소를 이해해야 합니다.
5) 컨테이너 메모리 = 힙 + 네이티브 + 페이지캐시 + 스택 + 메타데이터
많이들 GC가 관리하는 힙만 보는데, OOMKilled는 cgroup 메모리 전체가 리밋을 넘으면 발생합니다.
- JVM: Java heap 외에 metaspace, code cache, thread stack, direct buffer, JNI 등
- Node.js: V8 heap 외에 ArrayBuffer, native addon, libuv, thread stack 등
- Python: 힙(객체) 외에 C 확장 모듈이 잡는 메모리, 버퍼, mmap 등
따라서 “GC 로그상 힙은 여유인데 OOMKilled”가 충분히 가능합니다.
6) JVM 서비스: 리밋과 힙을 명시적으로 맞추기
6-1. 가장 흔한 실수
- 컨테이너 메모리 리밋
1Gi인데 - JVM 힙을
-Xmx1g로 잡아버려 - metaspace, direct memory, 스레드 스택이 들어갈 공간이 없어 OOMKilled
6-2. 권장 접근
- 컨테이너 리밋의 60~75% 정도를 힙 상한으로 잡고
- 나머지를 네이티브/오버헤드로 남겨둡니다.
예시 Deployment 일부:
resources:
requests:
memory: "768Mi"
cpu: "250m"
limits:
memory: "1024Mi"
cpu: "1000m"
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=40 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps"
volumeMounts:
- name: dumps
mountPath: /dumps
포인트
MaxRAMPercentage는 컨테이너 메모리 리밋 기준으로 힙을 잡는 데 유용- 힙덤프는 디스크가 필요하니
emptyDir또는 PVC를 붙여야 합니다.
6-3. GC 로그로 “GC가 원인인지” 판단
GC가 너무 잦아 CPU를 태우고, 힙 회수가 안 되며, 결국 OOMKilled로 가는지 확인합니다.
env:
- name: JAVA_TOOL_OPTIONS
value: "-Xlog:gc*:stdout:time,level,tags -XX:+UseG1GC"
관찰 기준
- Full GC가 연속 발생
- GC 후에도 Old 영역이 줄지 않음
- Allocation rate가 급증하는 특정 요청/배치 타이밍이 존재
7) Node.js 서비스: V8 힙 상한과 컨테이너 리밋 불일치
Node는 버전과 실행 방식에 따라 컨테이너 리밋을 제대로 반영하지 못하거나, 기본 힙 상한이 워크로드에 맞지 않아 문제를 만들 수 있습니다.
7-1. 힙 상한을 명시하고 여유를 남기기
컨테이너 리밋이 1024Mi라면 V8 힙을 600~750Mi 정도로 시작해 관찰합니다.
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=700"
주의
--max-old-space-size는 MB 단위- 이 값을 리밋에 가깝게 잡으면 네이티브 메모리 때문에 OOMKilled가 더 빨리 납니다.
7-2. 힙 스냅샷과 프로파일링
운영에서 바로 붙기 어렵다면, 최소한 OOM 직전의 힙 상태를 남길 수 있게 준비합니다.
node --heapsnapshot-near-heap-limit=3 server.js
또는 애플리케이션에 진단 엔드포인트를 두고 heapdump 같은 모듈로 스냅샷을 저장하는 방식도 많이 씁니다.
8) Python 서비스: GC보다 “참조 유지”와 C 확장 메모리
Python은 GC가 있어도, 참조가 남아 있으면 메모리는 줄지 않습니다. 또한 numpy, pandas, 이미지 처리 라이브러리처럼 C 확장이 잡는 메모리는 Python GC 로그만으로 파악이 어렵습니다.
8-1. 프로세스 RSS 추적이 중요
- 워커 수 증가에 따라 선형으로 RSS 증가
- 요청 1건당 임시 버퍼가 커서 피크가 생김
Gunicorn 예시라면 워커/스레드 모델을 점검합니다.
gunicorn app:app --workers 4 --threads 2 --max-requests 2000 --max-requests-jitter 200
max-requests는 누수 의심 시 “완전한 해결”은 아니지만, 피해를 줄이는 임시 완충책이 됩니다.
9) K8s에서 재현 가능한 “피크 메모리” 측정하기
kubectl top만으로는 피크를 놓치기 쉽습니다. Prometheus가 있다면 container_memory_working_set_bytes를, 없다면 최소한 다음을 고려하세요.
9-1. ephemeral debug 컨테이너로 관측
프로덕션 컨테이너 이미지를 건드리기 어렵다면 ephemeral container로 들어가 /sys/fs/cgroup와 프로세스 상태를 확인합니다.
kubectl -n myns debug -it pod/mypod-xxxxx --image=busybox --target=app
컨테이너 메모리 리밋 확인(환경에 따라 경로가 다를 수 있음):
cat /sys/fs/cgroup/memory.max 2>/dev/null || true
cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || true
현재 사용량 확인:
cat /sys/fs/cgroup/memory.current 2>/dev/null || true
cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || true
9-2. OOM 직전 패턴을 로그로 남기기
애플리케이션에 주기적으로 RSS와 힙 사용량을 찍는 로그를 넣으면, OOM 직전의 상승 곡선을 확보할 수 있습니다.
Node 예시:
setInterval(() => {
const m = process.memoryUsage()
console.log(JSON.stringify({
rss: m.rss,
heapTotal: m.heapTotal,
heapUsed: m.heapUsed,
external: m.external,
ts: Date.now(),
}))
}, 10000)
JVM은 jcmd를 붙이기 어렵다면 최소한 메트릭(Micrometer 등)로 heap, non-heap을 분리해 수집하는 게 좋습니다.
10) 리밋/GC 조정의 실전 가이드라인
10-1. 리밋을 올리기 전에
- OOM이
OOMKilled인지Evicted인지 먼저 구분 - 리밋 대비 힙 상한이 과도한지 확인
- 피크 타이밍(배치, 캐시 warmup, 특정 API)을 찾기
10-2. 리밋을 올릴 때의 원칙
- 한 번에 2배 올리기보다 20~40%씩 올리고, 피크/평균을 같이 본다
requests도 함께 올려서 노드 overcommit을 줄인다- HPA를 쓰는 경우, 메모리 기반 스케일링을 고려하거나 피크 작업을 분리한다
10-3. GC 튜닝은 “증거 기반”으로
- GC 로그/메트릭 없이 옵션만 바꾸면 재현이 어려워진다
- “힙이 부족해서 OOM”인지 “네이티브가 커서 OOM”인지 먼저 나눠라
11) 자주 나오는 함정 6가지
limits.memory만 올리고requests.memory는 낮게 유지해서 노드가 과밀해짐- JVM
-Xmx를 리밋과 같게 잡아서 metaspace 여유가 사라짐 - Node의 힙 상한을 리밋과 비슷하게 잡아 네이티브 메모리로 OOMKilled
- 캐시를 무제한으로 두고 “트래픽이 늘면 언젠가 죽는” 구조
- 배치 작업과 API 서버를 같은 Pod에 넣어 피크를 공유
- OOM 후 로그가 사라져 원인 분석이 불가능한데도 덤프/스냅샷 저장소가 없음
12) 권장 디버깅 플로우(요약)
kubectl describe pod로OOMKilledvsEvicted구분kubectl logs --previous로 OOM 직전 작업 확인- 리밋과 힙 상한(JVM
MaxRAMPercentage, Node--max-old-space-size)의 간격 확보 - 피크 메모리 관측(메트릭 또는 cgroup 값)
- 누수 의심 시 힙덤프/스냅샷 확보
- 캐시 상한, 배치 분리, 워커 수 조정으로 피크를 낮추고
- 마지막으로 리밋과 requests를 워크로드에 맞게 재설계
13) 운영 팁: 진단 커맨드 자동화 시 주의
OOM 진단 스크립트를 만들 때는, 중간 실패로 인해 중요한 정보를 놓치지 않게 셸 옵션을 신중히 써야 합니다. 예를 들어 set -euo pipefail은 편하지만, 일부 커맨드가 실패하는 환경에서도 계속 수집해야 하는 진단 스크립트에는 독이 될 수 있습니다. 관련 함정과 안전한 예외 처리 패턴은 다음 글이 도움이 됩니다: bash set -euo pipefail 함정과 안전한 예외처리
OOMKilled는 “메모리가 부족하다”는 결과일 뿐, 원인은 리밋 설계부터 GC, 누수, 피크 트래픽까지 다양합니다. 핵심은 컨테이너 리밋과 런타임 힙 상한을 분리해 여유를 만들고, OOM 직전의 피크를 관측 가능한 형태로 남긴 다음, 워크로드 구조(캐시/배치/워커)까지 포함해 줄이는 것입니다.