23 Workflow-Optimierung – Caching und Debugging

Ein Workflow, der funktioniert, ist gut. Ein Workflow, der schnell funktioniert, ist besser. Ein Workflow, der sich debuggen lässt, wenn er nicht funktioniert, ist Gold wert. Dieses Kapitel zeigt, wie man Workflows performant macht und wie man Probleme systematisch einkreist.

Wir decken Caching-Strategien (von Dependency-Caching bis Docker-Layer-Caching), lokales Testing mit act (Workflows ohne Push testen) und Debug-Logging (wenn Workflows mysteriös fehlschlagen). Am Ende haben wir einen optimierten badge-gen-Workflow mit sub-Minute Build-Zeiten und reproduzierbarem lokalem Testing.

23.1 Caching – Zeit ist Geld

Jeder Workflow-Run ist eine frische VM. Das bedeutet: Jedes Mal pip install, npm install, cargo build von Grund auf. Bei kleinen Projekten: erträglich. Bei großen: Minuten verschwendet.

Caching speichert Dateien zwischen Runs. Dependencies müssen nur einmal heruntergeladen werden, Build-Artefakte können wiederverwendet werden.

23.1.1 Wie GitHub Actions Caching funktioniert

GitHub bietet einen Cache-Service, der Dateien zwischen Workflow-Runs speichert. Der Cache ist:

Kernkonzept: Ein Cache-Key identifiziert eindeutig einen Cache-Eintrag. Wenn der Key matched → Cache Hit → Dateien werden restored. Wenn nicht → Cache Miss → Dateien werden neu erstellt und gecacht.

23.1.2 actions/cache – die Basis-Action

Die offizielle actions/cache Action ist das Fundament:

- name: Cache dependencies
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Parameter:

23.1.3 Cache-Key-Design – das Wichtigste

Der Cache-Key ist entscheidend. Er muss:

  1. Deterministisch sein (gleiche Dependencies → gleicher Key)
  2. Änderungen erkennen (neue Dependencies → neuer Key)

Typisches Pattern:

key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

Aufschlüsselung:

Beispiel:

23.1.4 hashFiles() – automatische Invalidierung

hashFiles() ist die zentrale Funktion für Cache-Keys:

# Single file
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

# Multiple files (OR-Pattern)
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json', 'yarn.lock') }}

# Glob patterns
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}

Wichtig: hashFiles() gibt leeren String zurück, wenn keine Dateien matchen. Das kann zu unbeabsichtigten Cache-Hits führen!

Best Practice: Immer mindestens eine existierende Datei im Pattern.

23.1.5 restore-keys – intelligente Fallbacks

Was, wenn requirements.txt sich ändert, aber 90% der Dependencies gleich geblieben sind?

Lösung: restore-keys ermöglicht partielle Matches:

key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
  ${{ runner.os }}-pip-
  ${{ runner.os }}-

Ablauf:

  1. Suche exakten Match für Linux-pip-abc123
  2. Wenn nicht gefunden: Suche Keys die mit Linux-pip- beginnen (neuester wird genommen)
  3. Wenn nicht gefunden: Suche Keys die mit Linux- beginnen
  4. Wenn nichts gefunden: Cache Miss

Das bedeutet: Selbst bei geänderten Dependencies wird der alte Cache als Basis genutzt, nur die Diffs werden neu geladen.

23.1.6 Cache-Limits und Eviction

Limit Wert
Max. Cache-Größe pro Repository 10 GB
Max. einzelner Cache-Eintrag Keine harte Grenze (praktisch: mehrere GB möglich)
Retention 7 Tage ohne Zugriff → automatische Löschung
Max. Caches Unbegrenzt (bis Gesamt-Limit erreicht)

Eviction-Policy:

Least Recently Used (LRU). Wenn 10 GB voll sind, werden die ältesten Caches gelöscht.

Best Practice:

Nicht jeden Build-Output cachen. Nur teure Operationen: - ✅ Dependencies (node_modules, .cache/pip, ~/.cargo) - ✅ Kompilierte Artefakte (für incremental builds) - ❌ Source-Code (wird per checkout geholt) - ❌ Test-Results (klein, schnell generiert)

23.2 Setup-Actions mit integriertem Caching

Die offiziellen Setup-Actions haben built-in Caching. Das ist meist eleganter als manuelles actions/cache.

