forked from jappel/leistungsbilanz-ts
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>
This commit is contained in:
parent
a17e2e3f4b
commit
01fa527b9c
14 changed files with 424 additions and 118 deletions
2
.gitignore
vendored
Normal file → Executable file
2
.gitignore
vendored
Normal file → Executable file
|
|
@ -1,6 +1,8 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
# Stray tsc output; drizzle-kit reads drizzle.config.ts.
|
||||||
|
/drizzle.config.js
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
|
|
|
||||||
33
AGENTS.md
Normal file → Executable file
33
AGENTS.md
Normal file → Executable file
|
|
@ -531,13 +531,44 @@ Users must be able to override sizing suggestions.
|
||||||
- Preserve stable UUIDs and explicit transaction boundaries for a later
|
- Preserve stable UUIDs and explicit transaction boundaries for a later
|
||||||
PostgreSQL adapter.
|
PostgreSQL adapter.
|
||||||
|
|
||||||
|
## Deployment Rules
|
||||||
|
|
||||||
|
There are two compose files and they must stay separate.
|
||||||
|
|
||||||
|
- `compose.yaml` is development only. Project name `leistungsbilanz-dev`, ports
|
||||||
|
bound to `127.0.0.1`, watch-mode servers, source bind mounts.
|
||||||
|
- `compose.prod.yaml` is the deployment. Project name `leistungsbilanz`, image
|
||||||
|
target `prod`, compiled output, no bind mounts, no file watchers.
|
||||||
|
|
||||||
|
Rules that a running deployment depends on:
|
||||||
|
|
||||||
|
- Never run the development stack permanently. `next dev` and `tsx watch`
|
||||||
|
produce load and grow in memory with no user activity.
|
||||||
|
- Never reintroduce `CHOKIDAR_USEPOLLING` or `WATCHPACK_POLLING`. They make the
|
||||||
|
watcher poll the source tree continuously.
|
||||||
|
- Container healthchecks target `/health` only. `/` is a redirect to
|
||||||
|
`/projects`, and `fetch` follows redirects, so probing `/` renders a full
|
||||||
|
page on every interval.
|
||||||
|
- Every service keeps a `mem_limit`; production services also keep a
|
||||||
|
`memswap_limit` of the same size so a leak kills the container instead of the
|
||||||
|
host.
|
||||||
|
- The production image runs as `node`. Data volumes written by an earlier root
|
||||||
|
container need a one-time `chown` (see `docs/deployment.md`).
|
||||||
|
- The Express API is not published on the host. It has no authentication and is
|
||||||
|
reached through the Next.js rewrite.
|
||||||
|
- Development migrates with `drizzle-kit`; the production image has no
|
||||||
|
devDependencies and migrates with `node scripts/run-migrations.js`.
|
||||||
|
- `SIGTERM`/`SIGINT` must close the HTTP server and the SQLite handle and then
|
||||||
|
exit. Do not reduce these handlers to logging.
|
||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
|
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
|
||||||
`docs/spec/revit-csv-integration-requirements.md` is being followed
|
`docs/spec/revit-csv-integration-requirements.md` is being followed
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
- supported production deployment
|
- authentication, authorization and TLS termination; until they exist the
|
||||||
|
deployment belongs in a trusted network or behind an authenticating proxy
|
||||||
|
|
||||||
Do not implement these while working on an unrelated phase.
|
Do not implement these while working on an unrelated phase.
|
||||||
|
|
||||||
|
|
|
||||||
51
Dockerfile
Normal file → Executable file
51
Dockerfile
Normal file → Executable file
|
|
@ -1,14 +1,51 @@
|
||||||
FROM node:22
|
# syntax=docker/dockerfile:1
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deps: complete dependency tree, shared by the build and the dev target.
|
||||||
|
# Stays on the full node image because better-sqlite3 needs a toolchain.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:22 AS deps
|
||||||
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# dev: watch-mode target used by compose.yaml. Keeps devDependencies so
|
||||||
|
# tsx, drizzle-kit and next dev are available. Sources come from bind mounts.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM deps AS dev
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p data
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
CMD ["npm", "run", "dev:api"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build: compile the API to dist/ and the frontend to .next/, then drop
|
||||||
|
# devDependencies so the runtime stage carries only what it executes.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM deps AS build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Next.js resolves next.config.mjs rewrites at build time and writes the literal
|
||||||
|
# destination into .next/routes-manifest.json. Setting API_INTERNAL_URL only at
|
||||||
|
# runtime has no effect on `next start`, so the API address of the target
|
||||||
|
# topology has to be known here. Server components read the variable at runtime.
|
||||||
|
ARG API_INTERNAL_URL=http://localhost:3000
|
||||||
|
ENV API_INTERNAL_URL=$API_INTERNAL_URL
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build:api && npm run build:web
|
RUN npm run build:api && npm run build:web
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
# ---------------------------------------------------------------------------
|
||||||
|
# prod: runtime image. node:22-slim shares the Debian release of the build
|
||||||
EXPOSE 3001
|
# stage, so the compiled better-sqlite3 binding stays loadable.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
CMD ["sh", "scripts/docker-start.sh"]
|
FROM node:22-slim AS prod
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=node:node /app ./
|
||||||
|
RUN mkdir -p data && chown node:node data
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
CMD ["node", "dist/server/index.js"]
|
||||||
|
|
|
||||||
23
README.md
Normal file → Executable file
23
README.md
Normal file → Executable file
|
|
@ -4,8 +4,9 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||||
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
||||||
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||||
|
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
Das Projekt befindet sich in aktiver Entwicklung. Unterstützt sind der lokale
|
||||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
Entwicklungsbetrieb (`compose.yaml`) und ein Einzelbenutzer-Deployment im
|
||||||
|
vertrauenswürdigen Netz (`compose.prod.yaml`). Authentifizierung,
|
||||||
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||||
implementiert.
|
implementiert.
|
||||||
|
|
||||||
|
|
@ -62,9 +63,21 @@ docker compose logs --follow
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
`compose.yaml` startet Entwicklungsserver mit Quellcode-Mounts, veröffentlicht
|
||||||
Produktionsdeployment. Details stehen in
|
seine Ports nur auf `127.0.0.1` und ist **kein Produktionsdeployment**. Es
|
||||||
[Deployment und Betrieb](docs/deployment.md).
|
gehört nicht in den Dauerbetrieb: `next dev` und `tsx watch` erzeugen auch ohne
|
||||||
|
Benutzer dauerhaft Last und wachsen im Speicher.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.prod.yaml up --build --detach
|
||||||
|
```
|
||||||
|
|
||||||
|
Startet die kompilierte API und `next start` in getrennten Containern, mit
|
||||||
|
Speicherlimits und ohne Dateibeobachter. Das Frontend hört auf Port 3090, die
|
||||||
|
API ist nur intern erreichbar. Vorbereitung eines bestehenden Datenvolumes und
|
||||||
|
weitere Details stehen in [Deployment und Betrieb](docs/deployment.md).
|
||||||
|
|
||||||
## Direkte lokale Entwicklung
|
## Direkte lokale Entwicklung
|
||||||
|
|
||||||
|
|
|
||||||
102
compose.prod.yaml
Executable file
102
compose.prod.yaml
Executable file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Production deployment.
|
||||||
|
#
|
||||||
|
# Differences to compose.yaml that matter operationally:
|
||||||
|
# - runs compiled output (node dist/, next start) instead of watch mode
|
||||||
|
# - no source bind mounts and no file watchers, so an idle stack costs ~0% CPU
|
||||||
|
# - healthchecks hit the cheap /health JSON endpoint, not a rendered page
|
||||||
|
# - every service has a hard memory limit and may not spill into host swap
|
||||||
|
# - the Express API is reachable only inside the compose network
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f compose.prod.yaml up --build --detach
|
||||||
|
name: leistungsbilanz
|
||||||
|
|
||||||
|
x-service-defaults: &service-defaults
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "10"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
<<: *service-defaults
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: prod
|
||||||
|
args:
|
||||||
|
# Baked into the Next.js rewrite manifest; see the build stage in the
|
||||||
|
# Dockerfile. Must match the API service address in this network.
|
||||||
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- node scripts/run-migrations.js && exec node dist/server/index.js
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3000"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
|
# Not published on the host: the API has no authentication and is reached
|
||||||
|
# through the Next.js rewrite in the web service.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
mem_limit: 512m
|
||||||
|
memswap_limit: 512m
|
||||||
|
volumes:
|
||||||
|
- leistungsbilanz-data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
web:
|
||||||
|
<<: *service-defaults
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: prod
|
||||||
|
args:
|
||||||
|
# Baked into the Next.js rewrite manifest; see the build stage in the
|
||||||
|
# Dockerfile. Must match the API service address in this network.
|
||||||
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
command:
|
||||||
|
- node_modules/.bin/next
|
||||||
|
- start
|
||||||
|
- -p
|
||||||
|
- "3001"
|
||||||
|
- --hostname
|
||||||
|
- 0.0.0.0
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "3090:3001"
|
||||||
|
mem_limit: 1g
|
||||||
|
memswap_limit: 1g
|
||||||
|
healthcheck:
|
||||||
|
# /health is rewritten to the API, so this verifies the web process and
|
||||||
|
# its API connectivity for the cost of one JSON response.
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
leistungsbilanz-data:
|
||||||
70
compose.yaml
Normal file → Executable file
70
compose.yaml
Normal file → Executable file
|
|
@ -1,26 +1,39 @@
|
||||||
name: leistungsbilanz
|
# Local development only. Runs watch-mode servers against bind-mounted sources.
|
||||||
|
# For a deployment use compose.prod.yaml.
|
||||||
|
#
|
||||||
|
# The project name must stay distinct from the production project so that
|
||||||
|
# `docker compose down` in this directory can never tear down a running
|
||||||
|
# production stack.
|
||||||
|
name: leistungsbilanz-dev
|
||||||
|
|
||||||
|
x-service-defaults: &service-defaults
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "10"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
|
<<: *service-defaults
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
target: dev
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
||||||
environment:
|
environment:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
# Development ports stay on the loopback interface; nothing here is
|
||||||
restart: unless-stopped
|
# authenticated and the API must not be reachable from the LAN.
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
|
mem_limit: 1g
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
- ./scripts:/app/scripts
|
- ./scripts:/app/scripts
|
||||||
|
|
@ -32,15 +45,17 @@ services:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 30s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
|
<<: *service-defaults
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
target: dev
|
||||||
command:
|
command:
|
||||||
- npm
|
- npm
|
||||||
- run
|
- run
|
||||||
|
|
@ -50,33 +65,30 @@ services:
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
environment:
|
environment:
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
WATCHPACK_POLLING: "true"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "127.0.0.1:3001:3001"
|
||||||
|
# next dev grows steadily under long-running use; the limit turns that into
|
||||||
|
# a container restart instead of host-wide swapping.
|
||||||
|
mem_limit: 2g
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
- ./next.config.mjs:/app/next.config.mjs:ro
|
- ./next.config.mjs:/app/next.config.mjs:ro
|
||||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
# Must stay on /health: "/" redirects to /projects and Node's fetch
|
||||||
|
# follows redirects, which turned every probe into a full page render.
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 30s
|
||||||
|
|
|
||||||
135
docs/deployment.md
Normal file → Executable file
135
docs/deployment.md
Normal file → Executable file
|
|
@ -2,21 +2,40 @@
|
||||||
|
|
||||||
## Aktueller Status
|
## Aktueller Status
|
||||||
|
|
||||||
Es gibt derzeit kein unterstütztes Produktionsdeployment.
|
Es gibt zwei getrennte Compose-Dateien. Sie dürfen nicht verwechselt werden.
|
||||||
|
|
||||||
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
|
| Datei | Projektname | Zweck |
|
||||||
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
| --- | --- | --- |
|
||||||
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
| `compose.yaml` | `leistungsbilanz-dev` | ausschließlich lokale Entwicklung |
|
||||||
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
| `compose.prod.yaml` | `leistungsbilanz` | Dauerbetrieb im LAN |
|
||||||
öffentlich erreichbar gemacht werden.
|
|
||||||
|
|
||||||
## Entwicklungs-Topologie
|
`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.
|
||||||
|
|
||||||
| Komponente | Port | Healthcheck | Persistenz |
|
`compose.prod.yaml` startet kompilierten Code ohne Dateibeobachter. Es enthält
|
||||||
| --- | ---: | --- | --- |
|
weiterhin weder TLS, Authentifizierung noch ein Benutzer-/Rollenmodell; die
|
||||||
| Next.js Web | 3001 | `GET /` | keine |
|
Express-API wird deshalb nicht auf dem Host veröffentlicht, sondern nur über den
|
||||||
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
Next.js-Rewrite erreicht. Der Stack gehört hinter einen Reverse Proxy mit
|
||||||
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
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:
|
Verwendete Umgebungsvariablen:
|
||||||
|
|
||||||
|
|
@ -24,15 +43,66 @@ Verwendete Umgebungsvariablen:
|
||||||
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
||||||
`http://api:3000`
|
`http://api:3000`
|
||||||
- `NEXT_TELEMETRY_DISABLED=1`
|
- `NEXT_TELEMETRY_DISABLED=1`
|
||||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
|
- `NODE_ENV=production` – nur in `compose.prod.yaml`
|
||||||
Dateibeobachtung in Docker
|
|
||||||
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
||||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
`LOG_LEVEL=verbose docker compose up`.
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
|
|
||||||
Beim API-Start laufen zuerst `npm run db:migrate` und
|
Beim API-Start laufen in der Entwicklung zuerst `npm run db:migrate` und
|
||||||
`npm run db:verify:circuit-schema`.
|
`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
|
## Logging
|
||||||
|
|
||||||
|
|
@ -68,23 +138,40 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
|
||||||
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
|
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
|
||||||
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
|
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
|
||||||
|
|
||||||
## Voraussetzungen für ein späteres Produktionssetup
|
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`.
|
||||||
|
|
||||||
Vor einer produktiven Installation werden mindestens benötigt:
|
## 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:
|
||||||
|
|
||||||
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
|
|
||||||
- TLS-Termination und Reverse Proxy
|
- TLS-Termination und Reverse Proxy
|
||||||
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
||||||
- definierte Secrets- und Konfigurationsverwaltung
|
- definierte Secrets- und Konfigurationsverwaltung
|
||||||
- persistenter, gesicherter Datenbankbetrieb
|
- Alarmierung auf Basis der Healthchecks
|
||||||
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
|
||||||
- Monitoring, strukturierte Logs und Alarmierung
|
|
||||||
- getestete Backup-, Restore- und Rollback-Prozeduren
|
- getestete Backup-, Restore- und Rollback-Prozeduren
|
||||||
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
||||||
Mehrbenutzerbetrieb erforderlich ist
|
Mehrbenutzerbetrieb erforderlich ist
|
||||||
|
|
||||||
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein
|
||||||
lokalen Entwicklungsstart in der README.
|
vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy.
|
||||||
|
|
||||||
## Backup und Wiederherstellung
|
## Backup und Wiederherstellung
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
"use strict";
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
const drizzle_kit_1 = require("drizzle-kit");
|
|
||||||
exports.default = (0, drizzle_kit_1.defineConfig)({
|
|
||||||
dialect: "sqlite",
|
|
||||||
schema: "./src/db/schema/*.ts",
|
|
||||||
out: "./src/db/migrations",
|
|
||||||
dbCredentials: {
|
|
||||||
url: "./data/leistungsbilanz.db",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
17
index.html
17
index.html
|
|
@ -1,17 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Leistungsbilanz API</title>
|
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="info">
|
|
||||||
<h1>Leistungsbilanz Backend</h1>
|
|
||||||
<p>Der aktuelle Stand laeuft als TypeScript Node/SQLite API.</p>
|
|
||||||
<p>Serverstart: <code>npm run dev</code></p>
|
|
||||||
<p>Healthcheck: <code>GET /health</code></p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
30
scripts/docker-start.sh
Normal file → Executable file
30
scripts/docker-start.sh
Normal file → Executable file
|
|
@ -1,4 +1,7 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Legacy single-container entrypoint. Kept for the existing deployment that
|
||||||
|
# still runs both processes in one container; new deployments use
|
||||||
|
# compose.prod.yaml, which runs the API and the web server separately.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "Running migrations..."
|
echo "Running migrations..."
|
||||||
|
|
@ -6,11 +9,34 @@ node scripts/run-migrations.js
|
||||||
|
|
||||||
echo "Starting API server on :3000..."
|
echo "Starting API server on :3000..."
|
||||||
node dist/server/index.js &
|
node dist/server/index.js &
|
||||||
|
api_pid=$!
|
||||||
|
|
||||||
echo "Waiting for API..."
|
echo "Waiting for API..."
|
||||||
until node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; do
|
until node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; do
|
||||||
|
if ! kill -0 "$api_pid" 2>/dev/null; then
|
||||||
|
echo "API process exited during startup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Starting Next.js on :3001..."
|
echo "Starting Next.js on :3001..."
|
||||||
exec node_modules/.bin/next start -p 3001
|
node_modules/.bin/next start -p 3001 &
|
||||||
|
web_pid=$!
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
kill -TERM "$api_pid" "$web_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap terminate TERM INT
|
||||||
|
|
||||||
|
# Previously the API ran unsupervised in the background: when it died the
|
||||||
|
# container stayed "up" and served a frontend whose every request failed.
|
||||||
|
# Exiting here lets the restart policy replace the container instead.
|
||||||
|
while kill -0 "$api_pid" 2>/dev/null && kill -0 "$web_pid" 2>/dev/null; do
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "A server process exited; stopping the container."
|
||||||
|
terminate
|
||||||
|
wait "$api_pid" "$web_pid" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
|
|
||||||
8
src/db/client.ts
Normal file → Executable file
8
src/db/client.ts
Normal file → Executable file
|
|
@ -7,3 +7,11 @@ const defaultDatabaseContext = createDatabaseContext(
|
||||||
|
|
||||||
export const db = defaultDatabaseContext.db;
|
export const db = defaultDatabaseContext.db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the process-wide SQLite handle. Only the composition root calls this,
|
||||||
|
* during shutdown, so pending writes are flushed before the process exits.
|
||||||
|
*/
|
||||||
|
export function closeDefaultDatabase(): void {
|
||||||
|
defaultDatabaseContext.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
src/proxy.ts
Normal file → Executable file
11
src/proxy.ts
Normal file → Executable file
|
|
@ -4,10 +4,13 @@ import { createLogger } from "./shared/logging/logger";
|
||||||
|
|
||||||
const logger = createLogger("web:navigation");
|
const logger = createLogger("web:navigation");
|
||||||
|
|
||||||
|
// Node's fetch() sends "User-Agent: node", so the previous "no User-Agent"
|
||||||
|
// check never matched and every healthcheck was logged as page navigation.
|
||||||
|
const PROBE_USER_AGENT = /^(node|curl|wget|go-http-client|kube-probe)\b/i;
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
// The Docker healthcheck hits "/" every few seconds with no User-Agent
|
const userAgent = request.headers.get("user-agent") ?? "";
|
||||||
// header; skip it so real navigation isn't drowned out in the logs.
|
if (userAgent && !PROBE_USER_AGENT.test(userAgent)) {
|
||||||
if (request.headers.get("user-agent")) {
|
|
||||||
logger.info("page request", {
|
logger.info("page request", {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: request.nextUrl.pathname,
|
path: request.nextUrl.pathname,
|
||||||
|
|
@ -17,5 +20,5 @@ export function proxy(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|api|health).*)"],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
34
src/server/index.ts
Normal file → Executable file
34
src/server/index.ts
Normal file → Executable file
|
|
@ -4,6 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
import { projectRouter } from "./routes/project.routes.js";
|
import { projectRouter } from "./routes/project.routes.js";
|
||||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||||
|
import { closeDefaultDatabase } from "../db/client.js";
|
||||||
|
|
||||||
const logger = createLogger("api");
|
const logger = createLogger("api");
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -62,8 +63,35 @@ process.on("unhandledRejection", (reason) => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => logger.info("received SIGTERM"));
|
let shuttingDown = false;
|
||||||
process.on("SIGINT", () => logger.info("received SIGINT"));
|
|
||||||
|
function shutdown(signal: NodeJS.Signals): void {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
logger.info("shutting down", { signal });
|
||||||
|
|
||||||
|
// Without this the process only logged the signal and kept running, so every
|
||||||
|
// stop waited for Docker's grace period and ended in SIGKILL mid-write.
|
||||||
|
const forceExit = setTimeout(() => {
|
||||||
|
logger.warn("shutdown timed out, exiting anyway");
|
||||||
|
process.exit(1);
|
||||||
|
}, 15_000);
|
||||||
|
forceExit.unref();
|
||||||
|
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) logger.error("http server close failed", toErrorMeta(error));
|
||||||
|
try {
|
||||||
|
closeDefaultDatabase();
|
||||||
|
} catch (closeError) {
|
||||||
|
logger.error("database close failed", toErrorMeta(closeError));
|
||||||
|
}
|
||||||
|
logger.info("shutdown complete");
|
||||||
|
process.exit(error ? 1 : 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const memory = process.memoryUsage();
|
const memory = process.memoryUsage();
|
||||||
|
|
@ -74,6 +102,6 @@ setInterval(() => {
|
||||||
});
|
});
|
||||||
}, heartbeatIntervalMs).unref();
|
}, heartbeatIntervalMs).unref();
|
||||||
|
|
||||||
app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
logger.info("server started", { port });
|
logger.info("server started", { port });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
15
styles.css
15
styles.css
|
|
@ -1,15 +0,0 @@
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background: #f5f6f8;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
max-width: 760px;
|
|
||||||
margin: 40px auto;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue