Skip to content

Ingest auf Azure Functions + Postgres Flex — Prod-Deployment-Design

Design-Dokument für den produktiven Ingest-Pfad nach dem Event-Hubs-Cutover (siehe Confluent → Event Hubs Cutover). Staging bleibt unverändert — die Compose-basierte Ingest-Worker-Flotte auf ts-admin-portal ist dort gut genug. Prod zieht auf Azure Functions (Consumption) → Azure Database for PostgreSQL Flexible Server (Public Endpoint + Firewall) um.

Zielbild

  • Ingest als Function, nicht als Compose-Service: ein Azure Function App pro Umgebung, Event-Hubs-Trigger, Node 22 Runtime. Kein Host-Patching, kein Docker-Stack, kein Restart-Handling.
  • Managed Postgres: Flexible Server mit Public-Endpoint + Firewall-Rules als Start — VNet-Integration lassen wir bewusst weg, bis wir den operativen Overhead rechtfertigen können.
  • Consumption Plan: pay-per-execution, skaliert automatisch mit dem Event-Hubs-Durchsatz. Cold-Start (~1–2 s) akzeptabel, da CDC-Durchlauf eventual-consistent ist — kein Real-Time-UI hängt direkt am Schreibpfad.
  • Prisma bleibt: das bestehende Schema und alle Upsert-Queries wandern unverändert mit; nur DATABASE_URL zeigt auf Flex Server.

Komponenten

Ressource SKU / Config Zweck
Resource Group targetshot-prod-ingest in Germany West Central Kapselung
Storage Account Standard LRS (targetshotprodfunc<rand>) Function-Runtime-State (Pflicht)
Function App Linux Consumption, Node 22 Ingest-Consumer
Postgres Flex Standard_B2s (2 vCPU, 4 GB), 64 GB SSD, Burstable App-Datenbank
— Backup 7 Tage PITR Wiederherstellung
— Auth Entra ID + Managed Identity (empfohlen) oder Passwort Function-App-Login
Event Hubs bestehender Namespace targetshot-eh Quelle (Topics prod.sds.*)
— SAS Policy prod-listen (Listen only) Function-App-Connection

Bewusst nicht drin: VNet, Private Endpoint, NAT Gateway, Application Insights (kommt erst wenn Observability-Bedarf steigt), Azure Front Door.

Netzwerk & Security

  • Postgres Flex Public Access, Firewall-Rules:
  • „Allow Azure services and resources to access this server" = On (Consumption-Plan Functions haben rotierende Outbound-IPs, nur so erreichbar).
  • Eigene Dev-IP für Admin-Zugriff + Migration-Runs.
  • Auth-Pfad:
  • Primär: Entra-Managed-Identity + DefaultAzureCredential in der Function, Postgres-Role per az postgres flexible-server ad-admin create. Kein Passwort in App-Settings, kein Key Vault nötig.
  • Fallback: klassisches Passwort in App-Settings, wenn Managed Identity im ersten Cutover hakt. Passwort dann in Key Vault, Function holt via Reference.
  • SSL: sslmode=require in DATABASE_URL; Flex Server erzwingt TLS.

Function-App-Struktur

Neues Repo-Subfolder targetshot/ingest-functions/ (oder separates Repo, je nach CI-Gewohnheit). Ein einziger Event-Hubs-Trigger, der alle vier Topics per Pattern abgreift:

ingest-functions/
├── host.json                 # Runtime-Config (batchSize, maxConcurrency)
├── package.json              # deps: @azure/functions, kafkajs  oder direkt
                             # @azure/event-hubs + @azure/identity
├── src/
   ├── functions/
      └── sdsIngest.ts      # app.eventHub("sds", { ... })
   ├── handlers/             # entity-spezifische Upsert-Logik
      ├── schuetze.ts
      ├── treffer.ts
      ├── scheiben.ts
      └── serien.ts
   ├── db/
      └── prismaClient.ts   # einmaliger Singleton pro Instanz
   └── topicContract.ts      # aus backend/src/ingest/ migriert, 1:1
└── prisma/
    └── schema.prisma         # symlink oder kopie des backend-Schemas

Trigger pro Event Hub (nicht ein Pattern!). Azure Functions v4 unterstützt einen Event-Hub-Trigger pro Function, aber man kann eine Function pro Hub anlegen — alle routen in die gleiche Upsert-Pipeline, die anhand des Topic-Namens den Handler auswählt (dieselbe Logik wie heute im Backend-Ingest).

import { app, InvocationContext } from "@azure/functions";

app.eventHub("schuetzeIngest", {
  connection: "EVENTHUBS_CONNECTION",
  eventHubName: "prod.sds.schuetze",
  consumerGroup: "targetshot-prod-ingest",
  cardinality: "many",
  handler: (messages, ctx) => handleBatch("prod.sds.schuetze", messages, ctx),
});
// analog treffer, scheiben, serien

cardinality: "many" gibt uns Batches statt Einzelevents → Batch-Upsert in Postgres + ein Offset-Commit pro Batch.

Consumer-Groups auf Event Hubs

Wichtig, nicht vergessen: Event Hubs Standard legt Consumer-Groups nicht auto-an, wenn der Kafka-Client sich mit einem neuen groupId verbindet. Vor dem Cutover per CLI erstellen:

