Files
petfeed/docker/SCALING-ROADMAP.md
T
admin 6a61bbcc81 feat(docker): zero-downtime rolling deploy for the 3 replicas
Adds a dependency-free liveness endpoint (GET /api/health), a Docker
healthcheck against it, and docker/rolling-deploy.sh which rebuilds
the shared image once and restarts pawfeed-1/2/3 sequentially, gated
on each becoming healthy before moving to the next. Verified
zero-downtime via continuous curl monitoring through a full run
against production.
2026-08-16 13:17:04 +02:00

9.0 KiB
Raw Blame History

Scaling-Roadmap: 2–3 Container-Replicas + NPM Load Balancing

Status: ✅ Live seit 2026-08-16. Alle 4 Schritte umgesetzt und per Lasttest verifiziert (CPU verteilt sich jetzt ~100% auf jede der 3 Replicas statt 133% auf einem Kern). Kompatibilitäts-Alias und alter Einzelcontainer sind entfernt — der finale Zielzustand aus Schritt 2 läuft unverändert in Produktion. Dieses Dokument bleibt als Runbook/Entscheidungs-Log stehen, falls die Config mal reproduziert oder debuggt werden muss.

Kontext: Gitea #26. Lasttest am 2026-08-15 zeigt eine harte Durchsatz-Decke bei ~60–65 Req/Sek. Ursache: docker/Dockerfile:78 startet PawFeed als einzelnen node server.js-Prozess (Next.js Standalone ist single-process). CPU-Last dabei: 133% von 400% verfügbar (4-Kern-NAS, i5-6500 @ 3.6GHz) — ein Kern ist ausgelastet, drei liegen brach. Statt eines zweiten physischen Servers + externem Loadbalancer: die 3 ungenutzten Kerne auf derselben Maschine per Container-Replicas + dem bereits vorhandenen Nginx Proxy Manager (NPM) nutzen.

Aufwand-Schätzung: ~1,5–2 Stunden, additiv (kein Breaking Change), Rollback = docker-compose.yml zurücksetzen.

Schritt 1 — Bestandsaufnahme (10 Min) — ✅ erledigt 2026-08-16

  • NPM-Weboberfläche: Forward Hostname/Port für pawfeed.org ist http://pawfeed:3000 über das shared nginx_default-Network (Container-DNS-Name, kein Host-Port-Mapping) — Annahme bestätigt. Achtung: Nach dem Umbau auf pawfeed-1/2/3 gibt es keinen Container mehr namens pawfeed — Compose-Deploy und NPM-Umstellung müssen im selben Wartungsfenster passieren, sonst Downtime.
  • Custom Locations in NPM: noch nichts hinterlegt.
  • Supabase-Dashboard → Database → Connection Pooling: Shared-Modus, Pool Size 15, Max Client Connections 200 (Upgrade nötig für mehr). 3 Replicas × DB_POOL_MAX=10 (in docker-compose.yml gesetzt) = 30 gleichzeitige Verbindungen, deutlich unter dem Limit — keine weitere Anpassung nötig.
  • Neuer Befund: Ein upstream {}-Block ist nur im Nginx-http{}-Kontext gültig. NPMs Advanced-Tab pro Proxy Host schreibt aber nur in den server{}-Kontext — der Upstream-Block lässt sich nicht über die Weboberfläche einrichten, sondern braucht eine Datei unter /data/nginx/custom/http_top.conf auf dem Docker-Volume des NPM-Containers (Dateisystem-Zugriff auf die NAS nötig). Siehe docker/npm-upstream.conf.

Schritt 2 — docker-compose.yml auf 3 Replicas umstellen (20–30 Min)

Explizite Services (pawfeed-1/2/3) statt docker compose up --scale — feste, vorhersehbare Hostnamen sind fürs NPM-Upstream nötig. YAML-Anchor vermeidet dreifache Duplizierung der Build-Args/Env:

x-pawfeed-common: &pawfeed-common
  build:
    context: ..
    dockerfile: docker/Dockerfile
    args:
      NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
      NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
      NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN}
      SENTRY_ORG: ${SENTRY_ORG}
      SENTRY_PROJECT: ${SENTRY_PROJECT}
      SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN}
      SENTRY_RELEASE: ${SENTRY_RELEASE}
  image: pawfeed:latest
  restart: unless-stopped
  env_file:
    - .env
  environment:
    NODE_ENV: production
    REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
  depends_on:
    - redis
  networks:
    - default
    - nginx_default

services:
  redis:
    image: redis:7-alpine
    container_name: pawfeed-redis
    restart: unless-stopped
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}

  pawfeed-1:
    <<: *pawfeed-common
    container_name: pawfeed-1

  pawfeed-2:
    <<: *pawfeed-common
    container_name: pawfeed-2

  pawfeed-3:
    <<: *pawfeed-common
    container_name: pawfeed-3

volumes:
  redis_data:

networks:
  default:
  nginx_default:
    external: true

Redis bleibt einfach (bereits geteilter State über alle Replicas — richtig so, nicht anfassen).

Schritt 3 — NPM Load Balancing konfigurieren (20–30 Min)

