Veröffentlicht am

Java Kubernetes OOMKilled: JVM Heap richtig setzen

Teilen:
Authors

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-LimitMaxRAMPercentageEffektiver HeapNon-Heap-Budget
512 Mi65%~333 MB~179 MB
1 Gi75%~768 MB~256 MB
2 Gi75%~1536 MB~512 MB
4 Gi80%~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