1. Wann ein Layered Multi-Stage Dockerfile nötig ist
Ehrlich gesagt: Ein Multi-Stage Dockerfile ist bereits ein Sieg – Sie haben die „Küche“ (Gradle, Quelltexte, Build) von der „Servierplatte“ (finales Runtime-Image) getrennt. Aber dieser Sieg hat eine kleine Fortsetzung im Stil von „und jetzt gewinnen wir nicht nur, sondern auch noch schön“. Wenn Sie in der Runtime-Stage ein einziges COPY ... app.jar ausführen, machen Sie die Anwendung wieder zu einem einzigen „Klotz“, und Docker cached es grob: Wenn Sie eine Zeile ändern, ändert sich die gesamte Jar-Schicht.
Spring Boot Layered Packaging löst genau dieses praktische Problem: Es hilft uns, die Abhängigkeiten in separaten Docker-Layern vom Anwendungscode zu trennen. Das ist besonders im Alltag angenehm, wenn Sie den Geschäftscode 20 Mal am Tag ändern, die Abhängigkeiten aber wochenlang unverändert bleiben. Infolgedessen werden der wiederholte Build und das erneute Herunterladen des Images merklich „intelligenter“.
Um die Idee zu verdeutlichen, schauen wir uns das „Vorher“ und „Nachher“ nicht als Glaubensfrage, sondern als Mechanik des Caches an.
„Vorher“: ein Jar – eine große Runtime-Schicht
# Runtime-Stage (vereinfacht)
WORKDIR /app
# Kopieren eines „dicken“ Artefakts: jede Änderung im Jar -> gesamte Schicht ändert sich
COPY --from=builder /workspace/build/libs/catalog-service.jar app.jar
# Jar direkt starten: gewohnt, aber grob gecached
ENTRYPOINT ["java", "-jar", "app.jar"]
Das ist eine funktionierende Variante. Das Problem ist nicht, dass sie „schlecht“ ist, sondern dass sie zu monolithisch ist: Das Jar ändert sich, die Schicht ändert sich.
„Nachher“: Abhängigkeiten und Code – separate Docker-Layer
# Runtime-Stage (vereinfacht)
# Abhängigkeiten separat: ändern sich selten -> Layer oft aus dem Cache
COPY --from=extract /extract/layers/dependencies/ ./
# Anwendungscode separat: ändert sich häufig -> hauptsächlich dieser Layer wird neu gebaut
COPY --from=extract /extract/layers/application/ ./
Der Sinn liegt genau darin: Docker sieht verschiedene COPY als verschiedene Layer und verwendet den Cache präziser. Spring Boot gibt uns vor, was genau in dependencies kommt, was zu application gehört und warum das keine „aus der Luft gegriffenen Ordner“ sind.
2. Schema des Dockerfile: Builder → Extract → Runtime
Jetzt kommt der Moment, in dem ein Anfänger denkt: „Oh, drei Stages … ich wollte doch nur einen Container starten.“ Das ist normal. Es ist wichtig, eine einfache Sache zu akzeptieren: Ein Dockerfile ist keine „magische Datei“, sondern ein Build-Skript. Es wird nicht aus Liebe zum Leiden länger, sondern weil wir die Phasen explizit trennen.
Unten sehen Sie das Schema, was wir tun. Beachten Sie: Wir haben Multi-Stage (Dockerfile-Stages) und darin die Boot-Layer, die wir durch separate COPY-Anweisungen in Docker-Layer verwandeln.
flowchart LR
A["Builder-Stage
JDK + Gradle Wrapper
./gradlew bootJar"] -->|bootJar| B["Extract-Stage
JRE
jarmode=tools extract"]
B -->|Layer-Verzeichnisse| C["Runtime-Stage
JRE
COPY jedes Layers separat"]
Und jetzt dasselbe in einer „Tabelle für den Kopf“, damit Sie nicht alles intuitiv behalten müssen:
| Teil des Dockerfile | Wozu dient er? | Was kommt raus? |
|---|---|---|
| builder | Erzeugt bootJar innerhalb des Docker-Builds (ohne Abhängigkeit vom Host-Build) | lauffähiges bootJar in build/libs/ |
| extract | Nimmt das Jar und zerlegt es über jarmode=tools in Layer-Verzeichnisse | layers/dependencies, layers/application etc. |
| runtime | Baut das finale Image, wobei jeder Layer mit einem eigenen COPY kopiert wird | Cache-freundliches, „geschichtetes“ Runtime-Image |
Wichtig: Spring Boot Layering ist das Wissen über die Struktur des Jars, während Docker Layering das ist, was nach unseren COPY-Anweisungen im Image entsteht. Wir verwenden Ersteres, um Letzteres richtig zu entwerfen.
3. Stage Builder: bootJar und Cache
In den vorherigen Tagen hatten wir bereits eine Builder-Stage. Hier erfinden wir das Rad nicht neu, sondern passen es behutsam an das Layered-Szenario an. Unser Ziel ist einfach: Am Ende der Builder-Stage soll ein klares bootJar liegen, das wir dann an extract übergeben können. Der tatsächliche Dateiname in build/libs kann versioniert sein; daher halten wir eine einfache Regel: Aus dem Build-Output nehmen wir das lauffähige Jar und benennen es in der nächsten Stage gleich application.jar.
Der entscheidende Punkt für die Geschwindigkeit ist die Reihenfolge des Kopierens. Zuerst kopieren wir den Gradle-Wrapper und die Build-Skripte (sie ändern sich selten), führen einen Schritt aus, der Abhängigkeiten lädt, und erst danach kopieren wir src. Dann zwingt eine Änderung am Geschäftscode Docker nicht dazu, den gesamten Dependency-Teil neu zu „durchlaufen“.
Hier eine kompakte Builder-Stage, die gut ins Übungs-Repository passt:
FROM eclipse-temurin:25-jdk-jammy AS builder
WORKDIR /workspace
# Zuerst Wrapper und Skripte: das ist der „stabile Teil“, praktisch für den Cache
COPY gradlew .
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts ./
# Abhängigkeiten vorwärmen: bei unveränderten Skripten kommt dieser Layer häufiger aus dem Cache
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies
# Anwendungscode kopieren wir nach den Abhängigkeiten: so zerstören Änderungen in src den Cache weniger
COPY src src
# bootJar bauen (Tests werden für den Image-Build oft aus Geschwindigkeitsgründen deaktiviert)
RUN ./gradlew --no-daemon bootJar -x test
Beachten Sie zwei „Kleinigkeiten“, die später viel Ärger sparen. chmod +x gradlew macht den Build widerstandsfähiger in gemischten Umgebungen (besonders wenn jemand das Repository unter Windows geklont hat und die Dateirechte zur Überraschung werden). Und --no-daemon entfernt den langlebigen Gradle-Daemon, der in Container-Builds normalerweise keinen Nutzen bringt, dafür aber in den Logs ein „Eigenleben“ führt.
Hier sollte die Builder-Stage mit einem einzigen benötigten lauffähigen Jar in build/libs/ enden. Wenn daneben noch ein plain.jar liegt, sollte das besser sofort geklärt werden, anstatt darauf zu hoffen, dass die nächste Stage „schon weiß, was zu kopieren ist“.
4. Stage Extract: jarmode=tools extract
Jetzt kommt der interessanteste Teil: Wir nehmen das Jar wie einen „gepackten Koffer“ und legen die Sachen ordentlich in Fächer. Und hier macht Spring Boot endlich das, wofür wir es lieben: Es liefert ein offizielles Werkzeug, anstatt uns zu bitten, das Jar von Hand zu entpacken und herauszufinden, wo BOOT-INF/lib ist und wer es so genannt hat.
Es ist wichtig, die Logik im Kopf zu behalten: Extract-Stage ist nicht Runtime. Es ist eine technische Zwischenstufe, die nur existiert, um die zerlegten Layer vorzubereiten.
Eine minimale Extract-Stage sieht so aus:
FROM eclipse-temurin:25-jre-jammy AS extract
WORKDIR /extract
# Das gebaute bootJar aus der Builder-Stage holen und innerhalb der Stage einen stabilen Namen geben
COPY --from=builder /workspace/build/libs/*.jar application.jar
# Das Jar in Spring Boot Layer (Verzeichnisse) entpacken, um sie später als separate Docker-Layer kopieren zu können
RUN java -Djarmode=tools -jar application.jar extract --layers --destination layers
Hier ist application.jar kein neuer „offizieller Name“ des Projektartefakts, sondern einfach ein stabiler Alias innerhalb der Extract-Stage. Danach sind für uns nicht mehr diese Datei selbst wichtig, sondern die Verzeichnisse layers/..., die nach dem extract entstanden sind.
Um nicht zu raten, was genau entstanden ist, ist es hilfreich zu verstehen, welche Struktur normalerweise nach der Extraktion entsteht. Für die Erklärung im Unterricht kann man sich folgendes Bild merken:
/extract/layers/
├── dependencies/
│ └── BOOT-INF/lib/...
├── spring-boot-loader/
│ └── org/springframework/boot/loader/...
├── snapshot-dependencies/
│ └── BOOT-INF/lib/...
└── application/
└── BOOT-INF/classes/...
Das sind keine vier abstrakten Ordner, sondern vier Teile ein und derselben Anwendung, die so zerlegt sind, dass Docker sie separat cachen kann.
Wenn Sie die Layer zuerst ansehen möchten (und das ist ein guter Wunsch, er ist ingenieurtechnisch), geschieht das mit dem Befehl list-layers. Es ist meist bequemer, dies lokal auszuführen und nicht im Dockerfile, um den Build nicht zum „Ausdruck von Informationen um der Information willen“ zu machen:
# Jar lokal bauen (um dann zu sehen, welche Layer Spring Boot erzeugt)
./gradlew bootJar
# Liste der Layer anzeigen, die Spring Boot über jarmode=tools extrahieren kann
java -Djarmode=tools -jar build/libs/<Ihr-bootJar>.jar list-layers
# dependencies
# spring-boot-loader
# snapshot-dependencies
# application
5. Stage Runtime: Finales Image aus den Layern
Die letzte Phase ist der Moment, in dem wir die „Layer-Verzeichnisse“ in ein echtes lauffähiges Image verwandeln. Und hier ist es wichtig, nicht zur alten Gewohnheit zurückzukehren: application.jar aus der Extract-Stage nehmen wir nicht wieder mit ins finale Image. Nach der Extraktion brauchen wir genau die Verzeichnisse layers/....
Das richtige Modell ist: In der Runtime-Stage kopieren wir den Inhalt jedes Layers mit einem eigenen COPY-Befehl. Diese Befehle werden zu separaten Docker-Layern. Und den Start führen wir über den Spring Boot Launcher durch (d.h. wir starten die Main-Klasse des Loaders aus spring-boot-loader), denn das Jar als Datei brauchen wir nicht mehr – wir haben eine „explodierte“ Struktur der Anwendung.
Die Runtime-Stage für ein Übungsprojekt sieht so aus:
FROM eclipse-temurin:25-jre-jammy AS runtime
WORKDIR /app
# Reihenfolge ist wichtig: stabilere Layer zuerst, dynamischere später
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/ ./
# Wir starten nicht die Jar-Datei, sondern den Spring Boot Launcher, der eine „explodierte“ Struktur erwartet
ENTRYPOINT ["java", "-cp", ".", "org.springframework.boot.loader.launch.JarLauncher"]
Hier gibt es einige wichtige „Warums“, die man laut aussprechen sollte.
Warum kopieren wir in genau dieser Reihenfolge? Weil sie dem Sinn der Layer entspricht: stabilere Abhängigkeiten liegen weiter vorne, und application (der dynamischste Layer) kommt als Letztes. So wird bei Codeänderungen meist nur der letzte Layer neu aufgebaut.
Warum -cp "."? Das ist einfach ehrliche Explizitheit. Normalerweise verwendet Java die aktuelle Verzeichnis sowieso als Classpath, aber es ist besser, sich nicht auf „normalerweise“ zu verlassen, sondern es vorhersagbar zu schreiben.
Warum ENTRYPOINT in Exec-Form? Das bewahrt unsere Baseline-Disziplin: Der Container kann Signale ordentlich an den Prozess weiterleiten, und der Java-Prozess bleibt der Hauptprozess des Containers (ohne unnötige Shell-Zwischenschichten).
Wenn man die drei Stages zusammenfasst, erhält man ein vollständiges Dockerfile, das man in das Repository-Wurzelverzeichnis legen kann:
# 1) builder: bootJar innerhalb des Docker-Builds erzeugen
FROM eclipse-temurin:25-jdk-jammy AS builder
WORKDIR /workspace
COPY gradlew .
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts ./
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies
COPY src src
RUN ./gradlew --no-daemon bootJar -x test
# 2) extract: Spring Boot Layer extrahieren (Zwischenstufe, keine Runtime)
FROM eclipse-temurin:25-jre-jammy AS extract
WORKDIR /extract
COPY --from=builder /workspace/build/libs/*.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination layers
# 3) runtime: Finales Image bauen, jeden Layer mit eigenem COPY kopieren
FROM eclipse-temurin:25-jre-jammy AS runtime
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/ ./
ENTRYPOINT ["java", "-cp", ".", "org.springframework.boot.loader.launch.JarLauncher"]
Aus Docker-Sicht sind das einfach drei klare Phasen. Aus Spring-Boot-Sicht ist das eine saubere Verwandlung des „dicken Jars“ in eine „fein cachebare Runtime“.
6. Cache und docker image history überprüfen
Der Effekt eines Layered Dockerfile ähnelt manchmal einem guten Refactoring: Solange man nicht „Vorher/Nachher“ vergleicht, scheint es, als hätte man nur mehr Zeilen geschrieben. Daher ist es wichtig, das Ergebnis nicht nur mit den Augen, sondern mit Werkzeugen zu sehen. Die einfachsten Werkzeuge sind der gewöhnliche Build und die Ansicht der Layer-Historie.
Der Build eines Layered-Images sieht wie gewohnt aus:
# Image aus dem Dockerfile im aktuellen Verzeichnis bauen
docker build -t catalog-service:layered .
Wenn Sie etwas besser sehen möchten, welche Schritte tatsächlich gecached werden, empfiehlt es sich, nicht nur auf die Tatsache „Build erfolgreich“ zu achten, sondern darauf, was Docker zur Wiederverwendung von Layern schreibt. Bei einem wiederholten Build sollten, wenn Sie die Build-Skripte und Abhängigkeiten nicht geändert haben, einige Schritte der Builder-Stage aus dem Cache kommen. Wenn Sie nur Java-Code geändert haben, wird in der Runtime-Stage meist nur der mit application verbundene Layer neu gebaut.
Um zu sehen, aus welchen Layern das Image besteht, ist der Befehl docker image history nützlich. Er „erklärt keine Philosophie“, zeigt aber hervorragend, wo sich große Layer befinden und welche Dockerfile-Anweisungen sie erzeugt haben:
# Zeigt die Layer des endgültigen Images und die Befehle, die sie erzeugt haben
docker image history catalog-service:layered
In diesem Moment wird der Preis und der Nutzen des manuellen Pfades deutlich. Er erfordert mehr explizite Schritte, gibt dafür aber die vollständige Kontrolle darüber, wo der Builder endet, wie die Layer extrahiert werden und was tatsächlich in die Runtime gelangt. Wenn diese Kontrolle nicht nötig ist, kann dieselbe Aufgabe auf eine stärker gemanagte Weise gelöst werden – aber sie richtig zu verstehen, ist einfacher, nachdem man diesen Baseline manuell aufgebaut hat.
Im Idealfall sehen Sie, dass die finale Stage aus mehreren großen Layern besteht, und der letzte, dynamischste, dem Kopieren von application/ entspricht. Wenn das gesamte finale Image zu einem einzigen Layer „kollabiert“ ist, ist das normalerweise ein Zeichen dafür, dass Sie irgendwo wieder ein großes COPY gemacht oder versehentlich COPY app.jar zurückgebracht haben.
Eine weitere nützliche Methode, über den Cache nachzudenken, ist ein Gedankenexperiment. Stellen Sie sich zwei Änderungen vor.
Im ersten Fall haben Sie nur die Datei src/main/java/.../CatalogItemService.java geändert. Dann sollte sich gemäß der Idee der Boot-Layer nur application ändern. Abhängigkeiten, Loader und Snapshot-Abhängigkeiten bleiben unverändert, sodass Docker sie als Layer wiederverwenden kann.
Im zweiten Fall haben Sie eine neue Abhängigkeit in build.gradle.kts hinzugefügt. Dann ändert sich die Zusammensetzung von dependencies (und möglicherweise snapshot-dependencies), sodass das Image ehrlich frühere Layer neu baut – und das ist normal: Sie haben tatsächlich das Fundament geändert, nicht nur die Tapete.
Eine solche Ursache-Wirkungs-Beziehung ist das Hauptmerkmal dafür, dass Sie nicht einfach „Befehle aus einem Artikel wiederholt“ haben, sondern wirklich die Struktur des Images wie Ingenieure steuern.
7. Typische Fehler im Layered Dockerfile
Fehler sind hier in der Regel nicht böswillig oder auf „Unaufmerksamkeit“ zurückzuführen, sondern darauf, dass wir gleichzeitig zwei Modelle haben: Docker Multi-Stage und Spring Boot Layering. Im Folgenden die häufigsten Stolpersteine, auf die fast alle treten (ich würde sagen: „obligatorischer Programmpunkt“, aber wir wollen ja Schmerz nicht zur Tradition machen).
Fehler Nr. 1: Layer extrahieren, aber trotzdem mit java -jar app.jar starten.
Das erscheint aus Gewohnheit logisch: Jar ist das vertraute Artefakt, also starten wir es. Wenn das Jar jedoch im finalen Image bleibt und genau dafür zum Start verwendet wird, wird die Layer mit dem Jar wieder groß und dynamisch, und der Nutzen der separaten COPY-Anweisungen nimmt drastisch ab. Im Layered-Pfad erfolgt der Start über die Launcher-Klasse von Boot, weil wir eine „explodierte“ Struktur starten, keine einzelne Datei.
Fehler Nr. 2: Layer-Verzeichnisse als Verzeichnisse kopieren, nicht als Inhalt.
Wenn man COPY ... /dependencies /app/ schreibt, erscheint in /app ein Ordner dependencies, in dem sich BOOT-INF/lib befindet, und dann findet JarLauncher die erwartete Struktur nicht. Die Variante COPY ... /dependencies/ ./ (beachten Sie den / am Ende) kopiert den Inhalt des Layers in das Arbeitsverzeichnis und legt das „Bild der Anwendung“ richtig an.
Fehler Nr. 3: Die Reihenfolge des Kopierens der Layer vertauschen.
Manchmal möchte man die Befehle „aus Schönheitsgründen“ umstellen. Aber die Reihenfolge ist keine Kosmetik. Sie beeinflusst sowohl den Cache (was stabiler ist) als auch die Vorhersagbarkeit der Struktur. Wenn Sie application früher und Abhängigkeiten später kopieren, erhalten Sie ein ungünstigeres Cache-Profil: Bei Änderungen der Abhängigkeiten könnte das neu aufgebaut werden, was hätte wiederverwendet werden können.
Fehler Nr. 4: Wildcard für das Jar verwenden und nicht prüfen, ob sie exakt auf ein bootJar passt.
Das Muster build/libs/*.jar ist praktisch, setzt aber voraus, dass in build/libs genau ein benötigtes lauffähiges Jar liegt. Wenn daneben noch ein plain.jar oder mehrere ähnliche Artefakte liegen, wird COPY zur Lotterie oder schlägt ganz fehl. Für den Layered-Pfad wird ein eindeutiges bootJar benötigt: Entweder bereinigen Sie das Ausgabeverzeichnis oder legen den Artefaktnamen in der Build-Konfiguration fest.
Fehler Nr. 5: --destination vergessen und „irgendwohin entpackt“ erhalten.
Ohne explizites Ziel kann die Extraktion an einer unerwarteten Stelle landen (besonders, wenn Sie WORKDIR geändert oder Befehle verschoben haben). Und dann beginnt das Lieblingsspiel des Docker-Anfängers: „Warum findet COPY --from den Pfad nicht?“. --destination layers ist eine kleine Disziplin, die viel Zeit spart.
GO TO FULL VERSION