23.2.1 setup-python mit Dependency-Caching

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

Was passiert:

  1. setup-python installiert Python 3.12
  2. Automatisch: Cached ~/.cache/pip basierend auf requirements.txt Hash
  3. Bei Cache Hit: Restore in <1 Sekunde
  4. Bei Cache Miss: Dependencies werden installiert, dann gecacht

Wichtig: Funktioniert nur, wenn requirements.txt oder pyproject.toml im Root liegen.

Vergleich:

Manuell mit actions/cache Built-in cache
Expliziter path nötig Automatisch korrekt
Manueller key Automatisch aus Lockfile
~10 Zeilen YAML 1 Zeile

Use-Case für manuelles Caching: Custom Cache-Pfade (z.B. ./vendor/ statt ~/.cache/pip).

23.2.2 setup-node mit npm/yarn/pnpm

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'  # oder 'yarn', 'pnpm'

Cached:

Lockfile-Detection: Automatisch package-lock.json, yarn.lock, pnpm-lock.yaml.

23.2.3 setup-java mit Maven/Gradle

- uses: actions/setup-java@v4
  with:
    distribution: 'temurin'
    java-version: '17'
    cache: 'gradle'  # oder 'maven'

Cached:

23.3 Praxis: Python Dependency Caching für badge-gen

23.3.1 Naiver Ansatz (keine Optimierung)

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install dependencies
        run: |
          pip install --upgrade pip
          pip install -e .[dev]
      - name: Run tests
        run: pytest

Laufzeit: ~2 Minuten (1:30 für pip install)

23.3.2 Optimierter Ansatz (mit Caching)

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          pip install --upgrade pip
          pip install -e .[dev]
      
      - name: Run tests
        run: pytest

Laufzeit:

Einsparung: 75% Laufzeit

23.3.3 Multi-OS Caching

Caches sind OS-spezifisch. Das ist gut so (Linux != Windows Binaries).

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    python-version: ['3.11', '3.12']

steps:
  - uses: actions/setup-python@v5
    with:
      python-version: ${{ matrix.python-version }}
      cache: 'pip'

Cache-Keys generiert:

Jeder Matrix-Job hat seinen eigenen Cache. Das ist korrekt: macOS-Wheels ≠ Linux-Wheels.

23.4 Advanced Caching: Docker Layer Caching

Docker-Builds sind notorisch langsam. Layer-Caching ist der Gamechanger.

23.4.1 Problem: Docker ohne Caching

- name: Build Docker image
  run: docker build -t myapp:latest .

Jeder Run: Alle Layers neu bauen. Auch wenn sich nur app.py geändert hat, wird RUN pip install komplett neu ausgeführt.

23.4.2 Lösung: docker/build-push-action mit GHA Cache

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

Was passiert:

Dockerfile-Optimierung für Caching:

# ❌ Schlecht: Cache-Bust bei jeder Code-Änderung
COPY . /app
RUN pip install -r requirements.txt

# ✅ Gut: Dependencies zuerst (ändern sich selten)
COPY requirements.txt /app/
RUN pip install -r requirements.txt
COPY . /app

Prinzip: Seltene Änderungen oben, häufige Änderungen unten im Dockerfile.

23.5 Lokales Testing mit act

Problem: Workflow schreiben → Push → Warten → Fehler → Korrektur → Push → Warten → …

Lösung: Workflows lokal testen mit act.

23.5.1 Was ist act?

act ist ein CLI-Tool (Open Source, nektos/act), das Workflows auf dem lokalen Rechner in Docker-Containern ausführt. Es simuliert die GitHub Actions Umgebung.

23.5.2 Installation

macOS (Homebrew):

brew install act

Linux:

curl -sSf https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash

Windows (Chocolatey):

choco install act-cli

23.5.3 Grundlegende Nutzung

# Workflow mit push Event triggern
act push

# Spezifisches Event
act pull_request

# Spezifischer Job
act -j test

# Alle Jobs auflisten
act -l

23.5.4 Runner-Image auswählen

Beim ersten Run fragt act nach einem Image:

Image Größe Beschreibung
Micro ~200 MB Minimal (nur Node.js)
Medium ~500 MB Ubuntu mit basics
Large ~18 GB Exakte GitHub-hosted Runner Kopie