for eh in prod.sds.schuetze prod.sds.treffer prod.sds.scheiben prod.sds.serien; do
  az eventhubs eventhub consumer-group create \
    --resource-group targetshot-eventhubs \
    --namespace-name targetshot-eh \
    --eventhub-name "$eh" \
    --name targetshot-prod-ingest
done

Genau dieser Fehler hat uns beim Staging-Cutover blockiert — Ingest hing im stillen Retry, weil die targetshot-backend-ingest-Group nicht existierte und EH den Subscribe-Call stumm abgelehnt hat.

Schema-Migration

  1. Vorbereitung auf dem Dev-Rechner: DATABASE_URL="postgres://pgadmin:<pwd>@targetshot-prod-pg.postgres.database.azure.com:5432/targetshot?sslmode=require" npx prisma migrate deploy --schema backend/prisma/schema.prisma
  2. Tabellen existieren leer. Dev-IP muss zu dem Zeitpunkt in der Postgres-Firewall stehen (Azure Portal → Networking → Add client IP).
  3. Prisma Client wird mit demselben Schema-Hash generiert und in die Function-App gebaut.

Data-Migration (bestehende Staging-Daten)

Entscheidung: Prod startet leer, nicht mit Staging-Dump. Grund: der Staging-DB-Inhalt ist Test-CDC-Data, keine echten Vereins-Einträge. Bei echten Clubs übernimmt ts-connect den initialen Backfill aus der jeweiligen Vereins-DB per Debezium-Snapshot, sobald der erste Club mit Prod-Connector-Config ausgerollt wird.

Falls später doch ein Prod-Staging-Dump nötig wird: pg_dump vom Staging-Server → pg_restore gegen Flex — Schema-Kompatibilität ist durch Prisma garantiert, solange beide auf der gleichen Migration stehen.

Deployment

GitHub Actions Workflow deploy-prod-ingest-functions.yml:

runs-on: ubuntu-latest
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with: { node-version: 22 }
  - run: npm ci --prefix ingest-functions
  - run: npx prisma generate --schema backend/prisma/schema.prisma
  - run: npm run build --prefix ingest-functions
  - uses: azure/login@v2
    with: { creds: ${{ secrets.AZURE_CREDENTIALS }} }
  - uses: Azure/functions-action@v1
    with:
      app-name: targetshot-prod-ingest
      package: ingest-functions

App-Settings werden einmalig per az functionapp config appsettings set gesetzt:

EVENTHUBS_CONNECTION=Endpoint=sb://targetshot-eh.servicebus.windows.net/;SharedAccessKeyName=prod-listen;SharedAccessKey=...;EntityPath=prod.sds.schuetze
DATABASE_URL=postgres://...
FUNCTIONS_WORKER_RUNTIME=node

Bei Managed-Identity-Auth entfällt das Passwort in DATABASE_URL; stattdessen baut die Function den Connection-String zur Laufzeit mit einem Entra-Access-Token als Passwort.

Kosten (Germany West Central, ca., pay-as-you-go)

Posten Monatlich
Function App Consumption (erste 1 M exec gratis, dann €0,17/M) ~€0–5
Storage Account (Function Runtime) ~€1
Postgres Flex B2s + 64 GB + 7 d Backup ~€45
Event Hubs (unverändert, bereits gebucht) €0 neu
Summe ~€50 / Monat

Bei steigendem Durchsatz oder strengerer Latenz kann der Function-Plan später auf Premium EP1 (~€140/mo, kein Cold-Start, VNet möglich) hochgezogen werden, ohne dass der Code sich ändert.

Cutover-Schritte (High-Level)

  1. Flex Server + Function-App anlegen, Firewall für Dev-IP öffnen.
  2. prisma migrate deploy gegen den frischen Flex Server.
  3. EH-Consumer-Groups targetshot-prod-ingest auf allen 4 prod.sds.* Hubs erstellen.
  4. Function-App deployen, App-Settings setzen, Test-Message auf prod.sds.schuetze produzieren (via Staging-ts-connect mit umgebogenem Prefix für 1 Request), Row in Flex DB verifizieren.
  5. Prod-Frontend + Prod-Main-App-Deploy nachziehen (eigener Cutover, separat — siehe prod-cutover-checklist.md).
  6. Erster echter Verein rollt aus: ts-connect mit prod-send SAS-Policy + Prefix prod.sds; Debezium-Snapshot füllt die frische DB.

Rollback-Plan

  • Funktion App auf Stopped setzen → Ingest pausiert, EH puffert bis 7 Tage Retention.
  • Falls DB kaputt: Flex Server per PITR auf T-5-Minuten zurück (7 Tage Historie).
  • Kompletter Rollback: Prod-Traffic gibt's zum Cutover-Zeitpunkt noch nicht — wir können das System so oft neu aufbauen, wie nötig.

Was dieses Dokument noch nicht enthält

  • Bicep- oder Terraform-Dateien — kommt als nächster Schritt.
  • Application-Insights-Setup (Logs + Traces).
  • Alerting auf Ingest-Lag / Function-Errors.
  • Multi-Region-Failover (Flex Server kann Replicas, Function-App kann Zonenredundanz — beides erst relevant wenn Prod-Traffic messbar ist).