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:21enthä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 dannorg.springframework.boot.loader.launch.JarLauncherstatt-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.xmlvorCOPY 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) inapplicationund das Verzeichnislib/independenciesliegt, hat/appnach dem Zusammenführen die Strukturapp.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.0bedeutet Heap nutzt maximal 75% des Container-Memory-Limits.ExitOnOutOfMemoryErrorlä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 hatwgetvon BusyBox, du brauchstcurlnicht extra zu installieren.ENTRYPOINTin Exec-Form (JSON-Array):javaist PID 1 und erhältSIGTERM, 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:
ImageFromDockerfilebaut das Image aus demDockerfilein deinem Projektverzeichnis. Der Build-Context ist das Verzeichnis, das das Dockerfile enthält. Der Parameterfalsebehä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 -uim Container aus. Wenn jemand versehentlichUSER spring:springlöscht, ist das Ergebnis0, 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.jarin Shell-Form. Dann ist/bin/shPID 1 und leitetSIGTERMnicht an die JVM weiter. Der Container wird hart abgebrochen, Graceful Shutdown funktioniert nicht. Nutze immer die Exec-Form (JSON-Array).- Festes
-Xmxsetzen. Wenn du-Xmx2gsetzt, aber der Container nur 1 GB Memory hat, wird der Container OOM-killed, ohne Stack Trace. NutzeMaxRAMPercentage, damit der Heap sich an die Limit anpasst. - Alpine und native Bibliotheken. Alpine nutzt
muslstattglibc. Manche Bibliotheken mit native Code (Netty native transport, manche Treiber, Bildbibliotheken) können fehlerhaft oder langsam sein. Wechsle dann zueclipse-temurin:21-jre-jammyoder-noble. Das Image ist ein paar Dutzend MB größer, aber stabiler. COPY-Befehle in falscher Reihenfolge. Wenn duapplicationvordependencieskopierst, 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 wie21.0.5_11-jre-alpineoder einen Digest für garantierte Reproduzierbarkeit.
Hinweis: Brauchst du noch kleinere Images, kannst du
jdepsundjlinknutzen, 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.