Bereits vor 1,5 Jahren habe ich in „Images für Java-Anwendungen bauen“ mehrere Möglichkeiten vorgestellt, um eine Java-Anwendung in ein Container-Image zu verpacken. Dort wurde am Schluss auch die, damals neue, Möglichkeit vorgestellt, Spring-Boot-Anwendungen mittels Buildpacks zu verpacken. Das neue daran waren jedoch nicht Buildpacks und deren Mechanismus, sondern die direkte Unterstützung von Spring-Boot.

Buildpacks selbst wurden zu diesem Zeitpunkt bereits seit fast 10 Jahren von Heroku für deren Platform as a Service (PaaS)-Angebot eingesetzt. Diese ermöglichten, und tun es dort bis heute, dass lediglich der push eines Git-Branches notwendig ist, um eine Anwendung zu bauen und dort zu deployen.

Durch den Siegeszug von Cloud-Plattformen und Containern und den dadurch entstandenen Druck auf interne IT-Abteilungen entwickelte sich zum gleichen Zeitpunkt ein Markt für interne PaaS-Angebote. Diese Lücke wurde durch Plattformen wie Open-Shift oder CloudFoundry besetzt. Neben der Vereinfachung vom Betrieb spielte hierbei auch die Entwicklungserfahrung eine Rolle und vielfach wurde hierzu das bereits von Heroku etablierte Modell übernommen.

2018 taten sich dann Pivotal, die Entwickler von CloudFoundry, und Heroku zusammen und gründeten unter dem Dach der Cloud Native Computing Foundation das Projekt Cloud Native Buildpacks. In diesem wird seitdem an der herstellerneutralen Spezifikation von Buildpacks und Tools rund um Buildpacks gearbeitet.

Im Folgenden sehen wir uns im Detail an, was genau Buildpacks sind und wie diese funktionieren. Außerdem schauen wir uns exemplarisch an, was wir tun müssen, um eigene Buildpacks zu erstellen.

Konzepte

In einem Satz zusammengefasst ermöglichen Buildpacks es uns, ein Container-Image aus unserem Quellcode zu erzeugen, ohne dass wir weitere Angaben, beispielsweise in Form eines Dockerfiles, machen müssen. Hierzu wird unser Quellcode inspiziert, um herauszufinden um was für eine Art von Anwendung es sich handelt und was gemacht werden muss, um für diese ein Container-Image zu erzeugen.

Für Java-Anwendungen heißt das in der Regel, ein passendes JDK bereitzustellen, den Quellcode zu kompilieren, zu paketieren und anschließend ein Container-Image mit JDK und der paketierten Anwendung zu erzeugen.

Hierzu werden die fünf Konzepte Lifecycle, Buildpack, Stack, Builder und Platform definiert und genutzt.

Lifecycle

Der für Buildpacks definierte Lifecycle besteht aktuell aus den folgenden acht Phasen:

Die Aufgabe der Analyze-Phase besteht darin, Dateien und Informationen aus vorherigen Läufen wiederherzustellen. Diese werden dann für Optimierungen innerhalb der Build- und Export-Phasen genutzt.

Die Detect-Phase ist anschließend dafür verantwortlich herauszufinden, welche Schritte in welcher Reihenfolge notwendig sind, um aus unserem Quellcode ein Container-Image zu erzeugen. Dabei entsteht ein Plan, der von den späteren Phasen ausgeführt wird.

In der Restore-Phase werden, basierend auf den in Analyze ermittelten Informationen, Layer, die in vorherigen Läufen erzeugt und gecacht wurden, wiederhergestellt und für die weiteren Phasen zur Verfügung gestellt.

Nun werden in der Build-Phase die im Plan festgehaltenen Schritte ausgeführt. Diese Schritte können neue Layer erzeugen oder Layer aus dem Cache wiederverwenden.

Innerhalb der Export-Phase wird aus den in Build erzeugten Layern das finale Container-Image erzeugt.

