- Authors

- Name
- Phillip Pham
- @ddppham
Java auf Kubernetes: OOMKilled durch JVM Heap verhindern
Exit Code 137. Der Pod startet, laeuft 20 Minuten, wird gekillt. Restart. Wieder 137. Wer Java auf Kubernetes betreibt, kennt dieses Muster. Die Ursache ist fast immer dieselbe: Die JVM verbraucht mehr Speicher als das Container-Limit erlaubt.
TL;DR
Die JVM braucht mehr Speicher als nur den Heap. Setzt -XX:MaxRAMPercentage=75.0 statt fester -Xmx-Werte. Container-Limit auf mindestens 1,3x den gewuenschten Heap. Aktiviert -XX:+UseContainerSupport (default seit JDK 10). Native Memory fuer Metaspace, Threads und Compiler nicht vergessen.
Warum Java-Container OOMKilled werden
Der Linux-Kernel beendet Prozesse mit SIGKILL (Signal 9), wenn sie mehr Speicher nutzen als das cgroup-Limit erlaubt. Kubernetes meldet das als OOMKilled mit Exit Code 137 (128 + 9).
Das Problem bei Java: Die JVM allokiert Speicher fuer deutlich mehr als nur den Heap.
JVM-Gesamtspeicher = Heap + Metaspace + Thread-Stacks + Code-Cache
+ Direct Buffers + GC-Overhead + JIT-Compiler
+ Native Memory (JNI, NIO)
Ein typischer Fehler:
# FALSCH: Heap fast gleich Container-Limit
containers:
- name: app
image: myapp:latest
resources:
limits:
memory: 512Mi
env:
- name: JAVA_OPTS
value: "-Xmx512m" # Das crasht garantiert
Die JVM versucht, 512 MB Heap zu allokieren. Dazu kommen ~150-250 MB Non-Heap. Gesamtverbrauch: 660-760 MB. Container-Limit: 512 MB. OOMKilled.
JVM Memory-Anatomie im Container
Container Memory Limit: 1024 MB
├── JVM Heap (-Xmx): 700 MB (68%)
├── Metaspace: 80 MB (8%)
├── Thread Stacks (200 x 1MB): 200 MB (20%) <- oft unterschaetzt
├── Code Cache: 48 MB (5%)
├── Direct Byte Buffers: 20 MB (2%)
├── GC-Overhead: 30 MB (3%)
└── JNI/Native: 20 MB (2%)
─────────────
Gesamt: ~1098 MB -> OOMKilled!
Thread-Stacks sind der haeufigste blinde Fleck. Jeder Thread reserviert standardmaessig 1 MB (-Xss1m). Ein Spring-Boot-Tomcat-Server hat leicht 200+ Threads. Das sind 200 MB nur fuer Stacks.
Die richtige Konfiguration
Option 1: MaxRAMPercentage (empfohlen)
Seit JDK 10 erkennt die JVM Container-Limits automatisch. Mit -XX:MaxRAMPercentage setzt ihr den Heap als Prozentwert des Container-Limits.
containers:
- name: app
image: myapp:latest
resources:
requests:
memory: 768Mi
cpu: 500m
limits:
memory: 1Gi
env:
- name: JAVA_OPTS
value: >-
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:+UseContainerSupport
-XX:+UseG1GC
75% Heap von 1 Gi = 768 MB Heap. Bleiben 256 MB fuer Non-Heap. Das reicht fuer die meisten Anwendungen.
Option 2: Explizite Werte (fuer Feinsteuerung)
env:
- name: JAVA_OPTS
value: >-
-Xms512m
-Xmx768m
-XX:MetaspaceSize=96m
-XX:MaxMetaspaceSize=128m
-Xss512k
-XX:ReservedCodeCacheSize=64m
-XX:MaxDirectMemorySize=32m
-XX:+UseG1GC
-XX:+UseContainerSupport
Der Vorteil: Ihr kontrolliert jede Komponente. Der Nachteil: Wenn sich das Container-Limit aendert, muesst ihr die Werte manuell anpassen.
Richtwerte fuer Container-Limits
| Container-Limit | MaxRAMPercentage | Effektiver Heap | Non-Heap-Budget |
|---|---|---|---|
| 512 Mi | 65% | ~333 MB | ~179 MB |
| 1 Gi | 75% | ~768 MB | ~256 MB |
| 2 Gi | 75% | ~1536 MB | ~512 MB |
| 4 Gi | 80% | ~3276 MB | ~724 MB |
Bei kleinen Containern (512 Mi) braucht Non-Heap einen groesseren Anteil, weil Metaspace und Threads nicht proportional schrumpfen. Bei grossen Containern koennt ihr den Heap-Anteil erhoehen.
Diagnose: Wo geht der Speicher hin
JVM Native Memory Tracking
# NMT aktivieren (Performance-Overhead ca. 5-10%)
env:
- name: JAVA_OPTS
value: "-XX:NativeMemoryTracking=summary"
# Im laufenden Pod pruefen
kubectl exec -it pod/myapp -- jcmd 1 VM.native_memory summary
# Ausgabe:
# Total: reserved=2048MB, committed=1456MB
# - Java Heap (reserved=1024MB, committed=890MB)
# - Class (reserved=128MB, committed=98MB) <- Metaspace
# - Thread (reserved=256MB, committed=256MB) <- Thread Stacks
# - Code (reserved=64MB, committed=42MB) <- JIT Code Cache
# - GC (reserved=48MB, committed=48MB)
# - Internal (reserved=16MB, committed=12MB)
# - Symbol (reserved=8MB, committed=8MB)
Container-Memory aus Kubernetes-Sicht
# Aktueller Memory-Verbrauch des Pods
kubectl top pod myapp
# Detaillierte cgroup-Metriken
kubectl exec -it pod/myapp -- cat /sys/fs/cgroup/memory.current
kubectl exec -it pod/myapp -- cat /sys/fs/cgroup/memory.max
# Memory-Events (OOM-Counter)
kubectl exec -it pod/myapp -- cat /sys/fs/cgroup/memory.events
Heap-Dump bei OOMKilled
env:
- name: JAVA_OPTS
value: >-
-XX:MaxRAMPercentage=75.0
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:+ExitOnOutOfMemoryError
Achtung: Der Heap-Dump funktioniert nur bei Java-OOM (java.lang.OutOfMemoryError), nicht bei Container-OOM (SIGKILL). Wenn der Kernel den Container killt, gibt es keinen Dump. Dann muesstet ihr den Heap proaktiv sichern, bevor das Limit erreicht wird.
Typische Fallstricke
1. JDK 8 ohne Container-Support
Vor JDK 8u191 erkennt die JVM keine cgroup-Limits. Sie sieht den gesamten Host-Speicher und setzt den Heap auf 1/4 davon.
# Pruefen ob Container-Awareness aktiv ist
kubectl exec -it pod/myapp -- java -XX:+PrintFlagsFinal -version 2>&1 | grep UseContainerSupport
# Sollte: bool UseContainerSupport = true
# Falls JDK 8 < u191:
# Explizites -Xmx MUSS gesetzt werden
2. Thread-Explosion bei Spring Boot
# Thread-Anzahl im Pod pruefen
kubectl exec -it pod/myapp -- jcmd 1 Thread.print | grep -c "^\""
# Typische Thread-Fresser:
# - Tomcat NIO: 200 Threads default (server.tomcat.threads.max)
# - HikariCP: 10 Threads (spring.datasource.hikari.maximum-pool-size)
# - @Async: unbegrenzt default
# - Schedulers, Kafka-Consumer, gRPC
Reduziert Thread-Pools:
# application.yml
server:
tomcat:
threads:
max: 50 # statt 200
min-spare: 5 # statt 10
spring:
datasource:
hikari:
maximum-pool-size: 5 # statt 10
50 Threads statt 200 spart ~150 MB. Bei kleinen Containern ist das entscheidend.
3. Metaspace bei vielen Libraries
Grosse Spring-Boot-Anwendungen mit Hibernate, Jackson, und Dutzenden Auto-Configurations brauchen 100-200 MB Metaspace. Setzt MaxMetaspaceSize explizit, sonst waechst Metaspace unbegrenzt.
# Metaspace-Verbrauch pruefen
kubectl exec -it pod/myapp -- jcmd 1 VM.metaspace
4. G1GC vs ZGC Memory-Overhead
# G1GC: Standard fuer die meisten Workloads
# Overhead: ~10% des Heaps fuer interne Strukturen
- name: JAVA_OPTS
value: "-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
# ZGC: Fuer Latenz-kritische Anwendungen
# Overhead: ~15-20% hoeher als G1GC
# Braucht mehr Headroom im Container
- name: JAVA_OPTS
value: "-XX:+UseZGC -XX:MaxRAMPercentage=65.0" # niedriger wegen ZGC-Overhead
Produktions-Template
Ein erprobtes Setup fuer Spring-Boot-Anwendungen:
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-app
spec:
replicas: 2
selector:
matchLabels:
app: java-app
template:
metadata:
labels:
app: java-app
spec:
containers:
- name: app
image: myapp:1.0
ports:
- containerPort: 8080
resources:
requests:
memory: 768Mi
cpu: 500m
limits:
memory: 1Gi
cpu: "1"
env:
- name: JAVA_OPTS
value: >-
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:+UseContainerSupport
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-Xss512k
-XX:MaxMetaspaceSize=150m
startupProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 15
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
Das startupProbe mit hohem failureThreshold gibt Spring Boot bis zu 160 Sekunden zum Starten, ohne dass Liveness den Pod vorher killt.
Monitoring-Setup
# JMX-Exporter fuer Prometheus (als Java-Agent)
env:
- name: JAVA_OPTS
value: >-
-javaagent:/opt/jmx-exporter/jmx_prometheus_javaagent.jar=9090:/opt/jmx-exporter/config.yaml
-XX:MaxRAMPercentage=70.0
# Wichtige Metriken:
# jvm_memory_bytes_used{area="heap"}
# jvm_memory_bytes_used{area="nonheap"}
# jvm_memory_bytes_max{area="heap"}
# jvm_threads_current
# jvm_gc_pause_seconds_sum
# process_resident_memory_bytes <- das zaehlt fuer OOM
Die entscheidende Metrik ist process_resident_memory_bytes. Wenn diese sich dem Container-Limit naehert, steht ein OOMKill bevor. Setzt einen Alert bei 90%.
FAQ
Welcher MaxRAMPercentage-Wert ist optimal?
75% ist ein guter Startwert fuer Container ab 1 Gi. Bei 512 Mi nehmt 65%, bei 4 Gi+ koennt ihr auf 80% gehen. Prueft mit NMT ob genug Non-Heap-Budget bleibt.
Soll ich Xmx oder MaxRAMPercentage nutzen?
MaxRAMPercentage, wenn das Container-Limit die fuehrende Groesse ist (Kubernetes-native Denkweise). Xmx, wenn ihr exakte Kontrolle ueber jeden Memory-Bereich braucht oder JDK 8 vor u191 einsetzt.
Warum hilft -Xmx allein nicht gegen OOMKilled?
Weil -Xmx nur den Heap begrenzt. Metaspace, Thread-Stacks, Code-Cache und Native Memory kommen obendrauf. Ein Container mit 1 Gi Limit und -Xmx1g wird immer OOMKilled.
Wie finde ich die richtige Container-Groesse?
Startet mit NativeMemoryTracking im Staging. Lasst die Anwendung unter Last laufen und beobachtet committed Memory. Setzt das Container-Limit auf 120-130% des beobachteten Maximums.
Funktioniert das auch mit GraalVM Native Image?
GraalVM Native Images haben kein JVM-Overhead. Die brauchen 50-80% weniger Speicher. MaxRAMPercentage greift dort nicht -- setzt Heap-Limits ueber -R:MaxHeapSize oder -Xmx.
Kaempft ihr mit OOMKilled-Pods in eurem Java-Stack? Ich helfe beim Memory-Tuning, JVM-Profiling und der Optimierung eurer Container-Limits -- damit die Pods oben bleiben.
Weiterführende Artikel:
Kubernetes-Beratung gesucht?
Wir helfen deutschen Unternehmen bei der Kubernetes-Implementierung, Migration und Optimierung. DSGVO-konform und praxiserprobt.
📖 Verwandte Artikel
Weitere interessante Beiträge zu ähnlichen Themen
Kubernetes CPU Throttling finden und beheben
CPU-Throttling in Kubernetes erkennen und lösen. Limits richtig setzen, Performance-Engpässe mit Prometheus diagnostizieren und QoS-Klassen verstehen.
Kubernetes Performance Probleme systematisch lösen
Performance-Bottlenecks in Kubernetes diagnostizieren und lösen: CPU-Throttling, Memory, Netzwerk, Storage, etcd und API-Server-Probleme beheben.
OOMKilled debuggen: Memory-Limits und Leaks finden
Kubernetes OOMKilled (Exit Code 137) beheben: Memory-Limits richtig setzen, Memory Leaks finden und QoS-Klassen für stabile Pods konfigurieren.
Java Spring Boot auf Kubernetes containerisieren
Spring Boot Anwendungen für Kubernetes containerisieren: Multi-Stage Dockerfile, JVM-Tuning, Health Checks mit Actuator und fertige Deployment-YAMLs.
CoreDNS Debugging: Kubernetes-DNS Probleme lösen
DNS-Probleme in Kubernetes systematisch debuggen: CoreDNS-Logs, ndots-Konfiguration, NXDOMAIN-Fehler und Corefile-Optimierung für schnellere Auflösung.