Empfehlung für Entwicklung: Medium (guter Kompromiss).

In .actrc dauerhaft setzen:

-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest

23.5.5 Secrets und Variables lokal

Secrets-Datei (.secrets):

API_KEY=test-key-12345
AWS_ACCESS_KEY_ID=local-testing

Variables-Datei (.vars):

API_URL=http://localhost:8080

Verwendung:

act --secret-file .secrets --var-file .vars

Wichtig: .secrets und .vars in .gitignore aufnehmen!

23.5.6 Limitierungen von act

act ist nicht identisch mit GitHub-hosted Runners:

Feature GitHub act
Linux-Jobs
macOS-Jobs ⚠️ Nur auf macOS-Host
Windows-Jobs ⚠️ Nur auf Windows-Host
workflow_dispatch Inputs ⚠️ Limitiert
GITHUB_TOKEN ❌ Lokal generierter Dummy
Hosted Secrets ❌ Lokale Dateien

Best Practice: act für schnelle Iterationen, finale Validierung immer auf GitHub.

23.5.7 Praxis-Beispiel: badge-gen lokal testen

# .github/workflows/test.yml
name: Test

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -e .[dev]
      - run: pytest

Lokal ausführen:

# Einfacher Test
act push

# Mit Debug-Output
act push -v

# Nur test-Job
act -j test

# Mit Secrets
echo "CODECOV_TOKEN=test" > .secrets
act push --secret-file .secrets

Workflow:

  1. Workflow lokal schreiben
  2. Mit act testen (Feedback in Sekunden)
  3. Iterieren bis es funktioniert
  4. Push → CI auf GitHub läuft durch

Zeit-Ersparnis: Entwicklungszeit von Stunden auf Minuten reduziert.

23.6 Debug-Logging – wenn Workflows mysterös fehlschlagen

Manchmal schlagen Workflows fehl, und die Logs sagen nichts. Dann braucht man Debug-Logging.

23.6.1 Step Debug Logging

Aktiviert verbose Output für jeden Step.

Aktivierung:
Repository Variable/Secret setzen:

ACTIONS_STEP_DEBUG = true

Settings → Secrets and variables → Actions → Variables → New repository variable

Effekt: Logs zeigen jetzt:

##[debug]Evaluating condition for step: 'Run tests'
##[debug]Evaluating: success()
##[debug]=> true
##[debug]Result: true
##[debug]Starting: Run tests
##[debug]Loading env
...

Informationen im Debug-Log:

23.6.2 Runner Diagnostic Logging

Aktiviert Runner-Prozess-Logs.

Aktivierung:
Repository Variable/Secret setzen:

ACTIONS_RUNNER_DEBUG = true

Effekt: Zwei zusätzliche Log-Dateien im Download:

Use-Case: Infrastruktur-Probleme (Runner startet nicht, Network-Issues, Docker-Probleme).

23.6.3 Re-run mit Debug-Logging

Alternativ zu dauerhaftem Debug-Logging: Re-run einzelner Jobs mit Debug aktiviert.

UI:
Failed Workflow → Re-run jobs → ☑️ Enable debug logging → Re-run failed jobs

Vorteil: Kein dauerhaftes Secret/Variable setzen, Debug nur für diesen Re-run.

23.6.4 Debug-Strategie: Systematisch einkreisen

1. Reproduzieren
Kann der Fehler lokal mit act reproduziert werden? Wenn ja → schnellere Iteration.

2. Logs analysieren
Standard-Logs durchlesen. Oft steht die Antwort im Exit-Code oder Error-Message.

3. Debug-Logging aktivieren
Wenn Standard-Logs nicht reichen: ACTIONS_STEP_DEBUG=true.

4. Step isolieren
Schlägt ein Multi-Command-Step fehl, Commands einzeln ausführen:

# ❌ Unübersichtlich bei Fehler
- run: |
    npm install
    npm test
    npm run build

# ✅ Klare Fehlerquelle
- run: npm install
- run: npm test
- run: npm run build

5. Context-Dumping
Unbekannte Werte in Contexts? Dumpen:

- name: Debug Context
  run: |
    echo "GitHub Context:"
    echo '${{ toJSON(github) }}'
    echo "Env Context:"
    echo '${{ toJSON(env) }}'
    echo "Matrix Context:"
    echo '${{ toJSON(matrix) }}'

