# Taegliche Sicherung der Datenbank. # # WARUM: 03-postgres.yaml haelt Bestellungen, Rechnungen, Kundenkonten und # Chargendaten in einem einzelnen PersistentVolume ohne jede Sicherung. Geht # dieses Volume verloren (Speicherfehler, versehentliches "kubectl delete # pvc", ein misslungenes Cluster-Update), sind saemtliche Bestellungen und # Rechnungen unwiederbringlich weg. Rechnungen unterliegen in Deutschland # ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein # Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem. # # Diese Sicherung liegt bewusst NUR im Cluster. Sie schuetzt gegen # Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten # Clusters oder Standorts. Fuer eine vollstaendige Absicherung die # erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster # herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung # und Wiederherstellung"). # # MYSQL STATT POSTGRES: laeuft die Installation mit DATABASE_PROVIDER=mysql # (03-mysql.yaml), stattdessen das Image "mysql:8.4.11" verwenden und den # pg_dump-Aufruf unten ersetzen durch: # mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \ # --single-transaction --routines wabenhain | gzip -9 > "$file.partial" apiVersion: v1 kind: PersistentVolumeClaim metadata: name: wabenhain-db-backup namespace: wabenhain spec: accessModes: - ReadWriteOnce # storageClassName: "dein-storage-class" resources: requests: # Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im # niedrigen Megabyte-Bereich; 10Gi tragen die 30 Tage Aufbewahrung # unten mit grossem Puffer. storage: 10Gi --- apiVersion: batch/v1 kind: CronJob metadata: name: wabenhain-db-backup namespace: wabenhain spec: # 03:15 Uhr — ausserhalb der ueblichen Bestellzeiten. Die Uhrzeit richtet # sich nach der Zeitzone des Clusters (meist UTC). schedule: "15 3 * * *" # Kein zweiter Lauf, solange der vorherige noch laeuft. concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 # Fehlgeschlagene Laeufe laenger aufheben als erfolgreiche: nur an ihnen # laesst sich per "kubectl logs" nachvollziehen, warum eine Sicherung # ausblieb. failedJobsHistoryLimit: 5 # Faellt ein Lauf aus (Knoten weg, Cluster-Wartung), gilt er nach einer # Stunde als verpasst statt ewig nachgeholt zu werden. startingDeadlineSeconds: 3600 jobTemplate: spec: backoffLimit: 2 template: spec: restartPolicy: OnFailure # 70 ist die UID/GID des "postgres"-Nutzers im offiziellen # Alpine-Image; fsGroup gibt dem Job Schreibrecht auf dem # gemounteten Backup-Volume (gleiche Begruendung wie beim # uploads-Volume in 05-app-deployment.yaml). securityContext: runAsUser: 70 runAsGroup: 70 fsGroup: 70 containers: - name: pg-dump # Gleiche Hauptversion wie der Server in 03-postgres.yaml — # ein aelteres pg_dump kann eine neuere Datenbank nicht lesen. image: postgres:16.15-alpine env: - name: PGPASSWORD valueFrom: secretKeyRef: name: wabenhain-secrets key: DB_PASSWORD command: ["/bin/sh", "-c"] args: - | set -eu # pipefail ist hier das Wichtigste an diesem Skript: # ohne es liefert "pg_dump | gzip" den Exit-Code von gzip. # gzip gelingt auch dann, wenn pg_dump vorher abgebrochen # ist — der Job galte als erfolgreich und im Verzeichnis # laege eine abgeschnittene, beim Wiederherstellen # wertlose Datei. Genau so entstehen Sicherungen, die erst # im Ernstfall als unbrauchbar auffallen. set -o pipefail stamp="$(date -u +%Y-%m-%d_%H%M)" target="/backup/wabenhain-${stamp}.sql.gz" # Erst unter einem Zwischennamen schreiben, dann # umbenennen: ein mitten im Lauf abgebrochener Job # hinterlaesst so keine Datei, die wie eine vollstaendige # Sicherung aussieht. pg_dump --host=wabenhain-postgres --username=wabenhain \ --dbname=wabenhain --clean --if-exists \ | gzip -9 > "${target}.partial" mv "${target}.partial" "${target}" # Rechnungsablage mitsichern (INVOICE_DIR, siehe # 04-uploads-pvc.yaml). Die Datenbank kennt von einer # Rechnung nur Nummer, Betrag und Pfad — das ausgelieferte # PDF liegt ausschliesslich auf dem Volume. Ein # Datenbank-Dump allein ersetzt den Beleg also NICHT. invoices="/backup/rechnungen-${stamp}.tar.gz" tar -czf "${invoices}.partial" -C /invoices . mv "${invoices}.partial" "${invoices}" # Angefangene Dateien frueherer Abbrueche mit aufraeumen. find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete find /backup -name 'rechnungen-*.tar.gz.partial' -mtime +1 -delete # 30 Tage Aufbewahrung. Achtung: ersetzt KEINE # langfristige Archivierung der Rechnungen (§ 147 AO, # zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig # ausgelagerte Sicherungen nutzen. find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete find /backup -name 'rechnungen-*.tar.gz' -mtime +30 -delete echo "Sicherung abgeschlossen:" ls -lh /backup resources: requests: cpu: 50m memory: 128Mi limits: memory: 256Mi volumeMounts: - name: backup mountPath: /backup # Nur lesend: dieser Job darf Belege sichern, niemals aendern. - name: invoices mountPath: /invoices readOnly: true volumes: - name: backup persistentVolumeClaim: claimName: wabenhain-db-backup # ACHTUNG bei mehr als einem Knoten: wabenhain-invoices ist # ReadWriteOnce (04-uploads-pvc.yaml). Ein solches Volume laesst # sich nur von Pods auf DEMSELBEN Knoten einhaengen. Landet dieser # Sicherungs-Pod auf einem anderen Knoten als der App-Pod, bleibt # er in "Pending" haengen — sichtbar nur, wenn man nachsieht, # denn ein Pending-Pod meldet keinen Fehler. # # Auf einem Ein-Knoten-Cluster ist das kein Thema. Bei mehreren # Knoten das PVC auf ReadWriteMany umstellen (NFS/SMB/CephFS) — # dann greift die Einschraenkung nicht mehr. - name: invoices persistentVolumeClaim: claimName: wabenhain-invoices --- # Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen. # # WARUM: eine Bestellung entsteht VOR der Zahlung und reserviert dabei sofort # Lagerbestand — sonst koennte zwischen Kaufentscheidung und Zahlung jemand # anders dasselbe Glas kaufen. Zurueck kommt der Bestand normalerweise ueber # zwei Wege: den Abbruch-Knopf bei Stripe (/checkout/cancel) und das Ereignis # checkout.session.expired, das Stripe an den Webhook schickt. # # Dieser CronJob ist das Netz unter beiden. Er greift genau dann, wenn der # Webhook nicht ankommt: das Ereignis ist im Stripe-Dashboard nicht abonniert, # der Endpunkt war beim Zustellversuch nicht erreichbar, oder die Versuche # sind abgelaufen. Ohne ihn bliebe der reservierte Bestand dauerhaft # blockiert — und zwar unsichtbar, denn eine solche Bestellung sieht im # Admin-Bereich aus wie jede andere offene. # # Gefragt wird immer Stripe, nie die Uhr allein: zurueckgebucht wird nur, was # dort nachweislich abgelaufen ist (siehe scripts/release-stale-orders.ts). # # Der Lauf endet mit Exit-Code 1, wenn er eine Bestellung findet, die bei # Stripe bezahlt ist, hier aber noch auf "NEU" steht. Das ist Absicht: dieser # Fall bedeutet entgangenen Umsatz und gehoert als fehlgeschlagener Job # sichtbar gemacht, nicht in eine Logzeile. apiVersion: batch/v1 kind: CronJob metadata: name: wabenhain-release-stale-orders namespace: wabenhain spec: # Alle 30 Minuten. Die Stripe-Sitzung laeuft nach 32 Minuten ab, der Lauf # betrachtet Bestellungen ab 45 Minuten Alter — haeufiger zu pruefen bringt # deshalb nichts, seltener laesst Ware unnoetig lange liegen. schedule: "*/30 * * * *" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 # Fehlgeschlagene Laeufe laenger aufheben: nur an ihnen laesst sich per # "kubectl logs" nachlesen, welche Bestellung bezahlt, aber nicht # verarbeitet ist. failedJobsHistoryLimit: 5 startingDeadlineSeconds: 600 jobTemplate: spec: # Kein endloses Wiederholen: schlaegt der Lauf zweimal fehl, laeuft er # ohnehin in 30 Minuten erneut. backoffLimit: 2 # Nach einer Stunde abbrechen — ein haengender Lauf wuerde sonst jeden # weiteren blockieren (concurrencyPolicy: Forbid). activeDeadlineSeconds: 3600 template: spec: restartPolicy: OnFailure # Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid # 1001 nextjs) — dasselbe Image, derselbe Nutzer. securityContext: runAsUser: 1001 runAsGroup: 1001 containers: - name: release-stale-orders # DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml. # Beide zusammen aktualisieren: der Lauf nutzt denselben # Prisma-Client wie die App und muss deshalb zum selben # Datenbankschema passen. image: git.encry.de/nick.rosenloecher/wabenhain:latest imagePullPolicy: IfNotPresent # Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY # — die stehen bereits in denselben beiden Quellen wie fuer die # App, deshalb hier keine eigene Liste, die auseinanderlaufen # koennte. envFrom: - configMapRef: name: wabenhain-config - secretRef: name: wabenhain-secrets command: ["/bin/sh", "-c"] args: - | set -eu # prisma-provider.mjs schreibt die provider-Zeile in # prisma/schema.prisma anhand von DATABASE_PROVIDER — ohne # diesen Schritt laedt der Prisma-Client den falschen # Treiber, wenn die Installation auf MySQL laeuft. node scripts/prisma-provider.mjs exec tsx scripts/release-stale-orders.ts resources: requests: cpu: 50m memory: 128Mi limits: # Derselbe Wert wie beim App-Container: der Lauf laedt # denselben Prisma-Client samt Treiber. memory: 512Mi