Wie man von lokalen Deployments zu schnellem CI/CD mit GitHub Actions wechselt
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.txtDockerfile.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:latestDer 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-unauthenticatedIch 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: frontendWenn 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.

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.