Die Create-Phase fasst die vorherigen Phasen Analyze, Detect, Restore, Build und Export zu einem einzelnen Kommando zusammen. Um die Anwendung auszuführen, wird die Launch-Phase genutzt.

Mittels Rebase-Phase ist es schließlich möglich, die Basis-Layer des Container-Images auf einen neueren Stand zu bringen, ohne die Anwendung erneut bauen zu müssen.

Buildpack

Ähnlich wie in Maven wird die eigentliche Arbeit nicht im Lifecycle selbst, sondern durch Plug-ins, sogenannte Buildpacks, erledigt.

Ein solches Buildpack besteht neben Metadaten innerhalb einer buildpack.toml-Datei aus den beiden ausführbaren Dateien bin/detect und bin/build. Diese werden vom Lifecycle an der passenden Stelle aufgerufen.

Die Aufgabe von bin/detect ist es dabei herauszufinden, ob dieses Buildpack für die aktuelle Anwendung benötigt wird. Außerdem können Abhängigkeiten, die von diesem Buildpack benötigt werden, definiert werden. Ein Maven-Buildpack würde hierzu beispielsweise nachschauen, ob eine Datei pom.xml existiert, und definieren, dass ein Buildpack benötigt wird, das ein JDK zur Verfügung stellt.

bin/build ist anschließend dafür verantwortlich, aus dem zur Verfügung gestellten Quellcode einen oder mehrere Layer zu erzeugen, der schlussendlich im Container-Image landet. Unser Maven-Buildpack könnte hierzu beispielsweise den Befehl mvn package ausführen und die erzeugte JAR-Datei in einen Layer packen.

Neben einem solchen konkreten Buildpack gibt es auch noch Meta-Buildpacks beziehungsweise Buildpack-Gruppen. Diese bestehen nur aus Metadaten. Innerhalb dieser werden andere Buildpacks referenziert und in eine spezifische Reihenfolge gebracht. So könnte beispielsweise ein Java-Buildpack die Buildpacks JDK und Maven referenzieren.

Stack, Builder und Platform

Der Lifecycle, und damit auch die konkreten Buildpacks, werden innerhalb eines Containers ausgeführt. Dieser basiert auf einem definierten Build-Image, welches alle benötigten Tools zur Ausführung bereitstellt.

Neben diesem Build-Image benötigten wir noch ein Container-Image, auf dem unser finales Container-Image basieren kann, das Run-Image. Da diese beiden Images miteinander harmonieren müssen, werden diese zu einem Stack zusammengefasst.

Ein solcher Stack wird nun zusammen mit mehreren Buildpacks und dem Lifecycle zu einem konkreten Builder kombiniert.

Eine Platform nutzt schlussendlich einen Builder, Lifecycle und den Quellcode, um das finale Container-Image zu erzeugen. Eine solche Platform kann dabei sowohl ein Tool wie Pack als auch eine PaaS wie Heroku sein.

Anwendung mit Buildpacks bauen

Im Folgenden bauen wir für eine Spring-Boot-Anwendung (s. Listing 1) mithilfe der Pack-Platform und des Heroku-Builders ein Container-Image. Hierzu nutzen wir den Befehl pack build spring-app --path=. --builder=heroku/buildpacks:20.

package de.mvitz.spring.container;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @RestController
    static class RootController {

        @GetMapping
        public String index() {
            return "Hello from Spring via Buildpacks!";
        }
    }
}
Listing 1: Quellcode der Beispiel-Spring-Boot-Anwendung

Pack beginnt nun damit, die benötigten Container-Images für den Build herunterzuladen. Einige Sekunden, oder Minuten, später sehen wir in der Logausgabe, Listing 2, dass der Builder erkannt hat, dass unsere Anwendung auf der JVM läuft und dass wir Maven nutzen. Außerdem ist zu sehen, dass unsere Anwendung mittels mvn -DskipTests clean install gebaut und das Container-Image spring-app erzeugt wurde.

