Files
petfeed/docker/SCALING-ROADMAP.md
T
admin 1e060e9179 feat(docker): scale to 3 load-balanced replicas (Gitea #26)
Next.js standalone is single-process, so one container only used one
CPU core. A load test hit a hard ceiling at ~60-65 req/sec with one
core pegged at 133% while 3 cores sat idle. Now runs as pawfeed-1/2/3
behind an NPM least_conn upstream, verified via load test showing CPU
spread evenly across all three replicas.

Also caps the Prisma pg pool per-replica via DB_POOL_MAX so 3 replicas
stay well under the Supabase pooler's connection limit.
2026-08-16 11:38:05 +02:00

7.4 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.

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 fehlt noch: docker compose up --build startet weiterhin alle 3 Container gleichzeitig neu → kurzes gemeinsames Downtime-Fenster bei jedem künftigen Deploy. Echter Zero-Downtime-Rolling-Deploy (Container einzeln nacheinander mit --no-deps neu bauen) ist ein sinnvoller Folgeschritt, aber nicht Teil dieser Roadmap.
  • 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.