잘 동작하던 Spring Boot 애플리케이션이 Kubernetes 환경에 배포된 직후 간헐적으로 파드 재시작(CrashLoopBackOff)을 반복한다. 로그를 확인해보면 Java 예외 스택 트레이스는 없고, Kubelet 이벤트에 단 한 줄 Reason: OOMKilled가 남아있다. 다급하게 JVM 힙(Heap) 사이즈를 늘려보지만 파드는 더 빨리 죽어버린다. 컨테이너 오케스트레이션 환경에서 Java 메모리 모델을 정확히 이해하지 못하면 무한정 노드 리소스만 낭비하게 된다.
[핵심 정의 / 스니펫 요약]: Kubernetes의 OOMKilled는 리눅스 커널(cgroup)이 컨테이너의 메모리 Limit 초과를 감지하고 SIGKILL을 보낸 결과다. Java 애플리케이션의 실제 메모리 사용량은 Heap 메모리뿐만 아니라 Non-Heap(Metaspace, Thread Stack, Direct Buffer) 영역을 합친 값이며, 이 총합이 K8s Limit을 초과할 때 컨테이너는 즉각 종료된다.
1. JVM 메모리 모델과 Linux Cgroup의 충돌
💡 개념 비유: 상가 임대차 계약과 매장 인테리어
Kubernetes Memory Limit은 건물주와 계약한 '총 상가 면적'이다. JVM Heap(-Xmx)은 손님을 받는 '전시 매장'이다. 만약 계약한 상가 면적의 95%를 전시 매장으로 채워버리면, 직원 휴게실이나 재고 창고(Non-Heap: Metaspace, Thread Stack)를 만들 공간이 부족해진다. 임의로 복도까지 물건을 쌓아두는 순간, 건물주(리눅스 커널 OOM Killer)는 경고 없이 즉각 강제 퇴거(Exit Code 137) 조치를 내린다.
컨테이너 기반 배포 환경(2026년 기준 Kubernetes 1.35 및 Spring Boot 3.5 배포 표준)에서 가장 빈번하게 발생하는 오해는 -Xmx 값이 프로세스가 사용할 최대 메모리라고 믿는 것이다. JVM은 OS로부터 메모리를 할당받을 때 힙 영역 외에도 다양한 메모리를 오버헤드로 요구한다. Cgroup v2 기반의 노드 환경에서 커널은 프로세스 내부의 Java 메모리 구조를 전혀 신경 쓰지 않는다. 커널은 단순히 할당된 페이지 캐시와 익명 메모리(Anonymous Memory)의 총합이 파드 스펙에 정의된 limits.memory를 넘어서는지 감시하고 임계점 돌파 시 137 코드로 프로세스를 파괴한다.
2. 프로덕션 레벨 JVM 옵션 및 매니페스트 설정
Spring Boot 애플리케이션을 안정적으로 운영하기 위해서는 컨테이너 Limit과 JVM의 메모리 인지(Container Awareness)를 완벽히 일치시켜야 한다. Java 21 이상 환경에서는 하드코딩된 -Xmx 대신 MaxRAMPercentage 옵션을 통해 Cgroup Limit을 기준으로 힙 사이즈를 동적 계산하도록 구성하는 것이 프로덕션 표준 패턴이다.
// 주의: Total K8s Limit > (Heap & Non-Heap)
apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
spec:
template:
spec:
containers:
- name: api-server
image: my-registry/spring-boot-api:3.5.0
resources:
requests:
memory: "1024Mi"
limits:
memory: "1024Mi" // Guaranteed QoS 확보
env:
- name: JAVA_OPTS
value: >-
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=75.0
-XX:MaxMetaspaceSize=128m
-Xss512k
-XX:+ExitOnOutOfMemoryError
⚠️ 주의사항 (Pitfalls): 컨테이너 Limit의 100%를 Heap으로 사용하려 시도하지 마라. -XX:MaxRAMPercentage=100.0으로 설정할 경우 JIT 컴파일러, 가비지 컬렉터(GC) 오버헤드, 네이티브 버퍼(NIO)가 사용할 공간이 원천 차단되어 트래픽 인입 시 즉각적인 OOMKilled가 발생한다. 일반적으로 70~75% 수준으로 힙을 설정하고 나머지 공간을 Non-Heap 영역의 마진(Headroom)으로 남겨두어야 한다.
Frequently Asked Questions
Q. K8s OOMKilled(Exit Code 137)와 Java OutOfMemoryError의 근본적인 차이는 무엇인가요?
A. Java의 OutOfMemoryError는 JVM 내부의 Heap 영역이 부족하여 발생하는 애플리케이션 레벨의 예외다. 이 경우 JVM 프로세스는 유지되며 에러 로그와 덤프를 생성할 수 있다. 반면 K8s OOMKilled는 JVM의 전체 메모리(Heap + Non-Heap) 점유량이 컨테이너 Limit을 넘어 리눅스 커널이 프로세스를 강제 종료(SIGKILL)한 OS 레벨의 조치다. 애플리케이션 관점에서는 로그 기록 없이 갑자기 컨테이너가 소멸한다.
Q. K8s Memory Limit을 설정할 때 Spring Boot의 적정 기준값은 어떻게 계산하나요?
A. 정확한 컨테이너 Limit은 (JVM Heap 최대값) + (Metaspace 약 256MB) + (Thread Stack 크기 × 스레드 수) + (Direct Buffers / Code Cache 256MB) + (OS 여유분 512MB)으로 산정된다. 예를 들어 Heap 영역에 2GB를 보장해야 한다면, 최소 2.5GB ~ 3GB 사이의 Memory Limit을 K8s 파드 스펙에 할당하는 것이 아키텍처 관점에서 안전하다.
Q. OOMKilled 발생 시 힙 덤프(Heap Dump)를 남길 수 없는 이유는 무엇인가요?
A. -XX:+HeapDumpOnOutOfMemoryError 옵션은 JVM이 스스로 메모리 부족(OOM)을 인지했을 때만 동작하는 메커니즘이다. 그러나 K8s OOMKilled는 OS 커널이 SIGKILL(-9) 시그널을 발송해 프로세스의 모든 실행 컨텍스트를 즉시 소멸시키므로, JVM이 파일 입출력을 통해 덤프를 기록할 시간적 여유조차 허락하지 않는다.
Post a Comment