6. Tmate-Debugging (Advanced)
Live-SSH in Runner für interaktives Debugging:

- name: Setup tmate session
  uses: mxschmitt/action-tmate@v3

Workflow pausiert, gibt SSH-Command für Zugriff auf Runner. Achtung: Secrets sind im Runner sichtbar!

23.7 Praxis: Vollständig optimierter CI-Workflow

Kombinieren wir alles: Caching, Matrix, Debugging.

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          pip install ruff mypy
      
      - name: Run linters
        run: |
          ruff check .
          mypy src/
  
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        python-version: ['3.11', '3.12']
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          pip install --upgrade pip
          pip install -e .[dev]
      
      - name: Run tests
        run: pytest -v --cov=src tests/
      
      - name: Upload coverage
        if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
        uses: codecov/codecov-action@v4
        with:
          fail_ci_if_error: true
  
  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      
      - name: Build package
        run: python -m build
      
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

Optimierungen:

  1. Parallele Jobs: lint und test laufen gleichzeitig
  2. Caching: Alle Python-Setup-Steps nutzen cache: 'pip'
  3. Matrix: Tests auf 3 OS × 2 Python-Versionen (6 Jobs parallel)
  4. Selective Steps: Coverage nur für Ubuntu+3.12 hochladen
  5. Dependencies: build wartet auf lint + test (kein Build bei Fehler)

Performance:

23.8 Performance-Tuning: Best Practices

23.8.1 1. Caching-Strategie

✅ Cachen:

❌ Nicht cachen:

23.8.2 2. Job-Parallelisierung

# ❌ Sequenziell
jobs:
  lint:
    steps: [...]
  test:
    needs: lint
    steps: [...]

# ✅ Parallel
jobs:
  lint:
    steps: [...]
  test:
    steps: [...]
  build:
    needs: [lint, test]
    steps: [...]

lint und test laufen parallel, build wartet auf beide.

23.8.3 3. Concurrency-Control

Vermeide parallele Runs für denselben Branch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Bei neuem Push auf denselben Branch: laufende Jobs werden gecancelt.

23.8.4 4. Conditional Execution

- name: Deploy docs
  if: github.ref == 'refs/heads/main'
  run: ./deploy-docs.sh

Deployment nur auf main, nicht auf PRs → spart Zeit.

23.8.5 5. Matrix-Strategie optimieren

# ❌ Overkill
matrix:
  python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
  os: [ubuntu, windows, macos]

# ✅ Fokussiert
matrix:
  python-version: ['3.11', '3.12']
  os: [ubuntu-latest]
  include:
    - python-version: '3.12'
      os: windows-latest

Tests auf allen Python-Versionen + Ubuntu, aber nur neueste auf Windows.

23.9 Edge-Cases und Troubleshooting

23.9.1 Problem: Cache restore dauert ewig

Symptom: Cache Hit, aber Restore dauert 5+ Minuten.

Ursache: Cache zu groß (mehrere GB).

Lösung: Granulareres Caching:

# ❌ Alles in einen Cache
path: |
  ~/.cache
  ~/.cargo
  node_modules

# ✅ Separate Caches
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ hashFiles('requirements.txt') }}

- uses: actions/cache@v4
  with:
    path: ~/.cargo
    key: cargo-${{ hashFiles('Cargo.lock') }}

Kleine Caches restoren schneller.

23.9.2 Problem: Cache nicht invalidiert bei Dependency-Update

Symptom: requirements.txt geändert, aber alte Dependencies werden genutzt.

Ursache: Cache-Key hat hashFiles() nicht korrekt gesetzt.

Debugging:

- name: Debug cache key
  run: |
    echo "Key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}"

Wenn Hash leer → Datei nicht gefunden oder Pattern falsch.

23.9.3 Problem: act schlägt lokal fehl, aber auf GitHub funktioniert es

Ursache: Unterschiedliche Runner-Images.

Lösung: GitHub-Image in act nutzen (Large):

act -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latest

Achtung: 18 GB Download!

23.9.4 Problem: Debug-Logging zeigt Secrets

Symptom: In Debug-Logs erscheinen Secrets als Plain-Text.

Ursache: GitHub maskiert Secrets nur in Standard-Output, nicht in transformierten Werten.

Lösung: ::add-mask:: explizit setzen (siehe Kapitel 3).