CodeGym /Kurse /Docker for Spring /Image-Schichten und Docker-Cache

Image-Schichten und Docker-Cache

Docker for Spring
Level 5, Lektion 0
Verfügbar

1. Docker-Cache und die Geschwindigkeit von docker build

Ein funktionierendes Dockerfile haben wir bereits: Der Container startet, der Port ist veröffentlicht, alles sieht ordentlich aus. Aber ein kleines Projekt ist täuschend: Bei ihm wirkt fast jedes Dockerfile „normal". docker build hat funktioniert, der Container ist gestartet — Sieg. Der echte Schmerz beginnt am nächsten Tag, wenn du eine Zeile Code änderst, den Build wieder startest… und er wieder zieht wie eine Serie mit 12 Staffeln. In Java fühlt sich das besonders deutlich an: viele Abhängigkeiten, schwerer Build, und die Geduld des Menschen ist eine infinite resource.

Der Docker-Cache ist nicht dafür da, „immer sofort zu bauen". Er ist dafür da, dass Docker ehrlich sagen kann: „Diese Schritte haben sich definitiv nicht geändert, ich habe sie schon gemacht, ich mache sie nicht noch einmal." Je besser wir verstehen, wie Docker diese Entscheidung trifft, desto leichter fällt es uns, das Dockerfile so zu gestalten, dass tägliche Änderungen in src/ sich nicht in „bauen wir die ganze Welt neu" verwandeln.

2. Dockerfile-Schritte und Image-Schichten

Ein Dockerfile wird oft als „einfache Liste von Befehlen" wahrgenommen. Für Docker ist es eher ein Rezept für den Build: Es geht Schritt für Schritt durch, von oben nach unten. Jeder Schritt liefert ein Zwischenergebnis, das Docker merken und später wiederverwenden kann. Genau dieses „gemerkte Ergebnis eines Schritts" nennen wir in unserem vereinfachten Lernmodell eine Schicht (Layer).

Stell dir das Image wie einen Schichtkuchen vor: Unten die Basis (zum Beispiel Linux + JDK), darüber deine Ergänzungen (Arbeitsverzeichnis, JAR, Umgebungsvariablen, Entrypoint). Docker „backt" jede Schicht separat. Und wenn sich die unteren Schichten nicht geändert haben, muss er sie nicht noch einmal „backen".

Zur Veranschaulichung kann man sich den Build so vorstellen (vereinfacht):

flowchart TD
    %% Vereinfachtes Modell: ein Dockerfile-Schritt = eine Schicht
    A["Dockerfile: Schritt 1"] --> B[Layer 1]
    B --> C["Dockerfile: Schritt 2"]
    C --> D[Layer 2]
    D --> E["Dockerfile: Schritt 3"]
    E --> F[Layer 3]
    F --> G["Fertiges Image = Schichten 1+2+3"]

In der Realität gibt es ein paar mehr Nuancen: Manche Anweisungen wirken sich auf das Dateisystem aus, andere auf Metadaten. Aber für das Verständnis des Caches reicht eine Idee: Docker baut ein Image als Kette von Schritten, und der Cache funktioniert schrittweise.

3. Layer, Image, Container

Eine sehr typische frühe Verwirrung: Man hört das Wort „Schicht", denkt an „Container-Schicht", „Dateisystem-Schicht" — und alles vermischt sich zu einem Klumpen. Lassen wir uns das sauber auseinandernehmen.

Image — das ist das Build-Ergebnis. Es wird auf der Festplatte als Satz von Schichten (plus Konfiguration) gespeichert. Man kann es viele Male starten, aber es selbst „läuft" nicht und lauscht nicht auf einem Port.

Container — das ist eine laufende Instanz eines Images. Ein Container lebt, solange sein Hauptprozess lebt (für uns ist das normalerweise java -jar ...). Der Container bekommt eine Writable Layer (beschreibbare Schicht über dem Image), in die der Prozess während der Laufzeit Dateien schreiben kann.

Und die Image-Schichten — das sind genau jene „Teile" des fertigen Images, die Docker beim nächsten docker build wiederzuverwenden versucht.

Fassen wir das in einer kurzen Tabelle zusammen, damit das Gehirn nicht alles im Nebel behält:

Begriff In einfachen Worten Wann es entsteht Warum es heute für uns wichtig ist
Layer Ergebnis eines Dockerfile-Build-Schritts, das wiederverwendet werden kann Während docker build Von den Schichten hängt die Geschwindigkeit des Neubuilds ab
Image Fertiges „Template" der Anwendung (Schichten + Konfiguration) Nach docker build Das starten wir und geben es weiter
Container Gestarteter Prozess aus einem Image Nach docker run Er lebt, solange der Java-Prozess lebt

