Container-Images: Abschied vom Dockerfile

Cloud Native Buildpacks, Paketo.io und Spring Boot Layered Jars machen das manuelle Schreiben und Pflegen von Dockerfiles in der Praxis obsolet.

In Pocket speichern vorlesen Druckansicht 87 Kommentare lesen
Abschied vom Dockerfile

(Bild: Shutterstock)

Lesezeit: 14 Min.
Von
  • Jonas Hecht
Inhaltsverzeichnis

Viele Entwicklerinnen und Entwickler erinnern sich sicherlich an das Glücksgefühl bei den ersten Gehversuchen mit Docker: Endlich ließen sich alle Abhängigkeiten eigener Anwendungen in Code gießen. Ohne Docker fiel das Dependency-Management oft schwer, da eigene Software in der Regel doch von mehr Dingen abhängt, als es auf den ersten Blick erscheint. So ist eine Java-Anwendung direkt an eine spezifische Version der Java Virtual Machine (JVM) gebunden, und die JVM wiederum basiert auf einer speziellen Betriebssystemversion und so weiter.

Mit Docker – und insbesondere dem Beschreibungsformat der Dockerfiles – lassen sich nun alle diese Abhängigkeiten festhalten, um jederzeit Klarheit darüber zu haben, ob ein Docker-Container auch so in Produktion deployt wurde, wie in der Entwicklung vorgesehen, und ob sich die Ausführungsumgebungen in Produktion sowie Entwicklungsphase auch entsprechen. Wo also liegt das Problem mit selbstgeschriebenen Dockerfiles?

Um das zu verstehen, gilt es, die Welt durch die Betriebsbrille zu betrachten. Am Anfang des Docker-Hypes haben sich die Verantwortlichen in vielen Projekten sicherlich nicht allzu viele Gedanken über den späteren Produktivbetrieb gemacht. Allerdings ist beispielsweise das Härten von Docker-Images für die Produktion keine "Nebensache" – es rächt sich häufig, diesen Punkt außer Acht gelassen zu haben. Denn hierfür sind meist umfangreiche Anpassungen an den Dockerfiles nötig. Doch im Entwicklungsalltag fällt dieser Punkt allzu oft unter den Tisch. Schon allein aus Budgetgründen wird diesem Thema meist wenig Zeit und Aufmerksamkeit im Entwicklungsprojekt eingeräumt – wenn das Thema denn überhaupt bekannt ist.

Der zweite Teil des Problems zeigt sich meist in der Nutzung der Docker-Container innerhalb von Continuous-Integration-Pipelines (CI). Auch hier lohnt ein tieferer Blick in den Aufbau der Dockerfiles. Als anschauliches Beispiel lässt sich eine Anwendung auf Basis von Spring Boot heranziehen. In der ursprünglichen Dokumentation auf spring.io zur "Dockerisierung" von Spring Boot-Anwendungen war das Dockerfile einfach aufgebaut: Als Basis diente ein OpenJDK-Docker-Image, dem die bereits gebaute ausführbare jar-Datei hinzugefügt und ein ENTRYPOINT definiert wurde. Mit ihm sollte die Spring-Boot-Anwendung genauso starten, wie das auch ohne Docker auf der Kommandozeile per java -jar-Befehl üblich ist.

Dieser Ansatz klingt nach einer guten Idee. Allerdings bleiben dabei viele Aspekte unberücksichtigt, die dem Projekt in der CI-Pipeline oder später im Betrieb auf die Füße fallen können. Um Sicherheitsrisiken zu reduzieren, sollte beispielsweise der Start der Anwendung im Docker-Container nicht mit dem root-Nutzer erfolgen. Auch ist es keine gute Idee, die als sogenanntes Fat Jar verpackte Anwendung innerhalb des Containers eins zu eins zu verwenden. Denn darin enthalten sind Teile der Anwendung, die seltener Änderungen unterworfen sind als andere. Beispielsweise wird sich der Anwendungscode deutlich häufiger ändern (bis zu mehrmals täglich).

Die genutzte Spring-Boot-Version dagegen ändert sich nur, wenn das Projekt auf eine neuere Version aktualisiert wird (also vielleicht nur ein paar Mal pro Monat). Auch die Version des Datenbank-Frameworks wird sich typischerweise nicht allzu oft ändern. Diese sich verschieden oft ändernden Teile der Anwendung sollten Entwicklerinnen und Entwickler jeweils separat behandeln und idealerweise in separaten Docker-Image-Layern unterbringen. So lassen sich in der Regel auch die Durchlaufzeiten der CI-Pipelines stark beschleunigen.

