Files
petfeed/docker/SCALING-ROADMAP.md
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

136 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`).