1. Ziel: Ein Dockerfile, zwei Images
Die Rollen der Stages sind bereits getrennt, JDWP ist verstanden, --target auch. Jetzt müssen wir nur noch alles in einem Dockerfile zusammenfassen, das keine parallelen Versionen desselben Dienstes hervorbringt.
Wir brauchen eine Datei, die denselben Dienst beschreibt, aber zwei klare Ergebnisse liefert: runtime für den normalen Start und development für die Diagnose. Beide Varianten starten denselben jar; nur die Containerumgebung und die Art des JVM-Starts ändern sich.
Stages-Schema: builder → development → runtime
Bei einem Multi-Stage-Dockerfile hat man manchmal das Gefühl, dass es sich um „einfach mehrere FROM hintereinander“ handelt. Technisch gesehen ja. Aber es ist hilfreicher, die Datei wie eine Geschichte zu lesen: zuerst das Artefakt bauen, dann den Dev-Pfad, dann das finale Runtime. Und noch ein wichtiges Detail: Docker baut standardmäßig den letzten Stage, daher ist es praktisch, runtime am Ende zu halten.
Zur Veranschaulichung – das Schema:
flowchart TD
A["Quellcode + Gradle Wrapper
im Build-Kontext"] --> B["Stage: builder
./gradlew bootJar"]
B --> C["Stage: development
JDK + JDWP + Port 5005"]
B --> D["Stage: runtime
sauberer Start ohne JDWP"]
C --> E["docker build --target development -t ...:dev ."]
D --> F["docker build -t ...:runtime . (default)"]
Achten Sie auf den Schlüsselgedanken: sowohl development als auch runtime kopieren denselben Jar aus builder. Wir machen nicht „zwei verschiedene Jars“ und starten nicht „im Dev etwas anderes, im Runtime etwas anderes“. Sonst haben Sie zwei Anwendungen unter einem Namen – und das ist nicht mehr Docker, das ist fast Philosophie.
2. Builder-Stage: Ein bootJar für beide Modi
Die Builder-Stage bleibt der einzige Ort, an dem bootJar gebaut wird. Das verhindert Drift zwischen Dev und Runtime: Beide folgenden Stages kopieren nur das fertige Artefakt, anstatt etwas Eigenes zu bauen.
Im aktuellen Baseline macht der Builder nur bootJar, daher bleibt COPY --from=builder /workspace/build/libs/*.jar app.jar eindeutig. Wenn Ihr Projekt mehrere Jars in build/libs ablegt, müssen Sie das Pattern auf ein bestimmtes Artefakt eingrenzen.
Development-Stage: JDK + JDWP und Port 5005
Der Development-Stage wird nur für einen anderen JVM-Modus benötigt: JDK-Umgebung, JDWP und Debug-Port. Die Anwendung selbst wird dabei nicht zu einer „speziellen Dev-Version“ – sie startet denselben Jar aus der Builder-Stage.
Runtime-Stage: sauberer Standard
Die Runtime-Stage nimmt denselben Jar, startet ihn aber ohne JDWP und ohne Dev-Tools. Es ist praktisch, ihn als letzte Stage zu halten, damit ein normaler docker build . ein ruhiges Standard-Image liefert.
Vollständiges Dockerfile: eine Datei, drei Rollen
Unten – das Dockerfile, das den gesamten Workflow ohne Dockerfile.dev und ohne zweiten Jar-Build zusammenfasst.
# ===== 1) Builder: bootJar bauen =====
FROM gradle:9.4.0-jdk25 AS builder
WORKDIR /workspace
# Vereinfacht: kopieren das gesamte Repository in den Build-Kontext
COPY . .
# Auf manchen Betriebssystemen können die Rechte für gradlew verloren gehen – explizit reparieren
RUN chmod +x gradlew
# Bauen den Jar einmal, damit beide folgenden Stages ihn verwenden können
RUN ./gradlew bootJar --no-daemon
# ===== 2) Development: Jar + JDWP =====
FROM eclipse-temurin:25-jdk AS development
WORKDIR /app
# Wichtig: In Dev und Runtime kopieren wir dasselbe Artefakt
COPY --from=builder /workspace/build/libs/*.jar app.jar
# In Dev veröffentlichen wir sowohl HTTP- als auch Debug-Port
EXPOSE 8080 5005
ENTRYPOINT ["java"]
# JDWP ist nur im Dev-Zweig aktiviert
CMD ["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]
# ===== 3) Runtime: sauberer Start (Standard) =====
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# Runtime nimmt denselben Jar und fügt keine Debug-Einstellungen hinzu
COPY --from=builder /workspace/build/libs/*.jar app.jar
# In Runtime wird nur HTTP benötigt
EXPOSE 8080
ENTRYPOINT ["java"]
CMD ["-jar", "app.jar"]
Wenn Sie jetzt lesen und denken: „Moment, hier steht zweimal dasselbe COPY --from=builder …“, dann ja – und das ist in Ordnung. Wir zeigen explizit, dass beide Pfade dasselbe Artefakt verwenden. Und das ist besser, als zu „tricksen“ und irgendwo „zu vererben“, aber am Ende einen Anfänger (und sich selbst in einer Woche) zu verwirren.
3. Bau der Images: Runtime und Development
Jetzt das Beste: ein Dockerfile, zwei Ergebnisse. Das Runtime-Image kann als Standard gebaut werden, weil runtime der letzte Stage ist.
# Runtime standardmäßig bauen (letzter Stage im Dockerfile)
docker build -t docker-java-catalog-service:runtime .
Wenn Sie ein Dev-Image möchten, wählen Sie explizit das Target:
# Explizit den Development-Stage bauen (mit JDWP und Debug-Port)
docker build --target development -t docker-java-catalog-service:dev .
Jetzt sind die Rollen direkt in den Tags sichtbar: :runtime und :dev. Das ist günstiger und verständlicher als ein nebulöses :latest, aus dem man dann erraten muss, was man gerade gestartet hat.
Wenn Sie zusätzliche Klarheit wünschen, können Sie Runtime auch über --target runtime bauen:
# Man kann explizit das Target runtime angeben, aber das ist nicht erforderlich
docker build --target runtime -t docker-java-catalog-service:runtime .
Aber methodisch angenehmer ist es, wenn der Standard-Build ein erwartetes „normales“ Ergebnis liefert und der Dev-Modus explizit eingeschaltet wird.
4. Start: Normaler und Debug-Modus
Der Start im Runtime-Modus ist das gewohnte Szenario: HTTP-Port veröffentlichen und API testen. Hier sollte es keine Überraschungen geben.
# Runtime starten: nur HTTP-Port veröffentlichen
docker run --rm -p 8080:8080 docker-java-catalog-service:runtime
Schneller Check, dass der Dienst antwortet:
# Prüfen, dass HTTP tatsächlich vom Host aus erreichbar ist
curl http://localhost:8080/api/catalog/items
Wenn alles ok ist, erhalten Sie eine JSON-Antwort (der genaue Inhalt hängt vom Starter-Repo ab, aber die Tatsache der Antwort ist wichtiger). Wenn Sie eine Antwort sehen – dann sind Container-Netzwerk, die Anwendung und die Portveröffentlichung in Ordnung.
Jetzt der Dev-Modus. Hier wird die Veröffentlichung des Debug-Ports 5005 hinzugefügt:
# Development starten: sowohl HTTP- als auch JDWP-Debug-Port veröffentlichen
docker run --rm -p 8080:8080 -p 5005:5005 docker-java-catalog-service:dev
Lifehack für die Beruhigung des Anfängers: Öffnen Sie die Container-Logs und suchen Sie eine Zeile, die einer Meldung ähnelt, dass JDWP auf einen Port horcht. Die JVM schreibt normalerweise so etwas wie „Listening for transport dt_socket at address: 5005“. Wenn Sie das sehen – sind Sie bereits auf dem richtigen Weg, auch ohne die IDE anzuschließen.
Und wieder kann man HTTP prüfen, weil die Anwendung trotzdem starten und antworten sollte:
# Auch im Dev-Modus sollte die Anwendung auf HTTP antworten
curl http://localhost:8080/api/catalog/items
Wenn HTTP funktioniert und der Debug-Port veröffentlicht ist, haben Sie tatsächlich einen debug-orientierten Container erhalten, keinen „kaputten Dienst mit unverständlicher Magie“.
Es ist wichtig, diesen Split nicht mit der normalen Startkonfiguration zu verwechseln. Ein separates Dev-Image wird nur benötigt, weil sich der JVM-Modus ändert. Das Anwendungsprofil, server.port, Env-Vars und andere Laufzeitparameter sollten weiterhin beim Start des Containers übergeben werden, nicht über ein neues Image für jeden Fall.
5. Typische Fehler beim Bau und Start
Fehler Nr.1: „Lassen Sie uns einfach JDWP in Runtime einschalten, warum kleinlich sein“.
Das ist sehr verlockend: eine Zeile in CMD und „alles ist immer debuggbar“. Das Problem ist, dass Sie den Debug-Modus zum Standard machen und die Disziplin eines „sauberen“ Images verlieren. In einem echten Team führt dies fast garantiert dazu, dass jemand versehentlich einen Dienst mit offenem Debug-Port dort startet, wo er nicht benötigt wird. Die Lösung ist einfach: JDWP lebt nur im development-Stage, und runtime bleibt langweilig und vorhersagbar.
Fehler Nr.2: Development und Runtime bauen unterschiedliche Jars, weil „es schneller ist“ oder „man es so gewohnt ist“.
Wenn Sie in einem Stage den Jar so bauen und im anderen anders, erzeugen Sie zwei Varianten der Anwendung unter einem Namen. Dann beginnt die Klassik: „Im Dev funktioniert alles, aber im Runtime irgendwie nicht“, und Sie vertreiben Stunden, obwohl das Problem nicht im Code, sondern in der Abweichung der Artefakte liegt. Das richtige Modell ist ein builder, ein bootJar, und beide Stages machen COPY --from=builder … app.jar.
Fehler Nr.3: -p 5005:5005 vergessen und wundern sich, dass der Debugger keine Verbindung herstellt.
Die JDWP-Parameter in der JVM aktivieren den Server innerhalb des Containers, aber der Container ist ein separates Netzwerk. Solange Sie den Port nicht veröffentlicht haben, können Sie von außen nicht darauf zugreifen. Das ist ähnlich der Situation „Ich habe zu Hause eine Tür, aber ich habe sie nicht nach außen geöffnet“. Die Behebung ist einfach: Für den Dev-Start immer sowohl 8080 als auch 5005 veröffentlichen.
Fehler Nr.4: Verwenden address=localhost:5005 (oder denken gar nicht über address nach).
Im Container ist localhost der Container, nicht Ihr Rechner. Wenn die JVM den Debug-Port nur auf dem Loopback-Interface innerhalb des Containers abhört, können Sie sich von außen nicht verbinden, selbst wenn der Port veröffentlicht wird. Für das Lern-Dev-Szenario lautet die Grundregel: address=*:5005, damit die JVM den Port auf dem Netzwerk-Interface des Containers abhört.
Fehler Nr.5: Versehentlich den Development-Stage als letzten gesetzt, und jetzt baut docker build . das „falsche“ Image.
Das ist ein subtiler und sehr ärgerlicher Fehler, weil es so aussieht, als „verhalte sich Docker seltsam“. In Wirklichkeit verhält sich Docker maximal ehrlich: Es baut den letzten Stage als finales Image. Wenn Sie die Reihenfolge der Stages geändert haben, haben Sie das Standardverhalten geändert. Halten Sie daher runtime als letzten und bauen Sie development über --target development.
GO TO FULL VERSION