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.
136 lines
9.0 KiB
Markdown
136 lines
9.0 KiB
Markdown
# 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`).
|