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.

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:

package de.cologneintelligence.learning.jre;

import java.net.URI;
import java.net.http.*;
import static java.net.http.HttpResponse.BodyHandlers.ofLines;

public class Main {
    public static void main(String[] args) throws Exception {
        URI uri = new URI("https://www.cologne-intelligence.de");

        System.out.printf("Running in module path? %s%n",
                Main.class.getModule().isNamed() ? "yes" : "no");

        HttpClient client = HttpClient.newBuilder().build();
        HttpRequest request = HttpRequest.newBuilder().GET().uri(uri).build();
        long lines = client.send(request, ofLines()).body().count();
        System.out.printf("The website '%s' has %d lines of HTML code%n", uri, lines);
    }
}

 

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


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
  <modelVersion>4.0.0</modelVersion>

  <groupId>de.cologneintelligence.learning</groupId>
  <artifactId>jre</artifactId>
  <version>1.0-SNAPSHOT</version>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <build>
    <finalName>app</finalName>

    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <configuration>
          <release>13</release>
        </configuration>
      </plugin>

      <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.1.2</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>de.cologneintelligence.learning.jre.Main</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

 

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


module cologneintelligence.jre {
    requires java.net.http;
}

 

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:

 

$ java -p app.jar -m cologneintelligence.jre/de.cologneintelligence.learning.jre.Main
Running in module path? yes
The website 'https://www.cologne-intelligence.de' has 85 lines of HTML code

 

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.

 

$ jlink \
  -p target/app.jar \
  --add-modules cologneintelligence.jre \
  --output target/jlink1 \
  --launcher app=cologneintelligence.jre/de.cologneintelligence.learning.jre.Main

 

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:


$ jlink \
  -p target/app.jar \
  --add-modules cologneintelligence.jre \
  --output target/jlink1 \
  --launcher app=cologneintelligence.jre/de.cologneintelligence.learning.jre.Main \
  --no-header-files \
  --no-man-pages \
  --strip-debug \
  --compress=2

 

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:


$ java -jar app.jar
Running in module path? no
The website 'https://www.cologne-intelligence.de' has 85 lines of HTML code

 

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:


$ jlink \
  --add-modules java.base \
  --output target/jlink2 \
  --no-header-files \
  --no-man-pages \
  --strip-debug \
  --compress=2
$ cp target/app.jar target/jlink2/app.jar
$ target/jlink2/java -jar target/jlink2/app.jar
Running in module Path? no
Exception in thread "main" java.lang.NoClassDefFoundError: java/net/http/HttpClient
    at de.cologneintelligence.learning.jre.Main.main(Main.java:16)
Caused by: java.lang.ClassNotFoundException: java.net.http.HttpClient
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(Unknown Source)
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(Unknown Source)
    at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
    ... 1 more

 

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:


$ rm -rf target/jlink2
$ jlink \
  --add-modules java.base \
  --add-modules java.net.http \
  --output target/jlink2 \
  --no-header-files \
  --no-man-pages \
  --strip-debug \
  --compress=2
$ cp target/app.jar target/jlink2/app.jar
$ target/jlink2/java -jar target/jlink2/app.jar
Running in module path? no
The website 'https://www.cologne-intelligence.de' has 85 lines of HTML code

 

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:


$ jlink \
  --add-modules java.se \
  --output target/jlink3 \
  --no-header-files \
  --no-man-pages \
  --strip-debug \
  --compress=2

 

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:


FROM ubuntu:latest

COPY target/jlink1 /app
CMD /app/bin/app

 

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

 

$ docker build -t test .
Sending build context to Docker daemon  131.9MB
Step 1/3 : FROM ubuntu:latest
 ---> cf0f3ca922e0
Step 2/3 : COPY target/jlink1 /app
 ---> 1105c7ca04cb
Step 3/3 : CMD /app/bin/app
 ---> Running in 517a84b22477
Removing intermediate container 517a84b22477
 ---> 1bd40086a3b9
Successfully built 1bd40086a3b9
Successfully tagged test:latest
$ docker run --rm -it test
Running in module path? yes
The website 'https://www.cologne-intelligence.de' has 85 lines of HTML code

 

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:


FROM ubuntu:latest AS builder

RUN mkdir /target; \
    for lib in /var/lib/dpkg/info/libc6:amd64.list /var/lib/dpkg/info/zlib1g:amd64.list ; do \
    while IFS='' read -r file; do \
      if [ -f "$file" ]; then \
        dir="/target/$(dirname $file)"; \
        mkdir -p "$dir"; \
        cp -d --preserve=all "$file" "$dir"; \
      fi; \
    done < "$lib"; \
  done; \
  rm -rf /target/usr/share/doc /target/usr/share/lintian

FROM scratch

COPY --from=builder /target /
COPY target/run3 /app

USER 65534
CMD "/app/bin/app"

 

Die Ausführung schlägt jedoch fehl:


$ docker run --rm -it test
docker: Error response from daemon: OCI runtime create failed: container_linux.go:346: starting container process caused "exec: \"/bin/sh\": stat /bin/sh: no such file or directory": unknown.

 

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:


FROM scratch

COPY --from=builder /target /
COPY target/run3 /app

USER 65534
ENTRYPOINT [ "/app/bin/java" ]
CMD [ "-m", "cologneintelligence.jre/de.cologneintelligence.learning.jre.Main" ]

 

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


$ docker run --rm -it test2
Running in module path? yes
The website 'https://www.cologne-intelligence.de' has 85 lines of HTML code

 

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.