From 80890a5fc38f776ff5616514231454e24123fa29 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 26 Aug 2026 17:43:49 +0200 Subject: [PATCH] feat: PostgreSQL als StatefulSet im Kubernetes-Cluster deployen Architektur-Entscheidung geaendert: PostgreSQL laeuft jetzt selbst im Cluster statt auf einer bestehenden externen Instanz. Neu: deploy/k8s/postgres/ mit StatefulSet (postgres:18-alpine, volumeClaimTemplates, pg_isready-Probes), headless Service (apicore-postgres) und Secret-Vorlage. Backend-DATABASE_URL zeigt auf den neuen In-Cluster-Service. CLAUDE.md/ARCHITECTURE.md aktualisiert: Backup-Verantwortung liegt jetzt beim Projekt, nicht mehr beim externen Cluster-/DBA-Betrieb (Backup-CronJob als offener Punkt in TASKS.md ergaenzt). --- ARCHITECTURE.md | 8 ++-- CLAUDE.md | 2 +- TASKS.md | 1 + deploy/k8s/backend/secret.example.yaml | 5 ++- deploy/k8s/postgres/secret.example.yaml | 11 +++++ deploy/k8s/postgres/service.yaml | 11 +++++ deploy/k8s/postgres/statefulset.yaml | 57 +++++++++++++++++++++++++ 7 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 deploy/k8s/postgres/secret.example.yaml create mode 100644 deploy/k8s/postgres/service.yaml create mode 100644 deploy/k8s/postgres/statefulset.yaml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cf916f2..eea90d4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,14 +101,16 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige `deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`). +`deploy/k8s/postgres/` enthält das PostgreSQL-`StatefulSet` (ein Replica, `volumeClaimTemplates` für persistente Daten, `postgres:18-alpine`), den zugehörigen headless `Service` (`apicore-postgres`, DNS-Name fürs Backend) sowie `secret.example.yaml` für `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB`. `deploy/k8s/backend/secret.example.yaml`s `DATABASE_URL` zeigt auf diesen Service. + ## 8. Backup & Restore **Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. `migrate deploy` läuft als eigener Schritt vor dem Rollout (z. B. Kubernetes-`Job` oder manueller Schritt in der Deploy-Pipeline), nicht im `CMD` des Runtime-Images, damit mehrere Pod-Replicas nicht gleichzeitig Migrationen ausführen. -**Datensicherung:** Die PostgreSQL-Instanz selbst ist bestehende Cluster-Infrastruktur (siehe CLAUDE.md, "keine eigene DB-Infrastruktur"); Backup-Ausführung liegt beim Cluster-/DBA-Betrieb. Empfohlene Strategie dort: +**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie: -- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), Aufbewahrung nach Retention-Policy des Clusters. -- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls vom Cluster unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps. +- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), z. B. als Kubernetes-`CronJob` gegen den `apicore-postgres`-Service, Ablage auf einem separaten PVC oder Objektspeicher außerhalb des Postgres-eigenen Volumes. +- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls die Storage-Class/das Backup-Tooling das unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps. - Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`). **Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird. diff --git a/CLAUDE.md b/CLAUDE.md index e9c2414..69fa2e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Ein Git-Repo für beide Projekte gemeinsam, Root ist `ApiCore/`. Feature-Branche **Frontend** (`ApiCore-Frontend/`): Next.js (App Router), TypeScript, Tailwind CSS, shadcn/ui, TanStack Query, Vitest + React Testing Library für Tests. Mobile-first, PWA-Manifest von Anfang an. -**Betrieb**: Beide Projekte containerisiert, bestehendes Kubernetes-Cluster, bestehende PostgreSQL-Instanz (keine eigene DB-Infrastruktur, kein Managed-Service). +**Betrieb**: Beide Projekte containerisiert, bestehendes Kubernetes-Cluster. PostgreSQL läuft selbst im Cluster als `StatefulSet` (`deploy/k8s/postgres/`, kein externer Managed-Service) – Backup-Verantwortung liegt damit bei diesem Projekt, siehe ARCHITECTURE.md Abschnitt 8. ## Lokal starten diff --git a/TASKS.md b/TASKS.md index cbb1221..58e7bc8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -147,6 +147,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - [x] Prisma-Migrationen reproduzierbar (kein `db push` in Produktion, nur `migrate deploy`) - [x] Backup-/Restore-Strategie in ARCHITECTURE.md dokumentiert - [x] `/health/ready` prüft DB-Erreichbarkeit +- [ ] Backup-CronJob (`pg_dump -Fc` gegen `apicore-postgres`) im Cluster einrichten, seit PostgreSQL selbst gehostet wird (`deploy/k8s/postgres/`), siehe ARCHITECTURE.md Abschnitt 8 ### 21. Datenexport diff --git a/deploy/k8s/backend/secret.example.yaml b/deploy/k8s/backend/secret.example.yaml index ff6af45..7bc2710 100644 --- a/deploy/k8s/backend/secret.example.yaml +++ b/deploy/k8s/backend/secret.example.yaml @@ -6,6 +6,9 @@ metadata: name: apicore-backend-secret type: Opaque stringData: - DATABASE_URL: "postgresql://user:password@host:5432/apicore?schema=public" + # Host ist der In-Cluster-Service aus deploy/k8s/postgres/service.yaml. + # Anderer Namespace als das Backend? Dann FQDN nutzen: + # apicore-postgres..svc.cluster.local + DATABASE_URL: "postgresql://apicore:change-me-postgres-password@apicore-postgres:5432/apicore?schema=public" JWT_ACCESS_SECRET: "change-me-access-secret" JWT_REFRESH_SECRET: "change-me-refresh-secret" diff --git a/deploy/k8s/postgres/secret.example.yaml b/deploy/k8s/postgres/secret.example.yaml new file mode 100644 index 0000000..668740e --- /dev/null +++ b/deploy/k8s/postgres/secret.example.yaml @@ -0,0 +1,11 @@ +# Vorlage – keine echten Werte. Kopieren, echte Werte setzen, per `kubectl apply` außerhalb +# der Versionskontrolle anwenden (oder ueber Secret-Manager/Sealed-Secrets im Cluster). +apiVersion: v1 +kind: Secret +metadata: + name: apicore-postgres-secret +type: Opaque +stringData: + POSTGRES_USER: "apicore" + POSTGRES_PASSWORD: "change-me-postgres-password" + POSTGRES_DB: "apicore" diff --git a/deploy/k8s/postgres/service.yaml b/deploy/k8s/postgres/service.yaml new file mode 100644 index 0000000..90c0849 --- /dev/null +++ b/deploy/k8s/postgres/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: apicore-postgres +spec: + clusterIP: None + selector: + app: apicore-postgres + ports: + - port: 5432 + targetPort: 5432 diff --git a/deploy/k8s/postgres/statefulset.yaml b/deploy/k8s/postgres/statefulset.yaml new file mode 100644 index 0000000..a43e0bc --- /dev/null +++ b/deploy/k8s/postgres/statefulset.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: apicore-postgres +spec: + serviceName: apicore-postgres + replicas: 1 + selector: + matchLabels: + app: apicore-postgres + template: + metadata: + labels: + app: apicore-postgres + spec: + containers: + - name: postgres + image: postgres:18-alpine + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: apicore-postgres-secret + env: + # Unterordner statt Mount-Root: Postgres' initdb verlangt ein + # leeres Datenverzeichnis, der Mount-Root von PersistentVolumes + # enthaelt aber oft ein verstecktes "lost+found"-Verzeichnis. + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + livenessProbe: + exec: + command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"] + initialDelaySeconds: 30 + periodSeconds: 15 + readinessProbe: + exec: + command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"] + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi