Die JVM im Container

#JAVAPRO #JCON2017 #JVM #Docker

Immer mehr Java-Anwendungen laufen mit Hilfe von Containern wie Docker in der Cloud. Die JVM selbst war bislang jedoch nicht für den Betrieb in Cluster-Umgebungen ausgelegt, sodass u.U. komplexe und aufwändige Workarounds notwendig waren. Mit der aktuellen Java 8 Version und dem neuen JDK 9 gibt es erste Verbesserungen.

Die Entwicklung von Anwendungen in, für oder mit einer Cloud und Containern ist aktuell ein vorherrschendes Thema auf vielen Konferenzen, in Zeitschriften und im Internet.

Abseits der Diskussionen über die Verteilung von Funktionalitäten, z.B. Microservices ja/nein oder gar Serverless, die Art und Weise des Deployments, die Auswahl des Container-Systems, z.B. Docker oder Rkt von CoreOS, und vieles andere mehr, steht die etwas grundsätzlichere Frage: Ist Java bzw. die Java-Virtual-Machine (JVM) überhaupt auf den Betrieb im Container vorbereitet, oder mit anderen Worten, ist die JVM Container-ready?

Um den Rahmen nicht zu sprengen, konzentriert sich dieser Artikel auf den Hauptspeicher (RAM) als eine der wichtigsten Ressourcen für die Ausführung von Java-Anwendungen sowie auf die bei Cloud- und Container-Anwendungen sehr weit verbreitete Kombination aus Docker und einem Linux-Host. Die folgenden Ausführungen lassen sich jedoch analog auch auf die anderen Ressourcen, wie z.B. Rechenleistung (CPU) oder I/O übertragen.

Testumgebung

Eine der schnellsten Möglichkeiten, eine funktionsfähige Docker-Umgebung auf einem Linux-Host (VM) zu erstellen, ist der Einsatz des Programms Docker-Machine. Durch Eingabe des folgenden Kommandos in einem Terminal werden innerhalb kurzer Zeit (circa einer Minute) eine lokale VM mit einer speziellen
Linux-Variante namens Boot2Docker erzeugt, sowie ein SSH-Daemon und Docker installiert und konfiguriert (Listing 1).

(Listing 1)
bf$ docker-machine create -d virtualbox \
–virtualbox-memory ‚2048’ \
dev01

Da Docker-Machine die eigentliche Erzeugung der VM im Beispiel an Oracles VirtualBox delegiert, ist die funktionsfähige Installation dieses Programms eine zusätzliche Voraussetzung. Die Besonderheiten dieser Installation einschließlich des verwendeten Betriebssystems sind an dieser Stelle jedoch von untergeordneter Bedeutung. Wichtig ist nur, dass diese VM über 2 GByte Hauptspeicher verfügt, wovon man sich mithilfe des Linux-Kommandos free im Detail überzeugen kann (Listing 2).

(Listing 2)
# Hinweis: der Parameter „-k“ steht für die Angabe in kiloBytes

bf$ docker-machine ssh dev01.prl „sh -c ‚free -k‘“

         total    used    free    shared    buffers    cached
Mem:     2045900  1824900 221000  183172    242204     1183408
-/+ buffers/cache: 399288 1646612
Swap:    1426052  7884    418168

Java und der Hauptspeicher

Mithilfe der Klasse Runtime kann relativ schnell eine einfache Java-Anwendung erstellt werden, die einen ersten einfachen aber ausreichenden Blick auf die Ressource Hauptspeicher ermöglicht.

(Listing 3)
public class MemoryTotal
{
   public static <E> void main( String[] args )
   {
      System.out.println(
         String.format(
            „Memory-Total: %1$,014d Byte“,
            Runtime.getRuntime().maxMemory()
         )
      );
   }
}

