forked from jappel/leistungsbilanz-ts
Harden the container runtime and shut down cleanly
Running the stack on built output stopped the idle CPU load, but the remaining findings from the deployment report were still open. The Dockerfile is now multi-stage. The prod target runs on node:24-slim as the "node" user instead of root and carries only its runtime dependencies; "npm prune --omit=dev" drops tsx, drizzle-kit and typescript. The migration scripts need drizzle-orm and better-sqlite3, both regular dependencies, so they keep working. The dev target keeps devDependencies for compose.dev.yaml. Both compose files get a mem_limit. The server stack also sets memswap_limit to the same value, so a leak lets the OOM killer replace the container instead of pushing the host into swap. The Express API is no longer published on the host: it has no authentication and is reached through the Next.js rewrite. Development ports are bound to the loopback interface. Services drop all capabilities and run with no-new-privileges. SIGTERM and SIGINT now close the HTTP server and the SQLite handle and exit, instead of only logging the signal and waiting for the container's SIGKILL ten seconds later. server.closeIdleConnections() releases the keep-alive sockets the Next.js server holds, which would otherwise keep server.close() pending for the whole grace period. The compose start commands exec into node, because otherwise sh stays the foreground process and the signal never reaches it. Delete scripts/docker-start.sh rather than fixing its unsupervised background API: both compose stacks set their own command, so the single-container entrypoint has no caller left. Also remove drizzle.config.js (a stray tsc artifact drizzle-kit never reads) and the pre-Next index.html and styles.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
204499d7e4
commit
2e3e85a8e5
13 changed files with 238 additions and 121 deletions
|
|
@ -6,3 +6,6 @@ data
|
||||||
*.db
|
*.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image.
|
||||||
|
.codex
|
||||||
|
.agents
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
||||||
|
/drizzle.config.js
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
|
|
|
||||||
57
Dockerfile
57
Dockerfile
|
|
@ -1,21 +1,56 @@
|
||||||
FROM node:24
|
# syntax=docker/dockerfile:1
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deps: vollstaendiger Abhaengigkeitsbaum, gemeinsam genutzt von "dev" und
|
||||||
|
# "build". Bleibt auf dem vollen node-Image, weil better-sqlite3 beim
|
||||||
|
# Installieren eine Toolchain braucht.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:24 AS deps
|
||||||
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# dev: Ziel fuer compose.dev.yaml. Behaelt devDependencies, damit tsx,
|
||||||
|
# drizzle-kit und "next dev" verfuegbar sind. Die Quellen kommen zur Laufzeit
|
||||||
|
# ueber Bind-Mounts, das COPY hier ist nur der Ausgangsstand.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM deps AS dev
|
||||||
|
ENV NODE_ENV=development
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN mkdir -p data
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
CMD ["npm", "run", "dev:api"]
|
||||||
|
|
||||||
# next build writes the rewrite destinations from next.config.mjs into
|
# ---------------------------------------------------------------------------
|
||||||
# .next/routes-manifest.json, so "next start" cannot pick up a different
|
# build: API nach dist/ und Frontend nach .next/ uebersetzen, danach die
|
||||||
# API URL later. The value has to be known here, not just at runtime.
|
# devDependencies entfernen, damit das Laufzeit-Image nur noch traegt, was es
|
||||||
|
# auch ausfuehrt. Die Migrationsskripte kommen mit drizzle-orm und
|
||||||
|
# better-sqlite3 aus, beides sind regulaere dependencies.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM deps AS build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# next build schreibt die Rewrite-Ziele aus next.config.mjs fest in
|
||||||
|
# .next/routes-manifest.json, "next start" kann eine andere API-Adresse
|
||||||
|
# spaeter nicht mehr uebernehmen. Der Wert muss hier bekannt sein, nicht erst
|
||||||
|
# zur Laufzeit. Server-Komponenten lesen die Variable zur Laufzeit.
|
||||||
ARG API_INTERNAL_URL=http://localhost:3000
|
ARG API_INTERNAL_URL=http://localhost:3000
|
||||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL
|
ENV API_INTERNAL_URL=$API_INTERNAL_URL
|
||||||
|
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: Laufzeit-Image. node:24-slim teilt sich das Debian-Release mit der
|
||||||
EXPOSE 3001
|
# build-Stufe, damit das kompilierte better-sqlite3-Binding ladbar bleibt.
|
||||||
|
# Laeuft als "node" (UID 1000) statt als root.
|
||||||
CMD ["sh", "scripts/docker-start.sh"]
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:24-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"]
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -50,12 +50,20 @@ docker compose up --build --detach
|
||||||
Danach:
|
Danach:
|
||||||
|
|
||||||
- Frontend: <http://localhost:3001>
|
- Frontend: <http://localhost:3001>
|
||||||
- API-Healthcheck: <http://localhost:3000/health>
|
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
||||||
|
|
||||||
|
Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine
|
||||||
|
Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der
|
||||||
|
Zugriff läuft über den Rewrite im Web-Dienst.
|
||||||
|
|
||||||
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
||||||
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
||||||
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
|
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
|
||||||
|
|
||||||
|
Das Laufzeit-Image läuft als Benutzer `node` (UID 1000), nicht als root. Ein
|
||||||
|
`data/`-Verzeichnis, das ein früherer root-Container angelegt hat, braucht
|
||||||
|
deshalb einmalig `sudo chown -R 1000:1000 data` auf dem Host.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs --follow
|
docker compose logs --follow
|
||||||
|
|
@ -63,7 +71,8 @@ docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
||||||
ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in
|
ohne Quellcode-Mounts und ohne Datei-Watcher, mit Speichergrenzen und ohne
|
||||||
|
root-Rechte im Container. Details stehen in
|
||||||
[Deployment und Betrieb](docs/deployment.md).
|
[Deployment und Betrieb](docs/deployment.md).
|
||||||
|
|
||||||
Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit
|
Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
# Development stack: source mounts, watching dev servers, hot reload.
|
# Entwicklungs-Stack: Quellcode-Mounts, laufende Dev-Server, Hot Reload.
|
||||||
# docker compose -f compose.dev.yaml up --build
|
# docker compose -f compose.dev.yaml up --build
|
||||||
#
|
#
|
||||||
# The polling watchers below are needed for bind mounts on Windows and
|
# Die Polling-Watcher unten sind fuer Bind-Mounts unter Windows und macOS
|
||||||
# macOS, where inotify events do not cross the VM boundary. They cost
|
# noetig, weil inotify-Events die VM-Grenze nicht passieren. Sie kosten
|
||||||
# continuous CPU, which is why the production stack in compose.yaml does
|
# dauerhaft CPU; genau deshalb laufen im Server-Stack (compose.yaml) gar
|
||||||
# not run watchers at all.
|
# keine Watcher.
|
||||||
|
#
|
||||||
|
# Der Projektname muss sich von dem des Server-Stacks unterscheiden, damit
|
||||||
|
# "docker compose down" in diesem Verzeichnis niemals einen laufenden
|
||||||
|
# Server-Stack abraeumen kann.
|
||||||
name: leistungsbilanz-dev
|
name: leistungsbilanz-dev
|
||||||
|
|
||||||
x-logging: &logging
|
x-service-defaults: &service-defaults
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 20s
|
||||||
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
|
|
@ -15,21 +23,25 @@ x-logging: &logging
|
||||||
|
|
||||||
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 && exec npm run dev:api
|
||||||
environment:
|
environment:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
init: true
|
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf
|
||||||
restart: unless-stopped
|
# nicht aus dem LAN erreichbar sein.
|
||||||
logging: *logging
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
|
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
||||||
|
# Puffer erwuenscht, die Grenze soll nur den Host vor Dauerlast schuetzen.
|
||||||
|
mem_limit: 1g
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
- ./scripts:/app/scripts
|
- ./scripts:/app/scripts
|
||||||
|
|
@ -41,15 +53,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=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
|
<<: *service-defaults
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
target: dev
|
||||||
command:
|
command:
|
||||||
- npm
|
- npm
|
||||||
- run
|
- run
|
||||||
|
|
@ -62,14 +76,14 @@ services:
|
||||||
WATCHPACK_POLLING: "true"
|
WATCHPACK_POLLING: "true"
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
logging: *logging
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "127.0.0.1:3001:3001"
|
||||||
|
# "next dev" haelt Kompilierzustand und waechst im Dauerbetrieb stetig;
|
||||||
|
# die Grenze macht daraus einen Container-Neustart statt Host-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
|
||||||
|
|
@ -80,7 +94,7 @@ services:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
|
||||||
57
compose.yaml
57
compose.yaml
|
|
@ -1,12 +1,31 @@
|
||||||
|
# Server-Stack: gebauter Stand, keine Quellcode-Mounts, keine Datei-Watcher.
|
||||||
|
# docker compose up --build --detach
|
||||||
|
#
|
||||||
|
# Fuer die Entwicklung mit Hot Reload compose.dev.yaml verwenden.
|
||||||
name: leistungsbilanz
|
name: leistungsbilanz
|
||||||
|
|
||||||
x-build: &build
|
x-build: &build
|
||||||
context: .
|
context: .
|
||||||
|
target: prod
|
||||||
args:
|
args:
|
||||||
# Baked into .next/routes-manifest.json by next build; see Dockerfile.
|
# Wird von next build in .next/routes-manifest.json eingebacken, siehe
|
||||||
|
# Dockerfile. Muss der API-Adresse in diesem Netz entsprechen.
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
|
||||||
x-logging: &logging
|
x-service-defaults: &service-defaults
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
# Der API-Prozess beendet HTTP-Server und SQLite-Handle auf SIGTERM; die
|
||||||
|
# Frist gibt laufenden Requests Zeit, statt sie nach 10s in ein SIGKILL
|
||||||
|
# laufen zu lassen.
|
||||||
|
stop_grace_period: 20s
|
||||||
|
# Das Image laeuft als "node" (UID 1000); zusaetzliche Rechte braucht
|
||||||
|
# weder der API- noch der Web-Prozess.
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
|
|
@ -14,20 +33,27 @@ x-logging: &logging
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
|
<<: *service-defaults
|
||||||
build: *build
|
build: *build
|
||||||
|
# "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und
|
||||||
|
# SIGTERM erreicht node nie.
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
|
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung
|
||||||
restart: unless-stopped
|
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht.
|
||||||
logging: *logging
|
expose:
|
||||||
ports:
|
- "3000"
|
||||||
- "3000:3000"
|
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
||||||
|
# schaltet Swap fuer den Container ab, damit ein Leck den Container trifft
|
||||||
|
# und nicht den Host.
|
||||||
|
mem_limit: 512m
|
||||||
|
memswap_limit: 512m
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -35,38 +61,43 @@ 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=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
|
<<: *service-defaults
|
||||||
build: *build
|
build: *build
|
||||||
command:
|
command:
|
||||||
- node_modules/.bin/next
|
- node_modules/.bin/next
|
||||||
- start
|
- start
|
||||||
- -p
|
- -p
|
||||||
- "3001"
|
- "3001"
|
||||||
|
- --hostname
|
||||||
|
- 0.0.0.0
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
logging: *logging
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
mem_limit: 1g
|
||||||
|
memswap_limit: 1g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
# /web-health statt /health: next.config.mjs leitet /health an die API
|
||||||
|
# weiter, und "/" wuerde wegen des Redirects auf /projects bei jeder
|
||||||
|
# Probe zwei Seiten rendern.
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ Es gibt zwei Compose-Stacks.
|
||||||
|
|
||||||
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
||||||
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
||||||
für einen Server.
|
für einen Server. Beide Dienste laufen aus der `prod`-Stufe des Dockerfiles,
|
||||||
|
also als Benutzer `node` statt als root, ohne devDependencies, mit
|
||||||
|
`cap_drop: ALL`, `no-new-privileges` und harten Speichergrenzen.
|
||||||
|
|
||||||
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
|
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
|
||||||
Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows
|
Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows
|
||||||
|
|
@ -20,11 +22,37 @@ erreichbar gemacht werden.
|
||||||
|
|
||||||
## Topologie
|
## Topologie
|
||||||
|
|
||||||
| Komponente | Port | Healthcheck | Persistenz |
|
| Komponente | Port | Auf dem Host | Healthcheck | Persistenz |
|
||||||
| --- | ---: | --- | --- |
|
| --- | ---: | --- | --- | --- |
|
||||||
| Next.js Web | 3001 | `GET /web-health` | keine |
|
| Next.js Web | 3001 | `3001` (Server) / `127.0.0.1:3001` (Dev) | `GET /web-health` | keine |
|
||||||
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` |
|
||||||
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||||
|
|
||||||
|
Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht
|
||||||
|
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie
|
||||||
|
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
|
||||||
|
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
||||||
|
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
||||||
|
|
||||||
|
### Speichergrenzen
|
||||||
|
|
||||||
|
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
||||||
|
`memswap_limit` auf denselben Wert gesetzt, womit Swap für den Container
|
||||||
|
abgeschaltet ist: ein Speicherleck lässt dann den Container am OOM-Killer
|
||||||
|
sterben und `restart: unless-stopped` greift, statt dass der Host in den Swap
|
||||||
|
läuft. Aktuell 512 MB für die API und 1 GB für das Frontend. Der
|
||||||
|
Entwicklungs-Stack bekommt 1 GB bzw. 2 GB und behält Swap, weil `next dev`
|
||||||
|
Kompilierzustand hält und im Dauerbetrieb stetig wächst.
|
||||||
|
|
||||||
|
### Rechte im Container
|
||||||
|
|
||||||
|
Das Laufzeit-Image läuft als `node` (UID 1000). Ein `data/`-Verzeichnis, das
|
||||||
|
ein früherer root-Container angelegt hat, ist dann nicht beschreibbar und
|
||||||
|
braucht einmalig auf dem Host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R 1000:1000 data
|
||||||
|
```
|
||||||
|
|
||||||
Verwendete Umgebungsvariablen:
|
Verwendete Umgebungsvariablen:
|
||||||
|
|
||||||
|
|
@ -83,11 +111,25 @@ 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.
|
||||||
|
|
||||||
|
## Herunterfahren
|
||||||
|
|
||||||
|
`SIGTERM` und `SIGINT` schließen in der API den HTTP-Server und anschließend
|
||||||
|
das SQLite-Handle und beenden den Prozess. Offene Keep-Alive-Verbindungen –
|
||||||
|
vor allem die des Next.js-Servers – werden dabei über
|
||||||
|
`server.closeIdleConnections()` freigegeben, laufende Requests dürfen zu Ende
|
||||||
|
laufen. Vorher wurde das Signal nur geloggt, der Prozess lief weiter und
|
||||||
|
Docker beendete ihn nach zehn Sekunden mit `SIGKILL`, mitten in einem
|
||||||
|
möglichen Schreibvorgang. Greift der Abbau nicht innerhalb von 15 Sekunden,
|
||||||
|
beendet sich der Prozess selbst mit Exit-Code 1; `stop_grace_period: 20s` in
|
||||||
|
beiden Compose-Dateien liegt bewusst darüber.
|
||||||
|
|
||||||
|
Die Startbefehle in `compose.yaml` enden mit `exec node …`. Ohne das `exec`
|
||||||
|
bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
|
||||||
|
|
||||||
## Voraussetzungen für ein späteres Produktionssetup
|
## Voraussetzungen für ein späteres Produktionssetup
|
||||||
|
|
||||||
Vor einer produktiven Installation werden mindestens benötigt:
|
Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
|
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Running migrations..."
|
|
||||||
node scripts/run-migrations.js
|
|
||||||
|
|
||||||
echo "Starting API server on :3000..."
|
|
||||||
node dist/server/index.js &
|
|
||||||
|
|
||||||
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
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Starting Next.js on :3001..."
|
|
||||||
exec node_modules/.bin/next start -p 3001
|
|
||||||
|
|
@ -7,3 +7,10 @@ const defaultDatabaseContext = createDatabaseContext(
|
||||||
|
|
||||||
export const db = defaultDatabaseContext.db;
|
export const db = defaultDatabaseContext.db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schliesst das SQLite-Handle des Prozesses. Nur beim Herunterfahren
|
||||||
|
* aufrufen: danach schlaegt jede Abfrage ueber {@link db} fehl.
|
||||||
|
*/
|
||||||
|
export function closeDefaultDatabase(): void {
|
||||||
|
defaultDatabaseContext.close();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ 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();
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||||
|
const shutdownTimeoutMs = 15 * 1000;
|
||||||
|
|
||||||
app.use(express.json({ limit: "25mb" }));
|
app.use(express.json({ limit: "25mb" }));
|
||||||
|
|
||||||
|
|
@ -62,9 +64,6 @@ process.on("unhandledRejection", (reason) => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => logger.info("received SIGTERM"));
|
|
||||||
process.on("SIGINT", () => logger.info("received SIGINT"));
|
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const memory = process.memoryUsage();
|
const memory = process.memoryUsage();
|
||||||
logger.verbose("heartbeat", {
|
logger.verbose("heartbeat", {
|
||||||
|
|
@ -74,6 +73,40 @@ setInterval(() => {
|
||||||
});
|
});
|
||||||
}, heartbeatIntervalMs).unref();
|
}, heartbeatIntervalMs).unref();
|
||||||
|
|
||||||
app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
logger.info("server started", { port });
|
logger.info("server started", { port });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
function shutdown(signal: NodeJS.Signals): void {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
logger.info("shutting down", { signal });
|
||||||
|
|
||||||
|
// Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der
|
||||||
|
// Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten.
|
||||||
|
const forceExit = setTimeout(() => {
|
||||||
|
logger.error("shutdown timed out, forcing exit");
|
||||||
|
process.exit(1);
|
||||||
|
}, shutdownTimeoutMs);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
|
||||||
|
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
|
||||||
|
server.closeIdleConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
|
||||||
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