Xmx가 남았는데 왜 OOMKilled인가: cgroup 안에서 JVM 메모리 예산 세우기
4 GiB container에서 heap·metaspace·code cache·direct buffer·thread stack·native·page cache를 하나의 예산으로 보고, memory.events와 NMT를 결합해 OOMKilled를 진단합니다.
이 글의 문제 발견 출처는 Threads의 JVM container memory 질문입니다. 원문 캡션·이미지·영상은 사용하지 않고 직접 링크만 provenance로 남겼습니다. 기술 주장은 Kubernetes 공식 resource management, Linux kernel의 cgroup v2 문서, Oracle JDK 25의 java 옵션 문서와 Native Memory Tracking을 기준으로 교차 검증했습니다.
먼저 결론
-Xmx는 Java heap의 최대치다. container의 memory limit은 그 process와 cgroup에 청구되는 전체 메모리의 경계다. 따라서 heap이 Xmx보다 훨씬 작아도 metaspace, JIT code cache, direct buffer, thread stack, GC native structure, JNI library, allocator overhead, memory-backed volume과 file page가 합쳐져 memory.max를 넘으면 kernel이 task를 종료할 수 있다.
container usage ≈
Java heap
+ metaspace / class space
+ JIT code cache
+ direct and mapped buffers
+ thread stacks
+ GC and JVM native structures
+ JNI / libc allocator memory
+ charged page cache and tmpfs
그래서 “heap OOM이 없었다”와 “container OOM이 없었다”는 다른 주장이다.
보드는 4 GiB limit에서 headroom을 둔 경로와 direct buffer가 급증하는 경로를 비교한다. 두 번째 경로에서는 heap이 Xmx 아래인데도 전체 cgroup 사용량이 한도를 넘어 oom_kill이 증가한다.
두 종류의 OOM부터 분리한다
Java heap OOM
JVM이 heap 안에서 객체를 위한 공간을 확보하지 못하면 java.lang.OutOfMemoryError: Java heap space 같은 오류를 던진다. process가 살아서 heap dump나 log를 남길 기회가 있을 수 있다. 원인과 설정에 따라 -XX:+HeapDumpOnOutOfMemoryError도 도움이 된다.
cgroup OOM kill
Linux cgroup의 사용량이 hard limit을 넘고 reclaim으로 회복되지 않으면 kernel OOM killer가 cgroup 안의 task를 종료할 수 있다. Kubernetes는 종료 상태를 OOMKilled로 표시할 수 있다. 이 경로에서는 JVM이 Java exception이나 heap dump를 남기기 전에 process가 사라질 수 있다.
관측 증거도 다르다.
| 질문 | 확인할 증거 |
|---|---|
| JVM이 heap allocation에 실패했는가? | JVM log, OutOfMemoryError, heap dump, GC log |
| cgroup 한도를 넘었는가? | Pod termination reason, memory.events의 oom·oom_kill, memory.current, node kernel event |
| native 영역이 커졌는가? | NMT summary/diff, direct buffer·thread count, allocator metric |
4 GiB에서 Xmx를 얼마로 둘 것인가
JDK 25의 -XX:MaxRAMPercentage는 JVM이 인식한 최대 사용 가능 memory를 기준으로 heap 최대치를 정하는 옵션이며 문서상 기본값은 25%다. JVM은 환경 constraint, 예를 들어 container limit을 고려해 사용 가능한 memory를 정한다. 그러나 “container-aware”가 “전체 memory를 자동으로 안전하게 배분한다”는 뜻은 아니다.
예를 들어 4 GiB limit에 70%를 명시하면 heap 상한은 대략 2.8 GiB다.
limit 4096 MiB
heap ceiling (70%) -2867 MiB
metaspace/code -450 MiB (measured peak budget)
direct/mapped -350 MiB (bounded by workload and flags)
threads/native -250 MiB (thread count × stack plus native)
uncertainty headroom -179 MiB
이 숫자는 추천값이 아니라 계산 형식이다. application마다 class 수, thread 수, network buffer, GC, JDK가 다르다. steady-state 평균이 아니라 representative load의 p99 또는 검증된 peak로 각 항목을 채운다. headroom은 allocator fragmentation, traffic burst, page cache, 계측 누락을 흡수해야 한다.
-Xmx4g를 4 GiB container에 넣으면 heap alone이 limit과 같아 non-heap 공간이 없다. MaxRAMPercentage=90도 off-heap이 큰 Netty 서비스에는 위험할 수 있다. 반대로 heap을 너무 작게 잡으면 GC pressure와 Java OOM이 늘어난다. 목표는 가장 큰 heap이 아니라 SLO를 만족하는 최소 총비용과 안전한 peak다.
주요 non-heap 영역
Metaspace와 compressed class space
class metadata는 native memory에 있다. dynamic proxy, bytecode generation, classloader leak이 있으면 계속 증가할 수 있다. -XX:MaxMetaspaceSize는 상한을 둘 수 있지만 낮게 두면 별도의 metaspace OOM을 만든다. class loading/unloading과 loader 수를 함께 본다.
Code cache와 JVM·GC native structure
JIT가 생성한 machine code와 compiler data, GC remembered set·region table 같은 구조도 heap 밖에 있다. collector 선택과 heap 크기가 native overhead를 바꿀 수 있으므로 heap만 고정하고 collector를 바꾸면 총예산이 달라질 수 있다.
Direct buffer와 mapped memory
ByteBuffer.allocateDirect, networking framework, compression·serialization library가 native buffer를 사용한다. -XX:MaxDirectMemorySize는 HotSpot direct-buffer 예산을 제한하는 도구지만 모든 JNI allocation을 포괄하는 cgroup 한도는 아니다. buffer pool metric, allocation rate, lifecycle을 같이 측정한다.
Thread stack
각 platform thread에는 native stack 예약과 실제 commit이 있다. 대략적인 위험을 볼 때는 live_threads × -Xss를 상한 후보로 계산하되 실제 commit, guard page, JVM thread를 별도로 본다. thread-per-request 폭주는 heap graph보다 native memory와 scheduler를 먼저 무너뜨릴 수 있다.
Page cache와 tmpfs
container가 읽고 쓴 file page, memory-backed emptyDir, shared memory도 cgroup memory에 영향을 줄 수 있다. application metric에 안 보이는 disk I/O buffer가 memory.current에는 보이는 이유다. filesystem cache를 모두 “누수”로 분류하면 잘못된 결론을 낸다.
cgroup v2에서 읽어야 할 파일
memory.current: 현재 cgroup이 사용하는 memory 양memory.max: hard limit;max면 제한 없음memory.high: 초과 시 reclaim과 throttling 압력을 주는 경계memory.events:low,high,max,oom,oom_killcountermemory.stat: anonymous, file, kernel 등 구성 분석
memory.events의 counter는 원인 timeline에 특히 유용하다. high이 계속 늘다가 max, oom, oom_kill이 증가했다면 memory pressure가 kill로 진행된 경로를 볼 수 있다. 단, container 안의 cgroup path와 권한은 runtime에 따라 다르므로 운영 metric exporter로 수집하는 편이 안전하다.
Kubernetes 공식 문서는 memory limit이 CPU limit처럼 지속적으로 throttle되는 것이 아니라 memory pressure를 감지했을 때 OOM kill로 반응적으로 집행될 수 있다고 설명한다. 순간적으로 limit보다 높은 관측이 보이거나 kill 시점이 지연될 수 있으므로 한 점만 보지 말고 counter와 timeline을 본다.
NMT가 보여 주는 것과 못 보여 주는 것
Native Memory Tracking은 HotSpot 내부 memory를 category별로 추적한다.
java -XX:NativeMemoryTracking=summary ...
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff
NMT의 reserved와 committed를 구분해야 한다. address space 예약이 곧 RSS와 같은 것은 아니다. 또한 NMT는 JVM 또는 HotSpot subsystem 중심이며 third-party native library의 모든 allocation과 file page cache를 완전히 설명하지 않는다. NMT 합계와 cgroup memory.current의 차이를 “측정 오차”로 버리지 말고 미추적 native, page cache, kernel charge의 탐색 단서로 사용한다.
NMT 자체도 overhead가 있으므로 production에 summary를 상시 켤지, 재현 환경에서 detail을 쓸지 부하 테스트로 결정한다.
OOMKilled 진단 순서
- 종료 경로 확정: Pod의 last state와 exit code,
OOMKilled, JVM OOM log를 구분한다. - cgroup counter 확인: 같은 시간의
memory.events,memory.current,memory.stat을 본다. - heap 분리: GC log와 heap committed/used가 Xmx에 가까웠는지 확인한다.
- native 분해: NMT baseline diff, direct buffer pool, thread count, classloader, JNI metric을 맞춘다.
- traffic과 배포 정렬: payload 크기, concurrency, 새 library, thread pool, cache 변경을 timeline에 놓는다.
- 한 항목씩 재현: 동일 limit의 staging에서 부하를 재현하고 peak와 counter를 기록한다.
- 예산과 limit 함께 조정: 원인 없이 memory limit만 올려 누수를 늦추지 않는다.
배포 전 검증
- 같은 image와 같은 cgroup limit에서 representative peak load를 실행한다.
- heap, NMT category, direct buffer, thread count,
memory.current를 같은 timestamp로 수집한다. - direct-buffer burst, classloader 증가, thread 폭주를 각각 주입한다.
memory.highpressure와 GC latency가 SLO에 미치는 영향을 본다.- OOM kill 시 liveness restart, readiness 제거, alert가 기대대로 이어지는지 확인한다.
- heap dump를 container filesystem에 남길 공간과 upload 경로가 실제로 있는지 확인한다.
안전한 설정은 Xmx = limit × 어떤 고정 비율 하나가 아니다. 전체 cgroup budget을 구성 요소별 peak로 분해하고, 설명되지 않는 차이를 headroom과 관측 대상으로 남기는 과정이다.