# 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 - [x] 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. - [x] Custom Locations in NPM: noch nichts hinterlegt. - [x] 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. - [x] **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: ```yaml 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. ```nginx 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): ```nginx 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 - [x] `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). - [x] `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. - [x] Lasttest: `npx autocannon -c 30 -d 10 https://pawfeed.org/` — 783 Requests in 10,07s, keine Fehler. - [x] 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.ts`s `isPublicRoute` eingetragen, sonst blockt Clerk den Check. 2. **Docker-`healthcheck`** in `docker-compose.yml`s `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.getByPostId`s 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`).