und schnellere CI-Builds mit Docker
CI-Builds, die fünf Minuten dauern, weil jeder Run Composer-Pakete und npm-Module komplett neu herunterlädt, sind kein Naturgesetz. Docker BuildKit Cache Mounts halten Package-Caches persistent zwischen Builds, ohne sie ins Image zu baken. Dieser Artikel zeigt, wie RUN --mount=type=cache funktioniert und wie Layer-Strategie, parallele Stages und Registry-Cache zusammenspielen, um CI-Laufzeiten drastisch zu kürzen.
Inhaltsverzeichnis
- 1. Das eigentliche Problem langer CI-Builds
- 2. BuildKit: was sich gegenüber dem alten Builder ändert
- 3. RUN --mount=type=cache: Mechanismus und Semantik
- 4. Composer-Cache persistent halten
- 5. npm- und yarn-Cache effizient nutzen
- 6. APT-Pakete ohne Layer-Bloat installieren
- 7. Layer-Reihenfolge: der unterschätzte Performance-Faktor
- 8. Registry-Cache für CI-Umgebungen ohne persistenten Disk
- 9. Cache-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das eigentliche Problem langer CI-Builds
Lange CI-Build-Zeiten sind selten ein Ressourcenproblem – sie sind ein Cache-Problem. Der typische Ablauf: Ein Entwickler pusht einen Commit, die Pipeline startet einen frischen Runner, der Docker-Build läuft los und installiert in jedem Run dieselben 200 Composer-Pakete und 500 npm-Abhängigkeiten von Grund auf. Der Download dauert Minuten, das Kompilieren nativer Erweiterungen nochmals Minuten. Das Resultat: fünf bis acht Minuten Wartezeit für einen Commit, der drei Zeilen PHP geändert hat.
Das klassische Gegenkonzept – Docker Layer Caching – hilft nur begrenzt. Layer-Cache greift nur, wenn ein Layer exakt gleich ist wie beim vorherigen Build. Ändert sich die composer.json auch nur in der Versions-Constraint eines einzigen Pakets, verfällt der Layer und alle darauf aufbauenden Layer werden neu gebaut. Das ist korrekt für den Image-Inhalt, aber ineffizient für den Download-Prozess: Die Pakete, die sich nicht geändert haben, werden erneut heruntergeladen, nur weil das Layer-Hash nicht mehr übereinstimmt. BuildKit Cache Mounts lösen dieses Problem, indem sie den Package-Manager-Cache vom Image-Layer trennen.
Das Ergebnis beim konsequenten Einsatz von BuildKit Cache Mounts in Kombination mit optimierter Layer-Reihenfolge und Registry-Cache ist messbar: CI-Build-Zeiten für PHP-Projekte mit Composer sinken von fünf auf unter eine Minute für normale Feature-Branches. npm-Builds mit umfangreichem Frontend-Build-Prozess von sieben auf zwei Minuten. Diese Zahlen sind nicht von der Infrastruktur abhängig, sondern von der richtigen Cache-Strategie im Dockerfile.
2. BuildKit: was sich gegenüber dem alten Builder ändert
Docker BuildKit ist seit Docker 23.0 der Standard-Builder und ersetzt den alten Legacy-Builder vollständig. Die relevanten Unterschiede für CI-Build-Performance: BuildKit baut parallele Stages tatsächlich parallel statt sequenziell; es unterstützt RUN --mount-Syntax für Cache Mounts, Secrets und SSH-Forwarding; und es bietet granularere Cache-Export-Optionen für Registry-basierten Cache. Der alte Builder hat keine dieser Features und wird in neuen Docker-Versionen nicht mehr weiterentwickelt.
Für CI-Systeme, die noch explizit BuildKit aktivieren müssen: Die Umgebungsvariable DOCKER_BUILDKIT=1 schaltet BuildKit für alle Docker-Build-Aufrufe ein. In GitHub Actions ist BuildKit seit der actions/docker-build-push-action-Version 3 standardmäßig aktiv. In GitLab CI muss der Docker-in-Docker-Service mit der Variable DOCKER_BUILDKIT=1 konfiguriert werden. Die --progress=plain-Flag beim Build gibt detaillierte Cache-Hit/Miss-Informationen aus und hilft beim Debuggen der Cache-Mount-Konfiguration.
Ein wichtiger Unterschied für CI: BuildKit Cache Mounts sind per se lokal – sie speichern den Cache im lokalen BuildKit-Daemon des Builders. Auf ephemeren CI-Runnern, die nach jedem Job neu gestartet werden, hilft lokaler Cache nichts. Dafür gibt es den Registry-Cache-Export (--cache-to type=registry), der den Build-Cache in eine Container-Registry schreibt und beim nächsten Run vom gleichen oder einem anderen Runner importiert werden kann. Diese Kombination aus Cache Mounts und Registry-Cache ist die Basis für konsistent schnelle CI-Builds ohne persistente Runner-Disk.
# Enable BuildKit explicitly for older Docker versions
export DOCKER_BUILDKIT=1
# Build with cache export to registry (for ephemeral CI runners)
docker buildx build \
--cache-from type=registry,ref=registry.example.com/app:buildcache \
--cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max \
--tag registry.example.com/app:${CI_COMMIT_SHA} \
--push \
.
# Inspect cache hit/miss ratio with verbose progress output
docker buildx build \
--progress=plain \
--cache-from type=registry,ref=registry.example.com/app:buildcache \
. 2>&1 | grep -E "(CACHED|cache miss|#[0-9]+ )"
# GitHub Actions: enable BuildKit and use buildx
# .github/workflows/build.yml
# - uses: docker/setup-buildx-action@v3
# - uses: docker/build-push-action@v5
# with:
# cache-from: type=gha
# cache-to: type=gha,mode=max
3. RUN --mount=type=cache: Mechanismus und Semantik
RUN --mount=type=cache hängt während des Build-Schritts ein persistentes Verzeichnis ein, das zwischen Builds erhalten bleibt, aber nicht Teil des entstehenden Image-Layers wird. Das ist der entscheidende Unterschied zum normalen Layer-Cache: Der Layer-Cache macht den gesamten Build-Schritt nur dann überflüssig, wenn sich Input und Befehl nicht geändert haben. Cache Mounts hingegen bleiben nützlich auch wenn sich der Befehl geändert hat – der Package-Manager findet seinen Cache vor, lädt nur geänderte Pakete und ist dadurch schneller, auch wenn das Ergebnis ein neuer Layer ist.
Die id-Option des Cache Mounts benennt den Cache-Bereich. Verschiedene Dockerfiles oder Services können denselben Cache-ID teilen und damit denselben Package-Manager-Cache nutzen. Das ist nützlich bei Monorepos mit mehreren Services, die denselben Paket-Pool nutzen. Der sharing-Mode bestimmt, was passiert wenn mehrere parallele Builds denselben Cache Mount verwenden: shared erlaubt gleichzeitigen Lesezugriff (Standard), private erstellt eine Kopie pro Build, locked serialisiert die Zugriffe. Für Package-Manager-Caches ist shared korrekt, weil die meisten Package-Manager ihren Cache threadsafe schreiben.
4. Composer-Cache persistent halten
Composer speichert heruntergeladene Pakete in einem lokalen Cache-Verzeichnis, das beim nächsten Install wiederverwendet wird. Ohne BuildKit Cache Mount wird dieses Verzeichnis mit jedem Build-Layer weggeworfen – Composer muss die Pakete jedes Mal neu herunterladen. Mit RUN --mount=type=cache,id=composer,target=/root/.composer/cache bleibt der Composer-Cache zwischen Builds erhalten. Das Ergebnis: Nur geänderte oder neue Pakete werden heruntergeladen; alle unveränderten Pakete werden aus dem Cache Mount serviert.
Wichtig dabei: Das --mount als Teil des RUN-Befehls muss direkt vor dem Composer-Aufruf stehen, nicht in einer separaten Zeile. Außerdem darf die composer install-Zeile nicht in einem Skript ausgeführt werden, das den Cache-Mount-Pfad nicht sieht. Ein häufiger Fehler ist es, Composer als anderen User auszuführen als den, dessen Home-Verzeichnis als Cache Mount konfiguriert ist – dann schreibt Composer in ein anderes Verzeichnis und der Cache bleibt leer. Die Kombination COMPOSER_CACHE_DIR=/composer-cache mit einem expliziten Mount-Pfad außerhalb von /root vermeidet dieses Problem.
# Dockerfile — PHP application with BuildKit cache mounts for Composer
# syntax=docker/dockerfile:1.6
FROM php:8.4-fpm-alpine AS vendor
# Install build dependencies without polluting the layer with APT cache
RUN --mount=type=cache,id=apk-cache,target=/var/cache/apk \
apk add --no-cache $PHPIZE_DEPS libzip-dev \
&& docker-php-ext-install zip pdo_mysql opcache
# Install Composer itself
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Copy dependency manifests first — enables layer cache when no deps change
COPY composer.json composer.lock ./
# Mount Composer cache — persists between builds, NOT included in image layer
RUN --mount=type=cache,id=composer-cache,target=/root/.composer/cache \
composer install \
--no-dev \
--no-interaction \
--optimize-autoloader \
--no-scripts \
--prefer-dist
# Copy application source — separate layer so source changes don't bust dep layer
COPY src/ ./src/
# Run post-install scripts after source is available
RUN composer dump-autoload --optimize --no-dev
# ---- Final image: only runtime files, no build tools ----
FROM php:8.4-fpm-alpine AS runtime
COPY --from=vendor /app /app
WORKDIR /app
5. npm- und yarn-Cache effizient nutzen
npm und yarn verhalten sich ähnlich wie Composer: Sie haben ein lokales Cache-Verzeichnis (~/.npm bzw. /root/.yarn/berry/cache), das heruntergeladene Pakete enthält. Mit BuildKit Cache Mounts lässt sich dieses Verzeichnis zwischen Builds persistent halten. Der Unterschied gegenüber Composer: npm-Builds sind oft verbunden mit Frontend-Build-Prozessen (Tailwind CSS, Webpack, Vite), die ihrerseits aggressive Caches nutzen. Das Cache Mount für npm-Pakete und ein separates Cache Mount für den Build-Tool-Cache lassen sich kombinieren.
Ein wichtiges Detail bei npm ci statt npm install: npm ci löscht das node_modules-Verzeichnis vor der Installation, nutzt aber weiterhin den ~/.npm-Cache für heruntergeladene Tarballs. Deshalb ist der Cache Mount auf ~/.npm auch bei npm ci wirksam und bringt messbare Zeitersparnis. Für Yarn Berry (v3+) ist der Cache unter /root/.yarn/berry/cache zu finden und funktioniert analog. Die Cache-ID sollte den Paketmanager im Namen tragen, um Kollisionen zu vermeiden wenn ein Monorepo sowohl npm als auch Composer nutzt.
6. APT-Pakete ohne Layer-Bloat installieren
APT-Pakete in Debian- und Ubuntu-basierten Images sind ein klassischer Quell von Image-Bloat und langen Build-Zeiten. Das traditionelle Pattern apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/* löscht den APT-Cache nach der Installation, um Layer-Größe zu sparen. Mit BuildKit Cache Mounts entfällt dieses Kompromiss: Der APT-Cache wird als Cache Mount eingehängt, bleibt zwischen Builds erhalten (schnellere Paketinstallation) und landet trotzdem nicht im finalen Image-Layer.
Das Muster: RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt --mount=type=cache,id=apt-lib,target=/var/lib/apt hängt beide APT-Verzeichnisse als Cache Mounts ein. apt-get update füllt den Cache, apt-get install installiert aus dem Cache, und beim nächsten Build ist apt-get update nur dann nötig, wenn der Cache ungültig geworden ist. Das Löschen des Caches am Befehlsende (rm -rf /var/lib/apt/lists/*) entfällt, weil er nie im Layer landet. Das Ergebnis ist ein kleineres Image und schnellere Installationen.
# Dockerfile — APT packages with BuildKit cache mounts (no cleanup needed)
# syntax=docker/dockerfile:1.6
FROM debian:bookworm-slim AS base
# Both APT cache dirs mounted — neither ends up in the image layer
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt \
--mount=type=cache,id=apt-lib,target=/var/lib/apt \
apt-get update && apt-get install -y \
git \
curl \
libpq-dev \
libxml2-dev \
unzip \
&& echo "Packages installed — cache stays warm for next build"
# npm build stage with cache mounts for both package download and build tools
FROM node:22-alpine AS frontend
WORKDIR /build
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
npm ci --prefer-offline
COPY web/tailwind/ ./web/tailwind/
# Vite/Tailwind build cache — dramatically speeds up CSS-only changes
RUN --mount=type=cache,id=vite-cache,target=/build/.vite \
npm run build
# Result: only compiled assets copied to final stage
FROM nginx:alpine AS static-server
COPY --from=frontend /build/dist/ /usr/share/nginx/html/
7. Layer-Reihenfolge: der unterschätzte Performance-Faktor
Die Reihenfolge der COPY- und RUN-Befehle im Dockerfile bestimmt, wie effektiv der Layer-Cache genutzt wird. Die Regel: Was sich selten ändert, kommt zuerst; was sich häufig ändert, kommt zuletzt. In der Praxis bedeutet das für PHP-Projekte: composer.json und composer.lock werden vor dem Applikations-Source-Code kopiert, damit ein reiner Composer-Layer gebaut wird, der nur bei Änderungen an den Dependencies verfällt. Der Source-Code, der sich bei jedem Commit ändert, kommt danach in einen separaten Layer.
Ein häufiger Fehler ist das Mischen von selten und häufig geänderten Dateien in einem COPY-Befehl: COPY . . kopiert alles in einem Layer, invalidiert ihn bei jeder Änderung irgendeiner Datei und macht alle nachfolgenden Layer (einschließlich Composer-Install) zu Cache-Misses. Das kostet bei jedem Commit Minuten. Die Lösung ist explizites, schrittweises Kopieren: zunächst Konfigurationsdateien, dann Dependency-Manifests, dann Source. Diese Layer-Strategie funktioniert unabhängig von BuildKit Cache Mounts und addiert sich mit ihnen zu maximaler Build-Performance.
8. Registry-Cache für CI-Umgebungen ohne persistenten Disk
Ephemere CI-Runner – der Standard in GitHub Actions, GitLab CI mit Auto-Scaling und anderen modernen CI-Systemen – starten für jeden Job von einem sauberen Zustand. Lokaler Docker-Layer-Cache ist nach dem Job weg. BuildKit Cache Mounts sind nach dem Job weg. Nur der Registry-Cache überlebt Job-Grenzen, weil er in eine Container-Registry geschrieben wird. Das BuildKit-Flag --cache-to type=registry,ref=...,mode=max exportiert den gesamten Build-Cache inklusive aller Intermediate-Layer in eine spezielle Cache-Manifest-Struktur in der Registry.
Beim nächsten Build importiert --cache-from type=registry,ref=... diesen Cache und macht ihn für alle Layer-Cache-Entscheidungen nutzbar. Das Ergebnis: Selbst auf einem frischen CI-Runner, der noch nie dieses Image gebaut hat, profitiert der Build vom Layer-Cache aller vorherigen Builds. In Verbindung mit Cache Mounts für Package-Manager ergibt sich ein mehrstufiges Caching: Registry-Cache für Layer, Cache Mounts für Package-Manager-Verzeichnisse. Beide Ebenen zusammen reduzieren die CI-Build-Zeit auf das Minimum, das durch tatsächlich geänderte Inhalte bedingt ist.
9. Cache-Strategien im Vergleich
Nicht jede Caching-Strategie passt zu jeder CI-Umgebung. Die Wahl hängt von Runner-Persistenz, Registry-Verfügbarkeit und Dockerfile-Komplexität ab.
| Cache-Strategie | Granularität | Ephemere Runner | Empfehlung |
|---|---|---|---|
| Layer-Cache (lokal) | Ganzer RUN-Schritt | Verloren nach Job | Nur auf Self-Hosted-Runner |
| BuildKit Cache Mounts | Package-Manager-Cache | Verloren nach Job | Kombinieren mit Registry-Cache |
| Registry-Cache (inline) | Alle geänderten Layer | Persistent | Standard für ephemere Runner |
| Registry-Cache (max mode) | Alle Layer inkl. Intermediate | Persistent | Beste Hit-Rate, mehr Registry-Speicher |
| GitHub Actions Cache | Layer-Hash-basiert | Persistent (7 Tage) | Einfachste Option für GitHub Actions |
Die optimale Kombination für GitHub Actions ist type=gha als Cache-Typ, der den GitHub Actions Cache als Backend nutzt. Für GitLab mit eigener Registry ist type=registry mit einer dedizierten Cache-Registry die robusteste Lösung. Self-Hosted Runner können auf persistentem Disk-Cache aufbauen und BuildKit Cache Mounts ohne Registry-Overhead nutzen. In allen Fällen bleibt die Layer-Reihenfolge im Dockerfile der erste Optimierungsschritt, weil sie unabhängig von der Cache-Backend-Wahl wirkt.
Mironsoft
CI/CD-Optimierung, Docker-Build-Performance und schnelle Deployment-Pipelines
CI-Build-Zeiten auf ein Minimum reduzieren?
Wir analysieren eure Dockerfiles und CI-Konfiguration, identifizieren Cache-Lücken und implementieren BuildKit Cache Mounts, Registry-Cache und optimierte Layer-Reihenfolge für kurze Build-Zeiten in euren Pipelines.
Dockerfile-Audit
Layer-Reihenfolge, Cache-Invalidierungspunkte und Cache-Mount-Potenzial identifizieren
BuildKit-Migration
Cache Mounts für Composer, npm, APT und Build-Tools implementieren
Registry-Cache-Setup
Cache-to/from-Konfiguration für GitHub Actions, GitLab CI und eigene Runner
10. Zusammenfassung
Docker BuildKit Cache Mounts mit RUN --mount=type=cache sind der effektivste Einzelhebel zur Reduzierung von CI-Build-Zeiten in Package-Manager-intensiven Projekten. Sie trennen den Package-Manager-Cache vom Image-Layer: Der Cache bleibt zwischen Builds erhalten, landet aber nie im finalen Image. In Kombination mit optimierter Layer-Reihenfolge (seltene Änderungen zuerst) und Registry-Cache für ephemere Runner ergibt sich ein dreistufiges Caching, das Build-Zeiten auf das Minimum des tatsächlich geänderten Inhalts reduziert.
Die Implementierung ist konkret und Schritt-für-Schritt möglich: Erst Layer-Reihenfolge prüfen und optimieren. Dann Cache Mounts für Composer, npm und APT hinzufügen. Zuletzt Registry-Cache für CI-System konfigurieren. Jeder Schritt reduziert die Build-Zeit messbar und ist einzeln deploybar. Die syntax=docker/dockerfile:1.6-Direktive am Dockerfile-Anfang stellt sicher, dass alle BuildKit-Features verfügbar sind, unabhängig von der installierten Docker-Version auf dem Runner.
BuildKit Cache Mounts — Das Wichtigste auf einen Blick
Cache Mounts
RUN --mount=type=cache,id=...,target=... — Package-Manager-Cache persistent, nie im Image-Layer. Für Composer, npm, APT und Build-Tools.
Layer-Reihenfolge
Seltene Änderungen zuerst. composer.lock vor Source-Code kopieren. COPY . . vermeiden — kostet bei jedem Commit alle nachfolgenden Layer.
Registry-Cache
--cache-to type=registry,mode=max und --cache-from type=registry für ephemere Runner. Überlebt Job-Grenzen, persistiert in der Registry.
BuildKit aktivieren
DOCKER_BUILDKIT=1 oder syntax=docker/dockerfile:1.6. Ab Docker 23 Standard. --progress=plain für Cache-Hit/Miss-Debugging.