Docker Grundlagen: Image, Container und Layer

1. Übersicht

Viele Docker-Anfänger verwechseln Image und Container, oder verstehen nicht, warum docker build beim zweiten Mal plötzlich so schnell geht. Dieser Artikel erläutert drei fundamentale Konzepte: Image, Container und Layer — mit praktischen Beispielen zum Erstellen eines Images für eine echte Spring Boot-Anwendung. Nach dem Lesen können Sie selbst vernünftige Dockerfiles schreiben, erfahren, wie Sie Layer-Caching zum schnelleren Build nutzen, und vermeiden häufige Fehler beim Containerisieren von Java-Anwendungen.

2. Was ist ein Image

Ein Image ist ein schreibgeschützter Snapshot, der alles enthält, was Sie zum Ausführen einer Anwendung benötigen: ein Basis-Betriebssystem, Runtime (z. B. JVM), Bibliotheken, Umgebungsvariablen und Ihren Code. Ein Image läuft nicht von selbst — es ist ähnlich wie eine “Vorlage”, aus der Sie Container erstellen.

Sie definieren ein Image mit einer Dockerfile:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Erklärung für jede Zeile:

  • FROM: wählt das Basis-Image aus (JRE 21 auf Temurin). Alles, was Sie hinzufügen, basiert darauf.
  • WORKDIR: legt das Arbeitsverzeichnis fest, nachfolgende Befehle werden relativ dazu ausgeführt.
  • COPY: kopiert die vorgefertigte JAR-Datei (aus mvn package) ins Image.
  • ENTRYPOINT: Befehl beim Start des Containers.

Erstellen Sie das Image mit:

docker build -t order-service:1.0 .

Ergebnis:

[+] Building 3.2s (8/8) FINISHED
 => [1/3] FROM eclipse-temurin:21-jre
 => [2/3] WORKDIR /app
 => [3/3] COPY target/order-service.jar app.jar
 => exporting to image
 => => naming to docker.io/library/order-service:1.0

Das Image wird lokal gespeichert, Sie können die Liste mit docker images anzeigen.

Hinweis: Ein Image ist nur eine statische Datei. Das mehrfache Ausführen von docker build mit derselben Dockerfile und unverändertem Code erzeugt keine laufende Anwendung — um auszuführen, müssen Sie einen Container aus dem Image erstellen.

3. Was ist ein Container

Ein Container ist eine laufende (oder gestoppte) Instanz, die aus einem Image erstellt wurde. Wenn das Image eine “Klasse” ist, ist der Container das “Objekt” — Sie können viele Container aus demselben Image erstellen, jeder Container ist unabhängig in Bezug auf Prozess, Netzwerk, und beschreibbares Dateisystem (writable layer).

Container erstellen und starten:

docker run -d --name order-svc -p 8080:8080 order-service:1.0
  • -d: im detached-Modus (Hintergrund) ausführen.
  • --name: den Container benennen für einfachere Verwaltung, anstelle einer zufälligen ID.
  • -p 8080:8080: Port 8080 des Hosts zu Port 8080 im Container zuordnen.

Laufende Container überprüfen:

docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS                    NAMES
3f2a9c1b8d4e   order-service:1.0      Up 5 seconds   0.0.0.0:8080->8080/tcp   order-svc

Sie können einen zweiten Container aus demselben Image starten, indem Sie nur den Namen und Port ändern:

docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0

Diese zwei Container sind vollständig unabhängig: wenn ein Container abstürzt oder Sie Dateien in sein Dateisystem schreiben, ist der andere Container nicht betroffen. Das ursprüngliche Image bleibt unverändert — das ist die schreibgeschützte Natur des Images.

Container stoppen und löschen, wenn nicht mehr benötigt:

docker stop order-svc && docker rm order-svc

3.1. Wie unterscheidet sich ein Container von einer virtuellen Maschine (VM)

Container teilen den Kernel mit dem Host, virtualisieren nicht das gesamte Betriebssystem wie eine VM. Deshalb starten Container in wenigen Sekunden (wie in der Build-Ausgabe oben), nicht etwa Dutzende von Sekunden wie eine VM, und verbrauchen wesentlich weniger Ressourcen.

4. Was ist ein Layer und warum ist er wichtig

Jede Zeile in einer Dockerfile (FROM, COPY, RUN, ADD…) erzeugt einen separaten Layer. Diese Layer stapeln sich übereinander (union filesystem), und Docker cached jeden Layer — wenn sich ein Layer nicht ändert im Vergleich zum vorherigen Build, nutzt Docker den Cache erneut, anstatt neu zu erstellen.

Betrachten Sie eine suboptimal geschriebene Dockerfile:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]

Problem: COPY . . kopiert den gesamten Quellcode, auch wenn Sie nur einen Kommentar ändern. Da sich der COPY-Layer ändert, muss Docker den nachfolgenden RUN mvn package-Layer neu erstellen — kostet ein paar Minuten jedes Mal, obwohl die Logik unverändert ist.

Die Optimierung besteht darin, Abhängigkeiten (die sich selten ändern) nach vorne zu setzen und den Code (der sich häufig ändert) nach hinten:

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Erklärung:

  • COPY pom.xml . gefolgt von RUN mvn dependency:go-offline: dieser Layer ändert sich nur, wenn Sie Abhängigkeiten in der pom.xml hinzufügen/entfernen. Meistens wird dieser Layer gecacht — überspringe den Schritt zum Herunterladen von Bibliotheken, was normalerweise am längsten dauert.
  • COPY src ./src später platziert: nur dieser und die nachfolgenden Layer werden neu erstellt, wenn Sie den Code ändern, der Abhängigkeits-Layer bleibt gecacht.
  • Multi-stage build (AS build und dann FROM ... jre): die letzte Phase nimmt nur die erstellte JAR-Datei, trägt JDK und Maven-Cache nicht mit sich — das Produktions-Image ist deutlich kleiner.

Zeigen Sie die Layer eines Images mit:

docker history order-service:1.0
IMAGE          CREATED        CREATED BY                      SIZE
a1b2c3d4e5f6   3 minutes ago  ENTRYPOINT ["java" "-jar"...]   0B
7f8e9d0c1b2a   3 minutes ago  COPY /app/target/order-ser…     45.2MB
...

Jede Zeile entspricht einem Layer, mit der Größe, die er hinzufügt. Der COPY jar-Layer nimmt den Großteil des Speicherplatzes ein — das ist sinnvoll, da er den eigentlichen Anwendungscode enthält.

4.1. Wie funktioniert Layer-Caching beim Neuerstellen

Angenommen, Sie ändern nur eine Java-Klasse und erstellen neu:

docker build -t order-service:1.1 .
 => [build 1/5] FROM eclipse-temurin:21-jdk
 => CACHED [build 2/5] COPY pom.xml .
 => CACHED [build 3/5] RUN mvn dependency:go-offline
 => [build 4/5] COPY src ./src
 => [build 5/5] RUN mvn package -DskipTests
 => [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar

Die zwei CACHED-Zeilen zeigen, dass Docker das Herunterladen von Abhängigkeiten überspringt — der Build ist deutlich schneller als bei der anfänglichen COPY . . Methode.

Hinweis: Docker vergleicht den Cache basierend auf Dateiinhalt und Befehlsreihenfolge, nicht basierend auf Zeitstempel. Wenn Sie die Reihenfolge von COPY oder RUN ändern, werden alle Layer von diesem Punkt an invalidiert, auch wenn der Inhalt faktisch gleich ist.

5. Überprüfung durch automatisierte Tests

Wenn Sie einen Service containerisieren, möchten Sie normalerweise sicherstellen, dass das erstellte Image eine ordnungsgemäße Gesundheitsprüfung besteht. Verwenden Sie die Testcontainers-Bibliothek, um Tests zu schreiben, die bestätigen, dass der Container startet und die richtige Antwort gibt.

Abhängigkeit hinzufügen:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

Test schreiben:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;

import static org.assertj.core.api.Assertions.assertThat;

class OrderServiceContainerTest {

    @Test
    void containerShouldRespondOnHealthEndpoint() {
        try (GenericContainer<?> container =
                new GenericContainer<>("order-service:1.0")
                        .withExposedPorts(8080)
                        .waitingFor(Wait.forHttp("/actuator/health"))) {

            container.start();

            assertThat(container.isRunning()).isTrue();
            assertThat(container.getMappedPort(8080)).isPositive();
        }
    }
}
  • GenericContainer erstellt einen temporären Container aus dem Image order-service:1.0, wird automatisch bereinigt, wenn der try-Block endet.
  • waitingFor(Wait.forHttp(...)): der Test wartet, bis der Health-Endpoint 2xx zurückgibt, bevor der Container als bereit gilt, um Race Conditions zu vermeiden.
  • getMappedPort(8080): Docker ordnet einen zufälligen Port auf dem Host zu, der Test ermittelt den tatsächlichen Port, um ihn bei Bedarf zu verwenden.

Das Ausführen dieses Tests bestätigt, dass das Image, das Sie in Abschnitt 2 erstellt haben, nicht nur erfolgreich erstellt wurde, sondern auch wirklich funktioniert — ein Überprüfungsschritt, den viele übersehen.

6. Häufige Fehler

  • Image und Container beim Aufräumen verwechseln: docker rm ausführen löscht Container, aber das Image bleibt bestehen und belegt immer noch Speicherplatz. Um ein Image zu löschen, verwenden Sie docker rmi.
  • Den gesamten Quellcode kopieren, bevor Sie Abhängigkeiten installieren: wie in Abschnitt 4 erwähnt, verliert dies den Vorteil des Layer-Caching, unnötig langsamer Build.
  • Ein zu großes Basis-Image verwenden (zum Beispiel vollständiges Ubuntu) für JRE Runtime: das Produktions-Image wird aufgebläht, erhöht die Pull-/Deploy-Zeit. Sie sollten ein minimales JRE-Image verwenden wie eclipse-temurin:21-jre oder die -alpine-Version, wenn kompatibel.
  • Ein neues Image erstellen, ohne das Tag zu ändern: das Tag latest wiederverwenden macht es schwierig, zurückzusetzen, wenn es Produktionsfehler gibt, da Sie nicht wissen, welches Image gerade läuft.

Wann Sie keinen Multi-stage Build brauchen: wenn Sie die JAR bereits in CI erstellen (zum Beispiel GitHub Actions) und dann erst docker build ausführen, benötigen Sie die build-Phase nicht in der Dockerfile — Sie müssen nur die vorhandene JAR-Datei kopieren wie im Beispiel in Abschnitt 2. Multi-stage ist nur notwendig, wenn Sie möchten, dass Docker den Code aus der Quelle kompiliert.

7. Fazit

Ein Image ist eine statische Vorlage, ein Container ist eine laufende Instanz aus diesem Image, und Layer sind übereinander gestapelte Schichten, die Docker intelligentes Caching ermöglichen, um schneller zu erstellen. Verstehen Sie die Reihenfolge der Layer in der Dockerfile — setzen Sie Teile, die sich selten ändern (Abhängigkeiten), nach vorne und Teile, die sich häufig ändern (Quellcode), nach hinten — ist der einfachste Weg, um die Build-Zeit zu optimieren. Von dieser Grundlage aus können Sie weitergehen zu Themen wie Docker Compose, um mehrere Services zu orchestrieren, oder um die Größe des Images zu optimieren mit distroless base.

dockerdevopscontainerimagespring-boot