# 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