leistungsbilanz-ts/docs/deployment.md
Grovy311 01fa527b9c Add production compose stack and stop idle load in containers
The development stack was running permanently on a server: polling file
watchers, a healthcheck that rendered a full page every five seconds and no
memory limit grew next dev to 10 GB and pushed the host into swap.

- add compose.prod.yaml running compiled output in separate api/web services
- make the Dockerfile multi-stage with dev and prod targets, prune
  devDependencies and run the runtime image as node instead of root
- bake API_INTERNAL_URL at build time; next start ignores it at runtime
  because rewrite destinations are resolved into routes-manifest.json
- drop CHOKIDAR_USEPOLLING and WATCHPACK_POLLING
- probe /health instead of /, which redirects to /projects and made every
  healthcheck render the project list
- give every service a memory limit and forbid swap in production
- rename the development compose project to leistungsbilanz-dev so its
  down command cannot target the production stack
- bind development ports to localhost
- close the http server and the SQLite handle on SIGTERM/SIGINT
- match probe user agents in the navigation log filter; Node's fetch sends
  one, so the previous check never matched
- exit docker-start.sh when either supervised process dies
- remove drizzle.config.js, a compiled copy drizzle-kit never reads, and the
  pre-Next index.html/styles.css leftovers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:27:22 +02:00

8.9 KiB
Executable file
Raw Blame History

Deployment und Betrieb

Aktueller Status

Es gibt zwei getrennte Compose-Dateien. Sie dürfen nicht verwechselt werden.

Datei Projektname Zweck
compose.yaml leistungsbilanz-dev ausschließlich lokale Entwicklung
compose.prod.yaml leistungsbilanz Dauerbetrieb im LAN

compose.yaml startet tsx watch und next dev und bindet Quellcode vom Host ein. Dieser Stack ist nicht für Dauerbetrieb geeignet (siehe Warum kein Dev-Stack im Dauerbetrieb). Seine Ports sind deshalb an 127.0.0.1 gebunden.

compose.prod.yaml startet kompilierten Code ohne Dateibeobachter. Es enthält weiterhin weder TLS, Authentifizierung noch ein Benutzer-/Rollenmodell; die Express-API wird deshalb nicht auf dem Host veröffentlicht, sondern nur über den Next.js-Rewrite erreicht. Der Stack gehört hinter einen Reverse Proxy mit Authentifizierung und darf nicht öffentlich erreichbar gemacht werden.

Topologie

Entwicklung (compose.yaml):

Komponente Port Healthcheck Speicherlimit Persistenz
Next.js Web 127.0.0.1:3001 GET /health alle 30 s 2 GB keine
Express API 127.0.0.1:3000 GET /health alle 30 s 1 GB ./data:/app/data

Produktion (compose.prod.yaml):

Komponente Port Healthcheck Speicherlimit Persistenz
Next.js Web 3090 GET /health alle 30 s 1 GB, kein Swap keine
Express API nur intern GET /health alle 30 s 512 MB, kein Swap Volume leistungsbilanz-data
SQLite Datei Integritäts-/FK-Prüfung via Backup und Skript data/leistungsbilanz.db

Verwendete Umgebungsvariablen:

  • PORT API-Port, Standard 3000
  • API_INTERNAL_URL internes API-Ziel des Next.js-Rewrites, im Compose-Netz http://api:3000
  • NEXT_TELEMETRY_DISABLED=1
  • NODE_ENV=production nur in compose.prod.yaml
  • LOG_LEVEL steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (error, warn, info, verbose, debug), Standard info. Setzbar über eine .env-Datei neben compose.yaml oder LOG_LEVEL=verbose docker compose up.

Beim API-Start laufen in der Entwicklung zuerst npm run db:migrate und npm run db:verify:circuit-schema (drizzle-kit, eine devDependency). Das Produktionsimage enthält keine devDependencies und migriert stattdessen über node scripts/run-migrations.js, das denselben Migrationsordner mit drizzle-orm anwendet.

Warum kein Dev-Stack im Dauerbetrieb

compose.yaml lief einmal fünf Tage durchgehend auf einem Server. Ergebnis: 10,3 GB belegter Arbeitsspeicher im Web-Container, 12,2 % Dauer-CPU im API-Container und ein Host, der 13 GB Swap belegt hatte. Vier Ursachen wirkten zusammen; alle vier sind inzwischen behoben:

  1. Polling-Dateibeobachter. CHOKIDAR_USEPOLLING und WATCHPACK_POLLING ließen tsx watch permanent den Quellbaum abklappern. Beide Variablen sind entfernt; unter Linux funktioniert inotify auf Bind-Mounts.
  2. Healthcheck als Seitenrendering. Der Web-Healthcheck rief / alle fünf Sekunden auf. / ist ein redirect("/projects"), und Node's fetch folgt Redirects jede Prüfung rendert also die vollständige Projektliste, rund 16.000-mal pro Tag. Der Healthcheck zeigt jetzt auf /health und läuft alle 30 Sekunden.
  3. Kein Speicherlimit. next dev hält Kompilierungs-State und wächst unter Dauerlast unbegrenzt. Beide Compose-Dateien setzen jetzt mem_limit; in der Produktion zusätzlich memswap_limit in gleicher Höhe, damit ein Leck den Container beendet statt den Host in den Swap zu ziehen.
  4. Kollidierender Projektname. compose.yaml hieß leistungsbilanz und damit genauso wie das Produktionsprojekt; ein docker compose down im Entwicklungsverzeichnis zielte auf den Produktionsstack. Der Entwicklungsname ist jetzt leistungsbilanz-dev.

Produktionsdeployment

docker compose -f compose.prod.yaml up --build --detach
docker compose -f compose.prod.yaml ps
docker compose -f compose.prod.yaml logs --follow

Das Frontend hört danach auf Port 3090. Die API ist nur innerhalb des Compose-Netzes erreichbar.

Das Produktionsimage läuft als Benutzer node statt als root. Wenn ein bestehendes Datenvolume von einem früheren root-Container beschrieben wurde, müssen dessen Dateien einmalig übereignet werden, sonst schlagen Schreibzugriffe mit SQLITE_READONLY fehl:

docker run --rm -v leistungsbilanz_leistungsbilanz-data:/data alpine \
  chown -R 1000:1000 /data

Ein bestehendes Ein-Container-Deployment über scripts/docker-start.sh wird abgelöst, indem dessen Stack gestoppt und compose.prod.yaml mit demselben Projektnamen leistungsbilanz gestartet wird; das Volume bleibt dabei erhalten.

Logging

Beide Dienste schreiben strukturierte, einzeilige JSON-Log-Zeilen nach stdout/stderr (docker compose logs --follow). Jede Zeile enthält timestamp, level, scope und message. compose.yaml konfiguriert für beide Dienste den json-file-Treiber mit Rotation (max-size: 20m, max-file: 10, also bis zu 200 MB je Dienst); ohne diese Einstellung würde Docker mit der Standardkonfiguration unbegrenzt in eine einzelne Datei unter /var/lib/docker/containers/<container-id>/ schreiben. Die Logs überleben einen Container-Neustart (docker compose restart), aber nicht das Entfernen des Containers (docker compose down gefolgt von up erzeugt neue Container und damit neue, leere Logdateien); für ein echtes Langzeitarchiv über Rebuilds hinweg müssten die Zeilen zusätzlich in eine Datei im gemounteten ./data-Verzeichnis oder an ein externes Log-System geschrieben werden. Die Express-API protokolliert jede abgeschlossene Anfrage (Methode, Pfad, Status, Dauer; /health wird nicht mitgeloggt) sowie unbehandelte Exceptions/Promise-Rejections. Das Next.js-Frontend protokolliert Seitenanfragen (Navigation) über src/proxy.ts und unbehandelte Fehler über src/instrumentation.ts. Beide Prozesse schreiben zusätzlich alle fünf Minuten einen verbose-Heartbeat mit Laufzeit und Speicherverbrauch nützlich, um Speicherlecks oder Hänger vor einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche LOG_LEVEL=debug setzen; das protokolliert zusätzlich den Start jeder API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar. Ein close-Ereignis ohne vorheriges finish wird als request aborted before response finished (warn) geloggt und zeigt damit vom Client oder einem vorgeschalteten Proxy abgebrochene Verbindungen.

Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet den jeweiligen Prozess anschließend bewusst (process.exit(1)), statt in einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit restart: unless-stopped, damit Docker sie danach automatisch neu startet; ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.

Anfragen von Healthcheck- und Monitoring-Clients (node, curl, wget, …) werden anhand des User-Agents aus dem Navigationslog gefiltert und /health läuft gar nicht erst durch src/proxy.ts.

Herunterfahren

SIGTERM und SIGINT schließen den HTTP-Server, danach das SQLite-Handle, und beenden den Prozess mit Code 0. Kommt der Server binnen 15 Sekunden nicht herunter, beendet sich der Prozess trotzdem. Vorher wurde das Signal nur geloggt: jeder docker stop lief in die Grace Period und endete mit SIGKILL, möglicherweise mitten in einem Schreibvorgang. stop_grace_period steht in beiden Compose-Dateien auf 20 s und liegt damit über dem internen Timeout.

Offene Punkte für einen vollwertigen Produktionsbetrieb

Umgesetzt:

  • reproduzierbares Produktionsimage und Next.js-Produktionsstart
  • kontrollierter Migrationsschritt vor dem API-Start
  • strukturierte Logs mit Rotation, Healthchecks, Speicherlimits
  • definiertes Herunterfahren

Weiterhin offen:

  • TLS-Termination und Reverse Proxy
  • Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
  • definierte Secrets- und Konfigurationsverwaltung
  • Alarmierung auf Basis der Healthchecks
  • getestete Backup-, Restore- und Rollback-Prozeduren
  • Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für Mehrbenutzerbetrieb erforderlich ist

Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy.

Backup und Wiederherstellung

npm run db:backup erzeugt unter data/backups/ ein konsistentes SQLite-Backup, öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.

Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei gestoppter API, gegen eine separate Datei und nach erneuter Integritätsprüfung erfolgen.