1. Debug im Container – normaler Modus
Wenn Sie Spring Boot lokal über die IDE starten, wirkt Debuggen selbstverständlich: Breakpoint gesetzt, Debug gedrückt – und alles läuft. Im Container gerät ein Neuling oft in eine seltsame psychologische Grube: „Ein Container ist sozusagen nicht mein Computer, also kann man dort nicht debuggen.“ Kann man doch, und wie, man muss nur daran denken, dass der Container ein Netzwerk, Ports und eine ganz normale JVM darin hat.
In unserem Kurs soll der Debug-Modus nicht zu einer eigenen Architektur werden. Wir erstellen keine separate Anwendung „app-debug“ und vermehren keinen zweiten Compose-Stack. Wir wollen genau eines: denselben Dienst app, denselben Satz von Abhängigkeiten und denselben Konfigurationsansatz, aber mit zwei punktuellen Unterschieden. Erstens muss der Container einen zusätzlichen Port für den Debugger öffnen. Zweitens muss die JVM mit aktiviertem JDWP (Java Debug Wire Protocol) starten, damit die IDE eine Verbindung herstellen kann.
Genau deshalb ist der Debug-Modus der ideale Kandidat für compose.dev.yaml: Es handelt sich um ein für die Entwicklung spezifisches Verhalten, das explizit (durch den Startbefehl) aktiviert werden sollte und nicht als „stille Mine“ innerhalb der Basis-compose.yaml leben sollte. Wenn Sie schon einmal die Situation erlebt haben, dass „in meinem Projekt ständig irgendein Port 5005 offen ist und ich nicht mehr weiß, warum“ – herzlichen Glückwunsch, Sie haben das Anti-Pattern in der freien Wildbahn gesehen.
Zur Orientierung halten wir einen einfachen Gedanken in einer Tabelle fest – sie wird nützlich sein, wenn Sie anfangen, sich zu fragen, „welche Einstellung wo liegen sollte“:
| Was konfigurieren wir | Normal-Modus (compose.yaml) | Debug-Modus (compose.dev.yaml) |
|---|---|---|
| Stack-Zusammensetzung (postgres/redis/rabbitmq) | ja | nein (Zusammensetzung unverändert) |
| HTTP-Port der Anwendung | ja | normalerweise nein (bleibt wie gehabt) |
| Debug-Port der JVM (z. B. 5005) | nein | ja |
| Auswahl des Dockerfile-Stage (build.target) | normalerweise nein | ja (falls es einen development-Stage gibt) |
| JVM-Debug-Parameter | nein | ja (über Umgebungsvariable) |
2. Remote Debug JVM: IDE verbindet sich über das Netzwerk
Remote Debug klingt bedrohlich, bis Sie es sich als ganz normalen Netzwerkdienst vorstellen. Die JVM im Debug-Modus beginnt, einen TCP-Port abzuhören und wartet darauf, dass die IDE eine Verbindung herstellt. Die IDE verbindet sich, stimmt sich mit der JVM ab, und dann setzen Sie Breakpoints genauso, als ob die Anwendung lokal liefe. Keine Magie, einfach Netzwerk.
Damit das Gehirn nicht mehr Widerstand leistet, hilft es, sich ein kleines Schema vorzustellen:
flowchart TD
IDE["IDE (Debugger)"] -->|"JDWP TCP :5005"| Host["Host (Ihr Computer)"]
Host -->|"ports: 5005:5005"| Docker["Docker / Compose"]
Docker --> App["container: app"]
App --> JVM["JVM + Spring Boot"]
Beachten Sie, dass es hier zwei Ebenen von Ports gibt. Ein Port wird von der JVM innerhalb des Containers abgehört (interner 5005), und der zweite ist der veröffentlichte Port auf dem Host (externer 5005). Wenn Sie JDWP aktiviert haben, aber kein ports: "5005:5005" gesetzt haben, wird sich die IDE zwar brav mit localhost:5005 verbinden … und brav „Connection refused“ erhalten.
Es gibt noch eine weitere klassische Falle, die gut mit dem Thema der Ports aus den frühen Kurstagen zusammenhängt. Die JVM kann nur auf localhost innerhalb des Containers hören, und dann hilft die Portweiterleitung nicht: Der Port wird zwar veröffentlicht, aber innerhalb des Containers ist er an das Loopback-Interface gebunden. Deshalb wollen wir bei den JDWP-Parametern fast immer ein Binding auf „alle Interfaces“. In der modernen Syntax sieht das aus wie address=*:5005. Aus Sicht eines Anfängers bedeutet das dasselbe wie bei Spring Boot, dem wir oft sagen „höre auf 0.0.0.0“.
Um leichter „mit den Händen sehen“ zu können, wo Sie den Breakpoint setzen werden, zeige ich ein kleines, beispielhaftes Code-Fragment (das ist keine neue Architekturebene, sondern nur eine Orientierung). In einem echten Projekt haben Sie einen Endpoint GET /api/catalog/items, und den Breakpoint können Sie z. B. im Controller setzen:
package com.example.catalog.catalog.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST-Controller: Hier ist es am bequemsten, Breakpoints bei eingehenden Anfragen zu setzen
public class CatalogItemsController {
@GetMapping("/api/catalog/items") // Endpunkt, den Sie aus dem Browser/HTTP-Client aufrufen, um das Debugging zu überprüfen
public String getItems() {
// Wenn die IDE über JDWP verbunden ist, wird die Ausführung am Breakpoint in der nächsten Zeile anhalten
return "ok"; // Setzen Sie hier einen Breakpoint, wenn Sie den Debugger verbunden haben
}
}
Die Idee ist: Der Container startet, die IDE verbindet sich mit Port 5005, Sie rufen den Endpoint auf – und die Ausführung „stoppt“ am Breakpoint.
3. build.target und Stage development
Wenn wir in einer Welt leben würden, in der „ein Dockerfile – ein Image – ein Modus“ gilt, würden wir Debug-Parameter direkt in das Dockerfile einbauen und leiden. Aber wir haben bereits früher einen wichtigen Schritt gemacht: Im Dockerfile gibt es mehrere Stages, und darunter befindet sich der development-Stage, der speziell für den lokalen Developer-Workflow gedacht ist.
Jetzt müssen wir das sauber mit Compose verknüpfen. Compose hat die Einstellung build.target: Sie besagt „baue das Image nicht bis zum finalen Stage, sondern bis zu einem bestimmten“. Und das ist ein sehr praktischer Punkt: Im Normal-Modus verwenden Sie den finalen Runtime-Pfad, im Debug-Modus den development.
Ein kleiner Ausschnitt aus dem Dockerfile (vereinfacht, nur um die Idee der Benennung der Stages zu zeigen):
# Runtime-Phase: minimal, nur zum Starten der Anwendung
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# Wichtig: Wir kopieren das fertige Artefakt (jar), nicht die Quellen
COPY build/libs/catalog-service.jar app.jar
# Einstiegspunkt für den Container im Normal-Modus
ENTRYPOINT ["java","-jar","app.jar"]
# Development-Phase: kann "schwerer" sein (JDK), aber bequemer für lokale Entwicklung/Debugging
FROM eclipse-temurin:25-jdk AS development
WORKDIR /app
COPY build/libs/catalog-service.jar app.jar
# Wichtig: Das Artefakt ist das gleiche, nur die Runtime-Parameter ändern sich (z.B. JDWP über Env-Variable)
ENTRYPOINT ["java","-jar","app.jar"]
Es geht nicht darum, dass der Development-Stage unbedingt gleich sein muss. Es geht darum, dass er einen Namen hat (development), und jetzt kann Compose sagen: „Ich möchte genau diesen bauen.“
In der Basis-compose.yaml geben wir target überhaupt nicht an. So ist es einfacher und ehrlicher: Die Basiskonfiguration soll lesbar sein und möglichst nah am „normalen Start“ bleiben.
# compose.yaml
services:
app:
build:
context: . # Build-Kontext: das gesamte Projekt (Dockerfile und Quellen daneben)
ports:
- "8080:8080" # HTTP: links Host-Port, rechts Container-Port
In der zweiten Datei, compose.dev.yaml, fügen wir nur die Dev-Abweichung hinzu: die Auswahl des Stages.
# compose.dev.yaml
services:
app:
build:
target: development # Im Debug/Dev-Modus wird genau der Development-Stage gebaut
Das ist auch ohne Debugger bereits nützlich: Sie haben einen expliziten „Entwicklungsmodus“, nicht über ein zweites Dockerfile, nicht über Chaos in Kommandos, sondern über eine kompakte Override-Datei. Und das Wichtigste – Sie zerstören das ursprüngliche Stack-Modell nicht: Der Dienst bleibt app und wird nicht zu app-debug, app-debug-2, app-debug-final-final2.
4. Ports für den Debug-Modus
Es ist sehr leicht (besonders in den ersten Malen), „alles durcheinanderzubringen“: Debug-Port hinzufügen, versehentlich die HTTP-Ports überschreiben, sich dann wundern, warum die API nicht mehr erreichbar ist, und denken, Docker sei schuld. In der Praxis ist es einfacher, sich an eine eiserne Regel zu halten: Im Debug-Modus versuchen wir, das, was bereits funktioniert, nicht zu zerstören, und fügen nur das hinzu, was fehlt.
Der HTTP-Port der Anwendung (8080) ist Teil des normalen Runtime-Pfads, also lebt er in compose.yaml. Und er bleibt dort, weil wir die API in jedem Modus benötigen – sowohl normal als auch Debug.
Der Debug-Port (5005) wird im Normalbetrieb nicht benötigt, daher sollte er nicht in der Basiskonfiguration leben. Sein Platz ist in compose.dev.yaml.
# compose.dev.yaml
services:
app:
ports:
- "5005:5005" # JDWP: host:5005 -> container:5005 (IDE verbindet sich mit localhost:5005)
Hier gibt es noch einen kleinen praktischen Punkt. Wenn Sie mehrere Java-Dienste gleichzeitig starten (oder auf Ihrem Rechner bereits jemand Port 5005 belegt), sagt Compose ehrlich: „Port ist belegt“ und der Container startet nicht. Das ist kein Grund zur Panik. Es bedeutet lediglich, dass auf dem Host der Port 5005 bereits verwendet wird, und Sie müssen entweder den störenden Prozess stoppen oder einen anderen externen Port wählen, z. B. "5006:5005".
Wichtig zu beachten: "5006:5005" bedeutet: außen verbinden wir uns auf 5006, innen im Container hört die JVM weiterhin auf 5005. Anfänger versuchen oft, „synchron“ überall auf 5006 zu ändern und geraten in Verwirrung. An dieser Stelle hilft der Gedanke „links Host, rechts Container“.
5. JDWP über JAVA_TOOL_OPTIONS
Jetzt haben wir zwei Dinge erledigt: Wir haben Compose beigebracht, den Development-Stage zu bauen, und den Debug-Port veröffentlicht. Aber noch hört die JVM diesen Port nicht ab – er ist lediglich ins Leere geöffnet. Wir müssen der JVM sagen: „Aktiviere den Debugger“.
Der bequemste und „containerfreundlichste“ Weg ist, die Option über die Umgebungsvariable JAVA_TOOL_OPTIONS zu übergeben. Die JVM liest diese beim Start automatisch und fügt die Argumente hinzu. Das passt sehr gut zum Prinzip „ein und dasselbe Artefakt, verschiedene Runtime-Parameter“ und erlaubt es, das Dockerfile nicht für Dev-Einstellungen zu verschandeln.
Das sieht so aus:
# compose.dev.yaml
services:
app:
environment:
# JDWP: Debug-Agent der JVM aktivieren (IDE kann sich per TCP verbinden)
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
Lassen Sie uns diese Zeile analysieren, ohne das Gefühl zu haben, ein Zauberwort aus einem alten Manuskript „JVM für Auserwählte“ ausgesprochen zu haben.
Der Parameter -agentlib:jdwp=... aktiviert den Standard-Debug-Agent. Danach folgt eine Reihe von Einstellungen, durch Kommas getrennt. transport=dt_socket besagt, dass wir über einen TCP-Socket debuggen (das ist die klassische Variante). server=y bedeutet „JVM wird Server sein und auf eine Verbindung warten“. suspend=n bedeutet „beim Start nicht anhalten, die Anwendung sofort starten“. Das ist eine sinnvolle Voreinstellung für die tägliche Entwicklung, denn sonst würde die Anwendung hängen und auf die IDE warten, während Sie sich fragen, warum Compose nicht startet.
Und address=*:5005 ist ein sehr wichtiger Teil speziell für den Container. Es bedeutet „höre auf allen Interfaces auf dem Port“. Wenn Sie address=localhost:5005 (oder eine Variante, die das Binding tatsächlich einschränkt) setzen, wird Docker den Port zwar brav veröffentlichen, aber die IDE wird sich brav nicht verbinden können. Und Sie erhalten eine wunderbare Lektion: „Im Container-Umfeld ist localhost oft nicht das, was Sie denken“.
Manchmal müssen Sie den Start der Anwendung debuggen, z. B. ein Problem mit der Datenbankverbindung oder Migrationen. In diesem Fall kann suspend=n unpraktisch sein: Sie schaffen es nicht, den Breakpoint zu setzen, weil alles schon passiert ist. Dann wechseln Sie vorübergehend auf suspend=y, und die JVM wartet auf die Verbindung der IDE:
# compose.dev.yaml (vorübergehende Variante zum Debuggen des Starts)
services:
app:
environment:
# Wichtig: Bei suspend=y "wartet" die Anwendung auf die IDE und setzt den Start nicht fort
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005"
Vergessen Sie nur nicht, dass die Anwendung bei suspend=y nicht „hochkommt“, bis Sie den Debugger verbinden. Für jemanden, der das zum ersten Mal sieht, wirkt es wie „der Container hängt“. Tatsächlich wartet er diszipliniert auf Sie, wie eine Katze vor der geschlossenen Badezimmertür.
Wenn wir alles zusammenfassen, wird compose.dev.yaml normalerweise zu einer kleinen, aber sehr gehaltvollen Datei: Sie kopiert nicht den gesamten Dienst, sondern fügt genau das hinzu, was zum Debuggen benötigt wird.
# compose.dev.yaml
services:
app:
build:
target: development # Wir nehmen den Development-Stage aus dem Dockerfile
ports:
- "5005:5005" # Debug-Port veröffentlichen (Host -> Container)
environment:
# JDWP aktivieren und auf allen Interfaces des Containers hören
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
Beachten Sie: Wir rühren SPRING_PROFILES_ACTIVE, die Datenbank-URL und andere Parameter nicht an. All das ist bereits korrekt in compose.yaml konfiguriert. Der Debug-Modus ist nicht „eine andere Konfiguration des Dienstes“, sondern „derselbe Dienst, aber mit der Möglichkeit, die IDE zu verbinden“.
6. Überprüfen und Starten des Debug-Modus
Nach allen Einstellungen kommt der Moment der Wahrheit. Und hier funktioniert die Gewohnheit aus der letzten Vorlesung perfekt: Bevor Sie starten, sehen Sie sich zuerst an, was Compose tatsächlich verstanden hat.
Der Befehl sieht so aus:
# Überprüfen der endgültigen Konfiguration nach dem Merge der beiden Dateien
docker compose -f compose.yaml -f compose.dev.yaml config
Das ideale Ergebnis ist, wenn Sie in der Ausgabe drei Dinge mit den Augen finden: build.target: development, den veröffentlichten Port 5005:5005 und die Variable JAVA_TOOL_OPTIONS innerhalb von app. Wenn in Ihrem Kopf auch nur der leiseste Zweifel besteht „ich glaube, ich habe das hinzugefügt“, dann lohnt es sich, config anzuschauen, anstatt zu raten.
Das Starten des Debug-Modus ist dasselbe up, nur mit zwei Dateien. Beachten Sie, dass der Befehl bereits dokumentiert, in welchem Modus Sie arbeiten: genau das, was wir im täglichen Workflow erreichen wollen.
# Start: base compose.yaml + dev override (Build + Hochfahren der Container)
docker compose -f compose.yaml -f compose.dev.yaml up --build
Wenn der Container startet, sehen Sie in den Anwendungslogs oft eine Zeile vom Debug-Agent (sie kann abweichen, aber die Bedeutung ist dieselbe). Wenn Sie etwas wie „Listening … 5005“ sehen, ist das ein hervorragendes Zeichen: Die JVM hört tatsächlich den Debug-Port ab.
Danach ist der einfachste „IDE-agnostische“ Sinn: Sie erstellen in Ihrer IDE eine Remote-Debug-Konfiguration (Remote JVM Debug), geben host=localhost, port=5005 an und verbinden sich. In diesem Moment beginnen die Breakpoints genauso zu funktionieren wie beim lokalen Debug-Start.
7. Typische Fehler beim Aktivieren des Debug-Modus über Compose
Fehler Nr. 1: Debug in JAVA_TOOL_OPTIONS aktiviert, aber Port 5005 nicht veröffentlicht.
Ein sehr häufiges Bild: Sie haben JAVA_TOOL_OPTIONS hinzugefügt, sehen in den Logs, dass der Agent aktiv ist, aber die IDE kann sich nicht verbinden. Der Grund ist simpel: Der Container hört den Port innerhalb seiner eigenen Umgebung, aber der Host weiß nichts davon. In Compose heilt man das mit genau einer Zeile in compose.dev.yaml: ports: - "5005:5005".
Fehler Nr. 2: Port ist veröffentlicht, aber die JVM hört nicht dort (oder nicht auf dem richtigen Interface).
Wenn Sie eine ungültige address in JDWP verwenden, kann es zu der Situation kommen „Port ist weitergeleitet, aber keine Verbindung kommt zustande“. Für Container-Szenarien braucht man fast immer address=*:5005, damit die JVM auf allen Interfaces lauscht. Varianten wie „default“ oder „localhost“ führen oft zu einem Gefühl von Mystik, obwohl das Problem rein netzwerktechnischer Natur ist.
Fehler Nr. 3: Debug-Parameter wurden in compose.yaml abgelegt und sind Teil des Normal-Modus geworden.
Zunächst erscheint es praktisch: eine Datei, alles beieinander. Dann beginnt das Leben: Ein Kollege schafft es plötzlich nicht, das Projekt zu starten, weil Port 5005 belegt ist; jemand committed versehentlich die Debug-Einstellungen in das gemeinsame Repository; jemand versteht nicht, warum der Dienst „mit einem Loch“ nach außen hängt. Die richtige Disziplin ist, Debug-Einstellungen in compose.dev.yaml zu halten, damit sie nur aktiviert werden, wenn Sie das wollen.
Fehler Nr. 4: Einen separaten Dienst app-debug erstellt, und nun laufen zwei Dienste auseinander.
Das ist ein stiller Killer der Wartbarkeit. Sobald zwei Dienste existieren, beginnen Abweichungen: In einem fehlt eine Env-Variable, im anderen ein Volume, im dritten ein Healthcheck, und das Ganze hört auf, ein System zu sein. Viel einfacher und erwachsener ist es, den vorhandenen Dienst app über eine Override-Datei zu überschreiben. Dann bleibt die Verkabelung einheitlich, und Sie ändern nur die „Moduslinse“.
Fehler Nr. 5: suspend=y gesetzt und dann gedacht, der Container hängt.
Bei suspend=y wartet die JVM brav auf den Debugger und setzt den Start nicht fort. Wenn Sie Compose starten und sehen, dass die Anwendung nicht in den „Ready“-Zustand kommt und der Healthcheck meckert, denken Sie zuerst: Habe ich nicht aus Versehen suspend=y aktiviert? Dieser Modus ist nützlich, aber man muss ihn bewusst einschalten und die Konsequenzen verstehen.
GO TO FULL VERSION