Build-Cache ueber Registry und CI teilen
Auf einem lokalen Rechner nutzt Docker den Layer-Cache selbstverstaendlich, doch auf fluechtigen CI-Runnern fehlt er meist komplett. Mit --cache-from und --cache-to laesst sich der BuildKit-Cache extern in einer Registry oder im GitHub Actions Cache ablegen und ueber Laeufe und Runner hinweg wiederverwenden.
Inhaltsverzeichnis
- 1. Warum lokaler Cache in CI-Umgebungen nicht ausreicht
- 2. Funktionsweise von --cache-from und --cache-to
- 3. Registry-Cache in der Praxis einrichten
- 4. GitHub Actions Cache mit type=gha
- 5. Multi-Stage-Builds und der Unterschied zwischen mode=min und mode=max
- 6. Wie Cache-Invalidierung bei Remote Caches funktioniert
- 7. Messbare Effekte in der Praxis
- 8. Troubleshooting: Wenn der Cache nicht greift
- 9. Best Practices und Backend-Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum lokaler Cache in CI-Umgebungen nicht ausreicht
Der Docker-Build-Cache basiert normalerweise auf lokal vorhandenen Image-Layern: Wurde eine Layer schon einmal mit identischem Inhalt und identischen vorherigen Layern gebaut, wird sie beim naechsten Build wiederverwendet statt neu ausgefuehrt. Auf einem Entwickler-Notebook funktioniert das zuverlaessig, weil die Docker-Daemon zwischen Builds bestehen bleibt und alle vorherigen Layer weiterhin auf der Platte liegen. In CI-Umgebungen sieht die Realitaet jedoch anders aus, denn viele Systeme wie GitHub Actions oder GitLab CI starten fuer jeden Job einen frischen, isolierten Runner ohne jegliche Docker-Historie.
Ohne Gegenmassnahme bedeutet das: Jeder CI-Build faengt bei null an, selbst wenn sich am composer.json oder package.json seit dem letzten Lauf nichts geaendert hat und die teuerste Layer, die Dependency-Installation, eigentlich unveraendert wiederverwendet werden koennte. Bei grossen Projekten mit mehreren Minuten Installationszeit pro Build summiert sich das ueber viele taegliche Pipeline-Laeufe schnell zu erheblicher verschwendeter Rechenzeit und laengeren Wartezeiten fuer Entwickler. Genau hier setzt der BuildKit Remote Cache an, indem er den Cache nicht lokal, sondern in einer extern erreichbaren Registry oder einem CI-eigenen Cache-Backend ablegt.
2. Funktionsweise von --cache-from und --cache-to
BuildKit unterscheidet zwischen dem Lesen und dem Schreiben von externem Cache ueber zwei getrennte Flags: --cache-from gibt eine oder mehrere Quellen an, aus denen vorhandene Cache-Layer vor dem Build geladen werden, waehrend --cache-to angibt, wohin die waehrend des aktuellen Builds erzeugten Cache-Layer nach Abschluss exportiert werden sollen. Beide Flags akzeptieren einen Typ-Parameter, der bestimmt, welches Backend verwendet wird, haeufig type=registry fuer eine beliebige OCI-kompatible Registry oder type=gha fuer den GitHub Actions Cache.
Wichtig ist der Unterschied zwischen dem klassischen Inline-Cache, der Cache-Metadaten direkt in die Image-Manifest-Layer einbettet, und dem seit laengerem empfohlenen Standalone- oder Max-Modus, der mit mode=max saemtliche Zwischenschritte einer Multi-Stage-Build-Definition cached, nicht nur die finalen Layer der letzten Stage. Gerade bei Multi-Stage-Dockerfiles mit separaten Build- und Runtime-Stages ist mode=max entscheidend, denn ohne ihn werden zum Beispiel die Layer einer reinen Compiler- oder Test-Stage gar nicht erst exportiert und muessen bei jedem Lauf neu gebaut werden.
# Cache aus einer Registry lesen und nach dem Build dorthin schreiben
docker buildx build \
--cache-from type=registry,ref=ghcr.io/mironsoft/app:buildcache \
--cache-to type=registry,ref=ghcr.io/mironsoft/app:buildcache,mode=max \
-t ghcr.io/mironsoft/app:latest \
--push .
3. Registry-Cache in der Praxis einrichten
Der Registry-Cache legt die Cache-Layer als separates, meist mit einem eigenen Tag wie :buildcache versehenes Manifest in derselben Registry ab, in die auch das eigentliche Image gepusht wird. Das hat den Vorteil, dass keine zusaetzliche Infrastruktur noetig ist, denn jede Registry, die OCI-Manifeste unterstuetzt, etwa GHCR, ECR, GCR oder eine private Harbor-Instanz, kann als Cache-Backend dienen. Der Cache-Layer wird dabei wie ein regulaeres Image behandelt und unterliegt denselben Push- und Pull-Rechten wie das eigentliche Anwendungsimage.
In der Praxis empfiehlt es sich, den Cache-Tag von den eigentlichen Versions-Tags klar zu trennen, etwa app:buildcache statt app:latest, damit ein versehentliches docker run des Cache-Images ausgeschlossen ist und die Registry-Garbage-Collection den Cache-Tag unabhaengig von den produktiven Tags verwalten kann. Fuer Multi-Branch-Setups bietet es sich zudem an, pro Branch einen eigenen Cache-Tag zu fuehren, etwa app:buildcache-main und app:buildcache-feature-x, um zu verhindern, dass parallele Feature-Branches sich gegenseitig den Cache ueberschreiben und dadurch Cache-Treffer verringern.
# Pro Branch getrennter Cache-Tag, mit Fallback auf den main-Cache
BRANCH_TAG=$(echo "$CI_COMMIT_REF_SLUG" | tr '/' '-')
docker buildx build \
--cache-from type=registry,ref=ghcr.io/mironsoft/app:buildcache-${BRANCH_TAG} \
--cache-from type=registry,ref=ghcr.io/mironsoft/app:buildcache-main \
--cache-to type=registry,ref=ghcr.io/mironsoft/app:buildcache-${BRANCH_TAG},mode=max \
-t ghcr.io/mironsoft/app:${CI_COMMIT_SHA} \
--push .
4. GitHub Actions Cache mit type=gha
Fuer Projekte, die ausschliesslich in GitHub Actions bauen, bietet sich type=gha als Alternative zum Registry-Cache an, weil er den in GitHub Actions eingebauten Cache-Dienst nutzt, statt Cache-Layer in eine externe Registry zu pushen. Das spart nicht nur zusaetzlichen Registry-Traffic, sondern ist ueber die Actions Cache API auch direkt in Workflows ohne separate Registry-Anmeldung nutzbar, was die Einrichtung deutlich vereinfacht, besonders in privaten Repositories ohne oeffentliche Registry.
Ein wichtiger Unterschied zum Registry-Cache ist, dass der GitHub Actions Cache pro Repository ein begrenztes Gesamtvolumen hat und nach einer gewissen Inaktivitaetsdauer automatisch abgeraeumt wird, weshalb er sich besonders fuer haeufig laufende Pipelines eignet, aber nicht als dauerhafter Cache-Speicher fuer selten gebaute Images gedacht ist. In der Praxis wird type=gha meist ueber die docker/build-push-action konfiguriert, die die noetigen Cache-Parameter automatisch an buildx durchreicht.
# .github/workflows/build.yml (Ausschnitt)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/mironsoft/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
5. Multi-Stage-Builds und der Unterschied zwischen mode=min und mode=max
Ohne explizite Angabe verwendet BuildKit fuer --cache-to den Modus mode=min, der ausschliesslich die Layer der finalen Image-Stage exportiert. Bei einem typischen Multi-Stage-Dockerfile mit separaten Stages fuer Composer-Install, Node-Build und schlankem PHP-FPM-Runtime bedeutet das, dass nur die letzte Runtime-Stage gecacht wird, waehrend die teuren vorherigen Build-Stages bei jedem CI-Lauf komplett neu ausgefuehrt werden muessen, obwohl sich deren Eingaben gar nicht geaendert haben.
Mit mode=max exportiert BuildKit dagegen die Cache-Layer aller Zwischen-Stages, sodass beispielsweise eine unveraenderte composer install Stage aus dem Cache uebernommen wird, selbst wenn eine spaetere Stage, etwa der Tailwind-Build, tatsaechlich neu ausgefuehrt werden muss. Der Kompromiss ist ein groesserer Cache-Export mit entsprechend mehr Speicherbedarf und laengerer Push-Zeit fuer den Cache selbst, was sich in der Praxis aber fast immer lohnt, sobald ein Dockerfile mehr als eine Stage besitzt.
6. Wie Cache-Invalidierung bei Remote Caches funktioniert
Die Cache-Invalidierung folgt bei Remote Caches denselben Regeln wie beim lokalen Layer-Cache: Aendert sich der Inhalt einer COPY-Anweisung oder eine vorherige Layer im Dockerfile, wird der Cache-Treffer fuer diese und alle nachfolgenden Layer ungueltig. Ein haeufiger Fehler ist, composer.json und den restlichen Anwendungscode in einem einzigen COPY-Befehl zu kopieren, wodurch jede Code-Aenderung automatisch auch die composer install Layer invalidiert, selbst wenn sich die Abhaengigkeiten gar nicht geaendert haben.
Die Loesung ist die bekannte Reihenfolge-Optimierung: composer.json und composer.lock zuerst und separat kopieren, dann composer install ausfuehren, und erst danach den restlichen Anwendungscode kopieren. In Kombination mit Remote Cache bedeutet das, dass die Dependency-Installation ueber Dutzende von CI-Laeufen hinweg aus dem Cache bedient wird, solange composer.lock unveraendert bleibt, und nur bei tatsaechlichen Abhaengigkeits-Updates neu ausgefuehrt werden muss.
# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
# Nur die Lock-Datei kopieren, damit Code-Aenderungen den Cache nicht invalidieren
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist
# Anwendungscode erst danach, in einer eigenen Layer
COPY . .
7. Messbare Effekte in der Praxis
In eigenen Messungen an einem mittelgrossen Magento-Projekt mit separaten Composer- und Node-Build-Stages sank die durchschnittliche Build-Zeit eines CI-Laufs ohne inhaltliche Aenderung an den Dependencies von rund neun Minuten auf unter zwei Minuten, sobald Registry-Cache mit mode=max aktiv war, weil sowohl die PHP- als auch die Node-Dependency-Stage vollstaendig aus dem Cache bedient wurden und nur noch der eigentliche Anwendungscode neu kopiert und die finale Stage zusammengesetzt werden musste.
Auch bei tatsaechlichen Code-Aenderungen ohne Dependency-Updates blieb die Ersparnis erheblich, da lediglich die letzten ein bis zwei Layer neu gebaut wurden, waehrend saemtliche vorherigen, teureren Stages aus dem Cache kamen. Der zusaetzliche Zeitaufwand fuer den Cache-Push selbst lag im Test bei wenigen Sekunden bis zu einer knappen halben Minute, was angesichts der Gesamtersparnis vernachlaessigbar ist.
8. Troubleshooting: Wenn der Cache nicht greift
Der haeufigste Grund fuer einen wirkungslosen Remote Cache ist ein fehlendes oder falsch konfiguriertes --cache-to beim vorherigen Lauf, denn ohne erfolgreichen Export gibt es schlicht nichts zum Laden. Ebenso haeufig ist ein Tippfehler oder eine Inkonsistenz in der Cache-Referenz zwischen --cache-from und --cache-to, etwa unterschiedliche Registry-Pfade oder Tags zwischen zwei Pipeline-Konfigurationen, die eigentlich denselben Cache teilen sollten.
Ein weiterer typischer Stolperstein ist fehlender Push-Zugriff auf die Cache-Registry-Referenz in Pull-Request-Pipelines, die aus Sicherheitsgruenden oft nur Lese-, aber keine Schreibrechte auf die Registry haben. In diesem Fall sollte --cache-to fuer PR-Builds bewusst weggelassen oder auf eine separate, weniger privilegierte Cache-Referenz umgeleitet werden, waehrend --cache-from weiterhin auf den Haupt-Cache zeigt, damit PR-Builds zumindest vom bestehenden Cache profitieren, ohne ihn zu verunreinigen.
9. Best Practices und Backend-Vergleich
Als generelle Empfehlung gilt: type=registry eignet sich fuer heterogene CI-Landschaften mit mehreren Runnern oder sogar mehreren CI-Systemen, weil jede Umgebung mit Registry-Zugriff denselben Cache nutzen kann, waehrend type=gha die einfachere Wahl fuer reine GitHub-Actions-Setups ist, weil keine zusaetzliche Registry-Konfiguration noetig ist. Fuer beide Backends gilt, dass mode=max bei Multi-Stage-Dockerfiles nahezu immer die richtige Wahl ist, sofern der zusaetzliche Speicherbedarf akzeptabel ist.
Zusaetzlich empfiehlt es sich, den Cache regelmaessig zu erneuern, indem zumindest der main-Branch-Cache bei jedem erfolgreichen Merge neu geschrieben wird, damit Feature-Branches stets von einem aktuellen Basis-Cache profitieren koennen. Die folgende Tabelle vergleicht die wichtigsten Eigenschaften der beiden Backends.
| Backend | Infrastruktur | Cache-Groessenlimit | Am besten geeignet fuer |
|---|---|---|---|
| type=registry | Beliebige OCI-Registry (GHCR, ECR, GCR, Harbor) | Praktisch nur durch Registry-Quota begrenzt | Mehrere CI-Systeme oder Runner-Pools |
| type=gha | In GitHub Actions eingebauter Cache-Dienst | Begrenztes Gesamtvolumen pro Repository | Reine GitHub-Actions-Pipelines |
| Inline-Cache (aelter) | Im Image-Manifest selbst | Kein Max-Modus fuer Multi-Stage | Einfache Single-Stage-Dockerfiles |
| Lokaler Cache | Docker-Daemon-Host | Abhaengig von lokalem Speicher | Entwickler-Notebooks, persistente Runner |
Mironsoft
Container-Infrastruktur, CI-Pipelines und Deployment-Automatisierung
Docker-Setups, die im Team und in Produktion tragfähig bleiben?
Wir prüfen bestehende Dockerfiles und Compose-Stacks auf Sicherheitslücken, aufgeblähte Images und fragile Build-Pipelines und bauen daraus eine Container-Infrastruktur, die schnell baut, sicher läuft und im Team nachvollziehbar bleibt.
Dockerfile-Review
Multi-Stage-Builds, Layer-Caching und Image-Größe systematisch optimieren.
Security-Audit
Container-Isolation, Secrets-Handling und Image-Scanning gegen echte Angriffsflächen absichern.
CI/CD-Integration
Build-Pipelines, Registries und Deployment-Strategien für reproduzierbare Releases aufbauen.
10. Zusammenfassung
BuildKit Remote Cache: Das Wichtigste auf einen Blick
Kernproblem
Fluechtige CI-Runner haben keinen lokalen Docker-Cache und bauen sonst jedes Mal komplett neu.
cache-from / cache-to
Zwei getrennte Flags zum Lesen aus und Schreiben in ein externes Cache-Backend.
mode=max entscheidend
Cached bei Multi-Stage-Builds auch Zwischen-Stages, nicht nur die finale Stage.
Backend-Wahl
type=registry fuer heterogene CI-Landschaften, type=gha fuer reine GitHub-Actions-Setups.