Spring Boot als schlankes Docker Image verpacken

1. Überblick

Spring Boot in Docker zu verpacken ist sehr leicht. Willst du aber ein Image, das klein, schnell zu bauen und sicher zu betreiben ist, brauchst du ein paar zusätzliche Schritte. Mit dem gewohnten „Drei-Zeilen”-Dockerfile ist das Image oft mehrere hundert MB groß, muss man jedes Mal, wenn man eine Codezeile ändert, die gesamte JAR-Datei neu hochladen, und der Prozess läuft unter root.

Dieser Artikel führt dich von einem naiven Dockerfile zu einem Multi-Stage-Dockerfile mit Layered JAR und minimalem JRE. Danach siehst du, wie du Buildpacks als Alternative nutzen kannst, und schreibst einen Test mit Testcontainers, um das gerade erstellte Image zu überprüfen.

2. Beispielanwendung

Wir nutzen einen kleinen Service namens order-service mit einem REST-Endpoint und Actuator für Health Checks.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    record Order(long id, String customer, BigDecimal total) {}

    @GetMapping("/{id}")
    Order find(@PathVariable long id) {
        return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
    }
}

Der Befehl ./mvnw package erzeugt die Datei target/order-service-1.0.0.jar. Das ist eine „Fat JAR”, die deinen Code und alle abhängigen Bibliotheken enthält.

3. Naives Dockerfile und seine Probleme

Eine frühe Version, die viele schreiben, sieht so aus:

FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Dieses Dockerfile funktioniert, hat aber vier Probleme:

  • Base Image ist zu groß. eclipse-temurin:21 enthält das komplette JDK: Compiler, jshell, javadoc… Das brauchst du zur Laufzeit nicht.
  • Alles in einer Layer. Die Fat JAR ist 20–30 MB groß, dein Code macht aber nur ein paar hundert KB aus. Ändert sich eine Codezeile, ändert sich diese 30-MB-Layer, und du musst sie erneut bauen und hochladen.
  • Abhängig von der Build-Maschine. Das Image braucht eine bereits gebaute JAR-Datei. JDK und Maven-Version können auf verschiedenen Maschinen unterschiedlich sein.
  • Läuft als root. Wird die Anwendung exploitiert, hat der Angreifer Root-Rechte im Container.

Überprüfe die Größe:

docker build -t order-service:naive .
docker images order-service
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   naive   3f1c9a7e2b10   ~480MB

Die genaue Größe hängt von der Base-Image-Version ab, aber ein halbes GB für einen „Hello World”-Service ist zu viel.

4. Multi-Stage Build mit Layered JAR

4.1. Was ist Layered JAR?

Seit Spring Boot 2.3 wird die Fat JAR automatisch in mehrere Layer aufgeteilt, je nach Änderungshäufigkeit:

Layer Inhalt Änderungshäufigkeit
dependencies Bibliotheken mit Release-Version Selten
spring-boot-loader Spring-Boot-Class-Loader Sehr selten
snapshot-dependencies -SNAPSHOT-Bibliotheken Manchmal
application Dein Code und seine Ressourcen Häufig

Du kopierst jeden Teil einzeln ins Image mit jeweils einem eigenen COPY-Befehl, von selten zu häufig ändernd. Docker cached die oberen Layer. Ändert sich nur dein Code, wird nur die application-Layer neu gebaut und hochgeladen.

Ab Spring Boot 3.3 extrahierst du die Layer mit jarmode=tools:

java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application  dependencies  snapshot-dependencies  spring-boot-loader

Das Verzeichnis application enthält die „schlanke” Datei order-service-1.0.0.jar. Diese referenziert das Verzeichnis lib/ aus der dependencies-Layer. Nach dem Zusammenführen aller Layer kannst du java -jar wie gewohnt ausführen.

Hinweis: Bei Spring Boot 3.2 und älter ist die Syntax java -Djarmode=layertools -jar app.jar extract. Der Entrypoint ist dann org.springframework.boot.loader.launch.JarLauncher statt -jar.

4.2. Vollständiges Dockerfile

# ---------- Stage 1: Build mit Maven ----------
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

# ---------- Stage 2: Layer extrahieren ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---------- Stage 3: Runtime ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./

USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "app.jar"]

Erklärung der einzelnen Teile:

  • Stage build: COPY pom.xml vor COPY src, damit Docker die Dependencies-Layer beim bloßen Code-Change wiederverwenden kann. Du musst nicht jedes Mal die halbe Maven-Central neu herunterladen.
  • Stage extract: teilt die JAR in vier Verzeichnisse auf. Diese Stage ist nur eine Zwischenstufe und erscheint nicht im finalen Image.
  • Stage runtime: nutzt 21-jre-alpine, nur JRE auf Alpine Linux.
  • Vier COPY-Befehle: jeder erzeugt eine Docker-Layer, geordnet von selten zu häufig ändernd. Weil die JAR „schlank” ist (app.jar) in application und das Verzeichnis lib/ in dependencies liegt, hat /app nach dem Zusammenführen die Struktur app.jar + lib/.
  • USER spring:spring: der Java-Prozess läuft nicht mehr als root.
  • JAVA_TOOL_OPTIONS: die JVM liest diese Variable automatisch. MaxRAMPercentage=75.0 bedeutet Heap nutzt maximal 75% des Container-Memory-Limits. ExitOnOutOfMemoryError lässt die JVM ordnungsgemäß beenden, wenn der Speicher aufgebraucht ist, damit der Orchestrator den Container neu starten kann, anstatt ihn hängend zu lassen.
  • HEALTHCHECK: Alpine hat wget von BusyBox, du brauchst curl nicht extra zu installieren.
  • ENTRYPOINT in Exec-Form (JSON-Array): java ist PID 1 und erhält SIGTERM, wenn der Container gestoppt wird. So kann Spring Boot korrekt herunterfahren.

4.3. Vergiss die .dockerignore nicht

Der Build-Befehl sendet dein gesamtes Verzeichnis zum Docker-Daemon. Entferne, was nicht nötig ist:

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile

Ohne diese Datei können die Verzeichnisse target/ oder .git/ den Build-Context auf Hunderte MB aufblähen. Jeder Build wird merklich langsamer.

4.4. Ergebnis

docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   1.0.0   9b2e44d1c0af   ~200MB
order-service   naive   3f1c9a7e2b10   ~480MB

0B      ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB    COPY /extract/extracted/application/ ./
0B      COPY /extract/extracted/snapshot-dependencies/ ./
290kB   COPY /extract/extracted/spring-boot-loader/ ./
22MB    COPY /extract/extracted/dependencies/ ./

Das Image ist mehr als halb so groß. Wichtiger: die application-Layer ist nur ein paar Dutzend KB. Ändert sich der Code in OrderController, wird nur diese Layer neu gebaut und hochgeladen. Das Hochladen dauert nur noch Sekunden.

5. Alternative: Cloud Native Buildpacks

Wenn du kein Dockerfile selbst warten möchtest, kann das Spring-Boot-Maven-Plugin Images direkt mit Buildpacks bauen:

./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=shop/order-service:1.0.0

Du kannst das auch in der pom.xml konfigurieren:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>shop/order-service:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
            </env>
        </image>
    </configuration>
</plugin>

Buildpacks machen automatisch, was wir oben manuell getan haben: Layer trennen, JRE wählen, Non-Root-User, JVM-Memory berechnen. Du brauchst kein Dockerfile, nur einen laufenden Docker-Daemon.

Der Nachteil: weniger Kontrolle. Das Base Image wählt der Buildpack, der erste Build dauert länger wegen der Buildpack-Images, und tiefere Anpassungen sind schwerer als ein Dockerfile zu ändern. Teams, die schnell standardisieren wollen, sollten Buildpacks nutzen. Teams, die Kontrolle brauchen, zum Beispiel weil ein firmeninternes, sicherheitsgeprüftes Base Image vorgegeben ist, sollten ein Dockerfile schreiben.

6. Image mit Testcontainers testen

Ein „schlankes” Image, das nicht startet, ist sinnlos. Du kannst einen Test schreiben, der das Image aus deinem Dockerfile baut, einen Container startet und prüft, dass alles funktioniert.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

Spring-Boot-BOM verwaltet die Versionen beider Bibliotheken, du brauchst <version> also nicht anzugeben.

@Testcontainers
class OrderServiceImageIT {

    static final ImageFromDockerfile IMAGE =
            new ImageFromDockerfile("order-service-it", false)
                    .withDockerfile(Path.of("./Dockerfile"));

