Dockerfile richtig schreiben: Multi-Stage Build für Spring Boot-Anwendungen
1. Übersicht
Eine schnell geschriebene Dockerfile produziert normalerweise Images von mehreren hundert MB. Das Image enthält sowohl JDK, Maven, Quellcode als auch das Verzeichnis .m2. Jedes Mal, wenn du eine Codezeile änderst, lädt Docker alle Dependencies neu.
Dieser Artikel zeigt dir, wie du Multi-Stage Build verwendest, um den Build-Schritt vom Ausführungsschritt zu trennen. Nach diesem Artikel wirst du ein Dockerfile für eine Spring Boot 3.x (Java 21)-Anwendung schreiben können, das ein kleines Image produziert, schnell mit Cache neu gebaut wird, Tests in einem separaten Stage ausführt und den Prozess mit einem Benutzer ungleich root startet.
2. Probleme beim einstufigen Dockerfile
Wir verwenden eine kleine Anwendung mit einem Endpoint, der den Bestellstatus zurückgibt:
package com.nonla.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
@RestController
class OrderController {
@GetMapping("/orders/{id}/status")
String status(@PathVariable long id) {
return id > 0 ? "CONFIRMED" : "UNKNOWN";
}
}
Die Dependencies in pom.xml brauchen nur Web und Test:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Ein “funktionierendes” Dockerfile sieht normalerweise so aus:
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]
Dieses Dockerfile hat drei Probleme:
- Schweres Image: Das endgültige Image enthält Maven, vollständiges JDK, Quellcode und den gesamten
.m2-Cache. - Schlechtes Caching:
COPY . .kommt vormvn package. Das Ändern einer einzelnen.java-Datei invalidiert diesen Layer und führt zum erneuten Herunterladen aller Dependencies. - Große Angriffsfläche: Build-Tools und Quellcode sind im Production-Container vorhanden.
3. Grundlagen von Multi-Stage Build
Multi-Stage Build ermöglicht es, mehrere FROM-Anweisungen in einer Dockerfile zu haben. Jede FROM öffnet einen neuen Stage. Der nachfolgende Stage kann Dateien vom vorherigen Stage mit COPY --from=<stage-name> kopieren. Nur der letzte Stage wird zum resultierenden Image. Mittlere Stages werden verworfen.
3.1. Build-Stage und Runtime-Stage
# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests
# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Erklärung der einzelnen Teile:
AS buildbenennt den ersten Stage. Du verwendest diesen Namen beiCOPY --from=build.- Der Build-Stage verwendet ein Image mit Maven und JDK zum Kompilieren und Packen der JAR.
- Der Runtime-Stage verwendet
eclipse-temurin:21-jre. Dieses Image enthält nur JRE, nichtjavacoder Maven. COPY --from=buildkopiert nur die JAR-Datei ins finale Image.ENTRYPOINTin der Exec-Form (JSON-Array). Dadurch wirdjavaals PID 1 ausgeführt und empfängt das SignalSIGTERM, wenn der Container gestoppt wird.
3.2. Vergleich der Ergebnisse
Baue und überprüfe die Größe:
docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders
Beispielhafte Ergebnisse (tatsächliche Werte hängen von der Image-Version und den Dependencies ab):
REPOSITORY TAG SIZE
orders single ~560MB
orders multi ~290MB
Der Großteil der verbleibenden Größe ist JRE und das Basis-Betriebssystem. Du kannst es weiter reduzieren, indem du ein schlankeres Basis-Image wie eclipse-temurin:21-jre-alpine verwendest. Überprüfe jedoch sorgfältig, ob deine Anwendung Native Libraries verwendet.
4. Layer-Caching ausnutzen
Jeder RUN- und COPY-Befehl erstellt einen Layer. Docker wiederverwendet Layer, wenn der Befehl und die Eingaben gleich bleiben. Wenn sich ein Layer ändert, müssen alle nachfolgenden Layer neu gebaut werden. Daher gilt die Faustregel: Häufig geänderten Code nach unten stellen, sich selten ändernde Dinge nach oben.
4.1. Build-Schritt für Dependencies separieren
pom.xml ändert sich viel seltener als der Quellcode. Wir kopieren sie zuerst und laden die Dependencies in einem separaten Layer:
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
Wenn du nun nur OrderController.java änderst, wiederverwendet Docker den dependency:go-offline-Layer. Die Build-Ausgabe sieht etwa so aus:
=> CACHED [build 3/5] COPY pom.xml .
=> CACHED [build 4/5] RUN mvn -B dependency:go-offline
=> [build 5/5] COPY src ./src
Hinweis:
dependency:go-offlinelädt nicht immer 100% aller Plugins, die Maven beimpackage-Schritt braucht. Einige Plugins könnten später heruntergeladen werden. Das Ergebnis ist dennoch weitaus besser als ohne diese Trennung.
4.2. Cache-Mount mit BuildKit
BuildKit (Standard in modernem Docker) unterstützt --mount=type=cache. Ein gemountetes Verzeichnis wird zwischen Builds beibehalten, ist aber nicht im Image enthalten:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
- Die Zeile
# syntax=docker/dockerfile:1aktiviert die volle neue Dockerfile-Syntax. /root/.m2wird auf der Builder-Seite gecacht. Auch wenn sichpom.xmländert, lädt Maven nur neue Dependencies herunter, anstatt alles neu zu laden.
Du kannst beide Techniken kombinieren. In CI ist Cache-Mounting nur effektiv, wenn der Builder zwischen Läufen erhalten bleibt. Ansonsten ist die Layer-Trennung aus Abschnitt 4.1 kombiniert mit Registry-Caching zuverlässiger.
5. Layer für Spring Boot JAR separieren
Ein Spring Boot Fat JAR enthält sowohl deinen Code als auch dutzende Megabyte an Bibliotheken. Wenn du die ganze JAR ins Image kopierst, erstellt jede Codeänderung einen neuen Layer von mehreren Megabyte. Die Registry muss diesen Layer speichern und der Server muss alles erneut abrufen.
Spring Boot unterstützt das Auspacken der JAR in Layers nach ihrer Änderungshäufigkeit. Ab Spring Boot 3.3 kannst du jarmode=tools verwenden:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Wichtige Punkte:
- Der
extract-Stage packt die JAR in vier Verzeichnisse aus:dependencies,spring-boot-loader,snapshot-dependencies,application. - Die vier
COPY-Befehle erstellen vier Layer, angeordnet von am wenigsten bis am häufigsten ändernd. - Wenn du Code änderst, ändert sich nur der
application-Layer. Dieser ist normalerweise nur ein paar hundert KB groß. - Die Option
--launcherbehält den Launcher bei. Deshalb ruftENTRYPOINTJarLauncherauf statt-jar.
Hinweis: Bei Spring Boot 3.0–3.2 verwendest du die alte Syntax
java -Djarmode=layertools -jar app.jar extract. Das Launcher-Paket unterscheidet sich zwischen den Versionen: ab 3.2 ist esorg.springframework.boot.loader.launch.JarLauncher, davororg.springframework.boot.loader.JarLauncher. Die falsche Klassenbezeichnung führt zuClassNotFoundExceptionbeim Containerstart.
6. Tests in einem separaten Stage ausführen
Das Setzen von -DskipTests im Build-Stage beschleunigt den Build, aber du möchtest kein ungetestetes Image in die Registry pushen. Multi-Stage Build ermöglicht es, Tests in einen separaten Stage auszulagern und den zu bauenden Stage mit --target auszuwählen.
Der Test für den Controller verwendet JUnit 5 und AssertJ (bereits in spring-boot-starter-test enthalten):
package com.nonla.orders;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
String body = mockMvc.perform(get("/orders/42/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("CONFIRMED");
}
@Test
void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
String body = mockMvc.perform(get("/orders/0/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("UNKNOWN");
}
}
Füge einen test-Stage basierend auf dem Build-Stage hinzu:
FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test
In CI führst du zuerst Tests aus und baust dann das Runtime-Image:
docker build --target test -t orders:test .
docker build -t orders:1.0.0 .
Der erste Befehl baut nur bis zum test-Stage. Wenn ein Test fehlschlägt, gibt der RUN-Befehl einen Exit-Code ungleich 0 zurück und der Build stoppt:
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1
Der zweite Befehl baut den letzten Stage. Mit BuildKit wird der test-Stage, da er nicht vom letzten Stage referenziert wird, übergangen. Das Production-Image muss die Tests nicht erneut ausführen. Dank Caching werden gemeinsame Stages auch nicht neu gebaut.
Hinweis: Du solltest keine Docker-abhängigen Integrationstests (z. B. Testcontainers) innerhalb von
docker buildausführen. Die Build-Umgebung hat keinen Docker Daemon. Führe solche Tests in einem separaten CI-Schritt aus.
7. Sicherheit: Non-Root-Benutzer und .dockerignore
7.1. Nicht als Root ausführen
Standardmäßig werden Prozesse im Container als root ausgeführt. Wenn die Anwendung kompromittiert wird, hat der Angreifer Root-Rechte im Container. Erstelle einen dedizierten Benutzer im Runtime-Stage:
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Schnelle Überprüfung:
docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)
Die genaue uid/gid kann unterschiedlich sein, solange sie nicht 0 ist. Bei Alpine-Images verwendest du addgroup -S spring && adduser -S spring -G spring statt groupadd und useradd.
7.2. .dockerignore hinzufügen
Der Build-Kontext ist das gesamte Verzeichnis, das du an docker build übergibst. Ohne Filter sendet Docker auch target/, .git/ und IDE-Konfigurationsdateien zum Builder. Dies verlangsamt den Build, zerstört Cache und könnte sensible Informationen preisgeben.
target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*
Platziere diese Datei im Projektverzeichnis neben Dockerfile.
8. Häufige Fehler
COPY . .zu früh: zerstört das Dependency-Cache. Kopierepom.xmlzuerst, dannsrc.- JDK im Runtime-Stage: funktioniert, aber verschwendet dutzende bis hunderte MB. Production braucht nur JRE, es sei denn, du brauchst Tools wie
jcmdzur Diagnose. - Wildcard passt mehrere JARs:
COPY --from=build /workspace/target/*.jar app.jarschlägt fehl, wenntarget/zusätzliche*-plain.jaroder*-sources.jarenthält. Setze<finalName>inpom.xmlfest oder deaktiviere Sekundärartifakte. - Shell-Form für
ENTRYPOINT:ENTRYPOINT java -jar app.jarläuft durch/bin/sh -c. Dann empfängtjavakeinSIGTERM, also kein graceful Shutdown in Spring und der Container wird nach Timeout getötet. latest-Tag für Basis-Images: der Build heute und nächsten Monat könnte unterschiedlich sein. Pinne die Version (z. B.eclipse-temurin:21-jre) oder den Digest, wenn du absolute Reproduzierbarkeit brauchst.- Secrets in
ARG/ENVsetzen: die Werte können durchdocker historysichtbar werden. Wenn du Token zum Herunterladen privater Dependencies brauchst, verwendeRUN --mount=type=secret.
Wann ist dies nicht nötig? Bei kleinen Scripts oder Images nur für persönliche Dev-Umgebung reicht ein Stage. Falls dein Team bereits mvn spring-boot:build-image (Cloud Native Buildpacks) oder Jib verwendet, kümmern sich diese Tools schon um Layer-Trennung und Non-Root. Du brauchst dann kein Dockerfile von Hand zu schreiben.
9. Fazit
Multi-Stage Build trennt die “Build-Umgebung” von der “Runtime-Umgebung” deutlich. Das endgültige Image enthält nur das, was deine Anwendung wirklich braucht. Kombiniert mit sinnvoller Layer-Ordnung, Cache-Mounting, Spring Boot-Layer-Trennung, separatem Test-Stage und Non-Root-Benutzer erhältst du ein Dockerfile mit schnellem Build, schlankem Image und deutlich besserem Security.
Du kannst mit dem Dockerfile aus Abschnitt 3 starten. Wende dann schrittweise jede Optimierung an und miss jeweils Build-Zeit und Image-Größe neu, um den Fortschritt deutlich zu sehen.