diff --git a/.dockerignore b/.dockerignore index 80054d0..abd6bc2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,6 @@ data *.db .env .env.* +# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image. +.codex +.agents diff --git a/.gitignore b/.gitignore index ed5ffe6..e0ef1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ .next/ +# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. +/drizzle.config.js data/*.db data/backups/*.db .codex/*.log diff --git a/Dockerfile b/Dockerfile index 31d28c4..66147e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,56 @@ -FROM node:24 -WORKDIR /app +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# 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 ./ 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 . . +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 -# API URL later. The value has to be known here, not just at runtime. +# --------------------------------------------------------------------------- +# build: API nach dist/ und Frontend nach .next/ uebersetzen, danach die +# 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 ENV API_INTERNAL_URL=$API_INTERNAL_URL - +COPY . . RUN npm run build:api && npm run build:web +RUN npm prune --omit=dev -RUN mkdir -p data && chmod +x scripts/docker-start.sh - -EXPOSE 3001 - -CMD ["sh", "scripts/docker-start.sh"] +# --------------------------------------------------------------------------- +# prod: Laufzeit-Image. node:24-slim teilt sich das Debian-Release mit der +# build-Stufe, damit das kompilierte better-sqlite3-Binding ladbar bleibt. +# Laeuft als "node" (UID 1000) statt als root. +# --------------------------------------------------------------------------- +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"] diff --git a/README.md b/README.md index fc82fca..58f1832 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,20 @@ docker compose up --build --detach Danach: - Frontend: -- API-Healthcheck: +- API-Healthcheck: (ü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 automatisch aus. Die SQLite-Datei liegt auf dem Host unter `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 docker compose ps docker compose logs --follow @@ -63,7 +71,8 @@ docker compose down ``` `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). Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit diff --git a/compose.dev.yaml b/compose.dev.yaml index 86e42e2..b9c64b4 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,35 +1,47 @@ -# 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 # -# The polling watchers below are needed for bind mounts on Windows and -# macOS, where inotify events do not cross the VM boundary. They cost -# continuous CPU, which is why the production stack in compose.yaml does -# not run watchers at all. +# Die Polling-Watcher unten sind fuer Bind-Mounts unter Windows und macOS +# noetig, weil inotify-Events die VM-Grenze nicht passieren. Sie kosten +# dauerhaft CPU; genau deshalb laufen im Server-Stack (compose.yaml) gar +# 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 -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" +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: dev command: - sh - -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: PORT: "3000" CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging + # Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf + # nicht aus dem LAN erreichbar sein. 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: - ./src:/app/src - ./scripts:/app/scripts @@ -41,15 +53,17 @@ services: - CMD - node - -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 timeout: 3s retries: 5 start_period: 20s web: + <<: *service-defaults build: context: . + target: dev command: - npm - run @@ -62,14 +76,14 @@ services: WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging depends_on: api: condition: service_healthy 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: - ./src:/app/src - ./next.config.mjs:/app/next.config.mjs:ro @@ -80,7 +94,7 @@ services: - CMD - node - -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 timeout: 3s retries: 5 diff --git a/compose.yaml b/compose.yaml index 503a4b2..3438f90 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,33 +1,59 @@ +# 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 x-build: &build context: . + target: prod 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 -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" +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 + options: + max-size: "20m" + max-file: "10" services: api: + <<: *service-defaults build: *build + # "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und + # SIGTERM erreicht node nie. command: - sh - -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: NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: *logging - ports: - - "3000:3000" + # Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung + # und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. + expose: + - "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: - ./data:/app/data healthcheck: @@ -35,38 +61,43 @@ services: - CMD - node - -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 timeout: 3s retries: 5 start_period: 20s web: + <<: *service-defaults build: *build command: - node_modules/.bin/next - start - -p - "3001" + - --hostname + - 0.0.0.0 environment: NODE_ENV: production API_INTERNAL_URL: http://api:3000 NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: *logging depends_on: api: condition: service_healthy ports: - "3001:3001" + mem_limit: 1g + memswap_limit: 1g 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: - CMD - node - -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 timeout: 3s retries: 5 diff --git a/docs/deployment.md b/docs/deployment.md index 06289a7..909c2c0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,7 +6,9 @@ Es gibt zwei Compose-Stacks. `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 -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 Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows @@ -20,11 +22,37 @@ erreichbar gemacht werden. ## Topologie -| Komponente | Port | Healthcheck | Persistenz | -| --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /web-health` | keine | -| Express API | 3000 | `GET /health` | `./data:/app/data` | -| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | +| Komponente | Port | Auf dem Host | Healthcheck | Persistenz | +| --- | ---: | --- | --- | --- | +| Next.js Web | 3001 | `3001` (Server) / `127.0.0.1:3001` (Dev) | `GET /web-health` | keine | +| 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` | + +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: @@ -83,11 +111,25 @@ 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. +## 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 Vor einer produktiven Installation werden mindestens benötigt: -- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript - TLS-Termination und Reverse Proxy - Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung diff --git a/drizzle.config.js b/drizzle.config.js deleted file mode 100644 index 004045a..0000000 --- a/drizzle.config.js +++ /dev/null @@ -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", - }, -}); diff --git a/index.html b/index.html deleted file mode 100644 index dcb632f..0000000 --- a/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Leistungsbilanz API - - - -
-

Leistungsbilanz Backend

-

Der aktuelle Stand laeuft als TypeScript Node/SQLite API.

-

Serverstart: npm run dev

-

Healthcheck: GET /health

-
- - diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh deleted file mode 100644 index bf0d856..0000000 --- a/scripts/docker-start.sh +++ /dev/null @@ -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 diff --git a/src/db/client.ts b/src/db/client.ts index 02cfc7e..4d0c97b 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -7,3 +7,10 @@ const defaultDatabaseContext = createDatabaseContext( 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(); +} diff --git a/src/server/index.ts b/src/server/index.ts index 2f6ac5e..1f5c9b2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,11 +4,13 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; +import { closeDefaultDatabase } from "../db/client.js"; const logger = createLogger("api"); const app = express(); const port = Number(process.env.PORT || 3000); const heartbeatIntervalMs = 5 * 60 * 1000; +const shutdownTimeoutMs = 15 * 1000; app.use(express.json({ limit: "25mb" })); @@ -62,9 +64,6 @@ process.on("unhandledRejection", (reason) => { process.exit(1); }); -process.on("SIGTERM", () => logger.info("received SIGTERM")); -process.on("SIGINT", () => logger.info("received SIGINT")); - setInterval(() => { const memory = process.memoryUsage(); logger.verbose("heartbeat", { @@ -74,6 +73,40 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -app.listen(port, () => { +const server = app.listen(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); diff --git a/styles.css b/styles.css deleted file mode 100644 index 58c8f79..0000000 --- a/styles.css +++ /dev/null @@ -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; -}