Zwei Teile, unterschiedliche Nginx-Kontexte — geht nicht komplett über die NPM-Weboberfläche:

  1. Upstream-Block (docker/npm-upstream.conf) muss als Datei /data/nginx/custom/http_top.conf auf dem Docker-Volume des NPM-Containers liegen — upstream {} ist nur im http{}-Kontext gültig, den NPMs Advanced-Tab nicht erreicht. Braucht Dateisystem-/SSH-Zugriff auf die NAS. NPM zieht Dateien unter /data/nginx/custom/ automatisch beim nächsten Config-Reload ein.

    upstream pawfeed_upstream {
        least_conn;
        server pawfeed-1:3000 max_fails=3 fail_timeout=30s;
        server pawfeed-2:3000 max_fails=3 fail_timeout=30s;
        server pawfeed-3:3000 max_fails=3 fail_timeout=30s;
    }
    
  2. Proxy-Pass-Override im Proxy-Host für pawfeed.org, Advanced-Tab: eigener location /-Block statt dem generierten Ziel pawfeed:3000 (das nach dem Umbau ohnehin nicht mehr existiert):

    location / {
        proxy_pass http://pawfeed_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    

least_conn verteilt auf die Instanz mit den wenigsten aktiven Verbindungen — passender als reines Round-Robin bei ungleich langen Requests (z. B. Video-Uploads vs. einfache GETs).

Reihenfolge wichtig: Datei 1 zuerst anlegen + NPM neu laden, dann erst Schritt 2 (Compose-Umbau) deployen und in derselben Sitzung Punkt 2 im Advanced-Tab eintragen — sonst zeigt NPM zwischenzeitlich auf den nicht mehr existierenden Container pawfeed.

Schritt 4 — Deploy & Verifikation (20 Min) — ✅ erledigt 2026-08-16

  • docker compose up -d --build auf der NAS (/Dockers/PawFeed/docker) — Image 1× gebaut, alle 3 Container gestartet. Kompatibilitäts-Alias (pawfeed auf pawfeed-1) sorgte für nahtlosen Übergang, danach entfernt + pawfeed-1 einmal neu erstellt (config-only, kein Rebuild).
  • docker stats während Lasttest: pawfeed-1 104,76%, pawfeed-2 102,02%, pawfeed-3 93,58% — CPU verteilt sich jetzt sauber über alle 3 Prozesse.
  • Lasttest: npx autocannon -c 30 -d 10 https://pawfeed.org/ — 783 Requests in 10,07s, keine Fehler.
  • Supabase-Dashboard: Pool Size 15 / Max Client Connections 200 (Shared-Modus) — 3 × DB_POOL_MAX=10 = 30, unkritisch.
  • Login/Session-Konsistenz über mehrere Reloads noch nicht gezielt manuell verifiziert (Clerk ist JWT-basiert/stateless, sollte unkritisch sein) — bei Gelegenheit nachholen.
  • Mux-Webhook und Stripe-Webhook noch nicht gezielt gegen die neue Replica-Config getestet — bei Gelegenheit nachholen.

Schritt 5 — Rolling-Deploy (Zero-Downtime) — ✅ erledigt 2026-08-16

docker compose up -d --build startete bis hierhin alle 3 Container gleichzeitig neu → kurzes gemeinsames Downtime-Fenster bei jedem Deploy. Behoben mit drei Teilen:

  1. src/app/api/health/route.ts — reiner Liveness-Check (GET → {status:"ok"}), keine DB-/Redis-Abhängigkeit (ein DB-Ausfall darf nicht alle Replicas als "unhealthy" markieren). In src/proxy.tss isPublicRoute eingetragen, sonst blockt Clerk den Check.
  2. Docker-healthcheck in docker-compose.ymls x-pawfeed-common-Anchor — pollt GET http://127.0.0.1:3000/api/health per wget (einziger HTTP-Client im node:22-alpine-Runner-Image), interval: 5s, start_period: 15s, retries: 5.
  3. docker/rolling-deploy.sh — baut das Image einmal, geht dann pawfeed-1 → pawfeed-2 → pawfeed-3 einzeln durch, wartet nach jedem Neustart bis docker inspect healthy meldet (Timeout 60s), bricht vor der nächsten Replica ab, falls eine nicht gesund wird.

Verifiziert: Skript einmal live gegen die Produktionsseite gefahren, parallel jede Sekunde curl auf https://pawfeed.org/ — durchgehend HTTP 200, keine einzige fehlgeschlagene Anfrage während des kompletten Durchlaufs (Build + 3× sequenzieller Neustart).

Deploy-Workflow ab jetzt: ./rolling-deploy.sh statt docker compose up -d --build für Deploys gegen die laufende Seite. start.sh (docker compose up -d --build) bleibt für Erstinstallation bzw. wenn Downtime egal ist.

Risiken / offene Punkte

  • DB-Connection-Pool — gelöst: src/lib/prisma.ts cappt jetzt per DB_POOL_MAX (Default/gesetzt: 10). 3 × 10 = 30, weit unter dem verifizierten Supabase-Limit von 200 Max Client Connections.
  • Rolling-Deploy — gelöst, siehe Schritt 5.
  • Host-Port-Mapping — geklärt: NPM erreicht die Replicas per Docker-DNS-Name im nginx_default-Network (pawfeed-1/2/3:3000), kein Host-Port-Mapping nötig oder vorhanden.
  • Self-Heal-Fanout-Bug (2026-08-16, gefunden + behoben): videosRouter.getByPostIds Self-Heal-Pfad (Client pollt PROCESSING, fragt direkt bei Mux nach) setzte Video-Status auf READY, rief aber nie fanOutPost auf — Video war abspielbar, landete aber nie im Feed. Kein Replica-Problem, aber durch den Live-Test nach dem Rollout entdeckt. Fix: fanOutPost auch im Self-Heal-Zweig aufrufen (Commit faf8ee9).