1. Die Kette Dockerfile → Image → Container
Jetzt ist genau der Moment, in dem bei vielen das Docker-Verständnis endlich „klickt". Bisher haben wir Konzepte gelesen, eine Datei geschrieben und Begriffe diskutiert. Jetzt tun wir das, wofür das alles geplant war: Wir starten den Service in einem Container. Damit wir uns weiter nicht verwirren, halten wir eine einfache Kette im Kopf: Dockerfile ist die Anweisung, Image ist das Build-Ergebnis, Container ist der gestartete Prozess aus dem Image. Wer diese Rollen verwechselt, bekommt schnell klassische Sätze wie „ich habe das Image gestartet" und „mein Container ist weg, aber ich habe nichts gemacht".
Es ist sehr praktisch, sich das als Fließband vorzustellen:
flowchart TD
%% Von Dockerfile zum Image, vom Image zum Container und weiter zu HTTP auf dem Host
A["Dockerfile + Build-Kontext
(inklusive build/libs/*.jar)"] -->|docker build| B["Image
docker-java-catalog-service:day3"]
B -->|docker run| C["Container
catalog-service"]
C -->|Port-Veröffentlichung -p| D["HTTP verfügbar auf Host
http://localhost:8080"]
In dieser Lektion folgen wir genau den Pfeilen: zuerst docker build, dann docker run, danach prüfen wir kurz „Container lebt" über docker ps. HTTP-Prüfung und Logs vermischen wir noch nicht mit dem ersten Start: Zuerst muss man sehen, dass das Image gebaut wird, der Container startet und der Prozess überhaupt am Leben ist.
2. Image bauen: docker build -t ... .
Der Befehl docker build ist der Moment, in dem Docker dein Dockerfile und den Build-Kontext nimmt, die Anweisungen von oben nach unten ausführt und ein neues Image erzeugt. Hier sind zwei Dinge wichtig zu verstehen. Erstens: Docker „rät nicht", welches JAR du genau brauchst — er führt einfach das COPY aus, das du geschrieben hast. Zweitens: Builds brechen fast nie „zufällig": Normalerweise hat Docker eine Datei im Kontext nicht gefunden, oder du hast den Build nicht aus dem richtigen Verzeichnis gestartet.
Das direkteste Szenario sieht so aus, wenn du im Projektstamm stehst:
# 1) Zuerst das jar lokal bauen, damit es in build/libs erscheint
./gradlew bootJar
# 2) Dann das Docker-Image aus dem Dockerfile im aktuellen Verzeichnis bauen (Build-Kontext = .)
docker build -t docker-java-catalog-service:day3 .
# (Docker liest Dockerfile und Kontext aus dem aktuellen Verzeichnis)
Wenn du unter Windows arbeitest und kein bash hast, kann der Build-Befehl gradlew.bat bootJar lauten. Aber die Idee ändert sich nicht: erst JAR, dann Docker.
Jetzt zerlegen wir den Befehl docker build sorgfältig, ohne ihn in ein Flag-Handbuch zu verwandeln:
docker build — startet den Image-Build.
-t docker-java-catalog-service:day3 — setzt ein Tag, also einen lesbaren Namen für das Image.
. — das ist der Build-Kontext, das aktuelle Verzeichnis. Relativ dazu wird Docker Dateien für COPY suchen.
Image-Tag und latest
Ein Tag bei einem Image ist wie ein Etikett auf einem Glas. Man kann „irgendwas Leckeres" draufschreiben, aber nach einer Woche im Kühlschrank wird das zu einer Quest. In Docker sieht ein Tag normalerweise so aus: Repository:Tag.
In unserem Fall:
docker-java-catalog-service — der Repository-Name, also die Image-Familie im lokalen Docker.
day3 — der Tag, den wir gewählt haben, um explizit zu zeigen: „das ist die Version, die an Tag 3 gebaut wurde".
Man hätte :day3 weglassen können, und dann hätte Docker standardmäßig latest verwendet. Aber für Lernende und den Kopf eines Anfängers ist latest fast immer eine Quelle der Verwirrung. Heute hast du eins gebaut, morgen etwas anderes, aber der Tag ist bei beiden gleich. Deshalb ist es besser, sich anzugewöhnen, den Tag explizit anzugeben, auch wenn er nur zu Lernzwecken ist.
Die Ausgabe von docker build lesen
Wenn du docker build startest, zeigt Docker die Schritte. In modernen Docker-Versionen, besonders mit BuildKit, sieht die Ausgabe mit „Pfeilen" und Stufen aus. Sie kann in etwa so aussehen:
# Am wichtigsten ist hier der COPY-Schritt: genau dort scheitert der Build am häufigsten
[+] Building 2.8s (8/8) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [1/3] FROM eclipse-temurin:25-jdk
=> [2/3] WORKDIR /app
=> [3/3] COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar
=> exporting to image
=> => naming to docker.io/library/docker-java-catalog-service:day3
Du musst jetzt nicht jede Zeile verstehen. Es reicht, das Wesentliche zu sehen: Docker hat das Dockerfile gefunden, hat .dockerignore gelesen, hat das Basis-Image aus FROM heruntergeladen (wenn es lokal noch nicht vorhanden war), hat COPY der JAR-Datei ausgeführt und am Ende dem Image einen Namen gegeben.
Der nützlichste Gedanke hier: Wenn der Build im Schritt COPY fehlschlägt, ist das fast nie ein „Docker-Problem". Normalerweise bedeutet es, dass das JAR nicht gebaut wurde, der Pfad falsch geschrieben ist, oder du nicht aus dem Projektstamm baust.
3. Das Image prüfen: docker image ls
Nach dem Build möchte man nicht auf Treu und Glauben vertrauen, sondern sich vergewissern: Das Image ist erschienen, hat den erwarteten Namen und Tag, und wir starten genau dieses und keine alte Version, die „letzte Woche gebaut wurde, aber ich erinnere mich, dass alles gut war". Docker bietet dafür einen sehr einfachen Befehl — die Image-Liste. Hier verwechseln Anfänger oft die Liste der Images und der Container, deshalb direkt: Jetzt prüfen wir Images, keine laufenden Prozesse.
Die Prüfung sieht so aus:
# Alle lokalen Images anzeigen (das sind Images, keine Container)
docker image ls
# oder die Kurzform:
docker images
Um nicht den ganzen Zoo lokaler Images anzuschauen, kann man die Ausgabe nach Name filtern:
# Nach Repository-Name filtern, um den gesuchten Tag schneller zu finden
docker image ls docker-java-catalog-service
# REPOSITORY TAG IMAGE ID CREATED SIZE
# docker-java-catalog-service day3 1a2b3c4d5e6f 10 seconds ago 400MB
Die Zahlen bei dir werden anders sein — das ist normal. Was in der Tabelle wichtig zu bemerken ist:
| Spalte | Was sie einfach bedeutet |
|---|---|
| REPOSITORY | Die Image-„Familie", sein Name |
| TAG | Die Versionsmarkierung, in unserem Fall day3 |
| IMAGE ID | Technische Image-ID |
| CREATED | Wann das Image gebaut wurde |
| SIZE | Image-Größe |
Wenn du docker-java-catalog-service:day3 siehst, bedeutet das, dass der Build nicht nur in der Vorstellung existierte. Das Image ist wirklich da.
Und ja, die Größe kann beachtlich sein. Kein Grund zur Panik: In dieser Phase optimieren wir das Image nicht und veranstalten keine „Jagd auf Megabytes". Unser Ziel heute ist der erste funktionstüchtige Start. Bis zur Image-Schlankheitskur und Layer-Engineering kommen wir später, damit wir nicht gleichzeitig Schwimmen lernen und eine Ozeanologie-Prüfung ablegen.
4. Container starten: docker run
Der häufigste emotionale Moment bei Anfängern klingt so: „Ich habe das Image gebaut, warum funktioniert die Anwendung nicht?" Weil ein Image wie ein gepackter Koffer ist. Der Koffer reist nicht von selbst. Reisen muss der Mensch. In der Docker-Welt wird der „Mensch" zum Container, also dem gestarteten Prozess, der aus dem Image erstellt wird. Und gestartet wird er mit dem Befehl docker run.
Wir möchten gleichzeitig drei Dinge tun: einen Container erstellen, ihm einen menschlichen Namen geben und den Port nach außen veröffentlichen, damit unser API auf localhost erreichbar ist. Der Befehl sieht so aus:
# Container aus einem bestimmten Image:Tag starten, Namen setzen und Port nach außen weiterleiten
docker run --name catalog-service -p 8080:8080 docker-java-catalog-service:day3
Analysieren wir ihn ruhig:
docker run erstellt einen Container aus dem Image und startet ihn sofort.
--name catalog-service setzt den Container-Namen. Damit kann man docker logs catalog-service und docker stop catalog-service schreiben, ohne sich eine zufällige ID wie a8f3c9... merken zu müssen.
-p 8080:8080 veröffentlicht den Port.
docker-java-catalog-service:day3 — das Image, das wir starten.
--name für den Container
Ohne --name generiert Docker einen Namen wie focused_mendeleev — ja, Docker hält sich manchmal für einen Dichter. Das ist nett, aber im echten Leben unpraktisch. Du nennst Variablen in Java ja auch nicht a1, a2 und a3, wenn man sie catalogService nennen kann. Dieselbe Logik gilt hier.
Es gibt noch einen praktischen Aspekt: Wenn du versuchst, einen zweiten Container mit demselben --name zu starten, „überschreibt" Docker den alten nicht. Er sagt ehrlich: Name vergeben. Und das ist normal, denn Container sind separate Entitäten.
Wenn das passiert, macht man einen einfachen erwachsenen Zyklus:
# Zuerst den Container stoppen (wenn er läuft), dann löschen, um den Namen freizugeben
docker stop catalog-service
docker rm catalog-service
Danach kann man neu starten. Das ist keine „Müllbeseitigung", sondern normaler Lifecycle: stoppen — löschen — neu starten.
Start im „angehängten" Modus (attach)
Der Befehl docker run startet den Container standardmäßig so, dass du mit seiner Ausgabe „verbunden" bist. Das heißt, die Anwendungs-Logs laufen direkt ins aktuelle Terminal. Für Anfänger ist das sogar praktisch: Man sieht sofort, ob Spring Boot startet und ob es nicht sofort abstürzt.
Es gibt eine Nuance: Solange der Container läuft, „endet" der Befehl nicht. Das Terminal ist belegt. Das ist kein Einfrieren, sondern ehrliches Prozessverhalten. Wenn du weiter Befehle im selben Fenster eingeben möchtest, verwendet man normalerweise den Hintergrundstart — detach über -d:
# -d (detach) = im Hintergrund starten und sofort die Terminal-Kontrolle zurückgeben
docker run -d --name catalog-service -p 8080:8080 docker-java-catalog-service:day3
# 3f2a1c... (Docker gibt die Container-ID zurück)
Ich werde keine Variante als „einzig richtig" aufdrängen. Für den Lernweg sind beide praktisch. Denke nur daran: Wenn du ohne -d gestartet hast, brauchst du für die nächsten Befehle ein zweites Terminal oder musst den Container stoppen. Mit -d kehrt das Terminal sofort zurück, und du kannst den Service in Ruhe weitertesten.
5. Port-Veröffentlichung: -p host:container
Ports in Docker sind der Ort, wo auch ein selbstsicherer Mensch anfängt, an der Realität zu zweifeln. Denn die Anwendung „scheint gestartet zu sein", aber der Browser antwortet mit Connection refused. Und hier ist wichtig zu merken: Der Container hat seine eigene innere Welt. Ohne eine Brücke nach außen ist der Service nur innerhalb der Container-Welt erreichbar, und von deiner Maschine kommst du schlicht nicht ran.
Der Befehl -p 8080:8080 liest sich so: „Nimm Port 8080 auf meiner Maschine, also auf dem Host, und leite ihn auf Port 8080 im Container um."
Hier eine einfache Darstellung:
Dein Laptop (Host) Container
http://localhost:8080 ---> :8080 ---> Spring Boot (server.port=8080)
Wenn Port 8080 auf deiner Maschine bereits belegt ist — zum Beispiel durch eine andere Anwendung oder einen alten vergessenen Service —, kann man einen völlig normalen Trick anwenden: einen anderen Host-Port nehmen, aber den Container-Port auf dem lassen, auf dem Spring Boot wirklich lauscht.
Zum Beispiel:
# Links (8081) — Port auf dem Host, rechts (8080) — Port im Container
docker run -d --name catalog-service -p 8081:8080 docker-java-catalog-service:day3
Die Anwendung im Container lauscht weiterhin auf 8080, aber von außen ist sie für dich unter der Adresse http://localhost:8081 erreichbar.
Das ist einer der Momente, in denen es wichtig ist, nicht zu versuchen, „den Code zu reparieren", wenn einfach ein Port belegt ist. Der Code hat damit nichts zu tun. Das ist nur ein Kabel von außen nach innen, das man in eine andere Buchse stecken kann.
6. Prüfen „Container lebt": docker ps und docker ps -a
Nach docker run entsteht beim Anfänger oft der Wunsch, sofort den Browser zu öffnen und sich zu freuen. Das ist verständlich, aber es gibt einen nützlichen Zwischenschritt: sicherstellen, dass der Container nicht sofort beendet wurde. Ein Container lebt genau so lange, wie der Hauptprozess lebt, und in unserem Fall ist das java -jar app.jar. Wenn der Java-Prozess abstürzt, „stirbt" der Container — kein Rätsel, sondern eine direkte Folge des Modells.
Die Prüfung sieht so aus:
# Zeigt nur die Container, die gerade wirklich laufen
docker ps
Wenn der Container gestartet ist, siehst du ihn in der Liste, und in der Spalte STATUS steht etwas wie Up 15 seconds, und in PORTS eine verständliche Mapping-Zeichenfolge:
0.0.0.0:8080->8080/tcp
Wenn docker ps nichts zeigt oder deinen Container nicht zeigt, bedeutet das nicht „Docker ist kaputt". Es bedeutet nur eines: Der Container läuft gerade nicht. Dann schauen wir uns die erweiterte Liste an:
# Zeigt sowohl laufende als auch gestoppte Container (also "was überhaupt erstellt wurde")
docker ps -a
Dort sieht man möglicherweise einen Status wie Exited (1) oder Exited (137) und die Zeit, zu der der Container beendet wurde. Hier ist es nützlich, den Exit-Code wörtlich zu lesen: Statt vagem „es funktioniert nicht" hast du einen konkreten Fakt — „Container hat mit diesem Code geendet."
Der nächste logische Schritt, wenn der Container beendet wurde, sind die Logs. Hier reicht es zunächst, die Route festzuhalten: docker ps ist schneller Puls, docker ps -a zeigt das Beendigungsfaktum und den Code, und die Logs erklären den Grund. Gibt es Puls — weiter. Kein Puls — ps -a anschauen, dann Logs.
7. Typische Fehler beim Build und ersten Start
Fehler №1: Build nicht aus dem Projektstamm starten.
Das sieht sehr alltäglich aus: Man steht in einem Ordner, gibt docker build ... . ein, und Docker nimmt den „Punkt" ehrlich als Kontext — aber das ist nicht der richtige. Ergebnis: Entweder findet er Dockerfile nicht oder das JAR in build/libs. Das löst sich nicht mit Magie, sondern mit langweiliger Disziplin: Vor docker build sicherstellen, dass man in dem Verzeichnis steht, in dem Dockerfile und .dockerignore liegen.
Fehler №2: JAR nicht gebaut, aber Dockerfile versucht es zu kopieren.
Das klassischste Szenario: Im Dockerfile steht COPY build/libs/... app.jar, aber in build/libs ist es leer oder es liegt eine alte Datei mit anderem Namen. Docker gibt einen Fehler im COPY-Schritt aus, und das ist richtig — er muss nicht „raten", dass du bootJar vergessen hast. Das Heilmittel ist einfach: Zuerst ./gradlew bootJar, dann ls build/libs, und erst danach docker build.
Fehler №3: Du hast das Dockerfile angepasst, aber vergessen, das Image neu zu bauen.
Das ist ein sehr menschlicher Fehler, denn das Gehirn sagt „Ich habe die Datei doch geändert." Aber Docker ist kein Gedankenleser: Der Container startet aus dem Image, und das Image wird erst nach docker build aktualisiert. Einfache Regel: Dockerfile geändert — Image neu gebaut — erst dann Container starten.
Fehler №4: Port 8080 belegt, und Docker meckert beim Start.
Hier erscheint normalerweise eine Meldung wie „port is already allocated". Das ist kein Spring Boot-Problem und kein JAR-Problem. Es bedeutet einfach, dass auf deiner Maschine bereits jemand auf 8080 lauscht. Entweder du gibst den Port frei, indem du den alten Prozess stoppst, oder du wählst einen anderen Host-Port, z.B. -p 8081:8080. Hauptsache: Nicht versuchen, „die Anwendung zu reparieren", wenn das Problem darin liegt, in welche Buchse man das Kabel gesteckt hat.
Fehler №5: Container in docker ps nicht sichtbar, weil er sofort beendet wurde.
Das ist ein sehr häufiges Gefühl: „Ich habe gestartet, es ist weg." In Wirklichkeit muss ein Container nicht ewig leben. Wenn der Java-Prozess darin abgestürzt ist, ist der Container beendet. In so einer Situation schaue erst docker ps -a an, um das Beendigungsfaktum und den Exit-Code zu sehen. Und erst dann die Logs — so hört das Bild auf, mysteriös zu sein.
GO TO FULL VERSION