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 vor mvn 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 build benennt den ersten Stage. Du verwendest diesen Namen bei COPY --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, nicht javac oder Maven.
  • COPY --from=build kopiert nur die JAR-Datei ins finale Image.
  • ENTRYPOINT in der Exec-Form (JSON-Array). Dadurch wird java als PID 1 ausgeführt und empfängt das Signal SIGTERM, 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-offline lädt nicht immer 100% aller Plugins, die Maven beim package-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:1 aktiviert die volle neue Dockerfile-Syntax.
  • /root/.m2 wird auf der Builder-Seite gecacht. Auch wenn sich pom.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 --launcher behält den Launcher bei. Deshalb ruft ENTRYPOINT JarLauncher auf 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 es org.springframework.boot.loader.launch.JarLauncher, davor org.springframework.boot.loader.JarLauncher. Die falsche Klassenbezeichnung führt zu ClassNotFoundException beim 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 build ausfü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. Kopiere pom.xml zuerst, dann src.
  • JDK im Runtime-Stage: funktioniert, aber verschwendet dutzende bis hunderte MB. Production braucht nur JRE, es sei denn, du brauchst Tools wie jcmd zur Diagnose.
  • Wildcard passt mehrere JARs: COPY --from=build /workspace/target/*.jar app.jar schlägt fehl, wenn target/ zusätzliche *-plain.jar oder *-sources.jar enthält. Setze <finalName> in pom.xml fest oder deaktiviere Sekundärartifakte.
  • Shell-Form für ENTRYPOINT: ENTRYPOINT java -jar app.jar läuft durch /bin/sh -c. Dann empfängt java kein SIGTERM, 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/ENV setzen: die Werte können durch docker history sichtbar werden. Wenn du Token zum Herunterladen privater Dependencies brauchst, verwende RUN --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.

dockerdockerfilemulti-stage buildspring bootjava