1. Container startet und stoppt seltsam
Den Startbefehl und die Parameter zu trennen ist bereits nützlich, aber das reicht noch nicht. Ein Container kann mit demselben java -jar app.jar starten und trotzdem seltsam stoppen, wenn zwischen Docker und der JVM eine Shell liegt.
Fast jeder Docker-Anfänger macht dieselbe Erfahrung. Er baut ein Image, startet den Container, sieht das ersehnte „Started … in 2.3 seconds" in den Logs und denkt: „Gut, gewonnen, das kann ich jetzt so lassen." Dann macht er docker stop, und der Container stoppt entweder zu langsam, „stirbt abrupt" oder zeigt in den Logs kein ordentliches Herunterfahren. Das ist kein Docker-Eigensinn — das ist die Folge davon, welcher Prozess im Container als Hauptprozess gilt.
Ein Container ist keine „Mini-VM", sondern ein Prozess oder eine Prozessgruppe, die Docker von außen steuert. Für Docker ist ein bestimmter Prozess wichtig: derjenige, der als Hauptprozess gilt, normalerweise PID 1 im Container. Wenn du versehentlich die Shell (sh) als Hauptprozess gemacht hast und Java ihr „Kind" ist, dann redest du beim Stoppen des Containers nicht mit der JVM, sondern mit der Shell. Und die Shell gibt die Nachricht nicht unbedingt weiter.
Stell dir vor, du versuchst einen Freund anzurufen, aber statt ihm antwortet ein Anrufbeantworter. Der Anrufbeantworter kann sehr höflich sein, ist aber nicht verpflichtet, deine Bitte weiterzuleiten. Genau so funktioniert die Shell-Form von ENTRYPOINT oft — wie ein solcher Anrufbeantworter.
2. Die Mechanik von docker stop: SIGTERM und SIGKILL
Um zu verstehen, warum die Form des ENTRYPOINT so wichtig ist, müssen wir kurz in die Mechanik des Container-Stoppens schauen. Hier steckt keine „Docker-Magie" — das sind einfach Linux-Prozesse und Signale. Docker versucht standardmäßig höflich zu sein: Zuerst bittet er den Prozess, sich ordentlich zu beenden, und erst dann, wenn er ignoriert wird, greift er zur Gewalt.
Im typischen Szenario funktioniert docker stop so: Docker sendet das Signal SIGTERM an den Hauptprozess des Containers, wartet dann eine gewisse Zeit (standardmäßig etwa 10 Sekunden), und wenn der Prozess nicht beendet wurde — sendet er SIGKILL. SIGTERM ist „bitte beende dich ordentlich", SIGKILL ist „ich habe gesagt, Schluss, wir machen das Licht aus" — und das kann man nicht abfangen, verarbeiten oder um mehr Zeit bitten.
Beispielbefehl mit explizitem Timeout:
# Wir geben explizit an, wie lange Docker nach SIGTERM wartet
docker stop --time 30 <container-name>
# Docker sendet zuerst SIGTERM an PID 1 des Containers, dann wartet er bis zu 30 Sekunden
Warum ist das gerade für Java/Spring Boot wichtig? Weil ein Spring Boot-Dienst nicht ein „println und fertig" ist. Er hat einen Webserver, einen Thread-Pool, mögliche Hintergrundaufgaben, einen Spring-Kontext — und all das kann sich ordentlich schließen, aber nur wenn der Anwendung die Chance gegeben wird. SIGTERM gibt genau diese Chance: Spring beginnt, den Kontext zu schließen und Ressourcen freizugeben. SIGKILL gibt keine Chance — die JVM hört einfach auf zu existieren, als würde man den Stecker aus der Steckdose ziehen. Technisch bildlich, aber in der Wirkung sehr treffend.
Die Schlüsselfrage dieser Vorlesung: An wen schickt Docker SIGTERM? Antwort: an den Prozess mit PID 1 im Container. Deshalb müssen wir als nächstes verstehen, wer PID 1 ist und wie er sich ohne dein Zutun „ersetzen" kann.
3. PID 1 und Signale in Docker
In einem normalen Linux-System ist PID 1 gewöhnlich systemd oder ein anderes Init-System. Es startet Dienste, überwacht Prozesse, „holt" beendete Kind-Prozesse ab und ist der Haupt-Dispatcher. In einem Container ist alles einfacher und gleichzeitig gefährlicher: PID 1 ist der Prozess, den du als erstes gestartet hast.
Wenn du im Dockerfile ENTRYPOINT so geschrieben hast, dass Docker eigentlich eine Shell startet (/bin/sh -c ...), dann ist PID 1 die Shell. Und Java wird zu PID 2 oder PID 7 — egal, Hauptsache sie ist nicht die erste. Und dann geht das Signal bei docker stop an die Shell, nicht an die JVM.
Diesen Unterschied sieht man gut in einem Diagramm:
flowchart TD
A[docker stop] -->|SIGTERM| B[PID 1 im Container]
B --> C{Wer ist PID 1?}
C -->|exec-form| D["java -jar app.jar
empfängt SIGTERM"]
C -->|shell-form| E["/bin/sh -c ...
empfängt SIGTERM"]
E --> F["java -jar app.jar
empfängt SIGTERM möglicherweise nicht rechtzeitig"]
Beachte das Wort „möglicherweise". Manchmal leitet die Shell das Signal tatsächlich weiter, manchmal nicht, manchmal teilweise, manchmal beendet sie den Kind-Prozess auf ihre eigene Art, manchmal wartet sie einfach. Am Ende wird das Stopp-Verhalten des Containers nicht technisch — „ich verstehe, was jetzt passiert" — sondern zum Rätselraten: „Hört es heute schön auf oder nicht?" Im Kurs erlauben wir uns von Anfang an kein Rätselraten, denn Rätselraten führt zu seltsamen Bugs und dem ewigen „bei mir auf dem Laptop läuft es".
4. Shell-Form vs. Exec-Form von ENTRYPOINT
Jetzt kommt die entscheidende Wendung: Das Dockerfile erlaubt es, ENTRYPOINT in zwei Formen zu beschreiben. Äußerlich sind sie ähnlich, erzeugen aber unterschiedliche Prozessbäume. Anfänger wählen oft die Shell-Form, weil sie „einfacher" ist und weil „so die Artikel es schreiben". Und wundern sich dann, warum das Stoppen des Containers sich wie eine launische Katze verhält: heute schmusefreudig, morgen kratzt sie.
Die Shell-Form sieht so aus (eine Zeile, wie ein normaler Befehl):
# Shell-Form: Docker startet den Befehl fast immer über eine Shell (/bin/sh -c)
ENTRYPOINT java -jar app.jar
Die Exec-Form sieht so aus (Array von Argumenten):
# Exec-Form: Docker startet den Prozess direkt, ohne /bin/sh -c
ENTRYPOINT ["java", "-jar", "app.jar"]
Das Wichtigste: Die Shell-Form bedeutet fast immer, dass Docker eine Shell startet, grob äquivalent zu:
/bin/sh -c "java -jar app.jar"
Und die Exec-Form bedeutet, dass Docker den Prozess direkt startet, ohne zwischengelagerte Shell. Daher kommt auch das unterschiedliche Verhalten bei Signalen und die unterschiedliche Vorhersehbarkeit.
Unten — eine kurze Tabelle, um den Gedanken nicht nur in Worten, sondern auch visuell zu verankern:
| Was wir vergleichen | shell-form | exec-form |
|---|---|---|
| Schreibweise im Dockerfile | ENTRYPOINT java -jar app.jar | ENTRYPOINT ["java","-jar","app.jar"] |
| Wer oft PID 1 wird | /bin/sh (Shell) | java (JVM) |
| Wie Signale von docker stop ankommen | zuerst in der Shell, dann „wie es geht" | direkt in der JVM |
| Umgebungsvariablen im Befehl | leicht einzusetzen ($VAR) | lassen sich nicht direkt einsetzen (und das ist in Ordnung) |
| Vorhersehbarkeit des Verhaltens | mittel/niedrig | hoch |
Und hier lauert die typische Falle. Leute wählen die Shell-Form aus Bequemlichkeit: „Wie soll ich sonst eine Variable einsetzen?" oder „Wie mache ich einen schönen einzeiligen Befehl?" In der Java-Welt ist das besonders verlockend, weil man so etwas wie $JAVA_OPTS nutzen will. Aber ein Java-Dienst hat eine andere Priorität: Zunächst brauchen wir einen korrekten Prozess-Lebenszyklus, und erst dann bequeme Kanäle für JVM-Parameter. Sonst kehrt eine einzige „bequeme" Shell-Zeile sofort zum Signal-Problem zurück.
5. Shell-Form und Signale für Java
Das mag wie ein Stilstreit wirken: „Klammern oder keine Klammern." In Wirklichkeit geht es um das Systemverhalten beim Stoppen. Und Java leidet hier besonders sichtbar, weil ein Spring Boot-Dienst ein langlebiger Prozess ist, der sich ordentlich beenden können muss.
Das Fehlerszenario sieht typischerweise so aus: Du startest den Container, alles läuft. Dann machst du docker stop. Docker sendet SIGTERM an PID 1. Wenn PID 1 sh ist, entscheidet sh, was als nächstes passiert. Sie kann sich selbst beenden. Sie kann warten, bis der Kind-Prozess endet. Sie kann SIGTERM nicht an die JVM weiterleiten. Sie kann es weiterleiten, aber nicht so wie du denkst. Am Ende bleibt der Java-Prozess am Leben, der Container stoppt nicht rechtzeitig, und Docker schickt nach dem Timeout SIGKILL an die ganze Geschichte. Das ist keine saubere Beendigung, sondern „der Stecker wurde gezogen".
Warum ist das besonders unangenehm für Spring Boot? Weil bei einem normalen SIGTERM Spring nützliche Dinge tun kann: den Webserver stoppen, den Kontext korrekt beenden, Ressourcen schließen, Logs zu Ende schreiben. Selbst wenn unser Lernprojekt gerade im standalone-Modus ohne DB läuft, ist die Gewohnheit „ordentlich schließen" von Anfang an wichtig. In echten Diensten kann ein falsches Stop-Szenario eine unfertig geschriebene Export-Datei, einen abgebrochenen Request, seltsamen Cache-Zustand oder einfach „verschwundene" letzte Log-Zeilen hinterlassen — und genau die sind oft am nützlichsten.
Um die Verbindung „Signal → Beendigung" auf Java-Ebene zu spüren, reicht ein kleines Beispiel. In Spring kann man eine Methode definieren, die beim Schließen des Kontexts aufgerufen wird. Das ist kein „komplexes Spring", sondern ein einfacher beobachtbarer Marker: Hatte die Anwendung die Chance, sich korrekt zu beenden?
Beispiel einer kleinen Komponente:
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
// Diese Komponente dient nur als Marker: Sehen wir den Log, hatte Spring die Chance, sich korrekt zu beenden
@Component
class ShutdownMarker {
// Normaler Logger, damit wir das Ereignis in den Container-Logs bemerken
private static final Logger log = LoggerFactory.getLogger(ShutdownMarker.class);
@PreDestroy // Wird beim Schließen des Spring-Kontexts aufgerufen (normalerweise nach SIGTERM)
void onShutdown() {
log.info("Shutdown: Spring context is closing"); // Erscheint in den Logs bei normalem SIGTERM
}
}
Wenn der Container korrekt stoppt (SIGTERM hat die JVM erreicht, Spring beginnt zu schließen), erscheint diese Zeile fast sicher in den Logs. Wenn der Container per SIGKILL „gekillt" wurde, hat die Anwendung keine Zeit gehabt, den Kontext korrekt zu beenden, und du siehst diese Zeile nicht. Das ist keine Philosophie und kein Stil — das ist praktische Diagnostik: „Die Anwendung hat sich selbst beendet" vs. „Sie wurde von außen abgeschaltet".
Noch ein wichtiger Hinweis: Manchmal versuchen Leute zu „tricksen" und schreiben ein JSON-Array, starten aber trotzdem eine Shell:
ENTRYPOINT ["sh", "-c", "java -jar app.jar"]
Das sieht wie Exec-Form aus, ist aber inhaltlich dieselbe Shell-Form, nur in neuer Verpackung. PID 1 ist wieder sh. Wenn du also sh -c in ENTRYPOINT siehst, betrachte das als Rückkehr zum ursprünglichen Problem — nur jetzt ordentlich maskiert.
6. Exec-Form von ENTRYPOINT für Spring Boot-JAR
Jetzt fixieren wir die Regel, die sich wie ein roter Faden durch den gesamten Kurs zieht: Für Java/Spring Boot-Dienste schreiben wir ENTRYPOINT ausschließlich in Exec-Form. Wir brauchen, dass der Hauptprozess des Containers java ist, ohne Zwischenhändler. Das ergibt ein vorhersehbares Stop-Szenario und macht den Container ehrlich: Docker hat ein Signal gesendet — die JVM hat es empfangen.
Das minimale Dockerfile-Fragment für unseren Container-Ready Catalog Service sieht so aus:
# Basis-Runtime-Image: Hier ist nicht die Größe wichtig, sondern der korrekte Prozess-Lebenszyklus
FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app
# Den JAR-Pfad übergeben wir über ARG, damit COPY im Beispiel flexibler ist
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
# Wichtig: exec-form, damit PID 1 java ist und SIGTERM direkt in der JVM ankommt
ENTRYPOINT ["java", "-jar", "app.jar"]
Beachte: Das ist kein Dockerfile, das bereits alles löst. Das ist genau die richtige Variante aus der Perspektive des Prozess-Lebenszyklus. Wir verstecken java nicht hinter sh, machen keine „bequemen Zeilen" auf Kosten des Verhaltens und machen den Start nicht zu einem kleinen Schattentheater.
Und ja, du kannst fragen: „Wo sind die JVM-Parameter, wo das Profil, wo der Port?" Gute Frage, aber jetzt ist es wichtig, die Ebenen nicht zu vermischen. Zuerst fixieren wir, wer der Prozess ist und wie er durch Signale gesteuert wird. Sonst wird jede folgende Einstellung auf einem kaputten Start aufgebaut.
Psychologisch ähnelt das gutem Java-Code: Zuerst bauen wir das korrekte Modell, dann fügen wir Komfort hinzu. Sonst bekommen wir Thread.sleep(20000) im Code — nur in der Welt des Dockerfiles.
7. Skript in ENTRYPOINT: nur mit exec
Manchmal möchte ein Team die Anwendung über ein kleines Skript starten. Nicht weil es „modern" ist, sondern weil man zum Beispiel ein paar Diagnosezeilen ausgeben, die Existenz einer Datei prüfen, ein Verzeichnis erstellen oder einfach einen einzigen Einstiegspunkt haben will. Das Skript selbst ist kein Problem. Das Problem entsteht, wenn es dauerhaft PID 1 bleibt und zu einem weiteren Zwischenhändler wird, der nicht korrekt mit Signalen umgehen muss.
Die richtige Idee ist einfach: Wenn du Java über ein Skript startest, musst du im Skript exec verwenden. In der Shell bedeutet das: „Ersetze den aktuellen Skript-Prozess durch den Java-Prozess." Dann wird PID 1 Java, und Signale kommen dort an, wo sie sollen.
Minimales entrypoint.sh:
#!/bin/sh
set -e
# Kleine Diagnose: nützlich zu sehen, dass der Start begonnen hat
echo "Starting Container-Ready Catalog Service..."
# Der Schlüsselpunkt: exec ersetzt den Skript-Prozess durch java, damit PID 1 die JVM wird
exec java -jar app.jar
Das Wichtigste hier ist exec java .... Ohne exec bleibt das Skript der Hauptprozess und Java ist ein Kind-Prozess. Du hättest also selbst genau das Problem wieder herbeigeführt, von dem du gerade weggegangen bist.
Das Dockerfile bleibt dabei in Exec-Form (nur dass jetzt das Skript gestartet wird):
WORKDIR /opt/app
# Kopiert das Entrypoint-Skript ins Image
COPY entrypoint.sh entrypoint.sh
# Macht das Skript ausführbar, sonst kann ENTRYPOINT es nicht starten
RUN chmod +x entrypoint.sh
# Das ist Exec-Form: Docker startet das Skript direkt (aber im Skript brauchen wir trotzdem exec java)
ENTRYPOINT ["./entrypoint.sh"]
Hier steckt eine subtile Sache. ENTRYPOINT ["./entrypoint.sh"] ist Exec-Form, aber PID 1 beim Start ist trotzdem das Skript. Deshalb ist es entscheidend, dass das Skript exec java ... ausführt, sonst ist wieder ein Zwischenhändler da. Das ähnelt der Situation in Java, wenn du ein schönes Interface geschrieben hast, aber intern trotzdem direkt eine dreckige Methode aufrufst. Äußerlich gibt es eine Architektur, in der Praxis — keine.
Sobald java ehrlich PID 1 wird, liegt die nächste Falle nicht mehr im Stop-Szenario, sondern bei den Startparametern. Port, Profil und JVM-Optionen muss man so ändern können, dass sh -c nicht wieder ins Bild kommt und der normale Container-Lebenszyklus nicht gebrochen wird.
8. Diagnose: Wer ist PID 1 im Container
Wenn etwas seltsam läuft, schaltet ein Anfänger-Entwickler oft in den „Magie"-Modus: „Docker ist irgendwie kaputt", „Ich habe Windows", „Das liegt an Spring". In Wirklichkeit kann man das Problem oft sehr nüchtern sehen: Einfach nachschauen, welche Prozesse wirklich im Container laufen und wer der Hauptprozess ist. Das ist kein DevOps-Ritual, sondern derselbe Ansatz wie in Java: Erst beobachten, dann Schlussfolgerungen ziehen.
Zum Beispiel kann man den Container mit einem Namen starten, um nicht die ID kopieren zu müssen, und dann die Prozesse anschauen:
# Container mit Namen starten, damit wir ihn leichter mit docker top/logs/stop ansprechen können
docker run --name catalog -p 8080:8080 docker-java-catalog-service
# Prozesse im Container anschauen: im Idealfall ist PID 1 java
docker top catalog
# Im Idealfall siehst du, dass java der Hauptprozess ist
Wenn du siehst, dass sh -c dort lebt und java daneben, ist das fast immer ein Signal, dass du Shell-Form verwendest oder eine maskierte Shell über sh -c. Wenn du dagegen siehst, dass java -jar app.jar läuft, ist die Wahrscheinlichkeit eines korrekten Stop-Szenarios deutlich höher.
Noch ein nützlicher Gedanke: Wenn ein Container lange nicht stoppt und am Ende „abrupt stirbt", bedeutet das oft, dass er nicht rechtzeitig auf SIGTERM reagiert hat und Docker zu SIGKILL übergegangen ist. Das ist nicht immer „schlechter Anwendungscode". Manchmal liegt das Problem einfach darin, dass das Signal den falschen PID 1 erreicht.
Der Ausweg aus dem „Magie"-Modus ist hier wie beim Programmieren: Das Verhalten in beobachtbare Fakten übersetzen. Wer ist PID 1? Wer empfängt SIGTERM? Gibt es in den Logs Anzeichen für eine korrekte Beendigung? Wenn die Antworten klar sind, wird die Lösung technisch und nicht schamanisch.
9. Typische Fehler bei der Wahl der ENTRYPOINT-Form
Dieses Thema ist klein, aber es gibt viele Fehler darum, weil das Internet voll ist von „funktionierenden" Beispielen, die genau bis zum ersten echten Stop/Restart-Szenario funktionieren. Unten — die häufigsten Stolperfallen, die man sofort erkennen sollte, um nicht stundenlang Symptome zu behandeln.
Fehler Nr. 1: Shell-Form verwenden, „weil es einfacher ist".
Die Schreibweise ENTRYPOINT java -jar app.jar sieht tatsächlich einfacher aus als ein Array in Klammern. Aber sie startet die Anwendung über eine Shell, und Docker kommuniziert über Signale mit der Shell, nicht mit der JVM. Das Ergebnis: Das Stoppen des Containers wird weniger vorhersehbar, und das korrekte Herunterfahren funktioniert möglicherweise nicht so, wie du erwartest.
Fehler Nr. 2: Denken, dass „JSON-Array = immer Exec-Form", auch wenn sh -c drin ist.
Das Konstrukt ENTRYPOINT ["sh", "-c", "java -jar app.jar"] sieht visuell korrekt aus. Aber im Kern ist das derselbe Shell-Zwischenhändler. Wenn du eine „einzeilige Lösung" willst oder Variablen einsetzen musst, kehre nicht zu sh -c zurück. Finde lieber einen Weg, Parameter ohne Shell zu übergeben.
Fehler Nr. 3: Ein Entrypoint-Skript schreiben, aber exec darin vergessen.
Ein Skript ohne exec bleibt PID 1, und die JVM wird zum Kind-Prozess. Signale fliegen wieder „in die falsche Richtung", das Stoppen wird seltsam, und du verstehst ehrlich nicht, warum „wir doch ein entrypoint.sh gemacht haben, es sollte doch schön sein". Schön — nur mit exec.
Fehler Nr. 4: Einen langen Stopp durch Erhöhen des Timeouts „heilen", ohne PID 1 zu korrigieren.
Manchmal sehen Leute, dass der Container die 10 Sekunden nicht schafft und beginnen, docker stop --time 60 zu erhöhen. Das kann das Problem maskieren, behebt aber nicht die Ursache. Wenn SIGTERM die JVM nicht erreicht, kannst du eine Minute oder zehn warten — Java beginnt die korrekte Beendigung nicht, weil es das Signal einfach nicht empfangen hat.
Fehler Nr. 5: Den abrupten Tod des Containers mit „Spring-Eigenheiten" erklären, ohne die Start-Form zu prüfen.
Spring Boot reagiert in den meisten normalen Szenarien durchaus korrekt auf SIGTERM. Wenn du in den Logs keine Anzeichen für eine normale Beendigung siehst, prüfe zuerst, ob Java wirklich der Hauptprozess ist und kein „Kind-Schauspieler" im Shell-Theater. Sehr oft liegt das Problem nicht an Spring und nicht an Java, sondern daran, dass der Startbefehl in Shell-Form geschrieben wurde.
GO TO FULL VERSION