Minimale Java Docker Images mit Graal
Java Anwendungen haben den Ruf, schwergewichtig und langsam zu sein. Langsam ist Java dank ausgefeilter Optimierungen der JVM zwar nicht (mehr), jedoch sind Docker Container Images von Anwendungen relativ gross.
Das ist dadurch bedingt, dass eine JVM und ein Betriebssystem mitgeliefert werden muss, damit die Java Anwendung im Docker Container ausgeführt werden kann.
In aktuellen Java Versionen ist dank GraalVM die Möglichkeit gegeben, Java Anwendungen als native Programme zu kompilieren und statisch zu linken. Damit ist weder eine JVM noch ein Betriebssystem im Container Image erforderlich - vergleichbar mit dem aus der Go Welt bekanntem Vorgehen.
Wie das ganze funktioniert, wird im folgenden Beitrag vorgestellt.
Als Anwendunge wird ein Hello-World verwendet, um das Beispiel mit möglichst wenig Komplexität zu belasten. Dazu muss man wissen, dass Java eine sehr dynamische Laufzeitumgebung besitzt. Schließlich war das Ziel, eine Plattform zu bieten, bei der sich jeder Toaster aus dem Internet neue Anwendungen herunterladen kann.
Dazu eingesetzte Verfahren wie Reflection, dynamisches Laden von Klassen oder sogar Laufzeitgenerierung von Code wird durch Graal nur teilweise unterstützt.
Ahead of Time Compilation
Mit Java 9 wurde neben dem Modulsystem auch eine Möglichkeit geschaffen, um Java Klassen vorab zu übersetzen: Ahead of Time Compilation (AOT). Im Gegensatz zur üblichen Just-In-Time-Compilation (JIT) verspricht dies Verfahren kürze Startzeiten für die Programme und geringerer Resourcenbedarf. Auch verspricht man sich, dass Anwendungen damit schneller zur maximalen Geschwindigkeit kommen, was bei dem gestuften JIT sonst erst potentiell nach langer Zeit geschehen kann.
Seit Java 9 ist AOT als experimentelles Feature verfügbar und kann durch das jaotc
Werkzeug genutzt werden.
Der experimentelle Status ist dabei nicht zuletzt dem geschuldet, dass die Übersetzung der Java Klassen mit exakt der selben JVM Version erfolgen muss, mit der auch die spätere Ausführung geschieht.
Eine vollständige Übersetzung in native Anwendungen ist dabei auch nicht Gegenstand der Implementierung.
GraalVM native-image
GraalVM ist eine neue Runtime, vergleichbar der bisher eingesetzten HotSpot VM. Das Graal Projekt besteht neben der GraalVM auch aus weiteren Projekten, wie z.B. Truffle als Framework zur Implementierung von Sprachen.
Substrate VM ist ein Framework, mit dem eine vollständige Ahead Of Time Compilation (AOT) von Java Anwendungen unter Verwendung der GraalVM möglich ist.
Die Substrate VM stellt dabei die Laufzeitkomponenten bereit, die sonst von einer JVM bereitgestellt werden, wie zum Beispiel Memory Management oder Scheduling von Threads.
Als Ergebnis entstehen ausführbare Programme oder shared Libraries, die wie reguläre Programme oder Libraries eingesetzt werden können.
Native Image Java Beispiel
Zur Erstellung native Anwendungen wird eine Java Klasse mit einer typischen main()
Methode benötigt.
Statt einer Java Klasse kann auch ein JAR File transformiert werden.
Als Werkzeuge kommen das native-image
von Graal, der gcc
C-Compiler und entsprechende Bibliotheken zur Übersetzung von C-Programmen zum Einsatz.
class Hello
{
public static void main (String args[])
{
System.out.println("Hello, Graal");
}
}
Ohne Graal würde das Programm mit javac
in Java Bytecode übersetzt und dann durch die Java JVM ausgeführt.
Zur Messung der Zeit kann time
oder das präzisere perf
Werkzeug eingesetzt werden.
Letzteres ist ein Linux Werkzeug zum Profiling, unter Debian/Ubuntu ist es als linux-tools-generic
Paket verfügbar.
$ javac Hello.java
$ time java Hello
Hello, Graal
java Hello 0,06s user 0,00s system 94% cpu 0,065 total
$ sudo perf stat -e cpu-clock -r50 java Hello
...
Performance counter stats for 'java Hello' (50 runs):
51,039302 cpu-clock (msec) # 1,060 CPUs utilized ( +- 0,92% )
0,048132 +- 0,000498 seconds time elapsed ( +- 1,03% )
Das native-image
Werkzeug ist Teil von Graal.
Wer die entsprechende Installation scheut, der kann natürlich einen Build Container verwenden, wie auch im folgenden mit Docker und Graal gezeigt.
$ docker run --rm -v $PWD:/app -w /app \
-u $(id -u) \
oracle/graalvm-ce:1.0.0-rc10 \
native-image --static -H:Name=app -cp . Hello
Build on Server(pid: 10, port: 40229)*
[app:10] classlist: 1,818.87 ms
[app:10] (cap): 932.48 ms
[app:10] setup: 2,466.80 ms
[app:10] (typeflow): 3,581.77 ms
[app:10] (objects): 1,016.96 ms
[app:10] (features): 163.80 ms
[app:10] analysis: 4,868.25 ms
[app:10] universe: 270.15 ms
[app:10] (parse): 929.19 ms
[app:10] (inline): 1,638.40 ms
[app:10] (compile): 6,941.73 ms
[app:10] compile: 9,773.60 ms
[app:10] image: 408.60 ms
[app:10] write: 152.83 ms
[app:10] [total]: 19,838.37 ms
$ ls -l app
-rwxr-xr-x 1 root root 3275600 Dez 24 21:47 app
Eine Größe von drei Megabyte für eine Hello-World Anwendung inklusive der Laufzeitumgebung ist für eine Java Anwendung schon bemerkenswert.
Wie es um die Geschwindigkeit bestellt ist, soll als nächstes geprüft werden.
$ time ./app
Hello, Graal
./app 0,00s user 0,00s system 83% cpu 0,004 total
$ sudo perf stat -e cpu-clock -r50 ./app
Performance counter stats for './app' (50 runs):
0,644113 cpu-clock (msec) # 0,778 CPUs utilized ( +- 2,62% )
0,0008275 +- 0,0000232 seconds time elapsed ( +- 2,80% )
Von ca. 2.5 Sekunden für 50 Starts sind mit der nativen Java Anwendung rund 0,3 Sekunden geblieben. In diesem besonders simplen Beispiel ist der Start also rund Faktor 80 schneller!
Doch nicht nur die Startgeschwindigkeit ist ein wichtiger Faktor: Auch die Größe des resultierenden Docker Images reduziert sich deutlich, da keine JVM mehr bereitgestellt werden muss.
Multi Stage Docker Image
Die vollautomatische Übersetzung mittels Docker Buildcontainer für die Java GraalVM ist das Ziel des folgenden Abschnitts.
Als Basisimage soll das im Kontext von Kubernetes bzw. Google oft anzutreffende distroless
Image verwendet werden.
Diese Images sind auf geringe potentielle Angriffsfläche optimiert und bringen lediglich den minimalen Umfang für die jeweilige Zielsprache mit.
Als Basisimage würde sich distroless/base
eignen, da kein spezieller Support für eine Laufzeitumgebung benötigt wird.
Jedoch fehlt die libz-Bibliothek in dem Image, die von der Substrate VM benötigt wird.
Als Workaround lässt sich die Library jedoch einfach ergänzen.
Da für die Übersetzung ein anderes Image, als zur Laufzeit zum Einsatz kommt, bietet sich ein Multistage Build für das Image an.
FROM debian:stable-slim AS build-env
FROM oracle/graalvm-ce:1.0.0-rc10 AS native-image
COPY . /app
RUN cd /app; native-image --static -H:Name=app -cp . Hello
FROM gcr.io/distroless/base
COPY --from=native-image /app/app /app
COPY --from=build-env /lib/x86_64-linux-gnu/libz.so.1 /lib/x86_64-linux-gnu/libz.so.1
CMD ["/app"]
Der Build des Docker Image erfolgt, wie bei anderen Docker Images auch, mit docker build
.
$ docker build -t trion/graaldemo .
Sending build context to Docker daemon 4.096kB
Step 1/8 : FROM debian:stable-slim AS build-env
---> 9b0c99e744dd
Step 2/8 : FROM oracle/graalvm-ce:1.0.0-rc10 AS native-image
---> 38f60a0e29bc
Step 3/8 : COPY . /app
---> 27ee7973b77b
Step 4/8 : RUN cd /app; native-image --static -H:Name=app -cp . Hello
---> Running in f20dba68d56c
Build on Server(pid: 14, port: 46227)*
[app:14] classlist: 1,723.10 ms
[app:14] (cap): 1,100.20 ms
[app:14] setup: 2,722.99 ms
[app:14] (typeflow): 3,706.25 ms
[app:14] (objects): 882.86 ms
[app:14] (features): 155.68 ms
[app:14] analysis: 4,858.42 ms
[app:14] universe: 257.41 ms
[app:14] (parse): 934.50 ms
[app:14] (inline): 1,327.92 ms
[app:14] (compile): 6,468.11 ms
[app:14] compile: 9,027.76 ms
[app:14] image: 489.84 ms
[app:14] write: 186.13 ms
[app:14] [total]: 19,344.89 ms
Removing intermediate container f20dba68d56c
---> c8d1cab6a007
Step 5/8 : FROM gcr.io/distroless/base
---> dab6c8cba81d
Step 6/8 : COPY --from=native-image app /app
---> 79acb423ca95
Step 7/8 : COPY --from=build-env /lib/x86_64-linux-gnu/libz.so.1 /lib/x86_64-linux-gnu/libz.so.1
---> bc2cf9b03815
Step 8/8 : CMD ["/app"]
---> Running in 0f1153911720
Removing intermediate container 0f1153911720
---> 3e5dbfd85c82
Successfully built 3e5dbfd85c82
Successfully tagged trion/graaldemo:latest
$ docker images
trion/graaldemo latest 3e5dbfd85c82 43 seconds ago 20.3MB
Das Image kann natürlich auch ausgeführt werden.
$ docker run --rm trion/graaldemo
Hello, Graal
Im Vergleich zur nativen Ausführung fällt auf, dass Docker eigenen Overhead zur Bereitstellung des Containers erzeugt. Dies sollte jedoch nicht davon ablenken, dass in produktiven Umgebungen bspw. mit CRI-O eine andere Container-Laufzeit zum Einsatz kommt, die auf schnellen Start von Containern optimiert ist.
Fazit
Die Geschwindigkeit, die sich mit Substrate VM bzw. der GraalVM und dem native-Image Werkzeug ergibt ist schlichtweg atemberaubend. Java ist damit wieder ein diskutabler Kanditat für Serverless bzw. FaaS Architekturen. Als nächstes müssen Frameworkhersteller passende Unterstützung konzipieren, damit auf Reflection und dynamischen Umgang mit der JVM entsprechend verzichtet wird, wenn die Zielumgebung durch native Binaries definiert wird.
Die Vorteile gehen durch das plattformabhängige Format zu lasten der Portabilität. Waren Java Anwendungen bisher auf beliebigen JVMs ausführbar, erhält man nun einen Container, der sowohl an das Betriebssystem als auch die CPU Architektur gebunden ist.
Durch die Multi-Arch Images von Docker lässt sich jedoch auf anderer Ebene Portabilität erzielen, so dass dieser Trade-Off nicht all zu sehr ins Gewicht fallen dürfte.
Zu den Themen Spring Boot, Java und Cloud Architektur bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:
Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.