Mein Dilemma

Ich bin ein großer Fan von Docker und Kubernetes, da es mir die Möglichkeit gibt, kleine funktionale Einheiten zu bauen und sie später wieder zu großen zusammenzufügen. Ganz so, wie es mein Kollege Christopher Schmidt in seinen Kubernetes-Artikeln beschreibt. Das erfordert aber, dass ich kleine Container bauen kann. Mit “klein” meine ich: sowohl klein in seiner Funktionalität als auch klein im Speicher zur Laufzeit und klein zum Download beim Deployment.

Nebenbei bin ich aber auch ein großer Fan von Scala, weil mir hier der Compiler eine ganze Klasse von Fehlern schon zur Entwicklungszeit abfangen kann.

Mein Dilemma besteht nun darin, dass ich es bisher noch nicht geschafft habe eine JVM-basierte Sprache in einem Container auszuführen (und das kann durchaus an meinem Unvermögen liegen), der …

Zerlege ich dann ein System in minimale funktionale Einheiten, ist mein Entwicklungsrechner schon mal schnell überfordert, alle Umsysteme für den Entwicklungsbetrieb laufen zu lassen. Mal ganz davon abgesehen, dass sich hungrige Artefakte auch in den Betriebskosten niederschlagen.

Mein Hoffnungsschimmer

Oracles GraalVM kann offensichtlich vieles leisten. Doch mein Hauptinteresse gilt hier der Tatsache, dass sie JVM-Class-Dateien zu nativen Plattform-Binaries kompilieren kann, um so (laut Webseite) den Memory-Footprint zu verringern:

Native
Native images compiled with GraalVM ahead-of-time improve the startup time and reduce the memory footprint of JVM-based applications.

Das wollte ich mir doch mal genauer ansehen, in der Hoffnung, am Ende wunderschöne und in alle Dimensionen “kleine” Container mit JVM-Sprachen-Inhalt bauen und deployen zu können.

Ärmel hoch - Hände dran

Die GraalVM gibt es sowohl in der Enterprise (EE) als auch in der Community Edition (CE). Weil ich Open Source mag habe ich mich für die CE entschieden, die bei GitHub heruntergeladen werden kann:

wget https://github.com/oracle/graal/releases/download/vm-1.0.0-rc1/graalvm-ce-1.0.0-rc1-linux-amd64.tar.gz

Um gleich ein bisschen vorwärts zu machen, habe ich mir einen Docker-Container für die Build-Umgebung gebaut, in den ich das TAR entpacken kann, ohne meine aktuelle Landschaft zu stören:

Dockerfile:

FROM oraclelinux:7-slim

ENV GRAALVM_PKG=graalvm-ce-1.0.0-rc1-linux-amd64.tar.gz \
    JAVA_HOME=/usr/graalvm-1.0.0-rc1/jdk \
    PATH=/usr/graalvm-1.0.0-rc1/bin:$PATH

ADD $GRAALVM_PKG /usr/

RUN yum -y install gcc; \
    yum -y install zlib-devel;  \
    alternatives --install /usr/bin/java  java  $JAVA_HOME/bin/java  20000 && \
    alternatives --install /usr/bin/javac javac $JAVA_HOME/bin/javac 20000 && \
    alternatives --install /usr/bin/jar   jar   $JAVA_HOME/bin/jar   20000

In dem TAR-Archiv der GraalVM befindet sich ein komplettes Java-JDK, das ich zum Kompilieren von Java-Sourcen zu Class-Dateien benutzen kann. Zusätzlich liegt in dem bin-Verzeichnis das Tool “native-image”, das mir das versprochene Binary aus Class-Dateien bauen soll.

Hello World

Als Nächstes wollte ich mal sehen, wie problemlos ein kleiner Shell-Befehl gebaut werden kann. Um zu demonstrieren, dass auch fachlich hoch anspruchsvolle Anwendungen umgesetzt werden können, möchte ich also die Worte “Hello World” auf der Konsole ausgeben.

HelloWorld.java:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}
blog-native-java-graalvm> docker build . -t graalvm
blog-native-java-graalvm> docker run --rm -tiv `pwd`:/work -w /work graalvm
bash-4.2> javac HelloWorld.java
bash-4.2> time java HelloWorld
Hello World

real	0m0.100s
user	0m0.030s
sys	0m0.060s
bash-4.2>

Es ist keine Überraschung, dass der ganze Vorgang funktioniert. Etwas überrascht hat mich aber tatsächlich, dass es so einfach war.
Also ab zum nächsten Schritt, in dem das Binary erzeugt wird:

bash-4.2> time native-image HelloWorld
Build on Server(pid: 417, port: 26681)
...

real	0m11.389s
user	0m0.170s
sys	0m0.010s

Das Bauen hat einen Moment gedauert, das soll aber nicht weiter stören, denn das Ergebnis passt:

bash-4.2> time ./helloworld
Hello World

real	0m0.018s
user	0m0.000s
sys	0m0.000s

Und mit einer Größe von 5.2 MB ist das Binary auch noch in einem annehmbaren Rahmen.

Hello World Server

Um als Nächstes ein Gefühl für den Memory-Footprint zu bekommen, habe ich fix einen simplen Webserver kopiert und modifiziert.

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

public class HelloWorldServer {

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
        server.createContext("/", new MyHandler());
        server.setExecutor(null);
        server.start();
    }

    static class MyHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange t) throws IOException {
            String response = "Hello World";
            t.sendResponseHeaders(200, response.length());
            OutputStream os = t.getResponseBody();
            os.write(response.getBytes());
            os.close();
        }
    }

}

Kompilieren und starten:

bash-4.2> javac HelloWorldServer.java
bash-4.2> ^D
blog-native-java-graalvm> docker run --name hws --rm -tiv `pwd`:/work -w /work -p8080:8080 graalvm java HelloWorldServer

Messen:

blog-native-java-graalvm> docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" hws
CONTAINER           MEM USAGE / LIMIT
hws                 10.68MiB / 3.855GiB

Dann mal das Ganze als native Binary:

bash-4.2> native-image HelloWorldServer
bash-4.2> ^D
blog-native-java-graalvm> docker run --name hws --rm -tiv `pwd`:/work -w /work -p8080:8080 graalvm ./helloworldserver
blog-native-java-graalvm> docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" hws
CONTAINER           MEM USAGE / LIMIT
hws                 864KiB / 3.855GiB

Auch wenn 10 MB in der JVM durchaus nicht viel sind, so sind 864 KB spürbar weniger. Das Binary für den HelloWorldServer ist dabei immer noch nur 5,9 MB klein.

Container

Als Letztes wollte ich versuchen, meine Artefakte in einem Docker-Container verpackt laufen zu lassen. Der naive Ansatz, wie ich ihn von Go kenne, besteht darin, ein Binary in einen scratch-Container zu packen und zu starten:

Dockerfile.scratch:

FROM scratch
ADD helloworld /
CMD ["/helloworld"]
blog-native-java-graalvm> docker build -f=Dockerfile.scratch . -t
...
blog-native-java-graalvm> docker run --rm hello-scratch
standard_init_linux.go:190: exec user process caused "no such file or directory"

Schade – ein Fehler, hier verließ das Experiment den Pfad der Problemlosigkeit. Ein Blick in die im Stacktrace referenzierte Zeile der entsprechenden Go-Datei hat auch nicht viel zur Aufklärung beigetragen. Sie reicht lediglich einen Fehler weiter, den sie beim Ausführen des Binaries bekommt. Nach etwas Nachdenken, welche Datei das Binary überhaupt öffnen muss, kam ich zu dem Schluss, dass es sich eigentlich nur um eine Library handeln kann und das lässt sich ja rausfinden:

blog-native-java-graalvm> docker run --rm -tiv `pwd`:/work -w /work graalvm ldd helloWorld
	linux-vdso.so.1 =>  (0x00007ffcb033f000)
	libdl.so.2 => /lib64/libdl.so.2 (0x00007f8fd1806000)
	libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f8fd15ea000)
	libz.so.1 => /lib64/libz.so.1 (0x00007f8fd13d4000)
	librt.so.1 => /lib64/librt.so.1 (0x00007f8fd11cc000)
	libcrypt.so.1 => /lib64/libcrypt.so.1 (0x00007f8fd0f95000)
	libc.so.6 => /lib64/libc.so.6 (0x00007f8fd0bc8000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f8fd1a0a000)
	libfreebl3.so => /lib64/libfreebl3.so (0x00007f8fd09c5000)

Wunderbar, jetzt muss ich nur noch das Dockerfile anpassen, um die gelinkten Dateien mit in den Container zu packen. Sie sollten ja in dem Build-Container zu finden sein:

Dockerfile.scratch2:

FROM oraclelinux:7-slim AS BASE

FROM scratch

COPY --from=BASE /lib64/libc.so.6 /lib64/libc.so.6
COPY --from=BASE /lib64/libdl.so.2 /lib64/libdl.so.2
COPY --from=BASE /lib64/libpthread.so.0 /lib64/libpthread.so.0
COPY --from=BASE /lib64/libz.so.1 /lib64/libz.so.1
COPY --from=BASE /lib64/librt.so.1 /lib64/librt.so.1
COPY --from=BASE /lib64/libcrypt.so.1 /lib64/libcrypt.so.1
COPY --from=BASE /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
COPY --from=BASE /lib64/libfreebl3.so /lib64/libfreebl3.so

ADD helloworld /

CMD ["/helloworld"]
blog-native-java-graalvm> docker build -f=Dockerfile.scratch2 . -t hello-scratch
...
blog-native-java-graalvm> docker run --rm hello-scratch
Hello World
blog-native-java-graalvm>

Und tatsächlich, es läuft. Analog startet auch der HelloWorldServer. Die paar Libraries fügen dem Docker-Image nochmal 3 MB hinzu, für mich ist das aber auch noch in einem annehmbaren Bereich, wenn man bedenkt, dass ein jre-alpine Basis-image mehr als 100 MB dazupacken würde.

Fazit

Die GraalVM macht es einem in dem aktuellen Stadium noch nicht einfach, an schlanke Docker-Images und Prozesse zu kommen, aber sie macht es möglich. Für mich bedeutet das einen großen Schritt in die richtige Richtung. Wenn ich wirklich kleine Deployment-Artefakte bauen will, wo bisher der Griff zu Go fast selbsverständlich war, habe ich nun wieder die Wahl der Programmiersprache und des Ökosystems. Anhänger der Programmiersprache Go müssen jetzt bestimmt lächeln, da sie die hier ermittelten Zahlen leicht übertreffen können. Für mich ist aber mit den Möglichkeiten der GraalVM eine Schmerzgrenze unterschritten, wodurch ich Java für Docker-Container wieder passend finde.

Ich bin gespannt darauf, was sich ergibt, wenn ich mal mit Maven-, SBT- und Gradel-Builds auf die GraalVM losgehe und ob es sogar machbar ist, auch das Scala-Ökosystem unter meine Schmerzgrenze zu wuchten.

Links: