문제 상황#

온프렘 K8s 클러스터(mini-might 워커 노드)에서 Java 앱의 메모리 부족으로 OOM Killer가 호출되어 노드가 다운된 이슈 분석 및 해결 방안.


증상#

  • k9s에서 mini-might 노드의 모든 파드가 Unknown 상태로 표시
  • 재부팅 후 파드들이 순차적으로 복구됨
  • kubectl describe node mini-might Events에서 Rebooted 확인
NodeNotReady    5분 전    - 노드가 죽음
Rebooted        58초 전   - 재부팅 감지
NodeReady       17초 전   - 복구 완료

원인 분석#

1. dmesg에서 OOM 로그 확인#

mini-might 노드에 SSH 접속 후:

sudo grep -i "oom\|killed\|out of memory" /var/log/kern.log | tail -50

OOM 로그 발견:

2026-03-22T15:00:03 mini-might kernel: oom-kill:constraint=CONSTRAINT_MEMCG
task=java,pid=90057,uid=0
Memory cgroup out of memory: Killed process 90057 (java)
total-vm:2007864kB, anon-rss:518828kB

2. 분석 결과#

항목
프로세스java (PID 90057)
OOM 타입CONSTRAINT_MEMCG (cgroup 메모리 제한 초과)
메모리 사용~507MB (anon-rss: 518828kB)
가상 메모리~2GB (total-vm: 2007864kB)

3. 문제가 된 파드 설정 확인#

kubectl describe pod java-cloud-gateway-6558b496dd-nhhfw -n dev-webs

java-service 컨테이너 리소스:

resources:
  limits:
    cpu: 500m
    memory: 512Mi   # <- 문제!
  requests:
    cpu: 100m
    memory: 128Mi

JVM 옵션:

env:
  - name: JAVA_OPTS
    value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

4. 근본 원인#

설정설명
Memory Limit512Mi컨테이너 최대 메모리
MaxRAMPercentage75%JVM 힙 비율
계산된 힙~384Mi512 × 0.75
실제 사용량~507MBOOM 시점

문제점:

  • Java는 힙(384Mi) 외에도 Metaspace, Thread Stack, Native Memory 사용
  • 힙 + 비힙 메모리가 512Mi limit을 초과 → OOM Killer 호출

해결#

옵션 1: Memory Limit 증가 (권장)#

resources:
  limits:
    cpu: 500m
    memory: 1Gi     # 512Mi → 1Gi
  requests:
    cpu: 100m
    memory: 512Mi   # 128Mi → 512Mi

옵션 2: JVM 힙 비율 낮추기#

env:
  - name: JAVA_OPTS
    value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=50.0"

옵션 3: 힙 크기 직접 지정#

env:
  - name: JAVA_OPTS
    value: "-XX:+UseContainerSupport -Xmx256m -Xms256m"

노드 리소스 현황#

mini-might 노드 상태 (복구 후)#

항목상태
메모리28GB 중 7.4GB 사용정상
디스크98GB 중 55GB 사용 (59%)정상
CPU Limits118% 오버커밋주의
Memory Limits40% (11GB/28GB)정상
Swap비활성화 (0B)주의

주의사항:

  • Swap 비활성화 상태에서 메모리 부족 시 바로 OOM 발생
  • CPU 오버커밋(118%)은 burst 상황에서 경쟁 발생 가능

디버깅 명령어 정리#

노드 상태 확인#

kubectl get nodes
kubectl describe node <node-name>

OOM 로그 확인 (노드에서 직접)#

# 재부팅 후에도 파일 로그는 남아있음
sudo grep -i "oom\|killed\|out of memory" /var/log/kern.log | tail -50
sudo grep -i "oom\|killed" /var/log/syslog | tail -50

# dmesg는 재부팅 시 초기화됨
sudo dmesg | grep -i "oom\|killed\|out of memory"

kubelet 로그 확인#

sudo journalctl -u kubelet --since "1 hour ago" | tail -100

메모리/디스크 상태#

free -h
df -h

파드 리소스 설정 확인#

kubectl describe pod <pod-name> -n <namespace> | grep -A10 -iE "limits:|requests:|env:"

Java 컨테이너 메모리 가이드#

JVM 메모리 구성#

Total Container Memory
├── Heap (힙)
│   └── -Xmx 또는 MaxRAMPercentage로 제어
├── Metaspace (메타스페이스)
│   └── 클래스 메타데이터, 기본 무제한
├── Thread Stacks (스레드 스택)
│   └── 스레드당 ~1MB
├── Native Memory
│   └── JNI, 네이티브 라이브러리
└── Direct Buffers
    └── NIO 버퍼

권장 설정#

Container LimitMaxRAMPercentage계산된 힙비힙 여유
512Mi50%256Mi256Mi
1Gi75%768Mi256Mi
2Gi75%1.5Gi512Mi

안전한 JVM 옵션 예시#

env:
  - name: JAVA_OPTS
    value: >-
      -XX:+UseContainerSupport
      -XX:MaxRAMPercentage=75.0
      -XX:MaxMetaspaceSize=128m
      -XX:+ExitOnOutOfMemoryError

-XX:+ExitOnOutOfMemoryError: OOM 발생 시 JVM이 즉시 종료되어 K8s가 재시작 가능


교훈#

Java가 특히 OOM Killed에 취약한 이유#

Java는 JVM(가상 머신) 위에서 동작하기 때문에 메모리를 이중으로 관리한다:

컨테이너 memory limit (예: 512Mi)
├── JVM Heap (-Xmx)        ← GC가 관리하는 영역
├── JVM Non-Heap            ← Metaspace, CodeCache, Thread Stack 등
├── Native Memory           ← JNI, NIO DirectBuffer 등
└── OS/기타                  ← JVM 자체 오버헤드
  • JVM은 Heap만 제한하고 나머지는 제한 없이 사용한다. -Xmx256m으로 힙을 256MB로 잡아도 Non-Heap + Native 메모리가 추가로 100~200MB를 사용할 수 있다.
  • GC(Garbage Collection)는 Heap 내에서만 동작한다. 컨테이너 전체 메모리가 limit에 근접해도 JVM은 이를 인지하지 못하고 GC를 트리거하지 않는다.
  • 결과적으로 컨테이너 메모리가 limit을 초과하면 커널의 OOM Killer가 JVM 프로세스를 강제 종료시킨다. Java 입장에서는 OutOfMemoryError를 던질 기회도 없이 SIGKILL(kill -9)로 즉사한다.

Go, Python 등 다른 언어는 OS 메모리를 직접 사용하기 때문에 메모리 사용량이 예측 가능하지만, Java는 JVM이 중간에서 메모리를 관리하기 때문에 컨테이너 limit과 JVM 설정 사이의 갭에서 OOM이 발생한다.

실무 가이드라인#

  1. Java 컨테이너는 memory limit의 75% 이하로 힙 설정: 비힙 메모리 여유 필요

  2. OOM 로그는 /var/log/kern.log에서 확인: dmesg는 재부팅 시 초기화됨

  3. Swap 비활성화 환경에서 메모리 관리 중요: K8s 노드는 보통 swap off

  4. Memory Request도 적절히 설정: 스케줄링 시 노드 선택에 영향

  5. 모니터링 필수: Grafana에서 메모리 사용 추이 주기적 확인


수정 대상 파일#

  • 303-goormgb-k8s-helm/charts/java-cloud-gateway/values.yaml
    • resources.limits.memory: 512Mi → 1Gi
    • resources.requests.memory: 128Mi → 512Mi