24. Oktober 2020

Kubernetes, Spring Boot, JVM Performance Tuning – Teil 1

Teil 1: JVM und Kubernetes Einstellungen

In diesem Beitrag wollen wir betrachten, wie man Spring Boot und die dazugehörige JVM tunen kann. Da sich die JVM Flags von Java Version zu Version ändern, betrachten wir hier Java 9/10/11. In anderen Java Versionen sind die Flags analog zu setzen.

Wir müssen zunächst verstehen, wie Java Memory Management betreibt. Die JVM braucht nicht nur Heap Speicher, sondern auch Speicher für den Stack für jeden Thread (Stack Size – XSS), sowie einen konstanten Overhead, d.h. wir haben ungefähr

JVM Memory Requirement = Heap size + Stack size pro thread (XSS) * Anzahl Threads + konstanter Overhead (weder Heap noch Stack)

Der Wert für XSS ist OS/umgebungsabhängig und schwankt zwischen 256 KB and 1 MB. Das bedeutet: für jeden Thread brauche ich zusätzlichen Speicher von ca. 256 KB.

Um zu ermitteln, welche Werte für die JVM standardmäßig gesetzt sind, gibt man folgendes in der Bash ein

java -XX:+PrintFlagsFinal -XX:+UnlockExperimentalVMOptions -version | \
  grep -iE 'HeapSize|PermSize|ThreadStackSize|Metaspace|Maxram'

bzw. unter Windows

java -XX:+PrintFlagsFinal -XX:+UnlockExperimentalVMOptions -version | ^
  findstr /i "HeapSize PermSize ThreadStackSize Metaspace Maxram"

Die JVM hat mittlerweile Container Support für Umgebungen wie Kubernetes, wodurch die JVM bereits nur noch den Speicher sieht, welcher dem Container maximal zugeordnet ist. Trotzdem macht es Sinn den JVM Speicher zu begrenzen. Wenn man es nicht tut, dann kann man evtl. keine Debug Shell mehr auf dem Container öffnen, ohne ein „Killed by OOM“ zu provozieren. Mit den neuen Flags -XX:MaxRAM und -XX:MaxRAMFraction kann man steuern, wie viel von dem maximalen RAM die JVM für ihren Heap Speicher verwenden soll. Standardmäßig ist hier der Wert 4 eingestellt, was sehr vorsichtig ist. Wir verwenden daher einen Wert von 2, was bedeutet, dass 50% des maximalen Speichers für den Heap verwendet wird.

java -XX:MaxRAM=1g -XX:MaxRAMFraction=2 -XX:+UnlockExperimentalVMOptions -XX:+PrintFlagsFinal -version | \
  grep -iE 'HeapSize|PermSize|ThreadStackSize|Metaspace|Maxram'

Tatsächlich sind 1 GB maximaler Speicher für die JVM schon recht viel im Microservice Umfeld. In unseren Lasttests hatten wir gesehen, dass die JVM sich maximal 300 MB für den Heap nimmt, selbst wenn wir ihr mehr Speicher zur Verfügung stellen. D.h. es macht Sinn den maximalen JVM Speicher auf 600 MB zu beschränken, und 50% dabei für den Heap vorzusehen. Der Container selber hat dann ein Limit von 700 – 800 MB, wobei die 100 – 200 MB der vorgesehene Overhead für OS und eine evtl. Debug Shell sind.

Insgesamt haben wir also folgende Konfiguration für Kubernetes und die JAVA_OPTS

resources:
  limits:
    cpu: 2
    memory: 800Mi
  requests:
    cpu: 2
    memory: 800Mi

...

environment:
  - name: JAVA_OPTS
    value: >-
      -XX:MaxRAM=600m
      -XX:MaxRAMFraction=2
      -XshowSettings:vm
      -XX:+ExitOnOutOfMemoryError