$ pack build spring-app --path=. --builder=heroku/buildpacks:20
20: Pulling from heroku/buildpacks
...
20-cnb: Pulling from heroku/heroku
...
===> DETECTING
2 of 3 buildpacks participating
heroku/jvm 1.0.5
heroku/maven 1.0.3
===> RESTORING
...
===> BUILDING

[Installing Maven]
Selected Maven version: 3.6.2
Successfully installed Apache Maven 3.6.2

[Executing Maven]
$ mvn -DskipTests clean install
...
===> EXPORTING
...
Successfully built image spring-app
Listing 2: Build mit Pack-Platform und Heroku-Builder

Anschließend lässt sich die Anwendung mit einer beliebigen Container-Runtime, wie beispielsweise Docker, mit folgendem Befehl ausführen: docker run --rm -p8080:8080 spring-app.

Alternativ hätten wir die Anwendung auch mit den Buildpacks von Paketo erzeugen können. Wie in Listing 3 zu sehen ist, werden hier andere Buildpacks genutzt, das Ergebnis ist aber identisch. Wir erhalten ein ausführbares Container-Image mit der Anwendung.

$ pack build spring-app-paketo --path=. \
    --builder=paketobuildpacks/builder:tiny
tiny: Pulling from paketobuildpacks/builder
...
tiny-cnb: Pulling from paketobuildpacks/run
...
===> ANALYZING
Previous image with name "spring-app-paketo" not found
===> DETECTING
10 of 24 buildpacks participating
paketo-buildpacks/ca-certificates       3.4.0
paketo-buildpacks/bellsoft-liberica     9.9.0
paketo-buildpacks/syft                  1.12.0
paketo-buildpacks/maven                 6.11.0
paketo-buildpacks/executable-jar        6.5.0
paketo-buildpacks/apache-tomcat         7.8.0
paketo-buildpacks/apache-tomee          1.3.0
paketo-buildpacks/liberty               2.4.0
paketo-buildpacks/dist-zip              5.4.0
paketo-buildpacks/spring-boot           5.19.0
===> RESTORING
===> BUILDING
...
    Executing mvn --batch-mode -Dmaven.test.skip=true
        --no-transfer-progress package
...
===> EXPORTING
...
Successfully built image 'spring-app-paketo'
Listing 3: Build mit Pack-Platform und Paketo-Builder

Eigene Buildpacks schreiben

Nachdem wir den Einsatz von vorhandenen Buildpacks gesehen haben, wollen wir uns nun noch anschauen, wie eigene Buildpacks geschrieben werden können. Als Beispiel dient uns der Quellcode aus Listing 4, der in der Datei Example.java steht.

public class Example {

    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}
Listing 4: Quellcode der Beispielanwendung

Um diese Anwendung in ein Container-Image zu verpacken, schreiben wir im Folgenden zwei eigene Buildpacks. Dabei entsteht die in Listing 5 zu sehende Struktur.

$tree.
.
├── app
│ └── Example.java
└── buildpacks
  ├── example-java
  │ ├── bin
  │ │ ├── build
  │ │ └── detect
  │ └── buildpack.toml
  └── jdk
    ├── bin
    │ ├── build
    │ └── detect
    └── buildpack.toml
6 directories, 7 files
Listing 5: Struktur für unsere Buildpacks

Das JDK Buildpack ist dafür verantwortlich, ein JDK zu installieren, damit dieses anschließend zur Kompilierung und Ausführung unserer Anwendung verwendet werden kann. Hierzu teilt das Buildpack innerhalb von bin/detect (s. Listing 6) mit, indem es einen Eintrag im Buildplan erstellt, dass es ein JDK zur Verfügung stellt.

#!/usr/bin/env bash
set -euo pipefail

plan="$2"
cat >> "$plan" <<EOL
provides = [{ name = "jdk" }]
EOL
Listing 6: bin/detect des JDK Buildpacks

