본문 바로가기
Engineering/DevOps, Tools

JVM Container OOMKilled 트러블슈팅 가이드

by kghworks 2026. 3. 11.

 

JVM 위에서 돌아가는 서비스를 Kubernetes/컨테이너로 운영하다 보면, OOMKilled라는 메시지를 한 번쯤 마주하게 된다.

 

 OOMKilled는 Linux 커널의 OOM(Out Of Memory) Killer가 메모리 한도를 초과한 프로세스를 강제 종료(SIGKILL) 하는 것을 말한다. 컨테이너 환경에서는 cgroup(Control Group — Linux 커널이 프로세스 그룹별로 CPU, 메모리 등 자원 사용량을 격리·제한하는 메커니즘)이 설정한 메모리 limit을 프로세스의 RSS(Resident Set Size — 프로세스가 실제로 점유하고 있는 물리 메모리 크기) + Page Cache(파일 I/O 시 커널이 디스크 데이터를 RAM에 캐싱해 둔 영역)가 초과하면 발생한다. 프로세스는 어떤 정리 작업도 수행하지 못한 채 즉사하며, Kubernetes에서는 Pod 상태에 OOMKilled (Exit Code 137)로 기록된다.

 

 이것은 JVM 내부에서 발생하는 java.lang.OutOfMemoryError와는 다르다. JVM OOM은 Heap이나 Metaspace 같은 JVM이 관리하는 영역이 부족할 때 JVM 스스로가 던지는 예외다. 반면 OOMKilled는 JVM 바깥, 즉 Linux 커널이 컨테이너 전체의 물리 메모리 사용량을 보고 판단한다. JVM이 -Xmx로 Heap을 제한해도, Non-Heap(Metaspace, Thread Stack, Direct Memory, GC 오버헤드 등)까지 합산하면 컨테이너 limit을 초과할 수 있고, 이때 커널이 프로세스를 죽인다.

 

 JVM 내부의 java.lang.OutOfMemoryError는 Heap Dump 분석(-XX:+HeapDumpOnOutOfMemoryError), MAT(Memory Analyzer Tool) 등 잘 정립된 진단 체계가 있으므로, 해당 도구들을 활용해 깊이 파고들 수 있다. 반면 컨테이너 환경의 OOMKilled는 JVM 바깥에서 커널이 프로세스를 즉사시키기 때문에 Heap Dump도 남지 않고, 원인 파악이 훨씬 까다롭다.

 

 이 글에서는 JVM OOM과의 차이를 짚되, 주된 초점은 JVM 컨테이너 환경에서 발생하는 OOMKilled — 왜 일어나는지, 어떻게 예방하고, 발생 시 어떻게 진단·해결하는지 — 에 둔다.


목차

  1. JVM 메모리 구조 개괄
  2. Reserved vs Committed vs Used — 메모리 3단계 이해
  3. 컨테이너 환경에서 JVM이 메모리를 인식하는 방식
  4. OOMKilled 시나리오
  5. 진단 도구 & 트러블슈팅 가이드
  6. 컨테이너 환경 JVM 메모리 설정 Best Practice
  7. 정리 & 핵심 요약
  8. References

1. JVM 메모리 구조

Heap vs Non-Heap

JVM 메모리는 크게 HeapNon-Heap으로 나뉜다.

구분 영역 용도 주요 JVM 옵션
Heap Young Generation
(Eden + Survivor)
새로 생성된 객체 -Xms, -Xmx
  Old Generation 오래 살아남은 객체 -Xms, -Xmx
Non-Heap Metaspace 클래스 메타데이터 -XX:MaxMetaspaceSize
  Code Cache JIT(Just-In-Time) 컴파일러가 바이트코드를 네이티브 머신코드로 변환한 결과를 저장하는 영역 -XX:ReservedCodeCacheSize
  Thread Stacks 스레드별 호출 스택 -Xss (스레드당)
  Direct Memory NIO Direct ByteBuffer -XX:MaxDirectMemorySize
  Native Memory JNI, GC 내부 구조체 등 (명시적 제한 어려움)

 

출처: https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-2.html#jvms-2.5

 

JVM 주요 메모리 영역

JVM Process (RSS)
├── Heap (-Xmx)
│   ├── Young Generation
│   │   ├── Eden
│   │   ├── Survivor 0 (S0)
│   │   └── Survivor 1 (S1)
│   └── Old Generation
│
└── Non-Heap
    ├── Metaspace              (-XX:MaxMetaspaceSize)
    ├── Code Cache             (-XX:ReservedCodeCacheSize)
    ├── Thread Stacks          (-Xss × N threads)
    ├── Direct Memory          (-XX:MaxDirectMemorySize)
    └── Native Memory          (GC, JNI, Symbols, ...)

JVM 메모리 = Heap이 전부가 아니다

-Xmx를 512m으로 잡았으니 JVM은 512MB만 쓸거라고 생각하면안된다. JVM 프로세스의 실제 메모리 사용량(RSS)은 다음의 합산이다:

RSS ≈ Heap(Used) + Metaspace + Code Cache + (Xss × Thread 수)
      + Direct Memory + GC 내부 구조체 + Symbol/String Table
      + Native Library (JNI) + 기타 OS 오버헤드

 

 예를 들어 -Xmx512m으로 설정해도, 실제 RSS는 700~900MB에 달할 수 있다.

출처: https://docs.oracle.com/en/java/javase/17/troubleshoot/diagnostic-tools.html#GUID-710CAEA1-D16B-4F4E-A8B5-E1F0B9CDC09B


2. Reserved vs Committed vs Used — 메모리 3단계 이해

  • Reserved: OS에 가상 주소 공간만 예약한 상태. 물리 RAM은 소비하지 않는다.
  • Committed: Reserved 중 실제 읽기/쓰기를 하겠다고 OS에 매핑 요청한 상태. page fault 시 물리 RAM이 할당된다.
  • Used: Committed 중 실제로 데이터가 쓰여져 사용 중인 영역. 항상 Used ≤ Committed ≤ Reserved.

Reserved Memory: OS에 주소 공간만 예약

  • mmap(..., PROT_NONE) 또는 VirtualAlloc(..., MEM_RESERVE)가상 주소 공간만 확보
  • 물리 메모리(RAM)는 아직 할당되지 않음
  • 예: -Xmx4g 설정 시, JVM은 시작과 동시에 4GB의 가상 주소 공간을 reserve

 Reserved는 OS 입장에서 "이 주소 범위는 내가 쓸 거야"라고 선언만 한 상태다. 실제로 물리 RAM을 소비하지 않기 때문에, 64비트 시스템에서는 가상 주소 공간이 사실상 무한(128TB+)이므로 reserve 자체는 부담이 거의 없다.

JVM이 reserve하는 대표적인 영역:

  • Java Heap: -Xmx 크기만큼 연속된 가상 주소 공간을 시작 시 reserve (Compressed Oops — 64비트 JVM에서 객체 참조 포인터를 32비트로 압축하여 메모리 사용량을 줄이는 최적화 기법 — 를 위해 연속 공간 필요)
  • Metaspace: 기본적으로 CompressedClassSpaceSize(1GB) + 추가 non-class 영역
  • Code Cache: -XX:ReservedCodeCacheSize(기본 240MB)만큼 reserve
# 프로세스의 가상 메모리 매핑 확인 (Linux)
cat /proc/<pid>/maps | grep -c "---p"   # PROT_NONE (reserved only) 영역 수
pmap -x <pid> | tail -1                  # 전체 가상 메모리 크기

 

 Reserved가 크다고 걱정할 필요 없다. 컨테이너 OOM 판단에 Reserved는 관여하지 않는다.

출처: https://man7.org/linux/man-pages/man2/mmap.2.html

Committed Memory: 실제 물리 페이지 매핑 요청

  • Reserved 영역 중 일부를 mmap(..., PROT_READ|PROT_WRITE)커밋
  • OS에게 "이 영역에 물리 페이지를 매핑해달라"고 요청한 상태
  • Linux의 overcommit 정책에 따라, commit 시점에 즉시 물리 RAM이 할당되지 않을 수 있음 (lazy allocation)
  • 하지만 cgroup memory limit 관점에서는 committed memory가 핵심 지표

 Committed는 "OS에게 이 메모리 영역에 실제로 읽기/쓰기할 거야"라고 약속한 상태다. 이 시점에서 OS는 페이지 테이블 엔트리를 준비하지만, 실제 물리 프레임 할당은 첫 번째 접근(page fault) 시점까지 지연될 수 있다.

 

Linux overcommit 정책과의 관계:

Linux는 /proc/sys/vm/overcommit_memory 설정에 따라 commit 요청을 처리한다:

정책 설명
0 (기본) Heuristic 커널이 "합리적"이라 판단하면 허용. 물리 RAM + Swap보다 많이 commit 가능
1 Always 무조건 허용. commit 실패 없음
2 Never CommitLimit = (RAM × overcommit_ratio/100) + Swap 초과 시 거부

 

# 현재 overcommit 정책 확인
cat /proc/sys/vm/overcommit_memory

# 시스템 전체 commit 상태 확인
grep -E "Committed_AS|CommitLimit" /proc/meminfo
# CommitLimit:    16384000 kB
# Committed_AS:   12345678 kB  ← 시스템 전체에서 commit된 총량

 

컨테이너에서 왜 Committed가 중요한가?

 cgroup memory controller는 overcommit 정책과 별개로, 해당 cgroup에 속한 프로세스의 실제 물리 페이지 사용량(RSS + Page Cache)이 limit을 초과하면 OOM Kill을 수행한다. Committed memory가 높다는 것은 "언제든 page fault로 물리 RAM을 소비할 수 있는 잠재적 사용량"이 높다는 의미이므로, OOMKilled의 선행 지표로 봐야 한다.

출처: https://docs.oracle.com/en/java/javase/17/troubleshoot/diagnostic-tools.html#GUID-710CAEA1-D16B-4F4E-A8B5-E1F0B9CDC09B

출처: https://www.kernel.org/doc/html/latest/mm/overcommit-accounting.html

Used Memory: 애플리케이션이 실제 사용 중인 영역

  • Committed 영역 중 실제로 데이터가 쓰여진 부분
  • GC 이후 Used는 줄어들지만, Committed는 바로 줄어들지 않을 수 있음 (GC 구현에 따라 다름)

 Used는 JVM 내부에서 객체, 클래스 메타데이터, JIT 코드 등이 실제로 점유하고 있는 바이트 수다. Heap 영역의 경우 GC가 수행되면 Used는 즉시 감소하지만, OS 관점의 Committed(또는 RSS)는 JVM이 명시적으로 madvise(MADV_DONTNEED) 또는 munmap()을 호출하지 않는 한 줄어들지 않는다.

 

GC별 uncommit 동작 차이:

GC Uncommit 지원 조건 / 옵션
G1GC JDK 12+ (JEP 346) Concurrent cycle 후 빈 region을 OS에 반환. -XX:G1PeriodicGCInterval로 주기적 GC 트리거 가능
ZGC JDK 13+ (JEP 351) 기본 활성화. -XX:ZUncommitDelay(기본 300초)로 uncommit 지연 시간 설정
Shenandoah JDK 12+ -XX:ShenandoahUncommitDelay(기본 5분)로 제어
Serial/Parallel 제한적 -XX:MinHeapFreeRatio, -XX:MaxHeapFreeRatio로 간접 제어

 

예시 — G1GC의 경우:

Heap 설정: -Xms256m -Xmx1g

시점 1 (시작 직후):  Reserved=1g, Committed=256m, Used=50m
시점 2 (부하 증가):  Reserved=1g, Committed=700m, Used=600m
시점 3 (GC 후):     Reserved=1g, Committed=700m, Used=200m  ← Committed는 안 줄어듦!
시점 4 (JEP 346 동작): Reserved=1g, Committed=400m, Used=200m  ← 빈 region 반환!

 

-Xms와 Committed의 관계: -Xms는 JVM 시작 시 즉시 commit하는 Heap 크기다. -Xms=Xmx로 설정하면 시작부터 전체 Heap이 committed 상태가 되어, 런타임 중 commit 증가로 인한 OOMKilled 리스크를 예측하기 쉬워진다. 반면 메모리 낭비가 발생할 수 있다.

# Xms=Xmx: 시작부터 전체 Heap committed → 예측 가능하지만 낭비 가능
-Xms1g -Xmx1g

# Xms < Xmx: 필요 시 점진적 commit → 효율적이지만 commit 증가 시점에 OOM 위험
-Xms256m -Xmx1g

 

 G1GC는 -XX:-ShrinkHeapInSteps, -XX:MinHeapFreeRatio 등으로 uncommit 동작을 제어할 수 있다.

출처: https://openjdk.org/jeps/346(JDK 12+)

출처: https://openjdk.org/jeps/351(JDK 13+)

RSS(Resident Set Size)와 Committed의 관계

지표 의미 측정 방법
Committed JVM이 OS에 매핑 요청한 총량 jcmd <pid> VM.native_memory summary
RSS 실제 물리 RAM에 올라와 있는 페이지 cat /proc/<pid>/status | grep VmRSS
  • 일반적으로 RSS ≤ Committed (lazy allocation 때문)
  • 하지만 시간이 지나면 RSS ≈ Committed에 수렴
  • 컨테이너의 cgroup memory limit은 RSS + Page Cache 기준으로 OOM을 판단

왜 RSS ≤ Committed인가?

 Linux는 mmap(PROT_READ|PROT_WRITE)로 commit된 영역이라도, 실제로 해당 주소에 첫 번째 읽기/쓰기가 발생할 때(page fault) 비로소 물리 프레임을 할당한다. 이것이 demand paging(요구 페이징)이다.

 Demand paging은 OS가 프로세스에게 가상 메모리 주소를 먼저 부여하되, 실제 물리 메모리(RAM 프레임)는 해당 주소에 최초 접근이 발생하는 시점까지 할당을 지연시키는 기법이다. 프로세스가 아직 접근하지 않은 페이지에는 물리 RAM을 낭비하지 않으므로, 시스템 전체의 메모리 활용 효율이 높아진다. 접근 시 CPU가 page fault(페이지 부재) 인터럽트를 발생시키면, OS 커널이 그제서야 빈 물리 프레임을 찾아 매핑하고 프로세스 실행을 재개한다.

출처 : operating system concepts, abraham silberschatz

시간 흐름:
t0: JVM이 Heap 1GB commit → RSS ≈ 0 (아직 접근 안 함)
t1: 객체 생성 시작 → page fault 발생 → RSS 점진적 증가
t2: Heap 전체 사용 → RSS ≈ Committed (수렴)

 

RSS가 Committed보다 클 수 있는 예외 케이스:

 드물지만, JVM이 malloc()/mmap()을 NMT 추적 범위 밖에서 호출하는 경우(일부 JNI 라이브러리, glibc의 malloc arena 등) NMT의 committed 합계보다 RSS가 클 수 있다. 이 경우 NMT에 잡히지 않는 native 메모리 누수를 의심해야 한다.

# NMT committed vs 실제 RSS 비교
NMT_COMMITTED=$(jcmd <pid> VM.native_memory summary | grep "Total:" | awk '{print $4}')
RSS=$(cat /proc/<pid>/status | grep VmRSS | awk '{print $2}')
echo "NMT Committed: ${NMT_COMMITTED}, RSS: ${RSS}KB"
# RSS가 NMT Committed보다 유의미하게 크면 → NMT 밖의 native 누수 의심

 

cgroup의 OOM 판단 기준 상세:

cgroup v1에서 memory.usage_in_bytes는 다음을 포함한다:

  • RSS (Anonymous pages): 프로세스가 직접 사용하는 메모리
  • Page Cache (File-backed pages): 파일 I/O 시 커널이 캐싱한 페이지
  • Swap usage (swap이 활성화된 경우)

즉, JVM이 파일을 많이 읽는 경우(로그 파일, mmap된 파일 등) Page Cache까지 합산되어 OOMKilled가 발생할 수 있다.

출처: https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v1/memory.html

출처: https://www.kernel.org/doc/html/latest/admin-guide/mm/concepts.html

NMT(Native Memory Tracking)로 Committed 확인하기

JVM 시작 시 -XX:NativeMemoryTracking=summary (또는 detail) 플래그를 추가하면, 영역별 committed를 확인할 수 있다.

# NMT 활성화 (JVM 시작 옵션)
java -XX:NativeMemoryTracking=summary -jar app.jar

# 런타임에 확인
jcmd <pid> VM.native_memory summary

 

출력 예시:

Native Memory Tracking:

Total: reserved=2648MB, committed=987MB
-                 Java Heap (reserved=1024MB, committed=512MB)
                            (mmap: reserved=1024MB, committed=512MB)

-                     Class (reserved=1056MB, committed=42MB)
                            (classes #7823)
                            (  instance classes #7200, array classes #623)
                            (mmap: reserved=1056MB, committed=42MB)

-                    Thread (reserved=96MB, committed=96MB)
                            (thread #95)
                            (stack: reserved=95MB, committed=95MB)

-                      Code (reserved=248MB, committed=68MB)
                            (mmap: reserved=248MB, committed=68MB)

-                        GC (reserved=120MB, committed=120MB)

-                  Internal (reserved=4MB, committed=4MB)

-                    Symbol (reserved=14MB, committed=14MB)

-    Native Memory Tracking (reserved=5MB, committed=5MB)

-           Direct ByteBuffer (reserved=32MB, committed=32MB)

이 출력에서 committed 합계(987MB)가 컨테이너 memory limit보다 크면 OOMKilled 위험이 있다.

출처: https://docs.oracle.com/en/java/javase/17/troubleshoot/diagnostic-tools.html#GUID-710CAEA1-D16B-4F4E-A8B5-E1F0B9CDC09B


3. 컨테이너 환경에서 JVM이 메모리를 인식하는 방식

cgroup v1 vs v2와 JVM의 메모리 감지

컨테이너는 Linux cgroup으로 리소스를 제한한다. JVM은 cgroup 파일시스템을 읽어 메모리 한도를 감지한다.

항목 cgroup v1 cgroup v2
메모리 한도 파일 /sys/fs/cgroup/memory/memory.limit_in_bytes /sys/fs/cgroup/memory.max
현재 사용량 파일 /sys/fs/cgroup/memory/memory.usage_in_bytes /sys/fs/cgroup/memory.current
JVM 지원 시작 JDK 8u191, JDK 10+ JDK 15+ (완전 지원)
# 컨테이너 내부에서 확인
# cgroup v1
cat /sys/fs/cgroup/memory/memory.limit_in_bytes

# cgroup v2
cat /sys/fs/cgroup/memory.max

출처: https://bugs.openjdk.org/browse/JDK-8230305

출처: https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html#memory

-XX:+UseContainerSupport (JDK 10+)

JDK 10부터 기본 활성화된 플래그. JVM이 호스트 전체 메모리가 아닌 컨테이너의 cgroup limit을 기준으로 ergonomics를 계산한다.

# 기본값: 활성화 (JDK 10+)
-XX:+UseContainerSupport   # default: true

# 비활성화 시 (호스트 전체 메모리 기준으로 동작 — 컨테이너에서 위험!)
-XX:-UseContainerSupport

JDK 8에서는 8u191 이상에서 백포트되었다.

출처: https://bugs.openjdk.org/browse/JDK-8146115

MaxRAMPercentage / InitialRAMPercentage의 동작 원리

-Xmx를 직접 지정하는 대신, 컨테이너 메모리 limit의 비율로 Heap을 설정할 수 있다.

# 컨테이너 memory limit의 75%를 Max Heap으로 사용
java -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -jar app.jar

 

동작 방식:

컨테이너 memory limit = 1024MB (cgroup에서 감지)

MaxRAMPercentage=75.0
→ MaxHeapSize = 1024 × 0.75 = 768MB

InitialRAMPercentage=50.0
→ InitialHeapSize = 1024 × 0.50 = 512MB

 

주의: 이 비율은 Heap에만 적용된다. Non-Heap(Metaspace, Thread Stack, Direct Memory 등)은 별도이므로, 75%로 잡으면 Non-Heap 여유가 25%(=256MB)뿐이다.

출처: https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html

컨테이너 memory limit ≠ JVM이 쓸 수 있는 메모리

Container Memory Limit (e.g., 1024MB)
├── Heap (Xmx)                        512MB
├── Metaspace                          100MB
├── Code Cache                          60MB
├── Thread Stacks (1MB × 100 threads)  100MB
├── Direct Memory                       50MB
├── GC Overhead                         80MB
├── Native/JNI/Other                    50MB
└── OS/libc Overhead                    30MB
─────────────────────────────────────────────
    합계                               982MB  ← 1024MB 이내 ✅ Safe
    만약 합계 > 1024MB                        → OOMKilled ☠️

 

핵심 공식:

Container Limit ≥ Heap(Xmx)
                 + Metaspace
                 + Code Cache
                 + (Xss × Thread 수)
                 + Direct Memory
                 + GC Overhead
                 + Native/JNI
                 + OS Overhead (~50~100MB)

 

이 공식을 무시하고 -Xmx만 설정하면, Non-Heap 영역이 limit을 초과하여 OOMKilled가 발생한다.


4. OOMKilled 시나리오

OOMKilled vs java.lang.OutOfMemoryError

구분 java.lang.OutOfMemoryError OOMKilled (Exit Code 137)
누가 죽이나 JVM 자체 Linux Kernel (cgroup OOM Killer)
원인 Heap/Metaspace 등 JVM 관리 영역 부족 컨테이너 전체 메모리 사용량이 cgroup limit 초과
로그 JVM 로그에 스택트레이스 남음 dmesgoom-kill 기록, 컨테이너 로그에는 갑자기 끊김
Heap Dump -XX:+HeapDumpOnOutOfMemoryError로 생성 가능 프로세스가 즉시 SIGKILL → Heap Dump 생성 불가
복구 catch 가능 (비권장) 불가 — 프로세스 즉사
# OOMKilled 확인 (Kubernetes)
kubectl describe pod <pod-name> | grep -A5 "Last State"
# 출력 예:
#   Last State:  Terminated
#     Reason:    OOMKilled
#     Exit Code: 137

출처: https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v1/memory.html#oom-control

출처: https://docs.oracle.com/en/java/javase/17/troubleshoot/troubleshoot-memory-leaks.html

시나리오 ①: Heap 과다 할당 (Xmx가 container limit에 근접)

상황: -Xmx를 컨테이너 limit에 가깝게 설정

# Kubernetes Deployment
resources:
  limits:
    memory: "1Gi"
---
# JVM 옵션
JAVA_OPTS: "-Xmx900m"

 

문제: Heap 900MB + Non-Heap(최소 150~300MB) = 1050~1200MB > 1Gi(1024MB)

Timeline:
t0: Pod 시작, RSS=400MB ✅
t1: 트래픽 증가, Heap 사용 증가, RSS=800MB ✅
t2: Heap 900MB + Metaspace 80MB + Threads 100MB + GC 50MB = 1130MB
t3: cgroup limit(1024MB) 초과 → OOMKilled ☠️

 

해결: Heap은 container limit의 50~70% 이하로 설정

시나리오 ②: Off-Heap 누수 (Direct ByteBuffer, Netty, gRPC 등)

상황: Netty 기반 프레임워크(Spring WebFlux, gRPC)에서 Direct Memory 누수

// Direct ByteBuffer는 Heap이 아닌 Native Memory에 할당
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB

// GC가 ByteBuffer 객체를 수거해야 native memory도 해제됨
// → GC 압박이 없으면 해제가 지연됨

 

증상:

  • Heap 사용량은 정상 (200~300MB)
  • RSS가 지속적으로 증가
  • NMT에서 Internal 또는 Other 영역이 비정상적으로 큼
# NMT로 확인
jcmd <pid> VM.native_memory summary | grep -i direct
# Direct ByteBuffer: reserved=512MB, committed=512MB  ← 비정상!

 

해결:

# Direct Memory 상한 명시
-XX:MaxDirectMemorySize=256m

# Netty 사용 시 추가 (pooled allocator 메모리 제한)
-Dio.netty.maxDirectMemory=0  # JVM 설정을 따르도록

출처: https://netty.io/wiki/reference-counted-objects.html

시나리오 ③: Metaspace 무한 증가 (동적 클래스 로딩)

상황: 리플렉션, CGLIB 프록시, Groovy 스크립트 등으로 클래스가 계속 생성

# NMT 출력
Class (reserved=1200MB, committed=800MB)  ← 비정상!
      (classes #150000)                    ← 클래스 수 폭증

 

대표 원인:

  • Spring AOP 프록시 과다 생성
  • Groovy/Kotlin Script 동적 컴파일
  • Reflection.newProxyInstance() 반복 호출
  • CGLIB(Code Generation Library — 런타임에 바이트코드를 조작하여 프록시 클래스를 동적으로 생성하는 라이브러리) 프록시 과다 생성
  • Lambda 메타팩토리의 과도한 사용 (극단적 케이스)

해결:

# Metaspace 상한 설정 (미설정 시 무제한 증가)
-XX:MaxMetaspaceSize=256m

# 클래스 언로딩 활성화 (G1GC 기본 활성화)
-XX:+ClassUnloadingWithConcurrentMark

출처: https://openjdk.org/jeps/122

시나리오 ④: Thread 폭증 (Thread Stack × N)

상황: 요청당 스레드 모델에서 스레드 수가 폭증

Thread Stack 메모리 = -Xss(기본 1MB) × 스레드 수

예: 500 스레드 × 1MB = 500MB (Thread Stack만으로!)
# 현재 스레드 수 확인
jcmd <pid> Thread.print | grep "tid=" | wc -l

# 또는
cat /proc/<pid>/status | grep Threads

 

대표 원인:

  • Tomcat maxThreads 과다 설정
  • 비동기 처리 없이 blocking I/O로 외부 API 호출
  • Thread Pool 미사용 (new Thread() 직접 생성)
  • Kafka Consumer 스레드 과다

해결:

# 스레드 스택 크기 줄이기 (기본 1MB → 512KB)
-Xss512k

# Tomcat 스레드 제한
server.tomcat.threads.max=200

시나리오 ⑤: Native Memory 누수 (JNI, Compressed Class Space 등)

상황: JNI 라이브러리나 JVM 내부 구조체에서 native malloc 누수

# NMT baseline 설정
jcmd <pid> VM.native_memory baseline

# 일정 시간 후 diff 확인
jcmd <pid> VM.native_memory summary.diff

# 출력 예:
# Internal (reserved=50MB +30MB, committed=50MB +30MB)  ← 30MB 증가!

 

대표 원인:

  • JNI 코드에서 malloc()free() 누락
  • Inflater/Deflater (ZIP 처리) close 누락
  • sun.misc.Unsafe.allocateMemory() 직접 사용

해결:

// Inflater/Deflater는 반드시 close (native memory 해제)
try (var inflater = new Inflater()) {
    // ...
} // close()에서 native memory 해제

시나리오 ⑥: 여러 요인의 복합 — "조금씩 새는 메모리들의 합산"

실무에서 가장 흔한 케이스. 단일 원인이 아니라 여러 영역이 조금씩 초과하여 합산이 limit을 넘는다.

예: Container Limit = 1024MB

Heap (Xmx=512m):           512MB
Metaspace:                  120MB  (예상: 80MB, 실제: 120MB → +40MB)
Thread Stacks (200 threads): 200MB  (예상: 150MB → +50MB)
Code Cache:                  60MB
Direct Memory:               80MB  (예상: 50MB → +30MB)
GC Overhead:                 70MB
Native/Other:                30MB
─────────────────────────────────
합계:                      1072MB > 1024MB → OOMKilled ☠️

 

해결: 각 영역에 명시적 상한을 설정하고, 합산이 container limit 이내인지 계산


5. 진단 도구 & 트러블슈팅 가이드

NMT (Native Memory Tracking) 사용법

# Step 1: NMT 활성화 (JVM 시작 옵션에 추가)
java -XX:NativeMemoryTracking=detail -jar app.jar

# Step 2: 현재 상태 확인
jcmd <pid> VM.native_memory summary

# Step 3: Baseline 설정 (비교 기준점)
jcmd <pid> VM.native_memory baseline

# Step 4: 일정 시간 후 diff 확인 (메모리 증가 추적)
jcmd <pid> VM.native_memory summary.diff

# Step 5: 상세 분석 (개별 allocation site 확인)
jcmd <pid> VM.native_memory detail.diff

 

NMT 오버헤드: 약 5~10% 메모리 추가 사용. 프로덕션에서는 summary 레벨 권장.

출처: https://docs.oracle.com/en/java/javase/17/troubleshoot/diagnostic-tools.html#GUID-710CAEA1-D16B-4F4E-A8B5-E1F0B9CDC09B

jcmd / jmap / jstat 활용

# Heap 사용량 요약
jcmd <pid> GC.heap_info

# GC 통계 (1초 간격, 10회)
jstat -gcutil <pid> 1000 10

# 출력 예:
#   S0     S1     E      O      M     CCS    YGC   YGCT    FGC   FGCT    CGC   CGCT     GCT
#   0.00  98.44  45.21  67.33  95.12  92.45   124   1.234    3   0.567    12   0.123   1.924

# Heap Dump 생성 (OOM 전에 수동으로)
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <pid>

# 클래스 히스토그램 (Metaspace 문제 진단)
jmap -histo <pid> | head -30

출처: https://docs.oracle.com/en/java/javase/17/docs/specs/man/jcmd.html, https://docs.oracle.com/en/java/javase/17/docs/specs/man/jstat.html

컨테이너 내부에서 /proc/self/status, cgroup 메모리 확인

# RSS 확인 (KB 단위)
cat /proc/self/status | grep -E "VmRSS|VmSize|VmPeak|Threads"
# VmPeak:  1048576 kB   ← 최대 가상 메모리
# VmSize:   987654 kB   ← 현재 가상 메모리
# VmRSS:    654321 kB   ← 실제 물리 메모리 (핵심!)
# Threads:       95     ← 스레드 수

# cgroup 메모리 limit 확인
# v1
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
# v2
cat /sys/fs/cgroup/memory.max

# cgroup 현재 사용량
# v1
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
# v2
cat /sys/fs/cgroup/memory.current

# OOM 발생 횟수 (v1)
cat /sys/fs/cgroup/memory/memory.oom_control
# oom_kill 0   ← 0이면 아직 OOM 미발생

Prometheus + Grafana로 JVM 메모리 모니터링 구성

Spring Boot Actuator + Micrometer를 사용하면 JVM 메모리 메트릭을 Prometheus로 노출할 수 있다.

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: prometheus,health
  metrics:
    tags:
      application: ${spring.application.name}

 

주요 모니터링 메트릭:

Prometheus 메트릭 의미
jvm_memory_used_bytes{area="heap"} Heap Used
jvm_memory_committed_bytes{area="heap"} Heap Committed
jvm_memory_max_bytes{area="heap"} Heap Max (-Xmx)
jvm_memory_used_bytes{area="nonheap"} Non-Heap Used
jvm_buffer_memory_used_bytes{id="direct"} Direct Memory Used
jvm_threads_live_threads 현재 활성 스레드 수
process_resident_memory_bytes RSS (프로세스 전체)

 

Grafana 알림 설정 예시:

# RSS가 container limit의 85% 초과 시 경고
process_resident_memory_bytes / (container_limit) > 0.85

출처: https://micrometer.io/docs/ref/jvm

출처: https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.metrics

OOMKilled 발생 시 사후 분석 체크리스트

  • kubectl describe pod → Exit Code 137 확인
  • 노드의 dmesg 확인: dmesg | grep -i "oom\|kill"
  • 직전 Prometheus 메트릭 확인:
    • RSS 추이 (process_resident_memory_bytes)
    • Heap Used/Committed 추이
    • Non-Heap Used 추이
    • Thread 수 추이
    • Direct Memory 추이
  • NMT가 활성화되어 있었다면 마지막 snapshot 확인
  • Container limit vs JVM 옵션 합산 계산:
    • Xmx + MaxMetaspaceSize + ReservedCodeCacheSize + (Xss × maxThreads) + MaxDirectMemorySize + GC overhead(~100MB) < Container Limit ?
  • 재현 환경에서 NMT detail + baseline/diff로 누수 영역 특정
  • 필요 시 container limit 상향 또는 JVM 옵션 조정

6. 컨테이너 환경 JVM 메모리 설정 Best Practice

Container Limit 대비 Heap 비율 가이드라인

Container Limit 권장 Xmx Non-Heap 여유 비고
512MB 256MB (50%) 256MB 소규모 서비스, 스레드 수 제한 필수
1GB 512600MB (5060%) 400~512MB 일반적인 Spring Boot 서비스
2GB 1.21.4GB (6070%) 600~800MB 중규모 서비스
4GB+ 2.43GB (6075%) 1~1.6GB 대규모 서비스, Kafka/Flink 등

 

경험적 가이드라인이며, 실제 Non-Heap 사용량을 NMT로 측정한 후 조정해야 한다.

Non-Heap 영역별 명시적 제한 설정

# 각 영역에 명시적 상한을 설정하여 "예측 가능한" 메모리 사용을 보장
-XX:MaxMetaspaceSize=256m          # Metaspace 상한 (기본: 무제한!)
-XX:ReservedCodeCacheSize=128m     # Code Cache 상한 (기본: 240MB)
-XX:MaxDirectMemorySize=128m       # Direct Memory 상한 (기본: Xmx와 동일)
-Xss512k                           # 스레드 스택 (기본: 1MB)

 

핵심: MaxMetaspaceSizeMaxDirectMemorySize기본값이 사실상 무제한이므로, 컨테이너 환경에서는 반드시 명시적으로 설정해야 한다. (MaxDirectMemorySize의 기본값은 0이며, 이 경우 JVM이 자동으로 크기를 결정한다. 일반적으로 -Xmx 값과 유사한 크기가 허용된다.)

출처: https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html

GC 선택이 메모리 Footprint에 미치는 영향

GC 메모리 오버헤드 특징 권장 환경
G1GC (기본) 중간 (~10% Heap) Region 기반, 예측 가능한 pause 범용 (JDK 9+ 기본)
ZGC 높음 (~20% Heap) 초저지연 (수 ms 이하 pause), colored pointer로 추가 메모리 대용량 Heap + 저지연 요구 (JDK 15+ 프로덕션)
Shenandoah 높음 (~15% Heap) 저지연, forwarding pointer ZGC 대안
Serial GC 낮음 (~5%) 단일 스레드, 작은 footprint 소규모 컨테이너 (256~512MB)
Parallel GC 중간 처리량 최적화 배치 처리
# 소규모 컨테이너 (512MB 이하): Serial GC로 footprint 최소화
-XX:+UseSerialGC

# 일반 서비스: G1GC (기본)
-XX:+UseG1GC

# 대용량 + 저지연: ZGC
-XX:+UseZGC

출처: https://docs.oracle.com/en/java/javase/17/gctuning/available-collectors.html

출처: https://openjdk.org/jeps/333

출처: https://openjdk.org/jeps/377

 

Kubernetes resources.requests vs resources.limits 전략

resources:
  requests:
    memory: "768Mi"   # 스케줄링 기준 (노드 선택)
    cpu: "500m"
  limits:
    memory: "1Gi"     # OOMKill 기준 (cgroup limit)
    cpu: "2000m"
항목 requests limits
역할 스케줄러가 노드 배치 시 참고 cgroup으로 실제 제한
메모리 초과 시 다른 Pod에 의해 eviction 가능 OOMKilled
권장 비율 limits의 70~90% JVM 전체 메모리 합산 + 여유 10~15%

 

requests = limits로 설정하면 QoS Class가 Guaranteed가 되어 eviction 우선순위가 가장 낮아진다.

출처: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/

출처: https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/

JVM 옵션 템플릿 (Spring Boot + K8s 기준)

# ── Container Limit: 1Gi (1024MB) 기준 ──

JAVA_OPTS="\
  # Heap: container limit의 ~55%
  -Xms512m \
  -Xmx576m \
  \
  # Non-Heap 명시적 제한
  -XX:MaxMetaspaceSize=192m \
  -XX:ReservedCodeCacheSize=128m \
  -XX:MaxDirectMemorySize=64m \
  -Xss512k \
  \
  # GC
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  \
  # 컨테이너 지원
  -XX:+UseContainerSupport \
  \
  # 진단 (프로덕션에서도 summary 레벨은 오버헤드 적음)
  -XX:NativeMemoryTracking=summary \
  \
  # OOM 시 진단
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/tmp/heapdump.hprof \
  -XX:+ExitOnOutOfMemoryError \
"

 

메모리 합산 검증:

Heap (Xmx):          576MB
Metaspace:           192MB (상한)
Code Cache:          128MB (상한)
Thread Stacks:       100MB (512KB × 200 threads)
Direct Memory:        64MB (상한)
GC Overhead:         ~60MB (G1GC, Heap의 ~10%)
NMT Overhead:        ~10MB
OS/기타:             ~50MB
──────────────────────────
합계 (최대):       ~1180MB  ← 1024MB 초과 가능!

 

위 계산에서 합계가 초과하므로, 실제로는:

  • Metaspace 실사용이 192MB까지 가지 않거나 (보통 80~150MB)
  • Thread 수가 200까지 가지 않거나 (보통 50~100)
  • 이 모든 영역이 동시에 상한에 도달하지 않음

하지만 안전하게 가려면 container limit을 1.5Gi로 올리거나, Heap을 512m으로 낮추는 것을 권장한다.


7. 정리

OOMKilled 예방을 위한 체크리스트

설정 단계

  • -Xmx는 container limit의 50~70% 이하로 설정
  • -XX:MaxMetaspaceSize 명시적 설정 (기본값은 무제한)
  • -XX:MaxDirectMemorySize 명시적 설정
  • -XX:ReservedCodeCacheSize 명시적 설정
  • -Xss 적절히 줄이기 (512k~1m)
  • 모든 영역 합산 < container limit 검증

모니터링 단계

  • NMT 활성화 (-XX:NativeMemoryTracking=summary)
  • Prometheus + Grafana로 RSS/Heap/NonHeap/Thread 추이 모니터링
  • RSS가 container limit의 80% 초과 시 알림 설정

장애 대응 단계

  • OOMKilled 발생 → dmesg + kubectl describe pod 확인
  • NMT baseline/diff로 누수 영역 특정
  • 영역별 상한 조정 또는 container limit 상향
  • 근본 원인 해결 (코드 레벨 메모리 누수 수정)

References

댓글