Caching ist der einfachste Weg, eine Pipeline schneller zu machen. Man setzt drei Zeilen in den Workflow, die Build-Zeit halbiert sich, niemand fragt nach. Genau deshalb ist der Cache eine der am wenigsten geprüften Stellen in CI/CD. Er ist ein beschreibbarer, geteilter Speicher, der zwischen zwei Läufen Daten transportiert. Wer da hineinschreiben kann, bestimmt mit, was im nächsten Lauf ausgeführt wird.

Der Cache ist kein Zwischenspeicher, er ist eine Eingabe

Der gedankliche Fehler steckt schon im Wort. Ein Cache klingt nach einer Optimierung, die man wegnehmen kann, ohne dass sich am Ergebnis etwas ändert. In CI/CD stimmt das nur, wenn der Cache-Inhalt beim Restore gegen etwas geprüft wird. Passiert das nicht, ist der Cache eine ungeprüfte Eingabe in den Build, genau wie eine Abhängigkeit aus einer Registry. Nur ohne Signatur, ohne Lockfile und ohne dass jemand hinschaut.

In GitHub Actions ist der Cache pro Branch isoliert. Ein Workflow liest Caches aus dem eigenen Branch und aus dem Default-Branch. Ein fremder Feature-Branch kommt an den Cache von main nicht heran, und Pull Requests aus Forks laufen in einem eigenen Scope. Das ist eine sinnvolle Grenze. Sie schützt aber nicht davor, dass ein Job auf demselben Branch etwas in den Cache legt, das ein späterer, höher privilegierter Job auf diesem Branch wieder herausholt und ausführt.

Downloads cachen, keine Ergebnisse

Die praktisch wichtigste Regel: Cache nur das, was beim nächsten Lauf noch einmal gegen einen Hash geprüft wird. Der npm-Download-Cache unter ~/.npm ist so ein Fall. npm ci vergleicht jedes Paket gegen die Integrity-Hashes aus der package-lock.json. Ein manipulierter Tarball im Cache fällt dabei auf.

Ein gecachtes node_modules ist etwas völlig anderes. Das ist ein fertig installierter Baum, den niemand mehr prüft. Wer da eine Datei austauscht, hat Code im nächsten Build, und zwar bevor irgendein Test läuft. Dasselbe Muster gilt für Gradle, Maven und Go: ~/.m2/repository und ~/go/pkg/mod sind vertretbar, ein gecachtes Build-Output-Verzeichnis ist es nicht.

- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci

Der Key enthält den Hash der Lockfile. Ändert sich eine Abhängigkeit, gibt es einen neuen Key. Der restore-key als Fallback ist hier vertretbar, weil npm ci danach ohnehin prüft. Bei einem node_modules-Cache wäre derselbe Fallback fatal, weil dann ein Cache aus einem anderen Abhängigkeitsstand ungeprüft übernommen wird.

Was versehentlich mitwandert

Die zweite Kategorie von Problemen ist unspektakulärer und häufiger: Zugangsdaten, die im gecachten Verzeichnis liegen. Ein Cache auf das Home-Verzeichnis oder auf das ganze Workspace zieht schnell Dinge mit, die dort nichts zu suchen haben.

  • ~/.docker/config.json mit Registry-Token nach einem Login-Schritt
  • ~/.aws/credentials oder ~/.kube/config
  • .npmrc oder .netrc mit einem Auth-Token für private Pakete
  • .terraform/ samt Provider-Konfiguration und lokalem State
  • ~/.gradle/gradle.properties, wo Signing-Keys gerne landen

Cache-Einträge sind kein Geheimnis. In einem öffentlichen Repository kann sie jeder mit Lesezugriff über die API herunterladen. Ein Token, das einmal im Cache liegt, ist damit veröffentlicht, und ein Rotieren des Secrets im Workflow ändert daran nichts, solange der Cache-Eintrag existiert. Deshalb gehören Pfade in den Cache, nicht Verzeichnisbäume. path: ~/.npm statt path: ~.

Docker Layer Cache

Beim Image-Bau kommt ein eigener Fallstrick dazu. Ein Registry-Cache mit mode=max speichert auch die Layer der Zwischenstufen. Wer Secrets über --build-arg hereinreicht, hat sie damit im Cache und im Zweifel in einer Registry, auf die mehr Leute Zugriff haben als gedacht. Der saubere Weg sind BuildKit-Secret-Mounts, die nicht in einem Layer landen.

RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

Dazu gehört, den Cache in ein eigenes Repository zu schreiben statt in das Tag des Produktions-Images. Ein myorg/app:buildcache neben myorg/app:1.4.2 vermischt zwei Dinge mit völlig unterschiedlichem Vertrauensniveau.

Self-hosted Runner sind der Sonderfall

Alles bisher Gesagte setzt voraus, dass der Runner sauber startet. Auf einem persistenten Self-hosted Runner gibt es neben dem Actions-Cache noch den lokalen Docker-Layer-Cache, das Paket-Verzeichnis auf der Platte und das Workspace vom letzten Lauf. Diese Caches kennen die Branch-Isolation von GitHub nicht. Sie liegen einfach da, und der nächste Job findet sie vor. Wenn dieselbe Maschine Jobs aus mehreren Repositories oder aus Pull Requests bedient, ist die Grenze zwischen vertrauenswürdig und nicht vertrauenswürdig faktisch aufgehoben. Ephemere Runner lösen das, alles andere ist Verwaltung von Restrisiko.

Kurz zusammengefasst

Cache nur Downloads, die beim Restore noch einmal gegen eine Lockfile geprüft werden. Nimm enge Pfade statt Home- oder Workspace-Verzeichnisse. Bau den Key aus dem Hash der Lockfile und setze Fallbacks nur dort, wo eine Prüfung nachgelagert ist. Halte Secrets aus Build-Args und aus gecachten Verzeichnissen heraus. Und wenn du Self-hosted Runner betreibst, mach sie ephemer.

Der Punkt hinter all dem ist derselbe wie bei Signaturen und SBOMs: Eine Pipeline ist nur so vertrauenswürdig wie die Summe ihrer Eingaben. Der Cache ist eine davon, auch wenn er in keinem Dependency-Graph auftaucht. Wer sich für die organisatorische Seite davon interessiert, wie man solche Anforderungen in einem Team verankert statt sie einmalig zu reparieren, findet auf digital-business.blog die passendere Perspektive.

Hinterlasse einen Kommentar

Diese Seite verwendet Akismet, um Spam zu reduzieren. Erfahre, wie deine Kommentardaten verarbeitet werden..