Zurück zu allen Beiträgen
·4 Min. Lesezeit

Docker-Image-Builds beschleunigen: Layer-Cache und Multi-Stage-Dockerfiles

In vielen Teams ist der langsamste Schritt der CI-Pipeline nicht der Testlauf, sondern der Build des Docker-Images. Bei jedem Commit werden die Abhängigkeiten neu heruntergeladen, die Kompilierung läuft neu, und zehn Minuten später entsteht ein Image, das dem vorherigen fast gleicht. Richtig eingesetzt, kann der Layer-Cache von Docker den größten Teil dieser Arbeit überspringen.

In diesem Beitrag erkläre ich, wie der Cache funktioniert, wie Sie Ihr Dockerfile darauf ausrichten und wie Sie den Cache in der CI erhalten. Die Beispiele verwenden .NET und Node.js, die Logik ist aber in jedem Stack dieselbe.

Wie der Layer-Cache funktioniert

Jede Anweisung in einem Dockerfile erzeugt einen Layer. Bevor Docker eine Anweisung ausführt, prüft es, ob dieselbe Anweisung schon einmal mit denselben Eingaben gelaufen ist. Falls ja, wird das Ergebnis aus dem Cache genommen.

Die entscheidende Regel: Wird ein Layer ungültig, werden auch alle folgenden Layer neu erzeugt. Legen Sie also Dinge, die sich oft ändern, ans Ende der Datei und selten geänderte an den Anfang. Bei COPY ist die Eingabe der Inhalt der kopierten Dateien. Ändert sich eine einzige Quelldatei, wird ein COPY . .-Layer ungültig.

Abhängigkeiten vom Quellcode trennen

Der häufigste Fehler ist, das ganze Projekt zu kopieren und erst danach die Abhängigkeiten zu installieren:

COPY . .
RUN npm ci

Hier führt jede Codeänderung npm ci erneut aus. Richtig ist, zuerst nur die Abhängigkeitsdateien zu kopieren:

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Solange sich package-lock.json nicht ändert, kommt npm ci aus dem Cache. Bei Codeänderungen laufen nur die letzten beiden Schritte. In .NET gilt dasselbe Muster mit den Projektdateien:

COPY *.sln ./
COPY src/Api/Api.csproj src/Api/
COPY src/Domain/Domain.csproj src/Domain/
RUN dotnet restore
COPY . .
RUN dotnet publish src/Api -c Release -o /out --no-restore

Bei vielen Projekten ist es mühsam, jede csproj-Datei einzeln zu kopieren, aber nur so lässt sich der Restore-Schritt cachen.

Unnötige Eingaben mit .dockerignore ausschließen

COPY . . sieht alles im Build-Kontext. Gehören .git, node_modules, bin, obj und lokale Logs zum Kontext, wird dessen Übertragung langsamer, und eine unbeteiligte Dateiänderung kann den Cache brechen. Eine .dockerignore-Datei löst das:

.git
**/node_modules
**/bin
**/obj
*.log
.env*

Die Zeile .env* ist auch aus Sicherheitsgründen wichtig: Lokale Geheimnisse gelangen nicht versehentlich ins Image.

Das Image mit Multi-Stage-Builds verkleinern

Build-Werkzeuge werden zur Laufzeit nicht gebraucht. Ein Multi-Stage-Dockerfile kompiliert in einem schweren Image und übernimmt nur das Ergebnis in ein schlankes:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln ./
COPY src/Api/Api.csproj src/Api/
RUN dotnet restore
COPY . .
RUN dotnet publish src/Api -c Release -o /out --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /out .
ENTRYPOINT ["dotnet", "Api.dll"]

Das bringt zwei Vorteile. Das finale Image ist klein, lässt sich also schneller ziehen und ausrollen, und die Angriffsfläche schrumpft. Außerdem werden die Build-Stages unabhängig gecacht. Passen Sie die Tag-Versionen an die Version Ihres Projekts an; es geht um das Muster.

BuildKit-Cache-Mounts

Der Layer-Cache lädt alle Pakete komplett neu herunter, sobald sich die Abhängigkeitsdatei ändert. Der Cache-Mount von BuildKit behält das Cache-Verzeichnis des Paketmanagers zwischen den Builds:

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm \
    npm ci

Fügen Sie ein Paket hinzu, wird nur das neue Paket heruntergeladen, der Rest kommt aus dem Cache-Verzeichnis. Der Inhalt des Cache-Mounts wird nicht ins Image geschrieben und bläht es daher nicht auf. Derselbe Ansatz funktioniert für NuGet mit /root/.nuget/packages und für pip mit /root/.cache/pip.

Den Cache in der CI erhalten

Alles, was lokal funktioniert, ist umsonst, wenn die CI auf einer sauberen Maschine läuft, denn dann ist der Cache bei jedem Lauf leer. Es gibt zwei gängige Lösungen:

  • Registry-Cache: Am Ende des Builds schicken Sie die Cache-Layer in eine Registry und holen sie im nächsten Build von dort. Die Nutzung sieht so aus: docker buildx build --cache-from type=registry,ref=... --cache-to type=registry,ref=...,mode=max.
  • Cache-Speicher des CI-Anbieters: Unterstützt Ihr CI-Werkzeug ein lokales Verzeichnis oder einen eigenen Cache-Typ, nutzen Sie den passenden buildx-Typ für --cache-to/--cache-from.

mode=max exportiert auch Zwischen-Layer. Bei Multi-Stage-Builds ist das wichtig, weil der Standardmodus nur die Layer des finalen Images speichert und der Cache der Build-Stage verloren geht. Der Cache belegt Speicherplatz, legen Sie daher eine Aufbewahrungsrichtlinie fest, die alte Einträge bereinigt.

Messen, ob der Cache funktioniert

Optimieren Sie nicht nach Gefühl. In der Build-Ausgabe sind Schritte aus dem Cache mit CACHED markiert. Ändern Sie etwas Code, bauen Sie neu und prüfen Sie, dass der Abhängigkeitsschritt CACHED zeigt. In der CI verfolgen Sie die gesamte Build-Zeit in einem Dashboard und vergleichen vor und nach der Änderung.

Wenn der Cache unerwartet bricht, prüfen Sie Folgendes: Dateien, die per COPY hineinkommen und sich bei jedem Build ändern (etwa eine Datei, die die Build-Zeit schreibt), per ARG übergebene Werte, die jedes Mal anders sind (zum Beispiel eine Commit-SHA), und zeitabhängige Befehle wie RUN apt-get update. Verwenden Sie veränderliche Werte wie die Commit-SHA möglichst spät in der Datei.

Schluss

Für schnelle Docker-Builds gibt es keine Zauber-Einstellung, nur ein paar einfache Gewohnheiten. Kopieren Sie zuerst die Abhängigkeitsdateien, nutzen Sie .dockerignore, bauen Sie mehrstufig, ergänzen Sie Cache-Mounts für Paket-Caches und halten Sie den Cache in der CI vor. Messen Sie die Wirkung jedes Schritts anhand der CACHED-Zeilen und der Build-Zeit. Diese Änderungen passen in einen Nachmittag und zahlen sich bei jedem Commit aus.