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>
185 lines
8.9 KiB
Markdown
Executable file
185 lines
8.9 KiB
Markdown
Executable file
# 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](#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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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.
|