Wie der Methodenname vermuten lässt, wird als einziger Wert der gesamte für die Anwendung zur Verfügung stehende Hauptspeicher ausgegeben. Fordert die Anwendung über diesen Wert hinaus weiteren Hauptspeicher an, kommt es zu der bekannten Out-Of-Memory-Exception.

Verpackt in ein Docker-Image

(Listing 4)
# Dockerfile
FROM openjdk:8u131-jdk-alpine

ADD ./target/eval-docker-java.jar /opt/eval-docker-java.jar

WORKDIR /opt/
ENTRYPOINT [ „java“, „-cp“, „eval-docker-java.jar“ ]

# Buildbefehl
docker image build -f Dockerfile -t aemc/eval-docker-java .

und entsprechend gestartet,

(Listing 5)
bf$ docker run --rm aemc/eval-docker-java MemoryTotal
Memory-Total: 000506,855,424 Byte

bf$ docker run --rm aemc/eval-docker-java -Xmx2G MemoryTotal
Memory-Total: 02,075,918,336 Byte

liefert dieses Java-Programm auf diese Art und Weise Informationen über den maximal verfügbaren Hauptspeicher aus dem Inneren eines Docker-Containers. Die Ausgabe des ersten Kommandos aus (Listing 5) entspricht circa einem Viertel des physisch zur Verfügung stehenden Hauptspeichers. Das ist der
Default-Wert, den eine Oracle-Server-JVM einem Java-Prozess beim Start zuteilt. Durch die Angabe des Parameters -Xmx, der die maximale Größe des Java-Heaps festlegt, kann dieser Default- Wert überschrieben werden. Die exakte Aufteilung des Hauptspeichers, die Berechnung der Größen der einzelnen Bereiche und der Zusammenhang zwischen den durch die Methoden der Klasse Runtime angezeigten Werte und der Startparameter, wie z.B. -XmX sind um einiges komplexer als hier dargestellt. Für die Darstellung der grundlegenden Mechanismen und Zusammenhänge ist die hier verwendete, stark vereinfachende Herangehensweise jedoch ausreichend.

Bis hierher sind im Zusammenspiel von Java und (Docker-) Container noch keine (echten) Probleme erkennbar. Das liegt zum Teil daran, dass der Test bislang immer nur eine einzelne Anwendung betrachtete. In einer Cloud werden auf den einzelnen Knoten jedoch im Normalfall mehrere Anwendungen isoliert voneinander betrieben. Damit deren friedliche Koexistenz auch in Bezug auf die Ressourcen-Nutzung, im Beispiel die Verwendung des Hauptspeichers, erhalten bleibt, können die für einen Container zur Verfügung stehenden Ressourcen von außen vorgegeben bzw. eingeschränkt werden. Nebenbei erhält die Cloud auf diesem Weg weitere nützliche Informationen zur effizienten Verteilung der Anwendungen auf die einzelnen Knoten. Aber das ist ein anderes spannendes Thema.

Für eine Beispielanwendung mit einem angenommenen Ressourcen-Bedarf von max. 100 MByte RAM sind die Erzeugung und der Start eines Docker-Containers in (Listing 6) dargestellt. Die Einschränkung (Constraint) des Hauptspeichers erfolgt durch den im Vergleich zu (Listing 5) zusätzlichen Parameter -m 100MB.

(Listing 6)
bf$ docker run --rm -m 100MB aemc/eval-docker-java MemoryTotal
Memory-Total: 000506,855,424 Byte

Oops! Das sind ja weit mehr als 100 MByte! Die Java-Anwendung im Container geht nach wie vor vom Vorhandensein des gesamten Hauptspeichers des Knotens (VM) aus und interessiert sich anscheinend kein bisschen für die konfigurierte Einschränkung. Anders ausgedrückt: Von der Ausführungsumgebung Container und deren Besonderheiten bekommt die JVM nichts mit!

