Docker BuildKit · CI/CD · Performance · Cache Mounts
BuildKit Cache Mounts
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.

14 Min. Lesezeit BuildKit · Cache Mounts · Layer-Strategie · Registry-Cache · Multi-Stage Docker 24+ · BuildKit 0.12+ · GitHub Actions · GitLab CI

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.

11. FAQ: Docker BuildKit Cache Mounts und schnellere CI-Builds

1Was ist ein BuildKit Cache Mount?
Persistentes Verzeichnis, das beim RUN-Schritt eingehängt wird, aber nicht im Image-Layer landet. Package-Manager-Cache überlebt Builds — nur geänderte Pakete werden neu geladen.
2BuildKit in GitHub Actions aktivieren?
docker/setup-buildx-action@v3 + docker/build-push-action@v5 — automatisch aktiv. Cache mit type=gha konfigurieren.
3Cache Mounts auf ephemeren Runnern?
Lokal gespeichert, gehen nach Job verloren. Für ephemere Runner Registry-Cache (--cache-to type=registry) kombinieren.
4Warum composer.json vor COPY . .?
Damit der Composer-Install-Layer nur bei Dependency-Änderungen verfällt — nicht bei jedem Source-Code-Commit.
5Vergrößern Cache Mounts das Image?
Nein. Cache-Mount-Inhalt erscheint nicht im Image-Layer — explizit getrennt von der Image-Persistenz.
6mode=min vs. mode=max beim Registry-Cache?
min: nur finale Image-Layer. max: alle Intermediate-Layer. max hat höhere Hit-Rate bei Multi-Stage-Builds, braucht mehr Registry-Speicher.
7Denselben Cache Mount in mehreren Dockerfiles?
Ja, über gleiche id-Option. Monorepos mit mehreren Services können denselben Paket-Cache teilen.
8Cache-Hits beim Build debuggen?
--progress=plain zeigt CACHED vs. neuen Ausführungen. Package-Manager-Output zeigt Cache-Herkunft.
9Warum kein rm -rf /var/lib/apt/lists/* nötig?
APT-Cache ist via Cache Mount eingehängt — landet nie im Image-Layer. Löschen war nur nötig, um Layer-Größe zu reduzieren.
10Größter Gewinn für PHP-CI-Builds?
Composer-Cache-Mount + korrekte Layer-Reihenfolge. Reduziert Download-Anteil auf null bei unveränderten Dependencies.