Vereinfacht auf einen Satz: Eine Schicht ist für den Build, ein Container ist für den Start. Heute zielen wir auf Build-Geschwindigkeit ab, deshalb dreht sich alles um Schichten.

4. Docker-Cache: Hit, Miss, Invalidierung

Jetzt das Interessanteste: Was ist der Docker-Cache — nicht als „Magie", sondern als verständlicher Mechanismus. Während docker build geht Docker das Dockerfile von oben nach unten durch und fragt sich bei jedem Schritt: „Habe ich schon ein Ergebnis von genau diesem Schritt mit genau denselben Eingangsdaten?" Wenn ja — gibt es einen Cache-Hit, und der Schritt wird nicht erneut ausgeführt. Wenn nein — gibt es einen Cache-Miss, der Schritt wird ausgeführt, und Docker schreibt das neue Ergebnis in den Cache.

In der Build-Ausgabe siehst du das normalerweise als Markierungen wie CACHED (in der BuildKit-Ausgabe) oder Using cache (in älteren Formaten). Die Idee ist dieselbe: Docker macht die Arbeit nicht zweimal.

Nehmen wir ein ganz minimales Dockerfile für unseren Lernservice (noch ohne „erwachsene" Optimierung — wir wollen das Prinzip verstehen). Wir gehen davon aus, dass in build/libs bereits ein fertiges bootJar liegt, und legen es im Image unter dem stabilen Namen app.jar ab:

# Basis-Image mit JDK — ändert sich selten, deshalb eine gute "untere Schicht" für den Cache
FROM eclipse-temurin:25-jdk

# Arbeitsverzeichnis im Image (Image-Metadaten + ggf. Verzeichnis anlegen)
WORKDIR /app

# Fertiges bootJar unter stabilem Namen ins Image kopieren
COPY build/libs/*.jar app.jar

# Einstiegspunkt des Containers: genau dieser Prozess wird "Hauptprozess" (PID 1)
ENTRYPOINT ["java", "-jar", "app.jar"]

Beim ersten Build führt Docker alles gewissenhaft aus. Beim zweiten (wenn das JAR sich nicht geändert hat) sagt er ungefähr so:

[1/4] FROM eclipse-temurin:25-jdk
CACHED [2/4] WORKDIR /app
CACHED [3/4] COPY build/libs/*.jar app.jar
CACHED [4/4] ENTRYPOINT ["java", "-jar", "app.jar"]

Es ist wichtig, eine einfache Sache zu verstehen: Der Cache „beschleunigt nicht alles". Er beschleunigt nur das, was sich nicht geändert hat.

Und hier erscheint ein weiterer nützlicher Begriff — Cache-Invalidierung. Das ist der Fall, wenn Docker den Cache für einen Schritt nicht nutzen kann, weil sich etwas Wichtiges geändert hat: die Dockerfile-Anweisung selbst oder die Eingabedateien, die diese Anweisung verwendet. Zum Beispiel: Wenn sich das JAR geändert hat, muss die Schicht mit COPY ... app.jar neu gebaut werden, weil wir faktisch eine andere Datei kopieren. Und das ist normal: Wir wollen ja ein Image mit der neuen Anwendungsversion, kein „schöner Cache".

5. Kaskadierte Cache-Invalidierung

Jetzt kommt der Moment, wo viele zum ersten Mal ein „Aha!" erleben. Das Dockerfile wird von oben nach unten gelesen, und jeder Schritt baut auf dem vorherigen Ergebnis auf. Wenn also Schritt Nr. 2 sich geändert hat, kann Schritt Nr. 3 nicht mehr „genau derselbe" sein: Seine Basisschicht ist jetzt eine andere, auch wenn die Anweisung in Schritt Nr. 3 sich formal nicht geändert hat.

Das ist die kaskadierte Invalidierung: Eine Änderung in einem frühen Schritt zwingt oft dazu, alles darunter neu zu bauen — wie Dominosteine.

Schauen wir uns eine einfache Kette an:

# Basis-Image: wenn es geändert wird (Tag/Version), bricht der Cache aller Schritte darunter
FROM eclipse-temurin:25-jdk

# Metadaten/Verzeichnisvorbereitung: wird normalerweise stabil gecacht
WORKDIR /app

# Diese Schicht ändert sich, wenn sich das JAR ändert (also dein Code)
COPY build/libs/*.jar app.jar

# Jeder Schritt unter COPY wird neu berechnet, wenn COPY ein Cache-Miss war (Kaskade)
ENTRYPOINT ["java", "-jar", "app.jar"]

Wenn sich nur app.jar ändert, baut Docker die Schicht COPY neu, und alles darunter wird auch neu berechnet (in unserem Beispiel nur ENTRYPOINT). Aber FROM und WORKDIR bleiben gecacht.

Stell dir nun eine andere Situation vor: „Ich bin zu faul zum Nachdenken, ich kopiere einfach alles":

 
  FROM eclipse-temurin:25-jdk
WORKDIR /app

# Breiter Befehl: fast das gesamte Repository wird zur Eingabe des Schritts
COPY . .

# Jede Änderung im Projekt (auch "Kleinigkeiten") erzwingt einen erneuten Build
RUN ./gradlew bootJar

# Wichtig: In der Exec-Form wird der Wildcard nicht von der Shell expandiert, also funktioniert `*.jar` nicht direkt
ENTRYPOINT ["java", "-jar", "build/libs/*.jar"]
 

Hier macht ein einziger „harmloser" Schritt COPY . . den Layer abhängig von fast jeder Änderung im Projekt: Du hast eine .md-Datei korrigiert, eine Zeile in src/ hinzugefügt, die IDE hat irgendeine Hilfsdatei erstellt (wenn du .dockerignore vergessen hast) — fertig, die Schicht hat sich geändert. Das bedeutet, der nächste Schritt RUN ./gradlew bootJar wird erneut ausgeführt. Und hier beginnt der Java-Entwickler zu leiden, denn bootJar ist ein schwerer Schritt.

Der Hauptgedanke des heutigen Tages: Cache ist kein „Häkchen, das man aktiviert". Cache ist die ingenieurmäßige Belohnung dafür, dass du die Schritte so strukturiert hast, dass sie selten invalidiert werden.

6. Java/Gradle und Cache-Probleme

Java-Projekte haben eine unangenehme Eigenschaft: Selbst eine kleine Anwendung schleppt eine ansehnliche Menge an Abhängigkeiten mit sich, und der Build umfasst oft Kompilierung, Tests (manchmal), JAR-Paketierung und noch ein paar Freuden des Lebens. Dazu kommt Gradles eigene Logik für das Laden von Abhängigkeiten, eigener Cache und eigene Aufwärmzeiten. Am Ende wird docker build entweder zu einem angenehmen Tageswerkzeug oder zu einer „Zeitsteuer" für jede Codezeile.

Um zu fühlen, warum die Reihenfolge der Schritte so wichtig ist, muss man eine einfache Sache sehen: In einem Gradle-Projekt ändern sich verschiedene Dateien unterschiedlich häufig. Den Code in src/ berührst du ständig. build.gradle.kts und settings.gradle.kts — deutlich seltener. Und die Abhängigkeitsliste selbst (besonders in einem Lernprojekt) ändert sich vielleicht wochenlang nicht. Hier wird der Cache zum Verbündeten: Wenn du das Dockerfile so aufbaust, dass selten wechselnde Teile in frühe Schritte fallen und gecacht werden, treffen tägliche Code-Änderungen nur die späten Schritte.

Wir refaktorieren das Dockerfile jetzt noch nicht. Aber die Diagnose ist bereits wichtig: Für Java/Gradle bedeutet eine schlechte Dockerfile-Struktur normalerweise nicht „minus 5 Sekunden", sondern „alles wird 3–10 Minuten lang neu gebaut, und du fängst an zu vermuten, dass Docker dich nicht mag". Docker mag dich. Er ist nur kein Gedankenleser.

7. Den Cache sehen

Theorie über Cache klingt schön, bis man sie mit eigenen Augen sieht. Die gute Nachricht: Docker zeigt in der Build-Ausgabe ziemlich ehrlich, was es aus dem Cache genommen hat und was neu gebaut wurde. Und das ist selbst ohne spezielle Analyse-Befehle sichtbar — man muss nur aufmerksam auf den Build schauen.

Stell dir vor, wir arbeiten im Root des Repositories docker-java-catalog-service/ und verwenden unser einfaches Dockerfile, das das bereits gebaute JAR kopiert. Die Befehlsfolge sieht so aus:

# JAR lokal bauen (das ist ein separater Cache — Gradle-Cache, nicht Docker-Cache)
./gradlew bootJar

# Erster Image-Build: im Cache gibt es noch kaum Schichten, Schritte werden wirklich ausgeführt
docker build -t docker-java-catalog-service:day5 .

# Zweiter Build ohne Änderungen: wir erwarten viele CACHED
docker build -t docker-java-catalog-service:day5 .

Der erste Build führt Schritte fast sicher wirklich aus. Der zweite sollte viele CACHED zeigen. Und das ist logisch: Du hast nichts geändert, Docker muss nicht zweimal leiden.

Jetzt der Schlüsselmoment: Wenn du Code änderst, ändert sich das JAR, und der Schritt COPY build/libs/*.jar app.jar wird ein Cache-Miss. In der Ausgabe siehst du, dass genau dieser Schritt erneut ausgeführt wird, während die vorherigen CACHED bleiben. Das ist das vernünftige Verhalten: Wir bauen nur das neu, was sich wirklich geändert hat.

Umgekehrt gilt: Wenn du versehentlich etwas Frühes änderst (zum Beispiel FROM oder WORKDIR, oder du machst einen breiten COPY . .), löst du eine Kaskade aus: Docker kann den Cache weiter unten in der Kette nicht nutzen, weil sich die Basisschicht geändert hat. Das sieht aus wie „warum wurde das alles neu gebaut, ich habe doch fast nichts gemacht". Tatsächlich hast du etwas gemacht — du hast das geändert, von dem alles andere abhängt.

8. Typische Fehler beim Umgang mit Schichten

Fehler rund um den Cache entstehen meistens nicht wegen „komplexem Docker", sondern wegen unserer Erwartungen. Wir erwarten, dass das System versteht: „Das ist doch Kleinkram", aber das System ist stur und ehrlich — es vergleicht Schritte und Eingangsdaten. Keine Gefühle, keine Empathie, nur Schichten. Manchmal zeigt sogar Java-Code mehr Mitgefühl, indem es eine verständliche Exception wirft.

Fehler Nr. 1: Image-Schicht und Container verwechseln und dann versuchen, den „Cache zu säubern", indem man den Container löscht.
Ein Container ist ein Start, Schichten sind ein Build. Wenn du einen Container löschst (docker rm), löschst du nicht automatisch die Image-Schichten und den Build-Cache. Und umgekehrt: Man kann einen perfekten Build-Cache haben und trotzdem beliebig viele neue Container starten. Das sind verschiedene Teile der Welt.

Fehler Nr. 2: Einen Cache-Hit als „generelle Beschleunigung" betrachten und nicht bemerken, dass sich die Eingabedateien des Schritts ständig ändern.
Docker cached einen Schritt nur, wenn Anweisung und Eingaben übereinstimmen. Wenn du das gesamte Projekt mit einem einzigen Befehl COPY . . kopierst, wird „fast alles" zur Eingabe, und jedes Niesen invalidiert den Cache. Dann scheint es, als würde „der Cache nicht funktionieren", obwohl er funktioniert… es gibt nur fast nichts zum Wiederverwenden.

Fehler Nr. 3: Erwarten, dass wenn sich eine Datei ändert, nur ein Schritt neu gebaut wird.
Eine Änderung löst oft eine Kaskade aus: Eine frühe Schicht hat sich geändert — alle unteren wurden neu gebaut. Das ist normal und logisch, wenn man bedenkt, dass Docker das Image als Kette „auf dem vorherigen Ergebnis" aufbaut. Und das ist genau der Grund, warum die Reihenfolge der Schritte im Dockerfile keine Kosmetik ist, sondern ein Werkzeug zur Steuerung der Geschwindigkeit.

Fehler Nr. 4: Vergessen, dass der Cache lokal lebt, und sich wundern, dass der Build auf einer anderen Maschine „plötzlich langsam" ist.
Der Cache ist keine Cloud-Magie (standardmäßig). Wenn du ein Image auf deinem Laptop gebaut hast und dann ein Kollege es auf einer frischen Maschine baut, hat er nicht deine gecachten Schichten. Er hat einen „ersten Build" mit allen schweren Schritten. Das ist kein Defekt — das ist die ehrliche Realität.

Fehler Nr. 5: Den Cache absichtlich oder versehentlich deaktivieren und dann Docker für Langsamkeit beschimpfen.
Wenn du den Build mit dem Flag --no-cache startest, führt Docker buchstäblich deine Bitte aus: „Nutze keinen Cache." Das ist manchmal nützlich zur Überprüfung, aber wenn man das ständig macht, verwandelt man freiwillig jeden Build in einen ersten Build. Das ist, als würde man jedes Mal die IDE neu installieren, damit es „sicher sauber ist".

1
Aufgabe
Docker for Spring, Level 5, Lektion 0
Gesperrt
Wiederholter Build ohne Änderungen
Wiederholter Build ohne Änderungen
1
Aufgabe
Docker for Spring, Level 5, Lektion 0
Gesperrt
Cache miss nach Änderung des Jars
Cache miss nach Änderung des Jars
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION