Cache-Keys aus Hashsummen bilden und Invalidierung sauber steuern
Jede CI-Pipeline, die bei jedem Lauf Dependencies neu installiert oder Build-Artefakte komplett neu erzeugt, verschenkt Zeit und Geld. Ein Bash-Skript, das den Cache-Key konsequent aus einer Hashsumme relevanter Dateien bildet, sorgt dafür, dass ein Cache automatisch invalidiert wird, sobald sich etwas ändert, und ansonsten zuverlässig wiederverwendet wird.
Inhaltsverzeichnis
- 1. Warum Caching in CI-Skripten den Unterschied macht
- 2. Was sich sinnvoll cachen lässt und was nicht
- 3. Cache-Keys aus Hashsummen bilden
- 4. Cache-Verzeichnis-Struktur und Ablage nach Cache-Key
- 5. Cache-Hit prüfen und Installation bedingt überspringen
- 6. Invalidierung bei Änderungen sauber steuern
- 7. Race Conditions bei parallelen Pipelines: Locking mit flock
- 8. Cache-Grenzen und Fallstricke
- 9. Cache-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Caching in CI-Skripten den Unterschied macht
Ein typischer CI-Lauf verbringt oft mehr Zeit mit dem Installieren von Dependencies als mit dem eigentlichen Bauen oder Testen des Codes. Ein npm ci oder composer install ohne Cache zieht bei jedem einzelnen Pipeline-Lauf dieselben Pakete erneut aus dem Netz, obwohl sich die Abhängigkeiten zwischen zwei Commits meistens gar nicht geändert haben. Über hunderte Pipeline-Läufe im Monat summiert sich das zu erheblicher verschwendeter Rechenzeit und, bei bezahlten CI-Minuten, zu echten Kosten.
Ein sauber gebautes Caching in Bash-CI-Skripten löst genau dieses Problem, indem es das Ergebnis einer teuren Operation, etwa den installierten node_modules-Ordner oder ein kompiliertes Build-Artefakt, zwischen Pipeline-Läufen wiederverwendet, solange sich die Eingaben nicht geändert haben. Die Kunst liegt nicht im grundsätzlichen Cachen selbst, sondern darin, zuverlässig zu erkennen, wann ein Cache noch gültig ist und wann er verworfen werden muss.
2. Was sich sinnvoll cachen lässt und was nicht
Gute Kandidaten für Caching sind Ergebnisse, die deterministisch aus klar identifizierbaren Eingabedateien entstehen: installierte Dependencies aus einer Lockfile, kompilierte Build-Artefakte aus Quellcode, oder heruntergeladene Docker-Layer aus einem Dockerfile. In all diesen Fällen lässt sich eindeutig sagen, wann das Ergebnis noch gültig ist, nämlich solange sich die Eingabedatei nicht geändert hat.
Schlechte Kandidaten sind Zwischenergebnisse, die von externem, nicht versioniertem Zustand abhängen, etwa Testresultate mit Zeitstempeln, oder Daten, die sich bei jedem Lauf absichtlich unterscheiden sollen, wie generierte Zufallswerte für Testdaten. Wer solche Ergebnisse cacht, riskiert veraltete oder inkorrekte Ausgaben, die schwerer zu debuggen sind als die eingesparte Zeit wert ist.
3. Cache-Keys aus Hashsummen bilden
Der zentrale Baustein jeder Caching-Strategie ist ein Cache-Key, der eindeutig aus den relevanten Eingaben abgeleitet wird. Für eine Dependency-Installation ist das meist der Hash der Lockfile, etwa package-lock.json oder composer.lock, gebildet mit sha256sum. Ändert sich auch nur ein Zeichen in der Lockfile, ändert sich der Hash, und der Cache wird automatisch als ungültig erkannt, ohne dass irgendjemand eine Invalidierungslogik manuell pflegen muss.
Für komplexere Fälle lohnt sich, mehrere Faktoren im Cache-Key zu kombinieren, etwa den Hash der Lockfile zusammen mit der verwendeten Sprachversion und dem Betriebssystem des CI-Runners. Ein Cache, der unter Node 18 auf Linux erzeugt wurde, ist unter Node 20 auf macOS unter Umständen nicht mehr gültig, selbst wenn die Lockfile identisch ist, und ein zusammengesetzter Cache-Key verhindert genau solche stillen Fehlanpassungen.
#!/usr/bin/env bash
set -euo pipefail
compute_cache_key() {
local lockfile="$1"
local node_version os_name lockfile_hash
node_version="$(node --version)"
os_name="$(uname -s)"
lockfile_hash="$(sha256sum "$lockfile" | awk '{print $1}')"
# Combine everything that could invalidate the cache into one key
echo "${os_name}-${node_version}-${lockfile_hash}" | sha256sum | awk '{print $1}'
}
CACHE_KEY="$(compute_cache_key package-lock.json)"
echo "Cache key: $CACHE_KEY"
4. Cache-Verzeichnis-Struktur und Ablage nach Cache-Key
Sobald der Cache-Key feststeht, sollte er direkt in den Ablagepfad des Caches einfließen, etwa als /cache/deps-$CACHE_KEY.tar.gz. Diese pfadbasierte Versionierung hat den Vorteil, dass mehrere Cache-Versionen parallel im selben Cache-Verzeichnis existieren können, ohne sich gegenseitig zu überschreiben, was besonders bei parallel laufenden Pipeline-Jobs mit leicht unterschiedlichen Umgebungen wichtig ist.
Viele CI-Systeme unterstützen zusätzlich sogenannte Restore-Keys: ein Präfix, das beim Cache-Miss auf den exakten Key nach dem nächstbesten, teilweise passenden Cache sucht. Ein Restore-Key wie deps-linux-node20- findet dann einen etwas älteren Cache mit leicht anderer Lockfile, der zwar nicht perfekt passt, aber immer noch die meisten bereits heruntergeladenen Pakete enthält und einen inkrementellen statt einen kompletten Neuaufbau ermöglicht.
#!/usr/bin/env bash
set -euo pipefail
readonly CACHE_DIR="/cache"
readonly CACHE_KEY="$1"
readonly CACHE_FILE="${CACHE_DIR}/deps-${CACHE_KEY}.tar.gz"
save_cache() {
local source_dir="$1"
mkdir -p "$CACHE_DIR"
tar -czf "${CACHE_FILE}.tmp" -C "$source_dir" .
mv "${CACHE_FILE}.tmp" "$CACHE_FILE" # atomic: never a half-written cache file
}
save_cache "node_modules"
5. Cache-Hit prüfen und Installation bedingt überspringen
Der eigentliche Zeitgewinn entsteht erst, wenn das Skript vor der teuren Operation prüft, ob bereits ein passender Cache existiert, und die Operation im Erfolgsfall komplett überspringt. Diese Prüfung muss sowohl die Existenz der Cache-Datei als auch ihre grundsätzliche Integrität berücksichtigen, denn eine leere oder abgebrochen geschriebene Cache-Datei ist schlimmer als gar kein Cache, weil sie einen Fehlschlag vortäuscht, der schwer zu diagnostizieren ist.
Ein robustes Muster prüft deshalb nicht nur, ob die Datei existiert, sondern auch, ob sie eine plausible Mindestgröße hat, bevor sie entpackt wird. Schlägt das Entpacken trotzdem fehl, etwa weil die Datei zwar existiert, aber korrupt ist, sollte das Skript automatisch auf eine normale, vollständige Installation zurückfallen, statt mit einem kryptischen Fehler abzubrechen.
#!/usr/bin/env bash
set -uo pipefail # no -e: a cache miss or corrupt cache must not abort the script
restore_cache_or_install() {
local cache_file="$1" target_dir="$2"
if [[ -s "$cache_file" ]] && tar -tzf "$cache_file" >/dev/null 2>&1; then
echo "Cache hit: restoring $target_dir from $cache_file"
mkdir -p "$target_dir"
tar -xzf "$cache_file" -C "$target_dir"
return 0
fi
echo "Cache miss or corrupt cache, running full installation"
npm ci
}
6. Invalidierung bei Änderungen sauber steuern
Weil der Cache-Key bereits aus dem Hash der relevanten Dateien gebildet wird, passiert die eigentliche Invalidierung automatisch: Ändert sich die Lockfile, ändert sich der Key, und der alte Cache wird schlicht nie mehr angesprochen. Das allein reicht aber nicht aus, denn ungenutzte alte Cache-Dateien häufen sich mit der Zeit an und belegen unbegrenzt Speicherplatz im Cache-Verzeichnis, wenn niemand sie aufräumt.
Ein separates Aufräum-Skript, das regelmäßig läuft, etwa als eigener, nächtlicher CI-Job, sollte Cache-Dateien anhand ihres letzten Zugriffszeitpunkts löschen, die seit einer bestimmten Anzahl von Tagen nicht mehr verwendet wurden. Das trennt sauber zwei unabhängige Anliegen: Der Cache-Key sorgt für fachliche Korrektheit, das Aufräum-Skript sorgt für begrenzten Speicherverbrauch, ohne dass ein einzelnes Skript beide Aufgaben mit unnötiger Komplexität vermischen muss.
#!/usr/bin/env bash
set -euo pipefail
readonly CACHE_DIR="/cache"
readonly MAX_AGE_DAYS=14
# Remove cache files that have not been accessed in MAX_AGE_DAYS
find "$CACHE_DIR" -maxdepth 1 -name '*.tar.gz' -atime "+${MAX_AGE_DAYS}" -print -delete
7. Race Conditions bei parallelen Pipelines: Locking mit flock
Laufen mehrere Pipeline-Jobs parallel und schreiben potenziell gleichzeitig in dasselbe Cache-Verzeichnis, entsteht eine Race Condition: Zwei Jobs könnten gleichzeitig denselben Cache-Key als fehlend erkennen, beide die teure Installation durchführen und anschließend gleichzeitig in dieselbe Cache-Datei schreiben, was im schlimmsten Fall zu einer korrupten, halb von beiden Jobs überschriebenen Datei führt.
Das atomare mv aus dem vorherigen Abschnitt entschärft das Problem für den Schreibvorgang selbst bereits deutlich, weil am Ende immer eine vollständige Datei eines der beiden Jobs gewinnt statt einer vermischten. Wer zusätzliche Sicherheit will, etwa um zu verhindern, dass zwei Jobs gleichzeitig dieselbe teure Installation redundant durchführen, kann den kritischen Abschnitt mit flock gegen eine Lock-Datei absichern, sodass nur ein Job gleichzeitig schreiben darf, während parallele Jobs entweder warten oder den Cache nur lesend nutzen.
#!/usr/bin/env bash
set -euo pipefail
readonly LOCK_FILE="/cache/.deps.lock"
(
# Wait up to 120s for the lock, then proceed -- avoids indefinite hangs
flock -w 120 200 || { echo "Could not acquire cache lock, proceeding without cache"; exit 0; }
if [[ ! -f "$CACHE_FILE" ]]; then
npm ci
save_cache "node_modules"
fi
) 200>"$LOCK_FILE"
8. Cache-Grenzen und Fallstricke
Der häufigste Fallstrick ist ein zu schwacher Cache-Key, der wichtige Einflussfaktoren nicht einbezieht, etwa wenn nur die Lockfile gehasht wird, aber nicht die Sprachversion. Das Ergebnis ist ein falscher Cache-Hit, bei dem ein eigentlich inkompatibler Cache stillschweigend wiederverwendet wird und zu schwer nachvollziehbaren Fehlern erst deutlich später im Build führt, weit entfernt von der eigentlichen Ursache.
Ein zweiter, sicherheitsrelevanter Fallstrick ist, versehentlich Geheimnisse in den Cache zu schreiben, etwa wenn ein .env-File im gecachten Verzeichnis landet, weil ein zu breiter Pfad archiviert wurde. Da Caches oft länger existieren als einzelne Pipeline-Läufe und mitunter zwischen verschiedenen Branches geteilt werden, kann ein solches Leck Zugangsdaten über Team- und Zeitgrenzen hinweg verbreiten. Ein expliziter Ausschluss sensibler Dateien beim Erstellen des Caches ist deshalb Pflicht, nicht optional.
9. Cache-Strategien im Vergleich
Je nach Art der zu cachenden Ressource passt eine andere Kombination aus Cache-Key-Zusammensetzung, Speicherort und Invalidierungslogik am besten. Die folgende Übersicht ordnet die gängigsten Fälle ein.
| Ressource | Cache-Key-Basis | Invalidierung | Besonderheit |
|---|---|---|---|
| Dependencies (npm/composer) | Hash der Lockfile + Sprachversion | Automatisch bei Lockfile-Änderung | Restore-Keys für teilweise passende Caches nutzen |
| Build-Artefakte | Hash aller Quelldateien | Automatisch bei Code-Änderung | Feingranularer Hash pro Modul spart mehr Zeit |
| Docker-Layer | Hash des Dockerfiles + Kontext | Automatisch, meist vom Build-Tool verwaltet | BuildKit-Cache-Mounts oft effizienter als manuelles Caching |
| Testresultate | Nicht empfohlen zu cachen | Nicht zutreffend | Zeitstempel und Zufallsdaten machen Ergebnisse ungültig |
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Shell-Skripte, die in der Produktion zuverlässig laufen?
Wir analysieren bestehende Bash-Skripte, erkennen fragile Muster und ersetzen sie durch robuste Bash-Patterns: mit vollständiger Fehlerbehandlung, Logging und sicherer Parallelisierung für euren Deployment-Stack.
Code-Review
ShellCheck-Analyse und manuelle Prüfung auf kritische Bash-Pattern-Verstöße.
Refactoring
Fehlerbehandlung, Logging und sichere Dateioperationen nachrüsten.
CI-Integration
ShellCheck und BATS in Pipelines integrieren und Regressionstests aufbauen.
10. Zusammenfassung
Caching in CI-Skripten: Das Wichtigste auf einen Blick
Cache-Key
sha256sum über die Lockfile kombiniert mit Sprachversion und Betriebssystem bildet einen Key, der sich automatisch bei Änderungen invalidiert.
Atomares Speichern
Cache in eine temporäre Datei schreiben und erst per mv an den Zielpfad verschieben, um halb geschriebene Cache-Dateien zu vermeiden.
Race Conditions
flock gegen eine Lock-Datei verhindert, dass mehrere parallele Jobs dieselbe teure Installation redundant durchführen.
Aufräumen
Ein separates, regelmäßiges Skript löscht Cache-Dateien nach Zugriffszeitpunkt, unabhängig von der eigentlichen Cache-Key-Logik.