Im Rahmen dieses Beitrags lassen sich jedoch nur Teilaspekte des Problems der manuellen Pflege von Dockerfiles darstellen. In realen Projekten zwingen diese Themen verantwortliche Entwickler immer wieder dazu, Problemlösungen zu erarbeiten, die aus Sicht der Auftraggeber keinerlei Nutzen erkennen lassen. Die Probleme zu ignorieren, ist hingegen auch keine sinnvolle Option, denn das führt häufig nur zu lang laufenden CI-Pipelines und Security-Problemen, die Entwicklungsteams am Ende massiv ausbremsen oder gar in unkalkulierbare Risiken laufen lassen.

Um das manuelle Schreiben und Pflegen von Dockerfiles überflüssig zu machen, stehen mittlerweile eine Reihe von Werkzeugen parat. Dazu gehören unter anderen spotify/dockerfile-maven, Googles jib oder Red Hats source-to-image (s2i) aus dem OpenShift-Umfeld. Darüber hinaus lässt sich auch mit einem Configuration-Management-Werkzeug wie Ansible in Kombination mit automatischen Dependency-Update-Tools wie Renovate die Pflege automatisieren. Eine Standardvorgehensweise hat sich bisher aber nicht herauskristallisiert. In der Praxis werden Dockerfiles weiterhin meist manuell erstellt.

Mit der Vorstellung der Cloud Native Buildpacks (CNB) und Paketo.io sowie deren Integration in Spring Boot kündigt sich jedoch eine Zeitenwende im Containermarkt an. Ursprünglich 2011 von Heroku eingeführt, findet sich das Konzept der Buildpacks heute in vielen PaaS- oder CI-Pipeline-Werkzeugen – darunter Google App Engine, CloudFoundry, GitLab, Knative und Deis. Nach dem Zusammenschluss von Heroku und Pivotal 2018 übergaben beide das Cloud Native Buildpacks-Projekt in die "Sandbox" der Cloud Native Computing Foundation (CNCF).

Die Cloud Native Buildpacks Specification v3 lässt sich in jedem beliebigen Projekt als eine Art Interface für ein Buildpack-konformes Tooling nutzen. Praktischerweise hat das CloudFoundry Buildpack Engineering Team mit Paketo.io auch gleich eine Implementierung der Spezifikation veröffentlicht, die auf der langjährigen Erfahrung mit der Nutzung des Buildpack-Konzepts in CloudFoundry basiert.

Seit das Technical Oversight Commitee (TOC) der CNCF im November 2020 CNBs von der "Sandbox" in den Status "Incubating" befördert hat, scheint sich damit ein Standard zur Abschaffung der manuellen Pflege von Dockerfiles zu etablieren, auf den sich ein genauerer Blick lohnt.

Das auf der Website prangende Versprechen des Cloud-Native-Buildpacks-Projekts lässt aufhorchen:

(Bild: Buildpacks.io)

"Transform your application source code into [container] images that can run on any cloud."

Entwicklerinnen und Entwickler sollen mit dem Werkzeug beliebigen Anwendungscode zu Container-Images zusammenbauen können, die zudem in jeder Cloud-Umgebung lauffähig sind. Tatsächlich scheinen Cloud Native Buildpacks dieser Herausforderung gewachsen. Die Übersicht der Features macht deutlich, dass alle mit der manuellen Pflege von Dockerfiles verbundenen Probleme damit der Vergangenheit angehören. Darüber hinaus werden viele weitere Themen zum Erstellen zeitgemäßer Container-Images auf Basis des OCI-Image-Formats adressiert. So kommen Nutzer in den Genuss von Optimierungen, die sie nur mit hohem Aufwand im eigenen Projekt hätten implementieren können.

Ein zentraler Vorteil der Cloud Native Buildpacks ist die Unterstützung des sogenannten Rebasing bereits gebauter Container-Images. Damit lassen sich neuere Basis-Images, die beispielsweise aktuelle Patches und Updates auf Betriebssystemebene mitbringen, automatisch auf existierende Application-Images anwenden. Und das, ohne sie neu bauen zu müssen – in großen Microservices-Architekturen ist das ein Killer-Feature. Denn das kontinuierliche Updaten vieler Container ist sonst eine Mammutaufgabe, für die es aber kaum Alternativen gibt.

Neben dem Rebasing kümmert sich die Buildpacks-Implementierung von Paketo aber auch um weitere Aspekte wie erweitertes Caching, minimale Applikations-Images sowie den strikten Fokus auf Reproduzierbarkeit – genauso, wie es die Spezifikation der Cloud Native Buildpacks vorschreibt. Auch sind standardmäßig die Build- von den Runtime-Images entkoppelt. Damit werden die eigenen Container-Images fit für den Einsatz in Produktion und in Cloud-nativen Umgebungen wie Kubernetes. Der größte Vorteil der Nutzung von Buildpacks ist aber, dass man sich mit keinem dieser Themen als Entwickler im Detail beschäftigen muss. Alles wird "out of the box" mitgeliefert.

