Container-Images Deep Dive – 101 Wege zum Bauen und Bereitstellen
Kubernetes ist in aller Munde und viele Unternehmen migrieren fleißig ihre Anwendungen auf die Orchestrierungsplattform. Die Basis für eine erfolgreiche Migration auf Kubernetes bildet die Paketierung der Anwendungen in ein oder mehrere Container-Images. Dieser Artikel erklärt die Grundlagen zu Container-Images und welche Möglichkeiten ein Entwicklungsteam heute hat, diese zu bauen.
Was ist ein Container-Image?
Ein Container-Image ist eine Paketierungsmöglichkeit für die Laufzeitumgebung einer Anwendung und aller von ihr benötigten Bibliotheken und Dateien. Basierend auf einem Container-Image können mehrere Instanzen (Container) einer Anwendung isoliert von einander betrieben werden. Jedes Image besteht aus verschiedenen, unveränderlichen Schichten, sogenannten Layern, die aufeinander aufbauen. Jeder Layer enthält einen Befehl oder Dateien, die dem Image hinzugefügt wurden. Damit lässt sich die Historie eines Image nachvollziehen. Ein Image wird in ein sogenanntes Repository gespeichert.
In einem Repository liegen verschiedene Images in verschiedenen Versionen (Tags) vor. Ein Tag ist eine benannte Referenz auf einen bestimmten Layer des Images. Möchte die Entwicklerin ihr gebautes Image anderen zur Verfügung stellen, lädt sie das Image in ein Registry hoch (push).
Ein Image hat das Namensschema Registry:Port/Repository:Tag
, wobei nur der Repository
-Teil verpflichtend ist. Für die anderen Bestandteile greifen dann Default-Werte.
Mit dem Werkzeug Dive lässt sich der Inhalt eines Container-Images einfach auf der Kommandozeile analysieren [1].
Wie baue ich heute Images?
Dieser Abschnitt stellt eine Auswahl an Werkzeugen vor, mit denen Entwicklerinnen Container-Images bauen können. Als Beispiel-Applikation dient eine Java-Anwendung, die als Fat-Jar vorliegt. Das bedeutet, die Entwicklerin hat die Anwendung fertig kompiliert und möchte das fertige Kompilat in ein Container-Image verpacken.
Den Anfang machen die Docker-Bordmittel Docker Build, Docker Multi-Stage Build und die neuere Variante BuildKit.
Als Klassiker unter den Image-Build-Werkzeugen gilt der Docker Build-Befehl. Dafür muss auf dem System Docker installiert sein. Um die Java-Anwendung mit dem Docker-Befehl docker build -t sparsick/docker-plain:latest .
in einen Container zu packen, braucht es ein sogenanntes Dockerfile.
FROM gcr.io/distroless/java17-debian11
WORKDIR /application
EXPOSE 8080
COPY *.jar application.jar
ENTRYPOINT ["java", "-jar", "application.jar"]
Jede Zeile in einem Dockerfile repräsentiert einen Layer im Image. Die Layer bauen aufeinander auf. Sobald sich eine Zeile ändert, werden die repräsentierten Layer und die darauffolgenden neu erstellt. Die einzelnen Dockerfile-Kommandos können in der Dockerfile-Referenz nachgeschaut werden [2].
Es kann aber vorkommen, dass die Entwicklerin somit gezwungen wird, Bibliotheken und Werkzeuge mit zu verpacken, die zur Laufzeit nicht benötigt werden, aber für die Erstellung des Images. So möchte z. B. die Entwicklerin die Jar-Datei entpacken, um die Image-Buildzeit zu optimieren (zur Info: Javas Jar-Dateien sind technisch gesehen Zip-Dateien).
Um solche Fälle zu vermeiden, gibt es seit der Docker-Engine-Version 17.05 die Möglichkeit von sogenannten Multi-Stage-Builds.
// vorbereitung
FROM docker.io/eclipse-temurin:11.0.13_8-jre as builder
WORKDIR /application
COPY *.jar application.jar
RUN java -Djarmode=layertools -jar application.jar extract
// image zur Laufzeit
FROM gcr.io/distroless/java17-debian11
WORKDIR /application
EXPOSE 8080
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"]
Damit kann die Entwicklerin basierend auf verschiedenen Container-Basisimages genau unterscheiden, welche Bibliotheken und Werkzeuge zur Laufzeit benötigt werden und welche nur in der Vorbereitungsphase. Der Unterschied liegt zum Beispiel in der Größe und in diesem Fall auch in der Buildzeit beim wiederholten Bauen der Anwendungen bei Source-Code-Änderungen, da sich hier die Layer wie z. B. COPY --from=builder /application/dependencies/ ./
seltener ändern als das Layer COPY --from=builder /application/application/ ./
.
Ein Nachteil dieser Mechanismen ist, dass die Entwicklerin vom Docker-Daemon abhängig ist und die Docker-Builds je nach Umfang des Projektes lange dauern. Daher hat das Entwicklungsteam des Moby Projektes ein neues Build-Werkzeug namens BuildKit erschaffen [3]. Der Fokus von BuildKit liegt in der Verbesserung der Build-Laufzeit, Speicherverwaltung, Sicherheit und der Erweiterbarkeit. Außerdem erweitert es die Möglichkeit, Dockerfiles zu schreiben [4].
Es ist seit Docker-Version 18.09 Bestandteil von Docker und kann mithilfe der gewohnten Docker-Build-Befehlen benutzt werden (es muss mit der Systemvariable DOCKER_BUILDKIT=1
eingeschaltet werden) oder über einen neuen Docker-Befehl docker buildx build -t sparsick/docker-buildkit:latest -f Dockerfile-multistage .
. Buildkit kann aber auch unabhängig von Docker installiert und benutzt werden [5].
Benutzt die Entwicklerin kein Docker mehr auf ihrem Rechner, sondern Podman, so kann sie dieses Werkzeug ähnlich zu Docker benutzen, um damit ein Container-Image zu bauen [6]. Es können auch die Dockerfiles (bei Podman Containerfiles) von oben wieder verwendet werden.
podman build -t sparsick/podman-multistage:latest -f Containerfile-multistage .
Podman kann als Alternative zu Docker gesehen werden, da das Versprechen ist, OCI-Container ohne Daemon und somit auch ohne Root-Rechte laufen lassen zu können und dabei kann die Entwicklerin auf ihre gewohnten Docker-Befehle zurückgreifen (alias docker = podman
).
Podman verwendet im Hintergrund für das Bauen das Projekt Buildah [7]. Die Entwicklerin kann Buildah auch direkt ohne Podman verwenden (buildah bud -t sparsick/buildah-containerfile-mutlistage:latest -f Containerfile-multistage .
). Buildah bietet aber auch die Möglichkeit, die Container-Images mit Hilfe von Shell-Befehlen zu definieren.
# Beispiel für Multi-Stage Image Build
#!/usr/bin/env bash
set -x
builderContainer=$(buildah from "docker.io/eclipse-temurin:17.0.1_12-jre")
buildah config --workingdir="/application" "$builderContainer"
buildah copy "$builderContainer" "*.jar" "application.jar"
buildah run "$builderContainer" -- java -Djarmode=layertools -jar application.jar extract
baseContainer=$(buildah from "gcr.io/distroless/java17-debian11")
buildah config --port 8080 "$baseContainer"
buildah copy --from="$builderContainer" "$baseContainer" /application/dependencies/ ./
buildah copy --from="$builderContainer" "$baseContainer" /application/spring-boot-loader/ ./
buildah copy --from="$builderContainer" "$baseContainer" /application/snapshot-dependencies/ ./
buildah copy --from="$builderContainer" "$baseContainer" /application/application/ ./
buildah config --entrypoint '["java", "org.springframework.boot.loader.JarLauncher"]' "$baseContainer"
## Commit this container to an image name
buildah commit "$baseContainer" "sparsick/buildah-multistage"
buildah push "sparsick/buildah-plain" "docker-daemon:sparsick/buildah-multistage:latest"
Egal, für welches von den oben vorgestellten Werkzeugen man sich entscheidet, alle zwingen die Entwicklerin immer wieder, denselben Quellcode für ähnliche Applikationen (in den Beispielen basiert die Applikation auf Spring Boot) zu schreiben. Das erzeugt unnötige Aufwände in der Pflege des Quellcodes und ist ein Verstoß gegen das DRY-Prinzips [8]. Diese Problematik adressiert das CNCF-(Cloud Native Computing Foundation)-Projekt Cloud Native Buildpacks[9]. Die Idee hinter Buildpacks ist, dass ausgehend vom Quellcode der Applikation die Entwicklerin zum Container-Image kommt, ohne eigenen Quellcode für die Erstellung der Container-Images zu schreiben. Buildpacks ist selber modular aufgebaut und besteht aus mehreren Komponenten. Für den Container-Image Build sind die Buildpacks-Komponenten Builder und Buildpack entscheidend.
Der Builder beinhaltet alles um ein Container-Image ausgehend vom Quellcode der Anwendung bauen zu können. Dazu gehören auch die Buildpacks. Ein Buildpack fasst alle Funktionen zusammen, die benötigt werden, um ausgehend vom Quellcode der Applikation das Container-Image zu bauen und die Applikation in einem Container laufen zu lassen.
Es gibt mehrere Builder-Implementierungen. Paketo Buildpacks bietet z. B. mehrere an [10]. Wie der Name suggeriert, beinhaltet Paketo neben den Builder-Implementierungen auch Buildpacks für verschiedene Ökosysteme (u. a. auch mehrere für das Java- Ökosystem). Für eine Spring-Boot-Applikation basierend auf einem Maven-Build sieht der Container-Image-Bau mit Paketo folgendermaßen aus:
pack build sparsick/paketo:latest \
--path spring-boot-app \
--builder paketobuildpacks/builder:base \
--buildpack paketo-buildpacks/adoptium \
--buildpack paketo-buildpacks/java \
--env BP_JVM_VERSION=17
Dabei wird der Pfad zum Quellcode angegeben, welche Buildpacks und Builder benutzt werden sollen und die Konfigurationen für die Buildpacks werden gesetzt.
base: Pulling from paketobuildpacks/builder
Digest: sha256:b73e29503e40553e2ab25e898632835453aa68f29ec6570588cb4a39682bbfe6
Status: Image is up to date for paketobuildpacks/builder:base
base-cnb: Pulling from paketobuildpacks/run
Digest: sha256:22a6c82a584e3c224ea92424a4557005b9e84a1af6f6f7cc66ec468690519cf6
Status: Image is up to date for paketobuildpacks/run:base-cnb
gcr.io/paketo-buildpacks/adoptium@sha256:25f3a43dac8f600970d4a3f8c1f5d8576dde1991ae19c866e90fc2e01cda4ed1: Pulling from paketo-buildpacks/adoptium
Digest: sha256:25f3a43dac8f600970d4a3f8c1f5d8576dde1991ae19c866e90fc2e01cda4ed1
Status: Image is up to date for gcr.io/paketo-buildpacks/adoptium@sha256:25f3a43dac8f600970d4a3f8c1f5d8576dde1991ae19c866e90fc2e01cda4ed1
===> ANALYZING
Restoring data for sbom from previous image
===> DETECTING
9 of 22 buildpacks participating
paketo-buildpacks/adoptium 10.2.0
paketo-buildpacks/ca-certificates 3.0.3
paketo-buildpacks/bellsoft-liberica 9.2.0
paketo-buildpacks/syft 1.8.0
paketo-buildpacks/maven 6.3.0
paketo-buildpacks/executable-jar 6.0.4
paketo-buildpacks/apache-tomcat 7.2.0
paketo-buildpacks/dist-zip 5.1.0
paketo-buildpacks/spring-boot 5.6.0
===> RESTORING
Restoring metadata for "paketo-buildpacks/adoptium:helper" from app image
Restoring metadata for "paketo-buildpacks/adoptium:java-security-properties" from app image
Restoring metadata for "paketo-buildpacks/adoptium:jre" from app image
Restoring metadata for "paketo-buildpacks/adoptium:jdk" from cache
Restoring metadata for "paketo-buildpacks/ca-certificates:helper" from app image
Restoring metadata for "paketo-buildpacks/syft:syft" from cache
Restoring metadata for "paketo-buildpacks/maven:application" from cache
Restoring metadata for "paketo-buildpacks/maven:cache" from cache
Restoring metadata for "paketo-buildpacks/maven:maven" from cache
Restoring metadata for "paketo-buildpacks/spring-boot:helper" from app image
Restoring metadata for "paketo-buildpacks/spring-boot:spring-cloud-bindings" from app image
Restoring metadata for "paketo-buildpacks/spring-boot:web-application-type" from app image
Restoring data for "paketo-buildpacks/adoptium:jdk" from cache
Restoring data for "paketo-buildpacks/syft:syft" from cache
Restoring data for "paketo-buildpacks/maven:application" from cache
Restoring data for "paketo-buildpacks/maven:cache" from cache
Restoring data for "paketo-buildpacks/maven:maven" from cache
Restoring data for sbom from cache
===> BUILDING
Paketo Adoptium Buildpack 10.2.0
github.com/paketo-buildpacks/adoptium
Build Configuration:
$BP_JVM_TYPE JRE the JVM type - JDK or JRE
$BP_JVM_VERSION 17 the Java version
Launch Configuration:
$BPL_DEBUG_ENABLED false enables Java remote debugging support
$BPL_DEBUG_PORT 8000 configure the remote debugging port
$BPL_DEBUG_SUSPEND false configure whether to suspend execution until a debugger has attached
$BPL_HEAP_DUMP_PATH write heap dumps on error to this path
$BPL_JAVA_NMT_ENABLED true enables Java Native Memory Tracking (NMT)
$BPL_JAVA_NMT_LEVEL summary configure level of NMT, summary or detail
$BPL_JFR_ARGS configure custom Java Flight Recording (JFR) arguments
$BPL_JFR_ENABLED false enables Java Flight Recording (JFR)
$BPL_JMX_ENABLED false enables Java Management Extensions (JMX)
$BPL_JMX_PORT 5000 configure the JMX port
$BPL_JVM_HEAD_ROOM 0 the headroom in memory calculation
$BPL_JVM_LOADED_CLASS_COUNT 35% of classes the number of loaded classes in memory calculation
$BPL_JVM_THREAD_COUNT 250 the number of threads in memory calculation
$JAVA_TOOL_OPTIONS the JVM launch flags
Adoptium JDK 17.0.2: Contributing to layer
Downloading from github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.2%2B8/OpenJDK17U-jdk_x64_linux_hotspot_17.0.2_8.tar.gz
Verifying checksum
Expanding to /layers/paketo-buildpacks_adoptium/jdk
Adding 128 container CA certificates to JVM truststore
Writing env.build/JAVA_HOME.override
Writing env.build/JDK_HOME.override
Adoptium JRE 17.0.2: Contributing to layer
Downloading from github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.2%2B8/OpenJDK17U-jre_x64_linux_hotspot_17.0.2_8.tar.gz
Verifying checksum
Expanding to /layers/paketo-buildpacks_adoptium/jre
Adding 128 container CA certificates to JVM truststore
Writing env.launch/BPI_APPLICATION_PATH.default
Writing env.launch/BPI_JVM_CACERTS.default
Writing env.launch/BPI_JVM_CLASS_COUNT.default
Writing env.launch/BPI_JVM_SECURITY_PROVIDERS.default
Writing env.launch/JAVA_HOME.default
Writing env.launch/JAVA_TOOL_OPTIONS.append
Writing env.launch/JAVA_TOOL_OPTIONS.delim
Writing env.launch/MALLOC_ARENA_MAX.default
Launch Helper: Reusing cached layer
Java Security Properties: Reusing cached layer
Paketo CA Certificates Buildpack 3.0.3
github.com/paketo-buildpacks/ca-certificates
Launch Helper: Reusing cached layer
Paketo BellSoft Liberica Buildpack 9.2.0
github.com/paketo-buildpacks/bellsoft-liberica
Build Configuration:
$BP_JVM_TYPE JRE the JVM type - JDK or JRE
$BP_JVM_VERSION 17 the Java version
Launch Configuration:
$BPL_DEBUG_ENABLED false enables Java remote debugging support
$BPL_DEBUG_PORT 8000 configure the remote debugging port
$BPL_DEBUG_SUSPEND false configure whether to suspend execution until a debugger has attached
$BPL_HEAP_DUMP_PATH write heap dumps on error to this path
$BPL_JAVA_NMT_ENABLED true enables Java Native Memory Tracking (NMT)
$BPL_JAVA_NMT_LEVEL summary configure level of NMT, summary or detail
$BPL_JFR_ARGS configure custom Java Flight Recording (JFR) arguments
$BPL_JFR_ENABLED false enables Java Flight Recording (JFR)
$BPL_JMX_ENABLED false enables Java Management Extensions (JMX)
$BPL_JMX_PORT 5000 configure the JMX port
$BPL_JVM_HEAD_ROOM 0 the headroom in memory calculation
$BPL_JVM_LOADED_CLASS_COUNT 35% of classes the number of loaded classes in memory calculation
$BPL_JVM_THREAD_COUNT 250 the number of threads in memory calculation
$JAVA_TOOL_OPTIONS the JVM launch flags
Paketo Syft Buildpack 1.8.0
github.com/paketo-buildpacks/syft
Paketo Maven Buildpack 6.3.0
github.com/paketo-buildpacks/maven
Build Configuration:
$BP_MAVEN_BUILD_ARGUMENTS -Dmaven.test.skip=true package the arguments to pass to Maven
$BP_MAVEN_BUILT_ARTIFACT target/*.[ejw]ar the built application artifact explicitly. Supersedes $BP_MAVEN_BUILT_MODULE
$BP_MAVEN_BUILT_MODULE the module to find application artifact in
$BP_MAVEN_DAEMON_ENABLED false use maven daemon
$BP_MAVEN_POM_FILE pom.xml the location of the main pom.xml file, relative to the application root
Apache Maven 3.8.4: Reusing cached layer
Creating cache directory /home/cnb/.m2
Compiled Application: Contributing to layer
Executing mvn --batch-mode -Dmaven.test.skip=true package
[INFO] Scanning for projects...
[INFO]
[INFO] --------------< com.github.sparsick:spring-boot-example >---------------
[INFO] Building spring-boot-example 1.3.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-resources-plugin:3.2.0:resources (default-resources) @ spring-boot-example ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] Copying 1 resource
[INFO] Copying 3 resources
[INFO]
[INFO] --- maven-compiler-plugin:3.10.0:compile (default-compile) @ spring-boot-example ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 6 source files to /workspace/target/classes
[INFO]
[INFO] --- maven-resources-plugin:3.2.0:testResources (default-testResources) @ spring-boot-example ---
[INFO] Not copying test resources
[INFO]
[INFO] --- maven-compiler-plugin:3.10.0:testCompile (default-testCompile) @ spring-boot-example ---
[INFO] Not compiling test sources
[INFO]
[INFO] --- maven-surefire-plugin:2.22.2:test (default-test) @ spring-boot-example ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- maven-jar-plugin:3.2.2:jar (default-jar) @ spring-boot-example ---
[INFO] Building jar: /workspace/target/spring-boot-example-1.3.0-SNAPSHOT.jar
[INFO]
[INFO] --- spring-boot-maven-plugin:2.6.4:repackage (repackage) @ spring-boot-example ---
[INFO] Replacing main artifact with repackaged archive
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 3.071 s
[INFO] Finished at: 2022-03-04T13:39:43Z
[INFO] ------------------------------------------------------------------------
Removing source code
Restoring application artifact
Paketo Executable JAR Buildpack 6.0.4
github.com/paketo-buildpacks/executable-jar
Class Path: Contributing to layer
Writing env/CLASSPATH.delim
Writing env/CLASSPATH.prepend
Process types:
executable-jar: java org.springframework.boot.loader.JarLauncher (direct)
task: java org.springframework.boot.loader.JarLauncher (direct)
web: java org.springframework.boot.loader.JarLauncher (direct)
Paketo Spring Boot Buildpack 5.6.0
github.com/paketo-buildpacks/spring-boot
Creating slices from layers index
dependencies
spring-boot-loader
snapshot-dependencies
application
Launch Helper: Reusing cached layer
Spring Cloud Bindings 1.8.1: Reusing cached layer
Web Application Type: Contributing to layer
Servlet web application detected
Writing env.launch/BPL_JVM_THREAD_COUNT.default
4 application slices
Image labels:
org.opencontainers.image.title
org.opencontainers.image.version
org.springframework.boot.version
===> EXPORTING
Reusing layer 'paketo-buildpacks/adoptium:helper'
Reusing layer 'paketo-buildpacks/adoptium:java-security-properties'
Adding layer 'paketo-buildpacks/adoptium:jre'
Reusing layer 'paketo-buildpacks/ca-certificates:helper'
Reusing layer 'paketo-buildpacks/executable-jar:classpath'
Reusing layer 'paketo-buildpacks/spring-boot:helper'
Reusing layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
Reusing layer 'paketo-buildpacks/spring-boot:web-application-type'
Adding layer 'launch.sbom'
Reusing 4/5 app layer(s)
Adding 1/5 app layer(s)
Reusing layer 'launcher'
Adding layer 'config'
Reusing layer 'process-types'
Adding label 'io.buildpacks.lifecycle.metadata'
Adding label 'io.buildpacks.build.metadata'
Adding label 'io.buildpacks.project.metadata'
Adding label 'org.opencontainers.image.title'
Adding label 'org.opencontainers.image.version'
Adding label 'org.springframework.boot.version'
Setting default process type 'web'
Saving sparsick/paketo:latest...
*** Images (108ba29a72ce):
sparsick/paketo:latest
Adding cache layer 'paketo-buildpacks/adoptium:jdk'
Reusing cache layer 'paketo-buildpacks/syft:syft'
Adding cache layer 'paketo-buildpacks/maven:application'
Reusing cache layer 'paketo-buildpacks/maven:cache'
Reusing cache layer 'paketo-buildpacks/maven:maven'
Adding cache layer 'cache.sbom'
Successfully built image sparsick/paketo:latest
Die Idee, ohne Docker und standardisiert Container-Images zu bauen, ist zumindest im Java-Ökosystem nicht neu. Dort existiert mit Projekt Jib schon länger eine Möglichkeit, basierend auf JARs oder WARs standardisierte Container-Images ohne Docker-Daemon zu bauen [11].
jib jar --from=gcr.io/distroless/java17-debian11 --target=docker://sparsick/jib-plain:latest spring-boot-example-1.3.0-SNAPSHOT.jar
[WARN] Base image 'gcr.io/distroless/java17-debian11' does not use a specific image digest - build may not be reproducible
Using base image with digest: sha256:bfc3d0d7545a47b58d620fd5ced2ed8619a906ff35b76c8916384c5ebcdf1105
Container entrypoint set to [java, -cp, /app, org.springframework.boot.loader.JarLauncher]
Container program arguments set to []
Executing tasks:
[==============================] 100,0% complete
Container-Imagebau integriert in Buildwerkzeuge am Beispiel Java-Ökosystem
Wie oben beschrieben gibt es mittlerweile zahlreiche Möglichkeiten, Container-Images zu bauen. Die nächste Frage, die sich stellt, ist, wie der Container-Imagebau in den vorhandenen Entwicklungsprozess integrieren lässt. Die einfachste Variante ist, die Image-Buildwerkzeuge direkt nach dem Buildwerkzeug der genutzten Programmiersprache aufzurufen. Der Nachteil davon ist jedoch, dass viele Entwicklerinnen in ihrer täglichen Arbeit genau diesen Schritt vergessen und so erst auf dem CI-Server das Feedback erhalten, wenn etwas mit dem Container-Image nicht stimmt. Ich befürworte, dass der Container-Imagebau in den Buildprozess des Buildwerkzeuges der genutzten Programmiersprache integriert wird, um schnellstmöglich mitzubekommen, wenn etwas mit dem Container-Imagebau nicht stimmt. Ein weiterer Grund ist, dass abstrakt gesehen ein Container-Image ein anderes Format für ein Deployment-Artifakt ist. Jedes Buildwerkzeug hat u. a. die Zielsetzung, ein fertiges Deployment-Artifakt zu erzeugen. In diesem Fall ist es dann ein Container-Image.
Im nächsten Abschnitt erfahren Sie, wie ein Teil der oben gezeigten Imagebuilder in ein klassisches Buildwerkzeug integriert werden kann. Als Beispiel soll das Buildwerkzeug Maven aus dem Java-Ökosystem dienen.
Docker-Maven-Plugin
Beim Einbinden von Docker in einen Maven-Build hilft das Docker-Maven-Plugin [12]. Wenn die Entwicklerin das Plugin in ihrem Build aktiviert, dann hängt es sich in der Standard-Einstellung in die Maven-Phase install für den Bau des Container-Images und in die Phase deploy das Pushen des Images in eine Container-Registry. Das Plugin folgt der Maven-Philosophie "Convention over Configuration". Es sucht z. B. die Informationen für das Container-Image aus den POM-Informationen (<name>). Es erwartet, dass das Containerfile an einer bestimmten Stelle liegt (<build><dockerFile>, relativ zu src/main/docker). Es gibt eine Standardeinstellung, welche Maven-Artifakte berücksichtigt werden sollen (<build><assembly>) etc. Diese Konfiguration kann überschrieben werden.
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
<version>0.39.1</version>
<executions>
<execution>
<id>docker-build</id>
<goals>
<goal>build</goal>
<goal>push</goal>
</goals>
</execution>
</executions>
<configuration>
<images>
<image>
<name>spring-boot-demo:latest</name>
<build>
<dockerFile>Dockerfile</dockerFile>
<assembly>
<descriptorRef>artifact</descriptorRef>
</assembly>
</build>
</image>
</images>
</configuration>
</plugin>
Mit ein wenig Konfigurationsaufwand im System lässt sich das Docker-Maven-Plugin auch mit Podman benutzen [13]. Für Podman gibt es auch ein Podman-Maven-Plugin, das ähnlich zum Docker-Maven-Plugin funktioniert [14].
Spring Boot Buildpacks
Nutzt die Java-Entwicklerin Spring Boot als Basis für ihre Java-Anwendung, dann bietet Spring Boot seit der Version 2.3.0 die Möglichkeit, für den Container-Imagebau mit Hilfe des Spring-Boot-Maven-Plugins Buildpacks zu verwenden [15].
Auch hier verfolgt das Plugin die Maven-Philosophie "Convention over Configuration". Sie bindet das Imagebauen an die Maven-Phase package und benutzt z. B. in der Standard-Einstellung Bellsoft Buildpacks. Diese kann aber überschrieben werden, wenn andere benutzt werden sollen (wie z. B. Buildpacks, die Adoptium benutzen).
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-image</goal>
</goals>
</execution>
</executions>
<configuration>
<image>
<buildpacks>
<buildpack>gcr.io/paketo-buildpacks/adoptium</buildpack>
<buildpack>paketo-buildpacks/java</buildpack>
</buildpacks>
<name>docker.io/sparsick/spring-boot-demo:buildpack</name>
</image>
</configuration>
</plugin>
Ein Push des Images in eine Container-Registry erfolgt durch eine Konfiguration der Container-Registry über die Parameter docker.publishRegistry [16].
Jib Maven-Plugin
Auch für Jib gibt es ein Maven-Plugin.
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<id>jib-build</id>
<phase>package</phase>
<goals>
<goal>dockerBuild</goal>
</goals>
</execution>
<execution>
<id>jib-push</id>
<phase>deploy</phase>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
<configuration>
<to>
<image>spring-boot-demo:jib</image>
</to>
</configuration>
</plugin>
Möchte die Entwicklerin das Container-Image lokal verwenden, dann muss sie das Maven-Goal dockerBuild
aufrufen. Das Maven-Goal build
bewirkt, dass das Container-Image direkt in die Registry gepusht wird (Default: docker.io).
Good Practice für den Image-Build
Vulnerability-Scans der Container-Images
Spätestens wenn die Entwicklerin Container produktiv verwendet, möchte sie sicherstellen, dass die von ihnen benutzten Container-Images so wenige Sicherheitslücken wie möglich haben. Es ist möglich, eine kommerzielle Lösung zu benutzen, die die benutzten Container-Images überwacht oder sie baut sich mit Hilfe von Trivy, einem Open-Source-Vulnerability-Scanner, und Scripting etwas eigenes, um selbst mit Vulnerability-Scans zu beginnen [17]. Trivy ist ein CLI-Tool, um Container-Images nach Sicherheitslücken zu scannen. Die Entwicklerin bekommt eine Übersicht, welche bekannten Sicherheitslücken das gescannte Container-Image hat. Es zeigt auch an, ob es schon Versionen für die betroffenen Bibliotheken gibt, die die gefundene Sicherheitslücke behoben haben. Ein Auszug der Scan-Ergebnisse:
➜ trivy i gcr.io/distroless/java17-debian11
2022-05-20T20:55:17.053+0200 INFO Detected OS: debian
2022-05-20T20:55:17.053+0200 INFO Detecting Debian vulnerabilities...
2022-05-20T20:55:17.060+0200 INFO Number of language-specific files: 0
gcr.io/distroless/java17-debian11 (debian 11.2)
Total: 48 (UNKNOWN: 1, LOW: 23, MEDIUM: 6, HIGH: 9, CRITICAL: 9)
┌─────────────────────────┬──────────────────┬──────────┬────────────────────┬─────────────────────────┬──────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ Title │
├─────────────────────────┼──────────────────┼──────────┼────────────────────┼─────────────────────────┼──────────────────────────────────────────────────────────────┤
│ libc6 │ CVE-2021-33574 │ CRITICAL │ 2.31-13+deb11u2 │ 2.31-13+deb11u3 │ glibc: mq_notify does not handle separately allocated thread │
│ │ │ │ │ │ attributes │
│ │ │ │ │ │ avd.aquasec.com/nvd/cve-2021-33574 │
│ ├──────────────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2022-23218 │ │ │ │ glibc: Stack-based buffer overflow in svcunix_create via │
│ │ │ │ │ │ long pathnames │
│ │ │ │ │ │ avd.aquasec.com/nvd/cve-2022-23218 │
│ ├──────────────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2022-23219 │ │ │ │ glibc: Stack-based buffer overflow in sunrpc clnt_create via │
│ │ │ │ │ │ a long pathname │
Die Entwicklerin kann Trivy auch so konfigurieren, dass es nur Sicherheitslücken anzeigt, für die auch gepatchte Versionen existieren. Ein Auszug der Scan-Ergebnisse:
➜ trivy i --ignore-unfixed gcr.io/distroless/java17-debian11
2022-05-20T20:56:50.298+0200 INFO Detected OS: debian
2022-05-20T20:56:50.298+0200 INFO Detecting Debian vulnerabilities...
2022-05-20T20:56:50.309+0200 INFO Number of language-specific files: 0
gcr.io/distroless/java17-debian11 (debian 11.2)
Total: 23 (UNKNOWN: 1, LOW: 2, MEDIUM: 6, HIGH: 6, CRITICAL: 8)
┌─────────────────────────┬────────────────┬──────────┬────────────────────┬─────────────────────────┬──────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ Title │
├─────────────────────────┼────────────────┼──────────┼────────────────────┼─────────────────────────┼──────────────────────────────────────────────────────────────┤
│ libc6 │ CVE-2021-33574 │ CRITICAL │ 2.31-13+deb11u2 │ 2.31-13+deb11u3 │ glibc: mq_notify does not handle separately allocated thread │
│ │ │ │ │ │ attributes │
│ │ │ │ │ │ avd.aquasec.com/nvd/cve-2021-33574 │
│ ├────────────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2022-23218 │ │ │ │ glibc: Stack-based buffer overflow in svcunix_create via │
│ │ │ │ │ │ long pathnames │
│ │ │ │ │ │ avd.aquasec.com/nvd/cve-2022-23218 │
│ ├────────────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2022-23219 │ │ │ │ glibc: Stack-based buffer overflow in sunrpc clnt_create via │
│ │ │ │ │ │ a long pathname │
│ │ │ │ │ │ avd.aquasec.com/nvd/cve-2022-23219 │
│ ├────────────────┼──────────┤ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2021-43396 │ LOW │ │ │ glibc: conversion from ISO-2022-JP-3 with iconv may emit │
│ │ │ │ │ │ spurious NUL character on... │
│ │ │ │ │ │ avd.aquasec.com/nvd/cve-2021-43396 │
├─────────────────────────┼────────────────┼──────────┼────────────────────┼─────────────────────────┼──────────────────────────────────────────────────────────────┤
Trivy bietet auch an, die Ergebnisse im JSON-Format auszugeben. Ein Auszug der Scan-Ergebnisse als JSON:
➜ trivy i --ignore-unfixed -f json -o results.json gcr.io/distroless/java17-debian11
2022-05-20T21:02:06.669+0200 INFO Detected OS: debian
2022-05-20T21:02:06.669+0200 INFO Detecting Debian vulnerabilities...
2022-05-20T21:02:06.680+0200 INFO Number of language-specific files: 0
➜ cat results.json
{
"SchemaVersion": 2,
"ArtifactName": "gcr.io/distroless/java17-debian11",
"ArtifactType": "container_image",
"Metadata": {
"OS": {
"Family": "debian",
"Name": "11.2"
},
"ImageID": "sha256:3b43eb6a377ae274cb1e8347d2991d9637935cf22fe60466866a655acecc3e25",
"DiffIDs": [
"sha256:5b1fa8e3e100361047c8bcd5553ab6329b9c713c1d4eb87a646760329cea5b3a",
"sha256:0b3d0512394dcbd9c121ea350abd85b2490f4b80e4d4dae4691f80d94915474b",
"sha256:e83d4114481dc897d49d5a9a8b68bf71c4f08f1a5c1adff44603c56b958fed53",
"sha256:e9c7b3ff20f700d81d36eb0ec8232021d8f06d3cda9418d9683c9333122abb2c",
"sha256:6eee58189b758ac81d92d16f542d4ba354e8e805e5385441b6c407ec6f1d0a15"
],
"RepoTags": [
"gcr.io/distroless/java17-debian11:latest"
],
"RepoDigests": [
"gcr.io/distroless/java17-debian11@sha256:cf51650895da0b3d2290df05562b6282ac35283b25098ce2ae9a1b7613eca30b"
],
"ImageConfig": {
"architecture": "amd64",
"author": "Bazel",
"created": "1970-01-01T00:00:00Z",
"history": [
{
"created": "1970-01-01T00:00:00Z",
"created_by": "bazel build ..."
},
{
"created": "1970-01-01T00:00:00Z",
"created_by": "bazel build ..."
},
{
"created": "1970-01-01T00:00:00Z",
"created_by": "bazel build ..."
},
{
"created": "1970-01-01T00:00:00Z",
"created_by": "bazel build ..."
},
{
"created": "1970-01-01T00:00:00Z",
"created_by": "bazel build ..."
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:5b1fa8e3e100361047c8bcd5553ab6329b9c713c1d4eb87a646760329cea5b3a",
"sha256:0b3d0512394dcbd9c121ea350abd85b2490f4b80e4d4dae4691f80d94915474b",
"sha256:e83d4114481dc897d49d5a9a8b68bf71c4f08f1a5c1adff44603c56b958fed53",
"sha256:e9c7b3ff20f700d81d36eb0ec8232021d8f06d3cda9418d9683c9333122abb2c",
"sha256:6eee58189b758ac81d92d16f542d4ba354e8e805e5385441b6c407ec6f1d0a15"
]
},
Eigene Container-Registry benutzen
Eigene Container-Images sollten nur einmal gebaut und dann für andere zur Verfügung gestellt werden. Oft soll dieses Container-Image nur innerhalb des eigenen Unternehmens zur Verfügung gestellt werden. Daher bietet es sich im Unternehmensumfeld an, eine eigene Container-Registry und auch eine Mirror-Registry in Richtung der öffentlichen Registries aufzubauen. Meist ist im Unternehmen – je nach Ökosystem – schon ein Artifact-Repository-Manager wie Artifactory oder Sonatype Nexus im Einsatz. Diese bieten als Repository-Format auch Container-Registries an. Es gibt aber auch auf Container spezialisierte Artifact-Repository-Manager wie zum Beispiel Harbor[18].
Tags beim Releasen nur einmal verwenden
In vielen Ökosystemen, wie z. B. bei Java, hat es sich etabliert, für releaste Artifakte eine feste Versionsnummer zu vergeben und diese nicht wieder zu überschreiben. Damit versichert man dem Konsumenten des Artifakts, dass dieser immer dasselbe Artifakt bekommt, wenn er dieselbe Versionnummer anfordert. Bei Container-Images können Tags die Funktion der Versionierung übernehmen. Sie können aber wieder überschrieben werden. Daher muss von den Entwicklern darauf geachtet werden, dass bei Änderungen neue Tagnamen verwendet werden oder sie setzen eine Container-Registry (z. B. Sonatype Nexus) ein, die ein re-push von schon verwendeten Tags verhindert.
Container-Image-Größe optimieren
Es sprechen einige Punkte dafür, die Container-Image-Größe zu optimieren. Einerseits hält man damit den Bedarf an Speicherplatz in der Registry klein. Andererseits verringert man dadurch die Ladezeiten bei der Verteilung der Container-Images z. B. in einem Kubernetes-Cluster. Die Container-Image-Größe lässt sich gut optimieren, wenn man Werkzeuge, die zur Laufzeit nicht benötigt werden, aus dem Image weglässt. Beispielsweise bei debian-basierten Images sollte man bei der Installation via Package-Manager darauf achten, dass keine "empfohlenen" Pakete mitinstalliert werden. Dafür verwendet man das Flag --no-install-recommends
. Auch sollte darauf geachtet werden, dass die Entwicklerin den Package-Manager-Cache wieder leert, nachdem die Werkzeuge installiert wurden, die zur Laufzeit benötigt werden. Generell sollte sie überdenken, ob ein Package-Manager wirklich nötig ist (Stichwort: "Distroless"). Werden Werkzeuge zum Build-Zeitpunkt benötigt, aber nicht zur Laufzeit, dann helfen die oben beschriebenen Multi-Stage-Container-Files dabei, die Build-Schritte besser in Werkzeuginstallation und eigentlichen Build zu unterteilen, so dass am Ende ein kleineres Container-Image entsteht.
Build-Cache optimieren
Um Build-Zeiten zu verringern, lohnt es sich, die Container-Datei so zu strukturieren, dass die Schritte, die sich öfter ändern, am Ende des Build-Prozesses ausgeführt werden. Wie oben erwähnt, entspricht jede Zeile der Container-Datei einem Build-Schritt und damit einem Layer im Container-Image. Normalerweise werden alle nachgelagerten Layer neu gebaut, sobald innerhalb der Kette eine Änderung erfolgt. Sammeln sich die volatileren Schritte am Ende, können die vorherigen, stabilen Layer aus dem Build-Cache wiederverwendet werden.
Fazit
Heutzutage bietet das Container-Ökosystem zahlreiche Werkzeuge an, um Applikationen in einen Container zu verpacken. Jedes Werkzeug hat seine Stärken und Schwächen, sodass die richtige Auswahl abhängig von Kontext und Anforderungen ist. Mit dem Container-Image-Bau ist die Reise noch nicht vorbei, denn der Container soll noch auf die Kubernetes-Platform ausgeliefert werden, sodass sich die Entwicklerin noch über Deployment-Skripte (sogenannte Kubernetes Object Descriptor) Gedanken machen muss.
- Dive
- Dockerfile reference
- Moby-Projekt
- Buildkit Dockerfile Dokumentation
- Buildkit
- Bodman
- Buildah
- Wikipedia: DRY-Prinzip
- Cloud Native Buildpacks
- Paketo Buildpacks
- Github: Jib
- Github: Docker-Maven-Plugin
- Konfigurationsaufwand im System
- Github: Podman Maven Plugin
- Spring Boot Maven Plugin
- Maven Plugin Dokumentation
- Trivy
- Harbor
Weitere Informationen
Source Code zum Artikel
Matthias Haeussler: Vortrag Options Galore: From Source Code to Container-Image
Michael Vitz: Images für Java-Anwendungen bauen
Jonas Hecht: Goodbye Dockerfile: Cloud Native Buildpacks with Paketo.io & layered jars for Spring Boot
Puja Abbass: Building Container-Images with Podman and Buildah