1. „Ein Jar, drei Rollen“: Rollen der Images
Wenn Sie gerade erst anfangen, möchten Sie am liebsten ein einziges „richtiges“ Image haben und sich nie wieder darum kümmern. Verstehe ich. Das Gehirn liebt Stabilität, und Docker verhält sich manchmal wie eine Katze: solange man hinschaut – alles okay, dreht man sich um – ist etwas kaputtgegangen. Aber in der Realität hat eine Java-Anwendung in Docker verschiedene Aufgaben: Artefakt bauen, Artefakt ausführen und (manchmal) Probleme untersuchen. Und für jede Aufgabe ist es praktischer, ein „Image nach Rolle“ zu halten, statt ein „Image nach Laune“.
In einem Multi-Stage-Dockerfile werden die Rollen besonders deutlich. Der Builder-Stage ist eine „Werkstatt“, in der es laut, staubig ist, Werkzeuge herumliegen und man generell nicht in Hausschuhen hereinkommt. Der Runtime-Stage ist eine „Vitrine“, in die wir nur das geben, was der Benutzer braucht: das fertige jar und eine minimal ausreichende Umgebung. Und das Debug-freundliche Image ist ein „Erste-Hilfe-Kasten und Schraubendreher-Set“, das zwar schwer sein kann, aber hilft, wenn etwas schief gelaufen ist und Sie verstehen wollen warum, anstatt nur „neu zu bauen und zu hoffen“.
Damit das Gehirn das nicht als Magie wahrnimmt, ist es praktisch, ein einfaches Bild im Kopf zu behalten:
flowchart TD
A[Projektquellen + Gradle] --> B[Builder-Stage]
B -->|bootJar| C[Fertiges app.jar]
C -->|COPY --from| D[Runtime-Stage]
D --> E[Gestarteter Container]
B --> F[Debug-freundlicher Pfad bei Bedarf]
Wichtig: Wir ändern den Code von Container-Ready Catalog Service nicht, je nachdem, welches Image wir wählen. Wir ändern nur, „in welchen Schuhen“ dieser Code läuft. Und die Schuhe gibt es als Arbeits-, Alltags- und „für den Regenfall“.
2. JDK-Image: Erstellung von bootJar
Wenn es um das JDK-Image geht, denken viele Anfänger: „JDK größer → also ‚besser‘ → also überall einsetzen“. Das ist ungefähr so, als würde man in der Küche eine Axt bereithalten, falls man keine Gurke schneiden kann. Manchmal ist die Axt nützlich (besonders wenn die Gurke Legacy-Code ist), aber im Alltag stört sie. Das JDK-Image ist wichtig, aber es hat seine eigene Rolle: Build und erweiterte Diagnose.
Ein JDK-Image (im Container-Sinne) enthält normalerweise einen vollständigen Java-Toolchain. Es gibt java, aber oft auch javac (Compiler) und Hilfsprogramme, die helfen, in die JVM hineinzuschauen. Und das ist der Schlüsselpunkt: Der Builder-Stage kompiliert und baut das Artefakt tatsächlich, also lebt er logischerweise auf einem JDK-Image. Außerdem kann der Builder-Stage „dick“ sein – er wird trotzdem nicht in das finale Runtime-Image gelangen, wenn Sie Multi-Stage richtig machen.
Unten sehen Sie ein bewusst vereinfachtes Builder-Fragment: Jetzt ist es uns wichtig, genau die Rolle des JDK-Images zu sehen. Die cache-freundliche Reihenfolge von COPY und der Rest des Baseline-Layouts des Projekts bleiben die gleichen, die bereits früher zusammengestellt wurden.
Unten sehen Sie ein Mini-Fragment des Builder-Stage, das dem, was Sie nach dem Tag über Multi-Stage bereits haben, maximal ähnelt. Ich verwende eclipse-temurin als verständliches Beispiel; in Ihrem Repository kann der Vendor abweichen, aber der Sinn ändert sich nicht.
# syntax=docker/dockerfile:1
# Builder-Stage: Hier bauen wir das Artefakt, daher wird JDK benötigt
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace
# Kopieren Gradle-Umgebung und Quellen (vereinfacht, ohne Cache-Optimierungen)
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle gradle
COPY src src
# Erstelle bootJar innerhalb des Containers
RUN ./gradlew --no-daemon bootJar
Beachten Sie: Wir diskutieren jetzt überhaupt nicht, „wie man Gradle beschleunigt“ oder „wie man COPY für den Cache richtig anordnet“ – das hatten Sie an den vorherigen Tagen. Der Hauptsinn hier ist ein anderer: Der Build erfordert Werkzeuge, und das JDK-Image ist der natürliche Ort für diese Werkzeuge. Sie sagen quasi zu Docker: „In diesem Stage arbeiten wir wie eine Montagehalle, wir brauchen das ganze Set“.
Mit einer einfachen Command können Sie praktisch überprüfen, ob Sie tatsächlich in der JDK-Welt sind: Wenn das Image javac enthält, dann ist es definitiv nicht „runtime-only“.
# Überprüfen, ob im Image ein Compiler vorhanden ist (Kennzeichen JDK)
docker run --rm eclipse-temurin:25-jdk javac -version
# javac 25
Ja, das sieht zu einfach aus – und genau deshalb ist es gut: weniger Schamanismus, mehr Beobachtbarkeit.
3. Runtime-Image: Finaler Stage zum Starten
Im Runtime-Stage ist unser Ziel langweilig und wunderbar: den bereits erstellten jar starten. Nicht kompilieren, nicht bauen, nicht „mit Gradle spielen“, sondern einfach java -jar ausführen. Und sobald Sie diesen Gedanken akzeptieren, wird offensichtlich: Das finale Image braucht meistens nicht das gesamte JDK. Es braucht eine Java-Laufzeit (JRE-ähnliches Laufzeit-Image) und ein Minimum an Systemumgebung, damit die JVM normal arbeiten kann.
Warum ist das wichtig, abgesehen von der Ästhetik? Weil das finale Image das ist, was Sie tatsächlich zwischen Maschinen hin- und herschieben, pushen/pullen, speichern, aktualisieren werden. Es beeinflusst die Geschwindigkeit des „Service-Hochfahrens“, wirkt sich auf die Größe der lokalen Umgebung aus und spart Ihnen, ganz bodenständig gesprochen, einfach Zeit und Speicherplatz. Darüber hinaus: Je weniger Überflüssiges im Image ist, desto weniger „zufällige Details“, die sich als Sicherheitslücke, Konflikt oder Quelle von Unvorhersehbarkeit entpuppen können.
Aus Sicht des Dockerfiles sieht der Runtime-Stage fast banal aus – und das ist ein gutes Zeichen. Banalität bedeutet hier: „verständlich sogar am Montagmorgen ohne Kaffee“:
# Finaler Runtime-Stage: nur Start, ohne Build-Werkzeuge
FROM eclipse-temurin:25-jre
WORKDIR /app
# Holen das fertige jar aus dem Builder-Stage
COPY --from=builder /workspace/build/libs/*.jar app.jar
# Starten der Anwendung
ENTRYPOINT ["java", "-jar", "app.jar"]
Wenn Sie versuchen, in einem solchen Container javac auszuführen, werden Sie höchstwahrscheinlich einen Fehler erhalten (und das ist normal):
# Im Runtime-Image gibt es normalerweise keinen Compiler – das ist zu erwarten
docker run --rm eclipse-temurin:25-jre javac -version
# exec: "javac": executable file not found in $PATH
Für einen Anfänger klingt das nach „oh, da fehlt etwas“, aber für einen Ingenieur klingt es nach „prima, ich schleppe genau das nicht in die Runtime, was sie nicht braucht“. Ein Runtime-Image handelt genau davon: minimal ausreichend.
Ein weiterer wichtiger Punkt: Die Versionen sollten sinngemäß übereinstimmen. Wenn Sie bootJar auf Java 25 bauen und auf Java 25 Runtime ausführen, leben Sie ruhig. Wenn Sie auf 25 gebaut haben, aber auf 21 ausführen, dann wird es manchmal funktionieren, manchmal nicht, und manchmal wird es heißen: „alles funktionierte, bis ich eine Abhängigkeit hinzugefügt habe“. In diesem Kurs ist das Baseline fixiert (Java 25), und das ist nicht „Strenge um der Strenge willen“, sondern eine Möglichkeit, sich nicht die Quest „Rate die Inkompatibilität“ einzuhandeln.
4. Debug-freundliches Image: Werkzeug zur Diagnose
Jetzt könnte ein gefährlicher Gedanke aufkommen: „Okay, das Runtime-Image ist klein und schön, aber wie soll ich debuggen, wenn etwas kaputt geht? Vielleicht ist es einfacher, immer das JDK im finalen Stage zu verwenden und sich nicht zu quälen?“ Das ist, als würde man jeden Tag einen Wagenheber im Rucksack tragen, weil man eines Tages einen Platten hat. Der Wagenheber ist nützlich, aber ihn ständig mitzuschleppen – ein zweifelhaftes Vergnügen. In der Docker-Welt ist das der Sinn des Debug-freundlichen Images: Es wird nicht immer benötigt, sondern wenn Sie bewusst bequemer sehen möchten, was passiert.
Ein Debug-freundliches Image ist weder eine eigene Religion noch ein verbindlicher Standard. Es ist eine Abmachung: „In dieser Variante sind wir bereit, mit der Größe und ‚Breite‘ des Images zu bezahlen, um mehr Diagnosewerkzeuge zu erhalten.“ Oft besteht die einfachste Debug-freundliche Variante für Java darin, die Anwendung auf einem JDK-Image zu starten (oder vorübergehend das Runtime-Basis-Image durch JDK zu ersetzen), weil dort zusätzliche JVM-Utilities vorhanden sind. Ja, das ist „dicker“, aber im Moment der Problemuntersuchung ist die Dicke nicht der wichtigste KPI.
Die Mini-Idee sieht so aus: Ihr Dockerfile bleibt Multi-Stage, aber der Runtime-Stage erhält in der Debug-freundlichen Variante JDK anstelle von Runtime-only. Das kann man selbst mit dem kleinsten Fragment zeigen:
# Debug-freundliche Runtime: JDK vorübergehend wegen der Werkzeuge verwenden
FROM eclipse-temurin:25-jdk
WORKDIR /app
# Inhaltlich ist es derselbe Runtime-Stage: jar bereits im Builder gebaut
COPY --from=builder /workspace/build/libs/*.jar app.jar
# Der Start ist derselbe – der Unterschied liegt genau im Basis-Image und den verfügbaren Werkzeugen
ENTRYPOINT ["java", "-jar", "app.jar"]
Beachten Sie, wir haben hier nicht „eine Million Pakete“ hinzugefügt, keine separaten Modi eingeschaltet, das Image nicht zu einem Schweizer Taschenmesser gemacht. Wir haben einfach ehrlich gesagt: „Jetzt ist es mir wichtiger, Werkzeuge zu haben, als eine minimale Laufzeit“. Das ist nützlich, zum Beispiel, wenn Sie schnell etwas in der JVM-Welt überprüfen möchten oder zumindest mehr Standard-Utilities zur Hand haben möchten.
Eine wichtige Disziplin hier ist sehr einfach: Ein Debug-freundliches Image sollte nicht unbemerkt zu Ihrem „Default für immer“ werden. Es ist gut als Ermittlungswerkzeug, als temporärer Ersatz während der Diagnose, als Möglichkeit, ein Problem in einer verständlichen Umgebung zu reproduzieren. Aber wenn Sie jeden Tag „Debug-friendly als Default“ starten, dann ist das kein Debug-freundlich mehr, sondern einfach ungetrennte Rollen.
An dieser Stelle wird schon klar, dass ohne ein gemeinsames Baseline das Projekt schnell in die Situation „jeder hat sein Lieblingsimage“ abrutscht. Also brauchen wir als Nächstes nicht eine weitere lokale Lösung, sondern eine Projektregel.
Tabelle: JDK vs. Runtime vs. Debug-freundlich
Es ist sehr leicht, sich in Worten zu verlieren und über Geschmack zu streiten. Um das zu vermeiden, vergleichen wir die drei Rollen in einer möglichst bodenständigen Tabelle. Sie handelt nicht von „wer ist cooler“, sondern von „wer ist für was da“.
| Rolle des Images | Was es normalerweise bedeutet | Typische Umgebung im Inneren | Wo im Dockerfile es lebt | Was Sie gewinnen | Was Sie bezahlen |
|---|---|---|---|---|---|
| JDK-Image | Vollständiger Java-Toolchain | java, oft javac, JVM-Utilities | Im Builder-Stage | Man kann bootJar innerhalb Docker bauen, einfachere Diagnose im Build-Prozess | Größe und „Breite“ der Umgebung (aber oft unwichtig, da der Stage einmalig ist) |
| Runtime-Image (JRE-ähnlich) | Nur das, was zum Start nötig ist | java (normalerweise ohne Compiler) | Im finalen Runtime-Stage | Kleineres Image, schneller zu transportieren, weniger Überflüssiges | Weniger Werkzeuge „vor Ort“, Diagnose manchmal schwieriger |
| Debug-freundliches Image | Bewusst „etwas mehr“, der Bequemlichkeit halber | Oft JDK als Runtime oder Image mit zusätzlichen Utilities | Wird vorübergehend oder als separate Strategie genutzt | Einfachere Problemuntersuchung, mehr Werkzeuge im Container | Größe, potenziell mehr „überflüssige“ Komponenten, darf nicht zum stillen Default werden |
Wie nutzt man das im Leben? Ganz einfach: Bevor Sie FROM in einem bestimmten Stage schreiben, stellen Sie sich eine Frage: Bauen wir in diesem Stage das Artefakt, führen wir das Artefakt aus oder untersuchen wir ein Problem? Wenn Sie ehrlich geantwortet haben, wird die Wahl des Imagetyps fast mechanisch. Und das ist ehrlich gesagt eines der angenehmsten Gefühle in Docker: wenn statt „Zauberei“ eine „ingenieurmäßige Gewohnheit“ auftritt.
5. Container-Ready Catalog Service: Überprüfungen der Image-Rolle
Jetzt machen wir eine kleine Sache, die das Selbstvertrauen des Anfängers stark stärkt: Wir überprüfen, dass sich verschiedene Images wirklich „in der Rolle“ unterscheiden, nicht nur im Namen. Das geht nicht um das Schreiben neuen Codes, sondern um Beobachtbarkeit. Unser Service bleibt dieselbe Spring Boot-Anwendung, aber die Umgebung um ihn herum ändert sich – und wir müssen das sehen können.
Um die Rolle des Images nicht mit der Struktur des gesamten Dockerfiles zu vermischen, folgt unten ein schematisches Multi-Stage-Fragment nur zur Überprüfung der Rollen. Es ersetzt nicht das bereits erstellte Baseline des Projekts und überschreibt nicht die Reihenfolge von COPY.
Nehmen wir an, Ihr Dockerfile sieht nach dem Tag über Multi-Stage ungefähr so aus (vereinfacht, damit der Fokus auf den Images liegt, nicht auf Optimierungen):
# Stage 1: Build (benötigt JDK und Gradle)
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace
# Kopieren das Projekt und bauen jar (vereinfacht, ohne Details des cache-freundlichen Layouts)
COPY . .
RUN ./gradlew --no-daemon bootJar
# Stage 2: Start (Runtime reicht)
FROM eclipse-temurin:25-jre
WORKDIR /app
# Holen das Build-Ergebnis aus dem ersten Stage
COPY --from=builder /workspace/build/libs/*.jar app.jar
# Starten jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Jetzt zwei schnelle „Tests des gesunden Menschenverstands“.
Erster Test: Runtime-Image startet die Anwendung und zeigt die Java-Version.
# Erstellen das finale Runtime-Image
docker build -t catalog-service:runtime .
# Überprüfen die Java-Version innerhalb des finalen Containers
docker run --rm catalog-service:runtime java -version
# openjdk version "25" ...
Zweiter Test: Runtime-Image muss keinen Compiler enthalten.
# Überprüfen, dass im Runtime-Image kein javac vorhanden ist (und das ist normal)
docker run --rm catalog-service:runtime javac -version
# exec: "javac": executable file not found in $PATH
Wenn Sie vorübergehend eine Debug-freundliche Variante erhalten möchten (z. B. um zu vergleichen, „was ist überhaupt drin“), können Sie einen sehr einfachen Austausch von FROM eclipse-temurin:25-jre gegen FROM eclipse-temurin:25-jdk im Runtime-Stage vornehmen, ein weiteres Image bauen und den Unterschied mit demselben Befehl sehen.
Ja, das sieht aus wie „eine Zeile geändert“, aber methodisch ist es wichtig: Sie sehen, wie die „Rolle“ in einer sehr konkreten beobachtbaren Tatsache ausgedrückt wird. Das ist die Disziplin, die wir heute festigen: nicht mit Worten zu streiten, sondern die Umgebung mit kleinen beobachtbaren Aktionen manuell zu überprüfen.
6. Typische Fehler bei der Auswahl von Images
Fehler Nr. 1: „Da es auf JDK läuft, ist JDK die ideale Runtime.“
Das ist der häufigste logische Sprung. Der Start auf JDK funktioniert tatsächlich, und das erzeugt ein falsches Gefühl einer „universell richtigen“ Lösung. Aber Multi-Stage existiert genau dafür, dass der Runtime-Stage die Rolle des Starts ausdrückt, nicht die Rolle des Builds. Wenn Sie JDK ohne Grund im finalen Image behalten, haben Sie einfach die Rollen vermischt.
Fehler Nr. 2: „Ich versuche das Projekt im Runtime-Image zu bauen, denn da gibt es ja auch java.“
Auf den ersten Blick scheint es, dass, wenn es java gibt, Gradle irgendwie zurechtkommt. In der Praxis stoßen Sie auf fehlende Werkzeuge und unvorhersehbare Build-Fehler. Der Builder-Stage ist der Ort, an dem Build-Werkzeuge erlaubt sind, der Runtime-Stage ist der Ort, an dem sie nicht benötigt werden und oft schädlich sind.
Fehler Nr. 3: Erwarten, dass im Runtime-Image immer eine „normale Konsole“ und alle gewohnten Befehle verfügbar sind.
Manche Images sind minimalistisch: Sie enthalten möglicherweise nicht die gewohnten Utilities, manchmal nicht einmal eine Shell in der erwarteten Form. Für einen Anfänger sieht das aus wie „Container kaputt“. In Wirklichkeit ist es einfach der Preis der Minimalität. Wenn Sie ein Problem bequem untersuchen müssen, ist das genau der Anlass, vorübergehend auf die Debug-freundliche Variante umzusteigen.
Fehler Nr. 4: „Debug-freundlich“ mit „so muss es immer sein“ verwechseln.
Der Debug-freundliche Weg ist gut, wenn Sie ein konkretes Ziel haben: das Problem verstehen. Aber wenn er zum ewigen Default wird, verlieren Sie den Sinn der Rollentrennung und verwandeln das Projekt in eine Reihe von Kompromissen „für alle Fälle“. Beim nächsten Mal wird es Ihnen schwerer fallen zu erklären, was genau bei Ihnen als Baseline gilt und warum.
Fehler Nr. 5: Im Builder Java 25 bauen, aber in der Runtime versehentlich mit einer anderen Version starten.
Manchmal passiert das unbemerkt, besonders wenn Sie das Basis-Image „auf der Suche nach dem Besten“ ändern und parallel mit Tags experimentieren. Die Symptome können seltsam und nicht offensichtlich sein. Deshalb halten wir heute den Fokus auf die Rolle des Images und auf die Konsistenz der Versionen: Die Runtime muss kompatibel mit dem sein, wie Sie das Artefakt gebaut haben.
GO TO FULL VERSION