Paketo unterstützt eine Vielzahl von Programmiersprachen – darunter .NET Core, Go, Node.js, Java, Ruby und PHP, und immer neue kommen hinzu. Spezielle Buildpacks für weitverbreitete Frameworks zu diesen Sprachen liegen ebenfalls vor, beispielsweise für Spring Boot. Sollte ein Framework noch fehlen, lässt sich dennoch mit einer eigenen Implementierung starten. Es dürfte nur eine Frage der Zeit sein, bis das entsprechende Buildpack zur Verfügung steht.

Nachdem die Cloud Native Computing Foundation das Buildpacks-Projekt in den Status "CNCF incubating" befördert hat, stehen alle relevanten Cloud-Anbieter im Zugzwang, die Unterstützung für Cloud Native Buildpacks anzukündigen. Google hatte Unterstützung innerhalb der Google Cloud Platform im Oktober 2020 öffentlich gemacht. Azure bietet die "Preview"-Unterstützung für Cloud Native Buildpacks sogar bereits seit Oktober 2019 an.

Wie können Entwicklerinnen und Entwickler Cloud Native Buildpacks in eigenen Projekten nutzen? Liegt der Fokus auf JVM-basierten Sprachen, eröffnet sich eine breite Palette spezifischer Buildpacks. Ausgehend vom Buildpack für Gradle über das Scala SBT Buildpack bis zum Maven Buildpack sind alle verbreiteten Build-Tools abgedeckt. Darüber hinaus findet sich ein spezifisches für ausführbare Jar-Dateien sowie für Anwendungen, die auf Apache Tomcat basieren. Zudem lassen sich mehrere Buildpacks auch für ein und dieselbe Anwendung gleichzeitig nutzen.

Anhand von Spring Boot lässt sich eine noch stärkere Integration der Buildpacks in einen Anwendungs-Stack zeigen. Innerhalb dieses Frameworks gestaltet sich die Integration von Paketo.io in den üblichen Build-Prozess mit Maven oder Gradle für den Nutzer vollständig transparent, wie das beispielhafte Erstellen einer einfachen Spring-Boot-Anwendung über start.spring.io verdeutlicht (s. Abb. 1).

Eine Spring Boot-Beispielanwendung ist mithilfe von start.spring.io schnell erstellt (Abb. 1).

Alternativ lässt sich auch eine bestehende Spring-Boot-Anwendung nutzen, so lange eine aktuelle Spring Boot-Version zum Einsatz kommt – im Rahmen dieses Artikels ist es Version 2.4.4. Für das spätere vereinfachte Testen der Anwendung im Container kann zudem das Implementieren eines simplen REST-Service sinnvoll sein. Der im Folgenden verwendete Code liegt in einem Repository des Autors auf GitHub frei zugänglich bereit.

Für Spring-Boot-Nutzer ist die Integration der Buildpacks bereits abgeschlossen, da aktuelle Versionen direkt auf Paketo.io aufsetzen. Ist in der verwendeten Umgebung eine aktuelle Docker-Installation vorhanden, lässt sich der Maven-Build direkt mit dem Befehl mvn spring-boot:build-image starten. Das neue Maven-Goal build-image wurde extra für die Integration der Cloud Native Buildpacks eingeführt. Gradle-Nutzer verwenden den Befehl gradle bootBuildImage. Ist der Build gestartet, erscheint im Build-Log die neue Phase build-image. Zugunsten der Lesbarkeit sind im Folgenden die Details leicht gekürzt:

$ mvn spring-boot:build-image
...
[INFO] --- spring-boot-maven-plugin:2.3.7.RELEASE:build-image (default-cli) @ spring-boot-buildpack ---
[INFO] Building image 'docker.io/library/spring-boot-buildpack:0.0.1-SNAPSHOT'
[INFO]
[INFO]  > Pulling builder image 'gcr.io/paketo-buildpacks/builder:base-platform-api-0.3' 100%
[INFO]  > Pulled builder image 'gcr.io/paketo-buildpacks/builder@sha256:2b3d585ed785ea2e4ecc89c35512c54f8d339f4ca09c1d445c51077ebe21cfaf'
[INFO]  > Pulling run image 'docker.io/paketobuildpacks/run:base-cnb' 100%
[INFO]  > Pulled run image 'paketobuildpacks/run@sha256:33d37fc9ba16e220f071805eaeed881a508ceee5c8909db5710aaed7e97e4fc2'
[INFO]  > Executing lifecycle version v0.10.2
[INFO]  > Using build cache volume 'pack-cache-604f3372716a.build'
[INFO]
[INFO]  > Running creator
[INFO]     [creator]     ===> DETECTING
[INFO]     [creator]     5 of 18 buildpacks participating
[INFO]     [creator]     paketo-buildpacks/ca-certificates   2.1.0
[INFO]     [creator]     paketo-buildpacks/bellsoft-liberica 7.1.0
[INFO]     [creator]     paketo-buildpacks/executable-jar    5.0.0
[INFO]     [creator]     paketo-buildpacks/dist-zip          4.0.0
[INFO]     [creator]     paketo-buildpacks/spring-boot       4.1.0
[INFO]     [creator]     ===> ANALYZING
[INFO]     [creator]     Restoring metadata for "paketo-buildpacks/ca-certificates:helper" from app image
...
[INFO]     [creator]     ===> RESTORING
[INFO]     [creator]     ===> BUILDING
[INFO]     [creator]
[INFO]     [creator]     Paketo CA Certificates Buildpack 2.1.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/ca-certificates
[INFO]     [creator]
[INFO]     [creator]     Paketo BellSoft Liberica Buildpack 7.1.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/bellsoft-liberica
...
[INFO]     [creator]     Paketo Executable JAR Buildpack 5.0.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/executable-jar
...
[INFO]     [creator]     Paketo Spring Boot Buildpack 4.1.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/spring-boot
[INFO]     [creator]       Launch Helper: Reusing cached layer
[INFO]     [creator]       Web Application Type: Reusing cached layer
[INFO]     [creator]       Spring Cloud Bindings 1.7.0: Reusing cached layer
[INFO]     [creator]       Image labels:
[INFO]     [creator]         org.opencontainers.image.title
[INFO]     [creator]         org.opencontainers.image.version
[INFO]     [creator]         org.springframework.boot.spring-configuration-metadata.json
[INFO]     [creator]         org.springframework.boot.version
[INFO]     [creator]     ===> EXPORTING
...
[INFO]     [creator]     Reusing layer 'paketo-buildpacks/spring-boot:helper'
[INFO]     [creator]     Reusing layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
[INFO]     [creator]     Reusing layer 'paketo-buildpacks/spring-boot:web-application-type'
[INFO]     [creator]     Reusing 1/1 app layer(s)
[INFO]     [creator]     Reusing layer 'launcher'
[INFO]     [creator]     Reusing layer 'config'
[INFO]     [creator]     Adding label 'io.buildpacks.lifecycle.metadata'
[INFO]     [creator]     Adding label 'io.buildpacks.build.metadata'
[INFO]     [creator]     Adding label 'io.buildpacks.project.metadata'
[INFO]     [creator]     Adding label 'org.opencontainers.image.title'
[INFO]     [creator]     Adding label 'org.opencontainers.image.version'
[INFO]     [creator]     Adding label 'org.springframework.boot.spring-configuration-metadata.json'
[INFO]     [creator]     Adding label 'org.springframework.boot.version'
[INFO]     [creator]     *** Images (d831d6a66f8e):
[INFO]     [creator]           docker.io/library/spring-boot-buildpack:0.0.1-SNAPSHOT
[INFO]
[INFO] Successfully built image 'docker.io/library/spring-boot-buildpack:0.0.1-SNAPSHOT'
...

Um den Ablauf eines vollständigen Builds einer Spring-Boot-Anwendung mit Cloud Native Buildpacks und Paketo.io nachvollziehbar zu machen, steht ein Asciicast bereit. Nach anfänglichem Herunterladen einiger Images startet der Build in eine mit creator gekennzeichnete Phase. Die vorliegende Anwendung wird in den Abschnitten DETECTING und ANALYZING analysiert und anhand der Ergebnisse passende Buildpacks für die Anwendung identifiziert, die ihr später zu einem lauffähigen Container-Image verhelfen. Im vorliegenden Fall kommen fünf von 18 Buildpacks infrage. Das Buildpack paketo-buildpacks/bellsoft-liberica:jre bringt beispielsweise die Java-Runtime mit und paketo-buildpacks/executable-jar sorgt für die spätere Ausführbarkeit der Spring-Boot-Anwendung. Die Paketo-Dokumentation zu Spring Boot bietet einen detaillierten Einblick in den Build-Prozess.

Sobald das Build-Log einen Output wie Successfully built image 'docker.io/library/spring-boot-buildpack:0.0.1-SNAPSHOT enthält, steht lokal auch schon ein Container-Image zur Nutzung bereit. Mit dem zusätzlichen Parameter -Dspring-boot.build-image.publish=true lässt sich das Image sofort in jede Registry pushen. Die notwendige Konfiguration der Credentials erfolgt innerhalb des spring-boot-maven-plugin. Damit steht alles bereit, um die Beispielanwendung im Container auszuführen. Der folgende Befehl startet den Container lokal und bietet über ein simples Port-Binding Zugriff auf den REST-Service:

docker run -p 8080:8080 spring-boot-buildpack:0.0.1-SNAPSHOT

Im Browser lässt sich die Anwendung nun unter der Adresse http://localhost:8080 aufrufen.