    @Container
    static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
            .withExposedPorts(8080)
            .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
            .withStartupTimeout(Duration.ofMinutes(2));

    @Test
    void shouldServeOrdersAndReportHealthy() throws Exception {
        String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
        HttpClient client = HttpClient.newHttpClient();

        HttpResponse<String> response = client.send(
                HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
                HttpResponse.BodyHandlers.ofString());

        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
    }

    @Test
    void shouldNotRunAsRoot() throws Exception {
        var result = APP.execInContainer("id", "-u");

        assertThat(result.getStdout().trim()).isNotEqualTo("0");
    }
}

Dieser Test macht folgendes:

  • ImageFromDockerfile baut das Image aus dem Dockerfile in deinem Projektverzeichnis. Der Build-Context ist das Verzeichnis, das das Dockerfile enthält. Der Parameter false behält das Image nach dem Test, um das nächste Mal schneller zu bauen dank Cache.
  • Wait.forHttp("/actuator/health") wartet, bis die Anwendung wirklich bereit ist, nicht nur, bis der Container gestartet ist.
  • Erster Test ruft den Endpoint über den zufällig zugeordneten Port auf dem Host auf und prüft das JSON.
  • Zweiter Test führt id -u im Container aus. Wenn jemand versehentlich USER spring:spring löscht, ist das Ergebnis 0, und der Test schlägt sofort fehl.

Benenne die Klasse mit IT-Suffix und führe sie mit dem Maven-Failsafe-Plugin aus. Der Test baut das komplette Image, dauert also viele Sekunden und passt nicht in reguläre Unit-Tests.

7. Häufige Fehler und wann man diese Lösung nicht anwenden sollte

  • ENTRYPOINT java -jar app.jar in Shell-Form. Dann ist /bin/sh PID 1 und leitet SIGTERM nicht an die JVM weiter. Der Container wird hart abgebrochen, Graceful Shutdown funktioniert nicht. Nutze immer die Exec-Form (JSON-Array).
  • Festes -Xmx setzen. Wenn du -Xmx2g setzt, aber der Container nur 1 GB Memory hat, wird der Container OOM-killed, ohne Stack Trace. Nutze MaxRAMPercentage, damit der Heap sich an die Limit anpasst.
  • Alpine und native Bibliotheken. Alpine nutzt musl statt glibc. Manche Bibliotheken mit native Code (Netty native transport, manche Treiber, Bildbibliotheken) können fehlerhaft oder langsam sein. Wechsle dann zu eclipse-temurin:21-jre-jammy oder -noble. Das Image ist ein paar Dutzend MB größer, aber stabiler.
  • COPY-Befehle in falscher Reihenfolge. Wenn du application vor dependencies kopierst, wird jede Code-Änderung den Cache der Dependency-Layer ungültig. Du verlierst den ganzen Vorteil der Layered JAR.
  • latest-Tag für Base Image. Ein Build heute und eine Woche später können zwei verschiedene Images sein. Nutze einen spezifischen Tag wie 21.0.5_11-jre-alpine oder einen Digest für garantierte Reproduzierbarkeit.

Hinweis: Brauchst du noch kleinere Images, kannst du jdeps und jlink nutzen, um eine JRE zu bauen, die nur die benötigten Module enthält, oder native Code mit GraalVM kompilieren. Beide Wege sparen viel Platz, machen den Build aber komplexer und brechen leicht bei Reflection. Nutze das nur, wenn Image-Größe oder Startup-Zeit wirklich ein Problem sind, zum Beispiel bei Serverless oder Scale-to-Zero.

8. Fazit

In diesem Artikel hast du ein Drei-Zeilen-Dockerfile in ein Multi-Stage-Dockerfile mit folgenden Schritten umgewandelt:

  • Build im Container, damit die Build-Maschine egal ist;
  • Layered JAR, damit nur die Code-Layer sich nach einer Änderung neu buildet;
  • Alpine JRE zur Größeneinsparung;
  • Non-Root-User, Health Check und JVM-Memory-Konfiguration nach Container-Limit.

Du hast auch Buildpacks als Dockerfile-freie Alternative gesehen. Zum Abschluss hilft ein Testcontainers-Test sicherzustellen, dass das Image nach jeder Änderung noch startet und nicht als root läuft.

spring-bootdockerjavadevopscontainer