Zurück zum Blog

Wie man von lokalen Deployments zu schnellem CI/CD mit GitHub Actions wechselt

2026-01-198 min read

Ich habe eine Web-App für Aussprache-Coaching entwickelt, um meine englische Prosodie zu verbessern. Ich bin Muttersprachler des Spanischen und dachte, ich öffne sie, falls sie anderen hilft, die Englisch als Zweitsprache lernen. Das Backend ist Python mit FastAPI, das Parselmouth (ein Python-Wrapper für Praat) zur Audioanalyse und Google Gemini zur Generierung von Coaching-Feedback verwendet. Das Frontend ist React mit Vite und TypeScript.

Für die Infrastruktur habe ich mich für Google Cloud entschieden. Das Backend läuft auf Cloud Run, das Frontend wird auf Firebase Hosting gehostet, und die Benutzerdaten befinden sich in Firestore. Docker-Images werden an Artifact Registry gepusht. Der Code liegt auf GitHub. Es ist ein ziemlich Standard-Setup für ein Nebenprojekt, das ich innerhalb der kostenlosen Kontingentgrenzen halten wollte.

Das Problem

Ich habe sowohl das Frontend als auch das Backend von meinem lokalen Rechner aus bereitgestellt. Das Frontend war schnell genug, aber das Backend war mühsam. Jeder Deploy bedeutete, das Docker-Image von Grund auf neu zu erstellen, was 5–10 Minuten dauerte, während pip alle Python-Abhängigkeiten herunterlud.

Ich wollte auf CI/CD umsteigen, damit Deployments automatisch erfolgen, wenn ich auf main pushe. Aber ich wollte auch nicht 10 Minuten auf jeden Push warten. Also habe ich mich daran gemacht, GitHub Actions so einzurichten, dass sie intelligent erkennen, was sich tatsächlich geändert hat.

Die Monorepo-Struktur

Alles befindet sich in einem Repo mit einem frontend/ Ordner und einem backend/ Ordner. Der Workflow wird bei einem Push auf main ausgelöst, und ich wollte, dass er herausfindet, was sich tatsächlich geändert hat, und nur diesen Teil bereitstellt. Frontend-Dateien geändert? Nur auf Firebase bereitstellen. Backend-Code geändert? Nur auf Cloud Run bereitstellen. Und wenn sich requirements.txt geändert hat, das Basis-Docker-Image neu erstellen. Aber nur dann.

Einen Weg finden, Änderungen zu erkennen

Ich habe mich umgesehen und eine Action namens dorny/paths-filter gefunden. Sie prüft, welche Dateien sich bei einem Push geändert haben, und gibt Booleans aus, die andere Jobs verwenden können. Ich habe sie als ersten Job in meinem Workflow eingerichtet:

jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
requirements: ${{ steps.filter.outputs.requirements }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
requirements:
- 'backend/requirements.txt'
- 'backend/Dockerfile.base'

Dies gab mir drei Ausgaben, auf die ich in anderen Jobs verweisen konnte, um zu entscheiden, ob sie ausgeführt werden sollten.

Umgang mit langsamen Docker-Builds

Selbst mit funktionierender Änderungsdetektion waren Backend-Deployments immer noch langsam. Jeder Build führte pip install von Grund auf neu aus. Meine requirements.txt enthält ML-Bibliotheken, Audioverarbeitungszeug und alles Mögliche. Das ist eine Menge an Downloads.

Mir wurde klar, dass ich die Abhängigkeiten vielleicht einmal im Monat ändere. Die meiste Zeit ändere ich nur Anwendungscode. Also habe ich versucht, die Dockerfile in zwei Teile zu zerlegen.

Dockerfile.base

Hier habe ich all die langsamen Dinge untergebracht: Systempakete, Pip-Abhängigkeiten. Die Idee war, dies einmal zu bauen und an Artifact Registry zu pushen und es dann wiederzuverwenden.

FROM python:3.11-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt

Dockerfile.fast

Dieses startet vom Basis-Image und kopiert nur den Code. Kein apt-get. Kein Pip. Nur ein COPY-Befehl.

FROM us-central1-docker.pkg.dev/lescoach/prosody-repo/prosody-api-base:latest

WORKDIR /app
COPY app/ ./app/

ENV PYTHONUNBUFFERED=1
ENV PORT=8080
EXPOSE 8080

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Dies reduzierte die Build-Zeit von 5–10 Minuten auf etwa 30 Sekunden, was eine schöne Verbesserung war.

Den Workflow zusammenstellen

Ich habe den Basis-Image-Job so eingerichtet, dass er nur ausgeführt wird, wenn sich die Abhängigkeiten tatsächlich ändern:

build-base-image:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.requirements == 'true'
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- name: Build and push base image
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest

Der Backend-Deploy-Job erforderte einige Versuche und Irrtümer. Er musste warten, bis der Basis-Image-Build abgeschlossen war, falls er ausgeführt wurde, aber auch funktionieren, wenn er übersprungen wurde. Ich habe Folgendes erreicht:

deploy-backend:
runs-on: ubuntu-latest
needs: [detect-changes, build-base-image]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
(needs.build-base-image.result == 'success' || needs.build-base-image.result == 'skipped')
steps:
# auth steps...

- name: Check if base image exists
id: check-base
run: |
if docker manifest inspect ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi

- name: Build base image if missing
if: steps.check-base.outputs.exists == 'false'
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest

- name: Build and push app image
working-directory: backend
run: |
docker build -f Dockerfile.fast -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }} .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}

- uses: google-github-actions/deploy-cloudrun@v2
with:
service: prosody-api
region: us-central1
image: ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
flags: --allow-unauthenticated

Ich habe gelernt, dass always() dort benötigt wird. Ohne sie wird der Job seine Bedingung nicht einmal auswerten, wenn ein vorgelagerter Job übersprungen wurde. Ich habe auch eine docker manifest inspect-Prüfung hinzugefügt, um den Fall zu behandeln, dass das Basis-Image noch nicht existiert, z. B. beim ersten Deployment oder wenn es jemand versehentlich gelöscht hat.

Der Frontend-Teil

Der Frontend-Job stellte sich als einfacher heraus, da er überhaupt nicht vom Backend abhängt:

deploy-frontend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run build
working-directory: frontend
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LESCOACH }}
channelId: live
projectId: lescoach
entryPoint: frontend

Wenn sich nur das Frontend geändert hat, ist dies der einzige Job, der ausgeführt wird.

Auth-Setup

Ich habe mich für Workload Identity Federation anstelle von Service-Account-Schlüsseln entschieden. Es war anfangs mehr Einrichtungsaufwand, aber ich mochte es, mich nicht um das Rotieren von Schlüsseln kümmern oder um JSON-Dateien in Secrets sorgen zu müssen. GitHub erhält kurzlebige Tokens über OIDC, was sich für mich sauberer anfühlte.

Wo ich gelandet bin

Frontend-only-Änderungen werden jetzt in unter einer Minute bereitgestellt. Änderungen am Backend-Code dauern etwa 90 Sekunden. Änderungen an den Abhängigkeiten dauern immer noch etwa 4 Minuten, aber diese sind bei mir selten.

GitHub Actions Workflow, der zeigt, wie die Jobs detect-changes, build-base-image, deploy-frontend und deploy-backend in 1m 33s abgeschlossen werden
Frontend und Backend zusammen in 1m 33s bereitgestellt.

An den meisten Tagen optimiere ich UI-Elemente, und es wird jetzt einfach schnell bereitgestellt. Der gesamte Workflow umfasste etwa 130 Zeilen YAML. Die paths-filter Action leistet den größten Teil der Schwerstarbeit bei der Ermittlung, was sich geändert hat.

Bleiben Sie auf dem Laufenden

Erhalten Sie die neuesten Beiträge und Einblicke in Ihren Posteingang.

Unsubscribe anytime. No spam, ever.