Die eigentliche Installation des JDKs passiert dann innerhalb von bin/build (s. Listing 7). Als Erstes wird ermittelt, ob das JDK überhaupt installiert werden muss oder ob es bereits durch einen früheren Lauf gecacht wurde. Hierzu extrahieren wir mittels yq und jq einen Eintrag aus den Layermetadaten und vergleichen diesen mit der aktuell vorhandenen URL für den JDK-Download.

#!/usr/bin/env bash
set -euo pipefail

echo "---> JDK Buildpack"

layers_dir="$1"

layer_jdk="$layers_dir/jdk"
jdk_url="https://.../openjdk-19.0.1_linux-x64_bin.tar.gz"
cached_jdk_url=$(cat "$layers_dir/jdk.toml" \
    | yj -t \
    | jq -r .metadata.jdk_url 2>/dev/null || echo 'NOT_PRESENT')

if [[ $jdk_url == $cached_jdk_url ]]; then
    echo "---> Reusing cached JDK"
else
    echo "---> Installing JDK"
    mkdir -p "$layer_jdk"
    cat > "$layers_dir/jdk.toml" << EOL
[types]
build = true
launch = true
cache = true
[metadata]
jdk_url = "$jdk_url"
EOL
    wget -q -O - "$jdk_url"
        | tar -xzf - --strip-components 1 -C "$layer_jdk"
fi
Listing 7: bin/build des JDK Buildpacks

Sollten beide Werte identisch sein, brauchen wir nichts tun, da das JDK bereits über den Cache wiederhergestellt wurde. Wurde bisher kein Cache erstellt oder wir brauchen ein neueres JDK, erstellen wir ein neues Verzeichnis jdk für unseren Layer. Zusätzlich zu diesem Verzeichnis erstellen wir auch die Metadaten für diesen Layer. In diesem Fall deklarieren wir, dass dieser Layer sowohl während des Builds als auch zur Laufzeit (launch) vorhanden sein soll. Außerdem kann dieser gecacht werden und die konkret genutzte URL für den JDK-Download merken wir uns auch. Als letztes laden wir das JDK nun noch runter und entpacken es direkt in das erstellte Verzeichnis für unseren Layer.

Nachdem wir nun ein JDK zur Verfügung haben, erstellen wir ein zweites Buildpack, um die eigentliche Anwendung zu kompilieren und ausführen zu können.

In diesem geben wir innerhalb von bin/detect (s. Listing 8) zum einen an, dass wir ein JDK benötigen, und zum anderen prüfen wir, ob es eine Datei Example.java gibt. Sollte es diese nicht geben, wird dieses Buildpack als nicht benötigt markiert und nimmt dementsprechend nicht am weiteren Build teil.

#!/usr/bin/env bash
set -euo pipefail

# Check if Example.java file exists
if [[ ! -f Example.java ]]; then
    exit 100
fi

plan="$2"
cat >> "$plan" <<EOL
requires = [{ name = "jdk" }]
EOL
Listing 8: bin/detect des Example Java Buildpacks

Fehlt noch die eigentliche Logik zum Kompilieren und Ausführen der Anwendung in bin/build (s. Listing 9).

#!/usr/bin/env bash
set -euo pipefail

echo "---> Example.java Buildpack"

layers_dir="$1"

layer_example="$layers_dir/example"

cat > "$layers_dir/example.toml" << EOL
[types]
launch = true
EOL

echo "---> Compiling sources"
javac -d "$layer_example" Example.java

cat > "$layers_dir/launch.toml" << EOL
[[processes]]
type = "worker"
command = "java -cp $layer_example Example"
default = true
EOL
Listing 9: bin/build des Example Java Buildpacks

Hier erzeugen wir zuerst den Layer example. Dieser wird als zur Laufzeit vorhanden definiert und mittels Aufrufes von javac wird die Anwendung kompiliert und die .class-Dateien in diesen Layer gelegt.

Anschließend wird ein zweiter Layer, launch, definiert. Dieser wird zur Ausführung der Anwendung genutzt. Hierzu definieren wir, dass es sich um einen Worker-Prozess handelt, welcher als Standard genutzt werden soll. Dieser Prozess wird dabei mittels des Befehls java -cp $layer_example Example gestartet.

Wir könnten hier auch noch weitere Prozesse definieren, die von unserer Anwendung unterstützt werden. Ein weiterer üblicher Prozesstyp ist beispielsweise web für Prozesse, die eine Webanwendung starten.

Wir können nun die Anwendung mit unseren Buildpacks bauen und anschließend ausführen (s. Listing 10).

$ pack build example-java \
    --path app \
    --builder=cnbs/sample-builder:bionic \
    --buildpack=buildpacks/jdk \
    --buildpack=buildpacks/example-java
bionic: Pulling from cnbs/sample-builder
...
bionic: Pulling from cnbs/sample-stack-run
...
0.14.1: Pulling from buildpacksio/lifecycle
...
===> ANALYZING
[analyzer] Previous image with name "example-java" not found
===> DETECTING
[detector] javaspektrum/examples/jdk            0.0.1
[detector] javaspektrum/examples/example-java   0.0.1
===> RESTORING
===> BUILDING
[builder] ---> JDK Buildpack
[builder] cat: /layers/javaspektrum_examples_jdk/jdk.toml:
    No such file or directory
[builder] ---> Installing JDK
[builder] ---> Example.java Buildpack
[builder] ---> Compiling sources
===> EXPORTING
[exporter] Adding layer 'javaspektrum/examples/jdk:jdk'
[exporter] Adding layer 'javaspektrum/examples/example-java:example'
[exporter] Adding layer 'launch.sbom'
[exporter] Adding 1/1 app layer(s)
[exporter] Adding layer 'launcher'
[exporter] Adding layer 'config'
[exporter] Adding layer 'process-types'
[exporter] Adding label 'io.buildpacks.lifecycle.metadata'
[exporter] Adding label 'io.buildpacks.build.metadata'
[exporter] Adding label 'io.buildpacks.project.metadata'
[exporter] Setting default process type 'worker'
[exporter] Saving example-java...
[exporter] *** Images (9c76ad621699):
[exporter]   example-java
[exporter] Adding cache layer 'javaspektrum/examples/jdk:jdk'
Successfully built image example-java

$ docker run example-java:latest
Hello, world!!
Listing 10: Bauen und ausführen des Beispiels mit unseren eigenen Buildpacks

Auch wenn das Beispiel hier Bash-Skripte nutzt, sind wir nicht darauf limitiert. So nutzt das Standard-Paketo-Java-Buildpack Go in Verbindung mit der Bibliothek packit, das CloudFoundry-Java-Buildpack nutzt Ruby und das Heroku-Java-Buildpack nutzt dann wieder Bash.

Fazit

In diesem Artikel haben wir uns mit Buildpacks einen weiteren Weg angesehen, um Anwendungen als Container-Images zu paketieren. Das Ziel von Buildpacks ist es dabei, möglichst ohne zusätzliche Definitionen, das Container-Image direkt, ohne weitere Zwischenschritte, aus dem Quellcode zu erzeugen.

Buildpacks bestehen dabei aus den fünf Teilen Lifecycle, Buildpack, Stack, Builder und Platform. Diese werden seit 2018 innerhalb der Cloud Native Computing Foundation weiterentwickelt und die gesamte Spezifikation ist somit herstellerneutral. Implementierungen dieser Spezifikationen gibt es dabei vor allem von den großen PaaS-Anbietern wie CloudFoundry, Heroku, OpenShift und noch vielen weiteren.

Neben der Nutzung von existierenden Buildpacks haben wir in diesem Artikel auch gesehen, wie wir eigene Buildpacks entwickeln können, und damit einen Teil der Magie von Buildpacks entzaubert.