Dieses Ignorieren ist in diesem Fall nicht besonders hilfreich, da das Container-Laufzeitsystem eine Überschreitung der konfigurierten Constraints natürlich nicht duldet und den Container und damit alle enthaltenen Prozesse kurzerhand beendet. Im Gegensatz zum normalen Verhalten bei einem Out-Of-Memory findet sich dann noch nicht einmal eine Exception in der Log-Ausgabe. Ein Hinweis auf das Geschehen kann nur noch in die Meta-Daten des Containers gefunden werden, falls dieser noch nicht wie zuvor in (Listing 6) entfernt wurde. Der Parameter –rm führt zur Löschung des Containers einschließlich seiner Metadaten unmittelbar nach Beendigung des eigentlichen Prozesses. Dadurch ist eine Post-Mortem-Analyse natürlich nicht mehr möglich.

(Listing 7)
docker container inspect test --format=“{{ .State.OOMKilled }}“
true

Neben dem im Moment sichtbaren und erst einmal nicht dramatisch erscheinenden Effekt werden durch die falschen Annahmen über den zur Verfügung stehenden Hauptspeicher auch die JVM-internen Berechnungen für die Auslegung des Garbage-Collectors und anderes mehr verfälscht. Auch das ist mindestens unschön.
Analog gilt das auch für die aus der Betrachtung ausgeklammerten Ressourcen. Wird die Beispiel-Anwendung auf einem großzügig mit Rechenpower ausgestatteten Host ausgeführt, kann man das Ergebnis des Aufrufs der Methode Runtime.availableProcessors() recht leicht aus den bisherigen Darlegungen ableiten.

Lösung 1: Auf die harte Tour

Ein erster möglicher Workaround für die Ressource Hauptspeicher besteht im Einsatz des bereits in (Listing 5) verwendeten Start-Parameters -Xmx, über den die JVM in erster Näherung über die Größe des tatsächlich zur Verfügung stehenden Hauptspeichers informiert werden würde. Das Kommando aus (Listing 6) sieht dann wie folgt aus:

(Listing 8)
bf$ docker run --rm -m 100MB aemc/eval-docker-java \
   -Xmx100M MemoryTotal
Memory-Total: 000101,384,192 Byte

Die angezeigte Hauptspeichergröße stimmt nun schon eher mit unserer Erwartungshaltung überein. Für den Einsatz in der professionellen Praxis sollte das Berechnen und Hinzufügen der entsprechenden Start-Parameter jedoch automatisiert durchgeführt werden. So stellt u.a. das Fabric-Projekt im Docker-Store entsprechend vorbereitete Docker-Images bereit, deren Sourcen auf Github zu finden sind. Die angesprochenen Berechnungen können im Skript container-limits nachvollzogen werden.

Lösung 2: Die Zukunft beginnt

Der im vorigen Abschnitt skizzierte Lösungsweg funktioniert für alle Java-Versionen, behebt aber nicht das Grundproblem: Die Java-VM hat keinerlei Kenntnis bzw. Informationen über die Laufzeitumgebung Container. Die zunehmende Verbreitung dieser Technologien und der damit verbundenen Notwendigkeit, derartige Aufgabenstellungen auch oder gerade mit auf der JVM basierenden Anwendungen zu lösen, führte letztendlich zu der Anforderung, entsprechende Funktionalitäten direkt in Java zu implementieren.
Ein Ergebnis dieser Bemühungen ist der im Juni diesen Jahres angelegte JDK-Enhancement Proposal (JEP) „Container aware Java“9. Während die eigentlichen Arbeiten an ihm aufgrund der definierten zeitlichen Abfolge der JDK-Entwicklungsprozesse wohl erst in JDK 10 vollständig einfließen können, gibt es aus dieser Richtung auch gute Nachrichten10. Einige Feature werden bereits in Java 9 enthalten sein und wurden vor kurzem als „experimentelle Feature“ auch nach Java 8 (Java SE 8u131) portiert!