Um mehr Einblick in die vom Build-Prozess erzeugten Container-Images zu bekommen, bietet sich das unscheinbare Kommandozeilen-Werkzeug dive an. Es kommt recht einfach daher, hat es aber faustdick hinter den Ohren. Unter macOS ist es schnell per brew install dive installiert (Anleitungen für andere Betriebssysteme finden sich auf GitHub). Um die effiziente Nutzung des Tools sicherzustellen, sollten Entwicklerinnen und Entwickler vor dem ersten Öffnen die Konfigurationsdatei .dive.yaml im Home-Verzeichnis des Nutzers mit dem folgenden Inhalt anlegen:

diff:
  # You can change the default files shown in the filetree (right pane). All diff types are shown by default.
  hide:
    - unmodified

filetree:
  # Show the file attributes next to the filetree
  show-attributes: false

Mit dieser Konfiguration startet dive immer so, dass Datei-Attribute ausgeblendet und nur die pro Layer hinzugefügten Dateien in der Detailansicht zu sehen sind. Diese Einstellungen verschaffen einen deutlich besseren Einblick in die Details der Container-Images und helfen, sich mit dem Tool schnell vertraut zu machen. In den meisten Fällen sollten sie ausreichen, die dive-Dokumentation hält aber noch mehr Möglichkeiten parat.

Für den Einsatz von dive müssen Nutzer nur die Image-ID des gerade per Maven oder Gradle gebauten Container-Image aus dem Build-Log fischen. Die entscheidende Zeile sieht etwa so aus: [creator] *** Images (408f3d59f38e):. Mit der Image-ID 408f3d59f38e lässt sich dann in die Tiefen des Container-Image abtauchen:

dive 408f3d59f38e

Nach Ausführung des Befehls füllt sich die gesamte Terminal-Ansicht mit den durch den Build-Prozess und Paketo erstellten Layern des Container-Images (s. Abb. 2.).

Das Kommandozeilen-Werkzeug dive gibt Einblicke in die Layer des Container-Image (Abb. 2).

Die Maven- und Gradle-Plug-ins bilden lediglich eine Art Wrapper um den eigentlichen Paketo-Build. Daher lassen sich Paketo-Builds auch direkt ausführen. Dafür steht das wichtige Werkzeug pack CLI zur Verfügung. Es stellt auch die gängige Methode dar, Projekte auf Basis anderer Sprachen mit Cloud Native Buildpacks zu Containern zu bauen. Die Installation pack CLI gelingt unter macOS mit dem Befehl brew install buildpacks/tap/pack. Um das Tool auch auf anderen Betriebssystemen lokal nutzen zu können, finden sich passende Anleitungen in der Dokumentation.

Sobald pack CLI installiert ist, verschafft der Befehl pack builder suggest einen Überblick über die verfügbaren Buildpack-Builder, die bereits die Cloud Native Buildpack Specification implementieren:

$ pack builder suggest

Suggested builders:
	Google:                gcr.io/buildpacks/builder:v1      Ubuntu 18 base image with buildpacks for .NET, Go, Java, Node.js, and Python
	Heroku:                heroku/buildpacks:18              heroku-18 base image with buildpacks for Ruby, Java, Node.js, Python, Golang, & PHP
	Paketo Buildpacks:     paketobuildpacks/builder:base     Ubuntu bionic base image with buildpacks for Java, NodeJS and Golang
	Paketo Buildpacks:     paketobuildpacks/builder:full     Ubuntu bionic base image with buildpacks for Java, .NET, NodeJS, Golang, PHP, HTTPD and NGINX
	Paketo Buildpacks:     paketobuildpacks/builder:tiny     Tiny base image (bionic build image, distroless run image) with buildpacks for Golang

Tip: Learn more about a specific builder with:
	pack inspect-builder <builder-image>

Ein Default-Builder lässt sich mithilfe des Befehls pack set-default-builder festlegen. Aber auch ohne diesen Standard lässt sich eine Spring-Boot-Anwendung mit dem gleichen "pack CLI"-Befehl bauen, wie ihn das Maven- oder Gradle-Plug-in ausführen würden:

pack build spring-boot-buildpack --path . --builder paketobuildpacks/builder:base

Nichts anderes löst auch mvn spring-boot:build-image aus. Dafür leuchten nun aber in der Kommandozeile deutlich schönere Farben. Auch hier lohnt der Blick in ein entsprechendes Asciicast, das die Ausführung des Befehls im Video zeigt.

Sobald die Logausgabe etwas wie Successfully built image spring-boot-buildpack ausspuckt, ist der Build mit pack CLI erfolgreich abgeschlossen – und das ganz ohne eigenes Dockerfile.

Die Paketo Buildpacks lassen sich umfassend konfigurieren. Die Version des Java Development Kit (JDK) oder auch eine spezielle Maven-Einstellung lassen sich einfach per Umgebungsvariablen festlegen. Darüber hinaus ist es möglich, die Datei buildpack.yml innerhalb des Wurzelverzeichnisses des eigenen Projekts zu nutzen, um darin die gleichen Konfigurationen vorzunehmen, die auch mit Umgebungsvariablen möglich sind.

Credentials, die beispielsweise für private Binär-Repositories oder auch Application Performance Management Server nötig sind, lassen sich über sogenannte Bindings ebenfalls den Buildpacks bekannt machen und sicher in die Images übernehmen. Bindings decken zudem auch seltenere Anforderungen ab, wie das Konfigurieren eigener Download-URIs, wenn ein Zugriff aus abgeschotteten Umgebungen nicht möglich ist. Will beispielsweise das für die Bereitstellung des JDKs genutzte Buildpack bellsoft-liberica eigentlich gern die Zip-Datei des JDK von GitHub herunterladen, scheitert es regelmäßig an Zugriffsbeschränkungen.

Zu Irritationen könnten die Altersangaben von Container-Images führen, die auftauchen, wenn Entwicklerinnen und Entwickler den Paketo-Build völlig unverändert mit dem Befehl docker images ausführen. Dann erscheinen Images, die angeblich 40 Jahre alt sind:

$ docker images
...
paketobuildpacks/builder                  <none>                  914aba170326        40 years ago        654MB
pack.local/builder/axczkudrjk             latest                  69aeed7ad644        40 years ago        654MB
spring-boot-buildpack                     latest                  b529a37599a6        40 years ago        259MB
paketobuildpacks/builder                  base                    1435430a71b7        40 years ago        558MB

Dieses Phänomen ist von den Paketo-Entwicklern allerdings beabsichtigt. Die fixen Zeitstempel sind notwendig, um hundertprozentig nachvollziehbare Builds zu erstellen. Ohne sie würden sich die Hashes der Docker-Images nach jedem Build ändern, selbst wenn der eigentliche Inhalt des Containers unverändert bleibt. Auch Googles jib nutzt das gleiche Konzept. Detailliertere Informationen zu dem Thema bietet ein Blogartikel.

Die wichtigsten Aspekte der Cloud Native Buildpacks, die den Einsatz von Dockerfile obsolet machen, sind damit behandelt. Beim Blick auf Spring Boot fällt aber ein noch recht neues Feature auf, das Erstellen von Containern betreffend: das Layered-Jars-Feature. Wie die Cloud Native Buildpacks zielt es auf eine möglichst effiziente Nutzung von Containern ab.

Um die Vorteile beider Welten sinnvoll miteinander zu integrieren, lohnt sich vorab ein genauerer Blick auf das in Spring Boot 2.3.x vorgestellte "layered jars"-Feature. Nach dem Entpacken der jar-Datei innerhalb des target-Verzeichnisses des Beispielprojekts erhält man Einblick in das Standard-Layout einer Spring-Boot-jar-Datei. Dazu muss vorab der Maven- oder Gradle-Build einmal erfolgreich durchgelaufen sein. Im Beispielprojekt sieht der Befehl zum Entpacken etwa so aus:

unzip target/spring-boot-buildpack-0.0.1-SNAPSHOT.jar -d target/extractedjar

Die jar-Datei enthält drei Verzeichnisse: BOOT-INF, META-INF und org (s. Abb.3). Der gesamte Anwendungscode in Form von class- und Konfigurations-Dateien befindet sich in BOOT-INF/classes. Das benachbarte Verzeichnis BOOT-INF/lib enthält alle abhängigen Pakete und Libraries. Egal ob in der SNAPSHOT- oder der jeweiligen Release-Version. Innerhalb von META-INF finden sich noch ergänzende Meta-Informationen zum Projekt. Zuletzt umfasst das Verzeichnis org/springframework/boot/loader alle notwendigen Klassen, die für den gewohnten Start einer Spring-Boot-Anwendung per java -jar-Befehl sorgen. All das ist bereits seit vielen Spring-Boot-Versionen unverändert.

Das Standard-Layout einer Spring Boot jar-Datei im Dateiexplorer (Abb. 3).

Seit Spring-Boot-Version 2.3.x lässt sich im spring-boot-maven-plugin explizit das neue „layered jars“-Feature aktivieren (in den Versionen ab 2.4.x ist es bereits die Default-Einstellung), über die folgende Konfiguration in der pom.xml:

<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<layers>
						<enabled>true</enabled>
					</layers>
				</configuration>
			</plugin>
		</plugins>
	</build>

Anschließend sollte per mvn clean package ein frischer Maven-Build gestartet werden. Nach dessen erfolgreichen Durchlauf lässt sich die gebaute Datei target/spring-boot-buildpack-0.0.1-SNAPSHOT.jar entpacken. Im Verzeichnis BOOT-INF enthält sie die Datei layers.idx, mit folgendem Inhalt:

- "dependencies":
  - "BOOT-INF/lib/"
- "spring-boot-loader":
  - "org/"
- "snapshot-dependencies":
- "application":
  - "BOOT-INF/classes/"
  - "BOOT-INF/classpath.idx"
  - "BOOT-INF/layers.idx"
  - "META-INF/"

Die Datei layers.idx dient als eine Art Blaupause für den Aufbau von Container-Images. Dabei werden verschiedene (Unter-)Verzeichnisse innerhalb des Spring-Boot-jars auf getrennte Container-Layer gemappt. Zusätzlich definiert die layers.idx eine Reihenfolge für die Container-Layer. Der erste Layer wird mit Dependencies deklariert und umfasst alle Releases aller Abhängigkeiten der Anwendung. Dieser Layer ändert sich in der Anwendungsentwicklung typischerweise nur selten. Der zweite Layer spring-boot-loader enthält alle notwendigen Klassen zum Starten der Spring-Boot-Anwendung. Erst im dritten Layer folgen dann die SNAPSHOT-Abhängigkeiten in snapshot-dependencies. Diese ändern sich innerhalb des Entwicklungsprozesses einer Anwendung in der Regel häufiger. Im letzten Layer application finden sich alle Dateien, die Entwicklerinnen und Entwickler selbst beisteuern – er weist daher auch in der Regel die meisten Änderungen auf. Denn jeder vom Entwicklungsteam in eine Continuous-Integration-Umgebung gepushte Commit führt ja zu neuen Container-Versionen. Demgegenüber kommt das Ändern beispielsweise der Hibernate- oder der Spring-Version deutlich seltener vor.

Um die Layer auch ohne wiederholtes Entpacken der jar-Dateien sichtbar zu machen, steht eine zusätzliche Erweiterung für die Kommandozeilenparameter des java -jar-Befehls bereit. Der Parameter -Djarmode=layertools offenbart die Details der Layer-Konfiguration:

$ java -Djarmode=layertools -jar target/spring-boot-buildpack-0.0.1-SNAPSHOT.jar list

dependencies
spring-boot-loader
snapshot-dependencies
application

Alle Layer sind dabei in der korrekten Reihenfolge aufgelistet. Neben der list- steht die extract-Funktion bereit, um die Inhalte sauber nach Layern getrennt zu extrahieren:

$ java -Djarmode=layertools -jar spring-boot-buildpack-0.0.1-SNAPSHOT.jar extract --destination extractedjar

Nach dem Ausführen des dargestellten Befehls stehen im Verzeichnis target/extractedjar alle Layer aufgeteilt in einzelne Verzeichnisse zur Verfügung (s. Abb. 4).

Alle Layer einer Spring Boot-Anwendung lassen sich als separate Verzeichnisse extrahieren (Abb. 4).

Damit die extrahierten Verzeichnisse jeweils einen eigenen Container-Layer repräsentieren, lässt sich ein simpler COPY-Befehl innerhalb eines Dockerfile nutzen. Wie in einem Spring-Blogbeitrag beschrieben, lässt sich zudem der neue Parameter -Djarmode=layertools ebenfalls innerhalb eines Dockerfile verwenden. Im folgenden Dockerfile dient der Parameter zum Extrahieren der Layer in einem Build-Container eines Docker-Multistage-Builds. Im zweiten Container werden dann die extrahierten Verzeichnisse per COPY-Befehl genutzt, um jeweils eigene Layer zu erzeugen:

FROM adoptopenjdk:11-jre-hotspot as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM adoptopenjdk:11-jre-hotspot
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Der Build lässt sich auch mit dem Beispielprojekt auf GitHub, das die Datei DockerfileThatsNotNeededUsingBuildpacks enthält, ausführen:

docker build . --tag spring-boot-layered --file DockerfileThatsNotNeededUsingBuildpack

Die Anlage der separaten Layer lässt sich in der Log-Ausgabe nachvollziehen:

...
Step 8/12 : COPY --from=builder application/dependencies/ ./
 ---> 88bb8adaaca6
Step 9/12 : COPY --from=builder application/spring-boot-loader/ ./
 ---> 3922891db128
Step 10/12 : COPY --from=builder application/snapshot-dependencies/ ./
 ---> f139bcf5babb
Step 11/12 : COPY --from=builder application/application/ ./
 ---> 5d02393d4fe2
...

Beim Untersuchen des fertigen Container-Image mit dem Kommandozeilenwerkzeugs dive und dem Befehl dive spring-boot-layered lassen sich die Layer der Spring-Boot-Anwendung sichtbar machen. Sie erscheinen genauso, wie innerhalb der layers.idx definiert und wie im Dockerfile angewandt (s. Abb. 5).

Mit dive lassen sich die Layer der Spring Boot-Anwendung sichtbar machen (Abb. 5).

Idealerweise sollen Cloud Native Buildpacks das manuelle Schreiben von Dockerfiles unnötig machen, und beide Welten miteinander integrierbar sein. Beim Einsatz von Spring Boot 2.3.x genügt es dazu, das layered jars-Feature innerhalb der pom.xml aktiviert zu lassen (siehe Abschnitt "Layered Jars mit Spring Boot"). Ab Spring Boot 2.4.x ist das Feature bereits standardmäßig aktiv. Dann bedarf es lediglich eines neuerlichen Maven-Build mit mvn spring-boot:build-image. Alternativ kann pack CLI zum Einsatz kommen. Das Build-Log sollte in beiden Fällen einen Eintrag "Creating slices from layers index" im Abschnitt "Paketo Spring Boot Buildpack" enthalten:

$ mvn spring-boot:build-image
...
[INFO]     [creator]     Paketo Spring Boot Buildpack 3.5.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/spring-boot
[INFO]     [creator]       Creating slices from layers index
[INFO]     [creator]         dependencies
[INFO]     [creator]         spring-boot-loader
[INFO]     [creator]         snapshot-dependencies
[INFO]     [creator]         application
[INFO]     [creator]       Launch Helper: Reusing cached layer
...

Nach einem erfolgreichen Build lassen sich die Applikationslayer wieder mit dem Werkzeug dive betrachten (s. Abb. 6).

Nach einem erfolgreichen Build, lassen sich die Applikationslayer wieder mit dem Werkzeug dive betrachten (s. Abb. 6).

In dive zeigen sich die vier separaten Layer der Spring-Boot-Anwendung. Genau wie beim selbstgeschriebenen Dockerfile lässt sich auch innerhalb des Paketo-Build das Layered jars-Feature nutzen, um die Layer getrennt anzulegen. Abbildung 6 zeigt daher innerhalb des Application-Layers nur die .class- und Konfigurations-Dateien der Anwendung. Alle Abhängigkeiten und der spring-boot-loader finden sich in den anderen Layern.

Auch wenn der Abschied vom selbstgestalteten Dockerfile schwerfallen mag – die Vorteile der Cloud Native Buildpacks liegen auf der Hand. Allein das regelmäßige Aktualisieren aller Microservices auf aktuelle Basis-Images durch Einsatz von Rebasing erleichtert Entwicklerinnen und Entwicklern das Leben spürbar. Darüber hinaus wird nur der Teil der Images neu gebaut, in dem sich auch tatsächlich etwas geändert hat. Das reduziert überflüssige Dateitransfers und damit auch die Durchlaufzeiten von Builds innerhalb der Continuous-Integration-Pipelines. Das Konzept lässt sich zudem auf eine breite Palette von Programmiersprachen anwenden, was zum Vereinheitlichen der CI-Pipelines beitragen kann.

Die zu erwartende breite Unterstützung der Buildpacks-Spezifikation im Container-Markt wirkt sich auch positiv auf die Portierbarkeit eigener Images aus und vereinfacht den Umzug zwischen verschiedenen Cloud-Providern. Auch im eigenen Rechenzentrum lohnt sich der Einsatz, da sich unter anderen die Herausforderungen hybrider Szenarien entschärfen lassen. Nachdem das Projekt Cloud Native Buildpacks von der Cloud Native Computing Foundation das Status-Update auf "CNCF Incubating" erhalten hat, wächst die Hoffnung, dass CNBs eine langfristige Perspektive für das Erstellen von Container-Images bieten.

Innerhalb der JVM-basierten Sprachen und Frameworks sticht Spring Boot aufgrund seiner großen Verbreitung heraus. Auch ist dort die Integration der Cloud Native Buildpacks in Standard-Build-Tools wie Maven und Gradle bereits weit fortgeschritten. Entwicklerinnen und Entwickler können daher die Vorteile von Paketo und Co. nutzen, ohne sich mit den Details zwingend beschäftigen zu müssen. Auch ohne tiefgreifende Kenntnisse im Container-Umfeld entstehen produktionsreife Container-Images.

Die mit Spring Boot 2.3 eingeführten Layered Jars runden das Bild ab. Denn sie trennen den Anwendungscode von den Abhängigkeiten der Anwendung in separate Container-Layer. Dadurch zieht eine Änderung des Codes weniger Änderungen innerhalb des Container-Images nach sich, da nur noch ein Layer zu ändern ist. Dank Paketo ist zu guter Letzt sogar die gemeinsame Nutzung des Layered jars-Feature zusammen mit den Cloud Native Buildpacks möglich.

Jonas Hecht
ist mit viel Leidenschaft bei der codecentric AG unterwegs und integriert mit Vorliebe neueste Technologien, damit diese für eine große Community einfacher nutzbar werden. Neben der Arbeit beim Kunden bloggt er gerne, hält Vorträge und Vorlesungen und organisiert u.a. die JUG Thüringen. Aktuell beschäftigt er sich mit Test-driven Infrastructure as Code und GitHub Actions.

(map)