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:
Julian Appel 2026-09-16 11:04:34 +02:00
parent 204499d7e4
commit 2e3e85a8e5
13 changed files with 238 additions and 121 deletions

View file

@ -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
View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",
},
});

View file

@ -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>

View file

@ -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

View file

@ -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();
}

View file

@ -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);

View file

@ -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;
}