Das unter Verwendung der neuen Start-Parameter umgeschriebene Kommando aus (Listing 8) ist in (Listing 9) dargestellt:

(Listing 9)
bf$ docker run --rm -m 100MB aemc/eval-docker-java \
   -XX:+UnlockExperimentalVMOptions \
   -XX:+UseCGroupMemoryLimitForHeap \
   MemoryTotal

Memory-Total: 000050,724,864 Byte

Da eine Java-Anwendung den Hauptspeicher nicht nur für den Java-Heap verwendet, sondern darin auch noch andere Objekte wie z.B. die geladenen Klassen etc. unterbringen muss, werden als Memory-Total nur circa 50 MByte angezeigt. Zur Verifizierung wird der für die Beispielanwendung bereitgestellte Hauptspeicher auf 1 GByte vergrößert:

(Listing 10)
bf$ docker run --rm -m 1GB aemc/eval-docker-java \
   -XX:+UnlockExperimentalVMOptions \
   -XX:+UseCGroupMemoryLimitForHeap \
   MemoryTotal

Memory-Total: 000259,522,560 Byte

In dieser Konstellation wird der bereits in (Listing 5) zu erkennende 1/4-Default-Wert für die Größe des Heap wieder sichtbar. Wenn wir davon ausgehen, dass wie in den Docker-Best-Practices empfohlen, in diesem Container keine weiteren Anwendungen laufen, dann bleiben fast 3/4 des verfügbaren Hauptspeichers ungenutzt. Es wäre sinnvoller, wenn die JVM automatisch allen verfügbaren und nicht für andere Objekte benötigten Hauptspeicher für den Heap verwenden würde. Mit dem ebenfalls neuen Parameter MaxRAMFraction, der die Aufteilung oder Zersplitterung des Hauptspeichers beeinflusst, kann genau dieser Effekt erreicht werden.

(Listing 11)
bf$ docker run --rm -m 1GB aemc/eval-docker-java \
   -XX:+UnlockExperimentalVMOptions \
   -XX:+UseCGroupMemoryLimitForHeap \
   -XX:MaxRAMFraction=1 \
   MemoryTotal

Memory-Total: 01,037,959,168 Byte

Im Ergebnis steht nun fast der gesamte Hauptspeicher für den Heap zur Verfügung. Nur rund 35 MByte sind für andere Zwecke reserviert.

Fazit:

Aufgrund der bislang fehlenden „Container-Awareness“ der Java-VM waren für den sicheren Betrieb in Cluster-Umgebungen einige u.U. durchaus komplexe und aufwendige Workarounds notwendig. Mit der aktuellen JDK 8 Version und dem kommenden JDK 9 wurden erste Schritte zur Verbesserung der Situation unternommen. Zur vollständigen Lösung sind jedoch weitere Arbeiten notwendig, beispielsweise um diese auch auf anderen Betriebssystem-Plattformen, wie Windows u.a.m., bereitzustellen. Aus Sicht des Betriebes ist die Portierung in die aktuelle JDK8-Version besonders positiv zu bewerten, da für die Nutzung der neuen Funktionalitäten in produktiven Projekten nicht mehr auf die finale Version des JDK 9 mit anschließender Migration, sondern nur der Umstieg auf eine aktuelle JDK 8 Version notwendig ist.

Bernd Fischer

Bernd Fischer beschäftigt sich seit seinem Studium der Elektrotechnik mit Software-Entwicklung und -Architektur und arbeitet heute als CTO bei der Mind-Approach GmbH in Dresden. Über Assembler, Fortran, Pascal, C/C++ kam er vor mehr als 15 Jahren zur Programmiersprache Java und ist ihr seither weitestgehend treu geblieben. Neben seiner hauptberuflichen Tätigkeit ist er in der JUG Saxony e.V. und der Dresdner Docker-Community aktiv.

(Visited 75 times, 1 visits today)

Leave a Reply