refactor(k8s): schlanker Umfang, Postgres und Medien auf SMB
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m17s

Auf Wunsch des Betreibers auf das Noetige reduziert: StatefulSet
PostgreSQL, Services, Ingress, App-Deployment, zwei SMB-PersistentVolumes
(Postgres-Daten, Medien). Beibehalten nach Absprache: Worker-CronJob
(Folgeraten, Reservierungsfreigabe, Mail-Wiederholung), ConfigMap und die
NetworkPolicies (Sicherheitsbefund Rate-Limit-Umgehung).

- Migration als initContainer im App-Deployment statt eigenem Job
- Entfernt: Backup-CronJob/-Volume (NAS sichert die Freigabe),
  Migrations-CronJob, PodDisruptionBudget, HTTP-Redirect-Ingress und
  Middleware, Namespace-Objekt (einmalig von Hand anzulegen)
- PostgreSQL auf SMB als bewusste, dokumentierte Ausnahme mit
  hard, cache=strict, SMB 3.1.1 + seal, uid 70 / 0700

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 20:36:30 +02:00
1 parent 974c403a8b
commit c84eb1e740
12 files changed
+200 -327

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@
# docker build --target tools -t <registry>/artista-modelcamp-tools:<tag> . # docker build --target tools -t <registry>/artista-modelcamp-tools:<tag> .
# #
# runner: der Next.js-Server (standalone), sonst nichts. # runner: der Next.js-Server (standalone), sonst nichts.
# tools: Prisma-CLI + tsx fuer den kontrollierten Migrationsschritt # tools: Prisma-CLI + tsx fuer die Migration (initContainer im App-Deployment),
# (Kubernetes-Job), den Worker (CronJob) und `admin:create`. # den Worker (CronJob) und `admin:create`.
# Beide IMMER mit demselben Tag bauen und ausrollen — sie teilen das # Beide IMMER mit demselben Tag bauen und ausrollen — sie teilen das
# Datenbankschema. # Datenbankschema.
+31 -24
View File
@@ -25,19 +25,27 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
## 2. Einmalig einrichten ## 2. Einmalig einrichten
1. **Overlay anpassen** (`k8s/overlays/production/`): Image-Namen und Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name. **Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
2. **Speicher:** - der Worker-CronJob, nötig für Folgeraten, Reservierungsfreigabe und Mail-Wiederholung
- **PostgreSQL** bekommt Blockspeicher mit ReadWriteOnce (StorageClass im StatefulSet), auf keinen Fall eine SMB-Freigabe (Begründung in `k8s/base/postgres.yaml`). - ConfigMap
- **Medien und Sicherungen** liegen auf SMB, über `k8s/overlays/production/smb-volumes.yaml`: - zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
1. csi-driver-smb installieren, Befehl steht im Kopf der Datei.
2. Auf dem NAS die Ordner `modelcamp-media` und `modelcamp-backup` anlegen und `source` in der Datei anpassen. Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert.
3. Das SMB-Secret anlegen: `kubectl -n artista create secret generic artista-modelcamp-smb --from-literal=username='…' --from-literal=password='…'`. Empfohlen ist ein eigener NAS-Benutzer, der nur diese Ordner sieht.
- Die PVCs sind statisch an die PVs gebunden. Die Rechte setzen die `mountOptions`: `uid=1001` für die App, `uid=70` für den Backup-Job, der mit `0700` bzw. `0600` geschützt ist, weil die Dumps personenbezogene Daten enthalten. Auf SMB wirkt `fsGroup` nicht. Das Medienvolume ist für die App nur lesbar (`0550`/`0440`), das Backup-Volume nutzt ReadWriteOnce. 1. **Namespace:** `kubectl create namespace artista` und `kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted`
- Die Verbindungen sind verschlüsselt (`vers=3.1.1`, `seal`). Das NAS muss SMB 3.1.1 mit Verschlüsselung erlauben, z. B. Synology unter „SMB-Verschlüsselung für Übertragung“, Windows mit `Set-SmbShare -EncryptData $true`. 2. **Overlay anpassen** (`k8s/overlays/production/`): Image-Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name. Die HTTP→HTTPS-Umleitung erwartet das Manifest in Traefik selbst.
- Ohne SMB: `smb-volumes.yaml` und die beiden PVC-Patches im Overlay entfernen, dann greift die Default-StorageClass. Das Medienvolume braucht dann ReadWriteMany, sonst `replicas: 1`. 3. **SMB-Speicher** (`k8s/overlays/production/smb-volumes.yaml`):
3. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator. 1. csi-driver-smb installieren, Befehl steht im Kopf der Datei.
4. **Anwenden:** `kubectl apply -k k8s/overlays/production` 2. Auf dem NAS die Ordner `modelcamp-postgres` (leer) und `modelcamp-media` anlegen und `source` in der Datei anpassen.
5. **Migration** (siehe Abschnitt 3), danach das erste Admin-Konto: 3. SMB-Secret anlegen: `kubectl -n artista create secret generic artista-modelcamp-smb --from-literal=username='…' --from-literal=password='…'`. Empfohlen ist ein eigener NAS-Benutzer, der nur diese Ordner sieht.
4. Das NAS muss SMB 3.1.1 mit Verschlüsselung erlauben (`seal`), z. B. Synology unter „SMB-Verschlüsselung für Übertragung“, Windows mit `Set-SmbShare -EncryptData $true`.
Die Rechte setzen die `mountOptions`, auf SMB wirkt `fsGroup` nicht: Postgres `uid=70`, `0700`/`0600`; Medien `uid=1001`, für die App nur lesbar (`0550`/`0440`).
**PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden. Gemildert wird das durch `hard`, `cache=strict`, Verschlüsselung und ein 120 Sekunden langes, sauberes Herunterfahren. Die NAS-Sicherung ist nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Ein Dateikopie-Backup eines laufenden Datenverzeichnisses lässt sich nicht zuverlässig wiederherstellen. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7).
4. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator.
5. **Anwenden:** `kubectl apply -k k8s/overlays/production`. Der initContainer migriert die Datenbank vor dem App-Start.
6. **Erstes Admin-Konto:**
```bash ```bash
kubectl -n artista run admin-create --rm -it --restart=Never --labels=artista/db-client=true \ kubectl -n artista run admin-create --rm -it --restart=Never --labels=artista/db-client=true \
--image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 \ --image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 \
@@ -45,17 +53,16 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
``` ```
Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet. Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet.
## 3. Update ausrollen (kontrollierter Migrationsschritt) ## 3. Update ausrollen
```bash ```bash
# 1. Neuen Tag im Overlay setzen, anwenden (App rollt noch NICHT aus, solange Tag gleich bleibt) # Neuen Tag im Overlay setzen und anwenden. Jeder neue App-Pod migriert per
# initContainer "migrate" zuerst die Datenbank; scheitert das, startet er nicht
# und die alten Pods bleiben aktiv (maxUnavailable: 0).
kubectl apply -k k8s/overlays/production kubectl apply -k k8s/overlays/production
# 2. Migration bewusst starten und abwarten
JOB=migrate-$(date +%Y%m%d%H%M)
kubectl -n artista create job $JOB --from=cronjob/artista-modelcamp-migrate
kubectl -n artista wait --for=condition=complete job/$JOB --timeout=5m
# 3. Rollout beobachten
kubectl -n artista rollout status deploy/artista-modelcamp-app kubectl -n artista rollout status deploy/artista-modelcamp-app
# Migrationsausgabe bei Bedarf:
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate
``` ```
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können. Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
@@ -88,8 +95,8 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
## 7. Sicherung und Wiederherstellung ## 7. Sicherung und Wiederherstellung
- **Sicherung:** Der CronJob `artista-modelcamp-db-backup` erstellt täglich um 3:15 Uhr einen Dump im `pg_dump`-Custom-Format nach `artista-modelcamp-db-backup` und bewahrt ihn 30 Tage auf. - **Sicherung:** Die NAS-Sicherung der SMB-Freigabe übernimmt das Speichersystem. Für PostgreSQL gilt das nur mit atomaren Snapshots, siehe Abschnitt 2. Im Cluster läuft kein Backup-Job.
- **Aus dem Cluster sichern (regelmäßig):** - **Logischer Dump** (empfohlen vor Updates und vor dem Live-Verkauf):
```bash ```bash
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-modelcamp-postgres -o name | head -1) POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-modelcamp-postgres -o name | head -1)
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
@@ -97,9 +104,9 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
- **Wiederherstellen:** - **Wiederherstellen:**
1. App stoppen: `kubectl -n artista scale deploy/artista-modelcamp-app --replicas=0` und den Worker-CronJob pausieren: `kubectl -n artista patch cronjob artista-modelcamp-worker -p '{"spec":{"suspend":true}}'` 1. App stoppen: `kubectl -n artista scale deploy/artista-modelcamp-app --replicas=0` und den Worker-CronJob pausieren: `kubectl -n artista patch cronjob artista-modelcamp-worker -p '{"spec":{"suspend":true}}'`
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump` 2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
3. Migration laufen lassen (Abschnitt 3), App wieder hochskalieren, Worker fortsetzen. 3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“). 4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
- **Medien:** Das Volume `artista-modelcamp-media` separat sichern, z. B. mit Volume-Snapshots oder der Sicherung des Speichersystems. - **Medien:** liegen im SMB-Ordner `modelcamp-media` und sind in der NAS-Sicherung enthalten.
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. - **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
## 8. Stornierung und Erstattung ## 8. Stornierung und Erstattung
+16 -12
View File
@@ -2,8 +2,10 @@
# Reservierungen, Rate Limits, E-Mail-Ausgang und Zahlungsjobs liegen in # Reservierungen, Rate Limits, E-Mail-Ausgang und Zahlungsjobs liegen in
# PostgreSQL — beliebig viele Replikate moeglich. # PostgreSQL — beliebig viele Replikate moeglich.
# #
# Migrationen laufen NICHT beim Start, sondern als eigener, kontrollierter # Migrationen laufen als eigener Schritt VOR dem App-Container: initContainer
# Schritt (jobs.yaml, CronJob "artista-modelcamp-migrate", siehe docs/BETRIEB.md). # "migrate" (Werkzeug-Image, `prisma migrate deploy`). Scheitert er, startet
# die App nicht. Mehrere Replikate gleichzeitig sind unkritisch — Prisma
# serialisiert Migrationen ueber eine Datenbank-Sperre.
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -37,6 +39,18 @@ spec:
labelSelector: labelSelector:
matchLabels: matchLabels:
app.kubernetes.io/name: artista-modelcamp-app app.kubernetes.io/name: artista-modelcamp-app
initContainers:
- name: migrate
image: artista-modelcamp-tools
command: ["prisma", "migrate", "deploy"]
envFrom:
- secretRef: { name: artista-modelcamp-secrets }
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
containers: containers:
- name: app - name: app
image: artista-modelcamp image: artista-modelcamp
@@ -117,13 +131,3 @@ spec:
- port: 3000 - port: 3000
# Kubelet-Probes kommen vom Knoten und sind von NetworkPolicies je nach # Kubelet-Probes kommen vom Knoten und sind von NetworkPolicies je nach
# CNI (Canal/Calico in RKE2) ausgenommen. # CNI (Canal/Calico in RKE2) ausgenommen.
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: artista-modelcamp-app
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: artista-modelcamp-app
+5 -27
View File
@@ -1,11 +1,7 @@
apiVersion: v1 # Der Namespace "artista" wird einmalig von Hand angelegt:
kind: Namespace # kubectl create namespace artista
metadata: # kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted
name: artista #
labels:
# Pod Security Admission: nur eingeschraenkte (non-root) Pods.
pod-security.kubernetes.io/enforce: restricted
---
# Nicht geheime Einstellungen. Geheimnisse: k8s/secret.example.yaml. # Nicht geheime Einstellungen. Geheimnisse: k8s/secret.example.yaml.
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
@@ -23,26 +19,8 @@ data:
MEDIA_DIR: "/app/media" MEDIA_DIR: "/app/media"
EXTERNAL_VIDEO_PROVIDER: "none" EXTERNAL_VIDEO_PROVIDER: "none"
--- ---
# Freigegebene Bilder und Videos (Unterkunft, Portraets, Feedback-Videos).
# Dauerhaft, deshalb ein PersistentVolumeClaim und KEIN emptyDir.
#
# ReadWriteMany, weil mehrere App-Replikate dasselbe Verzeichnis lesen.
# Geeignet: z. B. Longhorn (RWX), NFS oder eine SMB/CSI-Freigabe — fuer
# Medien ist ein Netzwerkdateisystem unkritisch (anders als fuer PostgreSQL).
# Steht nur ReadWriteOnce zur Verfuegung: replicas im Deployment auf 1 setzen.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-media
spec:
accessModes: ["ReadWriteMany"]
# storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
# Die Datenbank ist nur fuer Pods mit dem Label artista/db-client erreichbar # Die Datenbank ist nur fuer Pods mit dem Label artista/db-client erreichbar
# (App, Migration, Worker, Sicherung). # (App inkl. Migrations-initContainer, Worker).
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: NetworkPolicy kind: NetworkPolicy
metadata: metadata:
-159
View File
@@ -1,159 +0,0 @@
# --- Migration ----------------------------------------------------------------
# Als SUSPENDIERTER CronJob definiert, damit Kustomize das Image mit der
# App gleichzieht. Er laeuft nie von selbst, sondern bewusst vor jedem
# Rollout:
# kubectl -n artista create job migrate-$(date +%Y%m%d%H%M) --from=cronjob/artista-modelcamp-migrate
# kubectl -n artista wait --for=condition=complete job/migrate-… --timeout=5m
apiVersion: batch/v1
kind: CronJob
metadata:
name: artista-modelcamp-migrate
spec:
suspend: true
schedule: "0 0 1 1 *"
jobTemplate:
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
artista/db-client: "true"
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1001
seccompProfile: { type: RuntimeDefault }
containers:
- name: migrate
image: artista-modelcamp-tools
command: ["prisma", "migrate", "deploy"]
envFrom:
- secretRef: { name: artista-modelcamp-secrets }
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
---
# --- Worker -------------------------------------------------------------------
# Gibt abgelaufene Reservierungen frei (nach Rueckfrage bei Stripe), zieht
# faellige Raten ein, trennt Zahlungsmethoden nach der letzten Rate,
# versendet den E-Mail-Ausgang. Idempotent; Forbid verhindert
# ueberlappende Laeufe zusaetzlich.
apiVersion: batch/v1
kind: CronJob
metadata:
name: artista-modelcamp-worker
spec:
schedule: "*/10 * * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 900
template:
metadata:
labels:
artista/db-client: "true"
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1001
seccompProfile: { type: RuntimeDefault }
containers:
- name: worker
image: artista-modelcamp-tools
command: ["tsx", "scripts/worker.ts"]
envFrom:
- configMapRef: { name: artista-modelcamp-config }
- secretRef: { name: artista-modelcamp-secrets }
resources:
requests: { cpu: 50m, memory: 192Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
---
# --- Sicherung ----------------------------------------------------------------
# Taeglicher pg_dump mit 30 Tagen Aufbewahrung. Das Backup-Volume darf ein
# Netzwerkspeicher (NFS/SMB) sein. Zusaetzlich regelmaessig aus dem Cluster
# kopieren (docs/BETRIEB.md, "Sicherung und Wiederherstellung").
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-db-backup
spec:
accessModes: ["ReadWriteOnce"]
# storageClassName: smb-backup
resources:
requests:
storage: 10Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: artista-modelcamp-db-backup
spec:
schedule: "15 3 * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
artista/db-client: "true"
spec:
restartPolicy: OnFailure
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 70
runAsGroup: 70
fsGroup: 70
seccompProfile: { type: RuntimeDefault }
containers:
- name: pg-dump
# Gleiche Hauptversion wie der Server.
image: postgres:18.1-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef: { name: artista-modelcamp-secrets, key: DB_PASSWORD }
command: ["/bin/sh", "-c"]
args:
- |
set -eu
set -o pipefail
target="/backup/artista-$(date -u +%Y-%m-%d_%H%M).dump"
# Custom-Format: komprimiert, selektiv wiederherstellbar.
pg_dump --host=artista-modelcamp-postgres --username=artista --dbname=artista \
--format=custom --file="${target}.partial"
mv "${target}.partial" "${target}"
find /backup -name 'artista-*.dump.partial' -mtime +1 -delete
find /backup -name 'artista-*.dump' -mtime +30 -delete
ls -lh /backup
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { memory: 256Mi }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: backup
mountPath: /backup
volumes:
- name: backup
persistentVolumeClaim: { claimName: artista-modelcamp-db-backup }
+2 -1
View File
@@ -5,9 +5,10 @@ kind: Kustomization
namespace: artista namespace: artista
resources: resources:
- config.yaml - config.yaml
- storage.yaml
- postgres.yaml - postgres.yaml
- app.yaml - app.yaml
- jobs.yaml - worker.yaml
labels: labels:
- pairs: - pairs:
app.kubernetes.io/part-of: artista-modelcamp app.kubernetes.io/part-of: artista-modelcamp
+15 -20
View File
@@ -1,14 +1,11 @@
# PostgreSQL fuer Buchungen, Zahlungen, Gutscheine und Protokoll. # PostgreSQL fuer Buchungen, Zahlungen, Gutscheine und Protokoll.
# #
# SPEICHER: Blockspeicher mit ReadWriteOnce verwenden (z. B. Longhorn, # Datenverzeichnis: PVC "artista-modelcamp-postgres-data" (storage.yaml), im
# local-path, Ceph RBD). KEINE SMB/CIFS-Freigabe als Datenverzeichnis: # Produktions-Overlay auf SMB. Das ist eine BEWUSSTE, vom Betreiber
# SMB bildet POSIX-Sperren, fsync-Garantien und Dateirechte nicht so ab, # akzeptierte Ausnahme von der Empfehlung "Blockspeicher": SMB garantiert
# wie PostgreSQL sie fuer Absturzsicherheit voraussetzt — Datenverlust bei # Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Die
# Stromausfall oder Neustart ist die Folge. Sicherungen (backup-CronJob) # Mount-Optionen in smb-volumes.yaml sind darauf ausgelegt; Risiken und
# duerfen dagegen auf SMB/NFS liegen. # Hinweise zur Sicherung stehen in docs/BETRIEB.md.
#
# Alternative fuer hoehere Verfuegbarkeit: CloudNativePG-Operator; dann
# dieses Manifest weglassen und DATABASE_URL auf dessen Service zeigen lassen.
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
@@ -24,11 +21,14 @@ spec:
labels: labels:
app.kubernetes.io/name: artista-modelcamp-postgres app.kubernetes.io/name: artista-modelcamp-postgres
spec: spec:
automountServiceAccountToken: false
# Beim Beenden sauber herunterfahren (Checkpoint), bevor das Volume
# ausgehaengt wird — auf Netzwerkspeicher besonders wichtig.
terminationGracePeriodSeconds: 120
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 70 runAsUser: 70
runAsGroup: 70 runAsGroup: 70
fsGroup: 70
seccompProfile: { type: RuntimeDefault } seccompProfile: { type: RuntimeDefault }
containers: containers:
- name: postgres - name: postgres
@@ -52,23 +52,18 @@ spec:
periodSeconds: 5 periodSeconds: 5
livenessProbe: livenessProbe:
exec: { command: ["pg_isready", "-U", "artista"] } exec: { command: ["pg_isready", "-U", "artista"] }
initialDelaySeconds: 30 initialDelaySeconds: 60
periodSeconds: 20 periodSeconds: 20
failureThreshold: 6
resources: resources:
requests: { cpu: 100m, memory: 256Mi } requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi } limits: { memory: 1Gi }
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] } capabilities: { drop: ["ALL"] }
volumeClaimTemplates: volumes:
- metadata: - name: data
name: data persistentVolumeClaim: { claimName: artista-modelcamp-postgres-data }
spec:
accessModes: ["ReadWriteOnce"]
# storageClassName: longhorn
resources:
requests:
storage: 10Gi
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
+27
View File
@@ -0,0 +1,27 @@
# Die beiden dauerhaften Speicher. Im Produktions-Overlay werden sie fest an
# die SMB-PersistentVolumes gebunden (k8s/overlays/production/smb-volumes.yaml).
# Eine separate Sicherung gibt es nicht: die SMB-Freigabe wird auf dem NAS
# selbst mehrfach gesichert.
# PostgreSQL-Datenverzeichnis.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-postgres-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
# Freigegebene Bilder und Videos. ReadWriteMany, weil mehrere App-Replikate
# lesen.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-media
spec:
accessModes: ["ReadWriteMany"]
resources:
requests:
storage: 5Gi
+43
View File
@@ -0,0 +1,43 @@
# Worker: gibt abgelaufene Reservierungen frei (nach Rueckfrage bei Stripe),
# zieht faellige Raten ein, trennt Zahlungsmethoden nach der letzten Rate und
# versendet den E-Mail-Ausgang. Ohne ihn laufen Ratenzahlung und
# Reservierungsfreigabe nicht. Idempotent; Forbid verhindert Ueberlappung.
apiVersion: batch/v1
kind: CronJob
metadata:
name: artista-modelcamp-worker
spec:
schedule: "*/10 * * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 900
template:
metadata:
labels:
artista/db-client: "true"
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1001
seccompProfile: { type: RuntimeDefault }
containers:
- name: worker
image: artista-modelcamp-tools
command: ["tsx", "scripts/worker.ts"]
envFrom:
- configMapRef: { name: artista-modelcamp-config }
- secretRef: { name: artista-modelcamp-secrets }
resources:
requests: { cpu: 50m, memory: 192Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
+3 -33
View File
@@ -1,6 +1,8 @@
# Ingress fuer Traefik mit cert-manager. # Ingress fuer Traefik mit cert-manager.
# Anpassen: Host (auch in APP_URL, kustomization.yaml), ingressClassName # Anpassen: Host (auch APP_URL in kustomization.yaml), ingressClassName
# ("kubectl get ingressclass"), ClusterIssuer, TLS-Secret-Name. # ("kubectl get ingressclass"), ClusterIssuer, TLS-Secret-Name.
# HTTP->HTTPS-Umleitung erwartet dieses Manifest EntryPoint-weit in Traefik
# (Helm: ports.web.redirections.entryPoint.to=websecure).
# #
# X-Forwarded-For/X-Real-Ip: Rate Limits verlassen sich darauf, dass Traefik # X-Forwarded-For/X-Real-Ip: Rate Limits verlassen sich darauf, dass Traefik
# vom Client mitgeschickte Werte verwirft. Dafuer im Traefik-EntryPoint # vom Client mitgeschickte Werte verwirft. Dafuer im Traefik-EntryPoint
@@ -30,35 +32,3 @@ spec:
name: artista-modelcamp-app name: artista-modelcamp-app
port: port:
name: http name: http
---
# HTTP -> HTTPS. Entfaellt, wenn Traefik das bereits EntryPoint-weit tut
# (Helm: ports.web.redirections.entryPoint.to=websecure).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: artista-modelcamp-http-redirect
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
traefik.ingress.kubernetes.io/router.middlewares: artista-artista-modelcamp-redirect-https@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: modelcamp.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: artista-modelcamp-app
port:
name: http
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: artista-modelcamp-redirect-https
spec:
redirectScheme:
scheme: https
permanent: true
+3 -3
View File
@@ -7,7 +7,7 @@ namespace: artista
resources: resources:
- ../../base - ../../base
- ingress.yaml - ingress.yaml
# Medien und Sicherungen auf SMB (PostgreSQL bewusst nicht, siehe Datei). # PostgreSQL-Daten und Medien auf SMB.
# Ohne SMB: diese Zeile und die beiden PVC-Patches unten entfernen. # Ohne SMB: diese Zeile und die beiden PVC-Patches unten entfernen.
- smb-volumes.yaml - smb-volumes.yaml
@@ -32,14 +32,14 @@ patches:
- op: add - op: add
path: /spec/volumeName path: /spec/volumeName
value: artista-modelcamp-media-smb value: artista-modelcamp-media-smb
- target: { kind: PersistentVolumeClaim, name: artista-modelcamp-db-backup } - target: { kind: PersistentVolumeClaim, name: artista-modelcamp-postgres-data }
patch: |- patch: |-
- op: add - op: add
path: /spec/storageClassName path: /spec/storageClassName
value: "" value: ""
- op: add - op: add
path: /spec/volumeName path: /spec/volumeName
value: artista-modelcamp-db-backup-smb value: artista-modelcamp-postgres-smb
- target: { kind: ConfigMap, name: artista-modelcamp-config } - target: { kind: ConfigMap, name: artista-modelcamp-config }
patch: |- patch: |-
- op: replace - op: replace
+53 -46
View File
@@ -6,22 +6,60 @@
# und das Zugangs-Secret "artista-modelcamp-smb" (k8s/secret.example.yaml). # und das Zugangs-Secret "artista-modelcamp-smb" (k8s/secret.example.yaml).
# #
# ANZUPASSEN: "source" (//<nas>/<freigabe>/<ordner>) je Volume. Die Ordner # ANZUPASSEN: "source" (//<nas>/<freigabe>/<ordner>) je Volume. Die Ordner
# muessen auf der Freigabe bereits existieren. # muessen auf der Freigabe bereits existieren; der Postgres-Ordner leer.
# #
# BEWUSST NICHT AUF SMB: das PostgreSQL-Datenverzeichnis. SMB bildet # Beide Verbindungen verschluesselt (SMB 3.1.1 + seal); das NAS muss
# POSIX-Sperren, fsync-Garantien und Dateirechte nicht so ab, wie PostgreSQL # SMB-Verschluesselung erlauben. fsGroup wirkt auf SMB nicht: Besitz und
# sie fuer Absturzsicherheit braucht (Briefing §13). Die Datenbank bleibt auf # Rechte setzen uid/gid/dir_mode/file_mode. reclaimPolicy Retain: Loeschen
# Blockspeicher und wird taeglich per pg_dump auf das Backup-Volume unten # von PVC/PV laesst die Dateien auf dem NAS.
# gesichert — damit liegen die Geschaeftsdaten trotzdem auf dem NAS. # Keine eigene Sicherung im Cluster: die Freigabe wird auf dem NAS gesichert.
#
# Rechte: fsGroup wirkt auf SMB nicht. Besitz und Rechte setzen deshalb
# uid/gid/dir_mode/file_mode in den mountOptions (Muster aus Wabenhain).
# Aenderungen an mountOptions greifen erst beim naechsten Einhaengen.
# reclaimPolicy Retain: Loeschen von PVC/PV laesst die Dateien auf dem NAS.
# --- Medien (Bilder/Videos) ---------------------------------------------------- # --- PostgreSQL-Datenverzeichnis ------------------------------------------------
# Die App liest nur (readOnly-Mount). Neue Bilder legst du direkt ueber die # BEWUSSTE AUSNAHME (Entscheidung des Betreibers): Datenbank auf SMB.
# SMB-Freigabe ab, z. B. vom Windows-Explorer aus. # Risiko: SMB garantiert Sperren/fsync nicht in jedem Fall wie ein lokales
# Dateisystem; bei Netzwerk- oder NAS-Ausfall waehrend Schreibvorgaengen kann
# die Datenbank beschaedigt werden. Die Optionen unten minimieren das:
# hard E/A wartet bei Netzstoerung statt Fehler an PostgreSQL zu
# melden (kein "soft"-Abbruch mitten im Schreiben)
# cache=strict strikte Cache-Kohaerenz, fsync geht bis zum Server
# nobrl keine Byte-Range-Locks (PostgreSQL braucht sie nicht)
# uid/gid 70, 0700/0600 PostgreSQL verlangt ein privates Datenverzeichnis
# Sicherung: nur NAS-Snapshots, die atomar sind (Btrfs/ZFS-Snapshot der ganzen
# Freigabe), sind fuer ein laufendes Datenverzeichnis konsistent. Einfaches
# Dateikopieren waehrend des Betriebs ergibt KEINE brauchbare Sicherung.
apiVersion: v1
kind: PersistentVolume
metadata:
name: artista-modelcamp-postgres-smb
spec:
capacity:
storage: 10Gi
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions:
- vers=3.1.1
- seal
- hard
- cache=strict
- uid=70
- gid=70
- dir_mode=0700
- file_mode=0600
- mfsymlinks
- nobrl
csi:
driver: smb.csi.k8s.io
volumeHandle: artista-modelcamp-postgres-smb
volumeAttributes:
source: //nas.example.lan/artista/modelcamp-postgres
nodeStageSecretRef:
name: artista-modelcamp-smb
namespace: artista
---
# --- Medien (Bilder/Videos) ------------------------------------------------------
# Die App liest nur. Neue Bilder legst du direkt ueber die SMB-Freigabe ab,
# z. B. vom Windows-Explorer aus.
apiVersion: v1 apiVersion: v1
kind: PersistentVolume kind: PersistentVolume
metadata: metadata:
@@ -34,7 +72,7 @@ spec:
storageClassName: "" storageClassName: ""
mountOptions: mountOptions:
- vers=3.1.1 - vers=3.1.1
- seal # SMB3-Verschluesselung: keine Klartextdaten im Netz - seal
- uid=1001 # Containerbenutzer "nextjs" (Dockerfile) - uid=1001 # Containerbenutzer "nextjs" (Dockerfile)
- gid=1001 - gid=1001
- dir_mode=0550 # App liest nur; Schreiben ausschliesslich ueber das NAS - dir_mode=0550 # App liest nur; Schreiben ausschliesslich ueber das NAS
@@ -43,40 +81,9 @@ spec:
- nobrl - nobrl
csi: csi:
driver: smb.csi.k8s.io driver: smb.csi.k8s.io
# Muss im Cluster eindeutig sein.
volumeHandle: artista-modelcamp-media-smb volumeHandle: artista-modelcamp-media-smb
volumeAttributes: volumeAttributes:
source: //nas.example.lan/artista/modelcamp-media source: //nas.example.lan/artista/modelcamp-media
nodeStageSecretRef: nodeStageSecretRef:
name: artista-modelcamp-smb name: artista-modelcamp-smb
namespace: artista namespace: artista
---
# --- Datenbank-Sicherungen ------------------------------------------------------
apiVersion: v1
kind: PersistentVolume
metadata:
name: artista-modelcamp-db-backup-smb
spec:
capacity:
storage: 10Gi
# ReadWriteOnce: nur der Backup-Job haengt dieses Volume ein.
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions:
- vers=3.1.1
- seal # SMB3-Verschluesselung: keine Klartextdaten im Netz
- uid=70 # Benutzer "postgres" im pg_dump-Job (postgres:18-alpine)
- gid=70
- dir_mode=0700 # Sicherungen enthalten personenbezogene Daten
- file_mode=0600
- mfsymlinks
- nobrl
csi:
driver: smb.csi.k8s.io
volumeHandle: artista-modelcamp-db-backup-smb
volumeAttributes:
source: //nas.example.lan/artista/modelcamp-backup
nodeStageSecretRef:
name: artista-modelcamp-smb
namespace: artista