arrow arrow--cut calendar callback check chevron chevron--large cross cross--large download filter kununu linkedin magnifier mail marker menu minus Flieger phone play plus quote share

Kleine JREs mit OpenJDK 13 erstellen

Wie wir verhindern, dass nach dem Wegfall des JRE ein JDK in die Produktionsumgebung gelangt und so unsere Systeme schlank und sicher halten.

Wer auf AdoptOpenJDK (https://adoptopenjdk.net/) das aktuelle Java 13 (oder auch die aktuelle LTS-Version Java 11) herunterladen will, könnte sich wundern: früher gab es neben dem JDK, dem Java Development Kit, auch das JRE, das Java Runtime Environment, zum Download. Der Unterschied bestand darin, dass im JDK der Java-Compiler und Debugging-Tools vorhanden waren, während zum reinen Ausführen einer Java-Anwendung nur das JRE benötigt wurde.

Foto von Jochen Wierum
Jochen Wierum

Software Developer

Motivation

Durch den Wegfall des JRE aus dem Download-Angebot beobachte ich, dass als Konsequenz immer häufiger ein JDK seinen Weg in die Produktionsumgebungen findet. Das finde ich ungeschickt, weil dadurch zwei Nachteile entstehen: einmal werden die Installationen damit größer. Das gilt auch für Docker-Container. Zwar wird Java selbst immer kleiner, sodass dies nicht so sehr ins Gewicht fällt, ich finde aber dennoch, dass kleine Artefakte in CD-Pipelines und auf dem eigenen Entwicklerrechner leichter zu handhaben sind.

Der zweite Aspekt ist ein Sicherheitsaspekt: Auf einer produktiven Umgebung sollten so wenig Programme wie möglich zu finden sein. Das minimiert einerseits die Sicherheitslücken, andererseits erschwert es einem erfolgreichen Angreifer das Weiterkommen auf dem System bzw. das Ausbrechen aus einem Container. Ich möchte deshalb in diesem Artikel zeigen, wie mit Java-Bordmitteln kleine JREs erstellt werden. Und zwar sowohl für den Module- als auch für den Classpath und werde im zweiten Teil auch auf Docker eingehen. Anfangen möchte ich mit dem leichtesten Fall: dem neuen Module-Path.

JREs unter Verwendung des Module Path

Beginnen wir mit einem kleinen (wenig sinnvollen) Projekt, welches aus einer Java-Klasse besteht:

Wir möchten Maven verwenden, um das Projekt zu compilieren:

Außerdem bauen wir das Projekt zunächst als Java-Modul, was zu folgender module-info.java führt:

Der Aufruf von mvn package führt nun zu einer Datei app.jar im target-Verzeichnis. Wir können dieses Programm nun wie gewohnt starten:

Nun kommt jlink zum Einsatz, welches mit dem Erscheinen des Modulsystems viel beworben wurde: jlink ist in der Lage, eine JRE zu erstellen, die genau soviele Module enthält, wie zum Ausführen des Programms benötigt werden.

Kleiner wird die Ausgabe, wenn wir auf Man-Pages und Debug-Informationen verzichten – hier muss jedes Projekt selbst entscheiden, welche Kombination an Parameter am geeignetsten sind:

Das Ergebnis ist 36 MB groß und kann mit target/jlink1/bin/app gestartet werden. Der Befehl jimage list target/jlink1/lib/modules verrät uns, welche Klassen es in unsere JRE geschafft haben. Das ganze Verzeichnis kann nun in Richtung Produktionsumgebung gehen.

JREs unter Verwendung des Class Path

Zugegeben habe ich es mir mit diesem Beispiel sehr einfach gemacht: jlink funktioniert nur für Module und wir haben uns halt eines gebaut. In der Realität sind viele Projekte aber leider noch nicht bereit für das Modulsystem. Spring etwa öffnet beim Start explizit jar-Dateien und sucht dort nach annotierten Klassen. Im neuen JImage-Format findet es nichts. Andere Projekte sind einfach nicht in Module überführt worden. Müssen wir hier trotzdem das gesamte JDK ausliefern?
Nein! Allerdings wird es ein klein wenig komplizierter. Zunächst kann unser Programm auch ganz regulär über den Classpath gestartet werden, wir können unser Beispiel also weiterverwenden:

Nun kommt der Trick: Zwar funktioniert jlink nur mit Modulen, jedoch hindert uns nichts daran, damit zunächst eine JRE mit Modulen zu erstellen und dann unseren Classpath zu ergänzen:

Offensichtlich schlug der erste Versuch fehl. Doch zunächst zu unserem Trick: wir haben jlink verwendet, um eine JRE zu generieren, welche das Modul java.base enthält. Auf das Anlegen eines Launchers haben wir verzichtet. Dann haben wir unsere Applikation dazugelegt und das JRE verwendet, um das Programm zu starten. Statt -jar hätten wir auch mit -cp mehrere Dateien angeben können, alles funktioniert wie gewohnt. Nun fehlt in der JRE jedoch die Klasse HttpClient. Das ist logisch, denn sie liegt nicht im Modul java.base. Ein Blick in die Dokumentation (https://docs.oracle.com/en/java/javase/13/docs/api/java.net.http/java/net/http/HttpClient.html) verrät, dass die Klasse Teil des Moduls java.net.http ist. Welche Module es insgesamt gibt verrät java –list-modules. Also versuchen wir es erneut:

Na bitte! Wenn wir also wissen, welche Module unsere Anwendung verwendet, können wir eine JRE für diese Anwendung bauen – egal ob mit oder ohne Modulepath. Wenn wir es uns einfach machen wollen, können wir auf Nummer sicher gehen und zum Beispiel java.se (https://docs.oracle.com/en/java/javase/13/docs/api/java.se/module-summary.html) als benötigtes Modul angeben. Damit erhalten wir eine JRE, die der aus früheren Java-Versionen sehr ähnlich ist und alle Java-Klassen beinhaltet. Diese JRE lässt sich dann völlig unabhängig vom eigentlichen Projekt verwenden:

Das Resultat ist mit 56 MB immer noch deutlich kleiner als die 333 MB, die das JDK sonst mitbringt. Für eine Spring-Boot-Applikation mit Web-MVC werden übrigens die Module java.naming, java.desktop, java.management, java.security.jgss und java.instrument benötigt. Im Unterschied zum Module-Path ist hier leider ein bisschen Finetuning nötig. Persönlich halte ich diesen Aufwand aber für vertretbar und die Toolunterstützung gut. Letztlich muss jedes Projekt selbst die Entscheidung treffen, was ihm hier wichtig ist.

Und in Docker?

Im letzten Teil möchte ich zeigen, wie sich ein solches Projekt exemplarisch in Docker bringen lässt. Wir beginnen mit dem naiven Weg und legen ein einfaches Dockerfile an:

Das Image lässt sich einfach bauen und starten und ist mit 101 MB verhältnismäßig klein.

Unschön sind die 60 MB Overhead gegenüber der eigentlichen JRE. Diese kommen durch das Ubuntu-Image zustande. Ich würde argumentieren, dass innerhalb des Containers bei strenger Auslegung der Philosophie von Containern kein apt-get und keine bash benötigt wird. Ich möchte diesen Artikel deshalb mit einem vorsichtigen Vorschlag schließen, wie man das Image ziemlich sicher und so minimal wie möglich bauen kann.

Ein minimaler Docker-Container

Die Idee ist simpel: In der JRE liegt eigentlich alles, was man zum Ausführen der Software braucht. Allerdings hat Java selbst noch eine Abhängigkeit gegen libc und gegen die zlib. Alles andere wird nicht benötigt. Googles Distroless-Projekt (https://github.com/GoogleContainerTools/distroless) geht genau in diese Richtung. Im zuvor verwendeten Ubuntu-Image ist die libc natürlich bereits installiert. Höflicherweise kann uns das Betriebssystem sogar mitteilen, welche Dateien dies genau sind – der Installer dpkg hält diese Information bereit, um das Paket wieder deinstallieren zu können. Was also, wenn wir unsere JRE und diese Dateien in einen neuen, leeren Container kopieren? Hier der Versuch eines Dockerfiles:

Die Ausführung schlägt jedoch fehl:

Hier treten gleich zwei Probleme auf: einerseits versucht Docker den String aus der CMD-Anweisung mit der sh auszuführen. Diese haben wir nicht mitkopiert. Wenn man kein Shellscript als String sondern ein Array mit dem Programm und den einzelnen Parametern übergibt, verzichtet Docker auf den Umweg und startet den Befehl direkt. Das zweite Problem ist, dass es sich bei der Datei app selbst um ein Shell-Script handelt. Schauen wir uns den Inhalt an stellen wir fest, dass wir das Script eigentlich gar nicht brauchen und java direkt aufrufen können. Verändern wir also die zweite Hälfte des Docker-Files:

Dieser Versuch ist erfolgreich – mit einem 48 MB Docker-Image:

Natürlich könnte auch hier wieder der normale Classpath verwendet werden, die entsprechenden Kopier-Befehle und die Argumente für ENTRYPOINT und CMD müssen dann entsprechend angepasst werden. Schafft es ein Angreifer nun in den Container, hat er zumindest nicht die Möglichkeit, tcpdump ohne weiteres nachzuinstallieren oder beliebige Shellscripte auszuführen. Auch sind Updates nur noch nötig, wenn sich die libc oder die zlib ändert. Schwieriger wird jedoch das Debuggen selbst. Hierfür bieten sich aber beispielsweise Sidecar-Container an – oder man versieht die Container temporär mit Zusatzsoftware. Andererseits gibt es mit Spring-Boot-Actuator und JMX auch viele Werkzeuge, die keine weitere Software im Container selbst benötigen.

Fazit

Ich hoffe, ich konnte zeigen, dass es nicht schwierig ist, auch mit aktuellen JDKs kleine und sogar angepasste JREs zu generieren. Ich möchte dazu einladen darüber zu reflektieren, ob dies im eigenen Projekt ein sinnvoller Weg sein kann. Das Erstellen minimaler Docker-Container hat viele Vorteile, jedoch ist das Debuggen zur Laufzeit entsprechend schwerer. Hier können Projekte wie Spring-Boot-Actuator (sinnvollerweise auf einem separaten Port, der nicht nach außen freigegeben ist) Abhilfe schaffen. Dennoch ist ein Umdenken nötig. Ob dieser Weg überall sinnvoll ist, muss jeder selbst entscheiden.

Mir haben die Experimente jedenfalls viel Spaß gemacht und ich hoffe, dass der eine oder andere von diesen Erkenntnissen profitieren kann.