Es gab bisher keinerlei Sicherung: Bestellungen, Rechnungen, Kundenkonten und Chargendaten lagen in einem einzelnen PersistentVolume ohne Netz. Ein Speicherfehler, ein versehentliches "kubectl delete pvc" oder ein misslungenes Cluster-Update haetten alles davon unwiederbringlich geloescht. Rechnungen unterliegen in Deutschland zusaetzlich einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein Datenverlust waere hier nicht nur aergerlich, sondern ein Rechtsproblem. Neu: 07-backup-cronjob.yaml — naechtlicher pg_dump auf ein eigenes Volume, gzip-komprimiert, 30 Tage Aufbewahrung, concurrencyPolicy Forbid. Fehlgeschlagene Laeufe werden laenger aufgehoben als erfolgreiche: nur an ihnen laesst sich per "kubectl logs" nachvollziehen, warum eine Sicherung ausblieb. Zwei Details, an denen Sicherungsskripte typischerweise scheitern: - "set -o pipefail". Ohne das liefert "pg_dump | gzip" den Exit-Code von gzip, und gzip gelingt auch dann, wenn pg_dump vorher abgebrochen ist. Der Job galte als erfolgreich, im Verzeichnis laege eine abgeschnittene Datei, und das faellt erst im Ernstfall auf. Beide Varianten mit einem absichtlich abbrechenden Dump geprueft: ohne pipefail Exit 0 samt vollstaendig aussehender .sql.gz, mit pipefail Exit 1 und keine .sql.gz. - Schreiben unter ".partial" und erst danach umbenennen, damit ein mitten im Lauf abgebrochener Job keine Datei hinterlaesst, die wie eine vollstaendige Sicherung aussieht. Liegengebliebene .partial-Reste raeumt derselbe Lauf mit auf. k8s/README.md: neuer Abschnitt "Sicherung und Wiederherstellung" mit Ansehen, manuellem Ausloesen, Herausziehen aus dem Cluster und dem vollstaendigen Wiederherstellungsweg (App auf 0 skalieren, Dump einspielen, wieder hochskalieren) — samt dem Hinweis, dass eine nie getestete Sicherung keine Sicherung ist. Der Abschnitt "Bekannte Grenzen" nannte "ohne Replikation/Backup-Automation"; das stimmt jetzt nur noch zur Haelfte und ist entsprechend praezisiert: die Sicherung liegt weiterhin im selben Cluster und ersetzt keine Replikation. Alle Manifeste gegen einen YAML-Parser geprueft. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
7.9 KiB
Wabenhain in Kubernetes
Rohe Manifeste, mit kubectl apply -f k8s/<datei> einzeln oder als Ordner
anwendbar. Nummerierung = empfohlene Reihenfolge.
Einmalige Vorbereitung
-
Gitea Actions:
.gitea/workflows/docker-build.ymlbaut bei jedem Push aufmaindas Image<registry>/<owner>/wabenhain:latest(+:sha-<kurzhash>,:vX.Y.Zbei Tags) in der Gitea-Container-Registry. Ein einzelnes Image reicht fuer05-app-deployment.yaml— es traegt sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates Migrations-Image mehr.Voraussetzung: ein Repo-/Org-Secret
REGISTRY_TOKENin Gitea anlegen (Personal Access Token mitpackage-Scope, unter Benutzereinstellungen → Anwendungen → Access Tokens erzeugen, dann unter Repo-Einstellungen → Actions → Secrets alsREGISTRY_TOKENhinterlegen). Der automatisch von Gitea Actions bereitgestellteGITEA_TOKENreicht dafuer aktuell NICHT aus (fehlendes Package-Write-Recht, Stand heute). -
Ein Docker-Image ist Provider-uebergreifend: Prisma generiert den Client Provider-spezifisch — ein
.next/standalone-Build kann seinen generierten Client nicht zur Laufzeit wechseln. Deshalb generiertscripts/generate-prisma-clients.mjs(Teil vonpnpm build, laeuft also automatisch bei jedem Image-Build) BEIDE Varianten ins selbe Image; die Wahl zwischen ihnen passiert zur Laufzeit ueberDATABASE_PROVIDER, siehesrc/lib/prisma.ts. Ein Image reicht also fuer Postgres UND MySQL — nurDATABASE_PROVIDER/DATABASE_URLin01-configmap.yaml/02-secret.yamlmuessen zur tatsaechlich gewaehlten Datenbank passen. Beim Container-Start fuehrt derselbe Prozess erstprisma migrate deployundprisma db seedaus, BEVOR der Next.js-Server startet (siehe Dockerfile-CMD in derrunner-Stage) — beide Schritte sind idempotent und bei jedem Neustart gefahrlos, kein separater Migrations-Job noetig. -
k8s/02-secret.example.yamlnachk8s/02-secret.yamlkopieren (per.gitignoreausgeschlossen — NIE mit echten Werten committen) und ausfuellen. Alternativ direkt perkubectl create secret, siehe Kommentar in der Beispieldatei. -
01-configmap.yaml:DATABASE_PROVIDER(postgresql/mysql) undNEXT_PUBLIC_SITE_URL(echte Domain) anpassen. -
04-uploads-pvc.yaml:storageClassNameauf eine im Cluster vorhandene StorageClass setzen (kubectl get storageclass; die Zeile ist auskommentiert, dann greift die als(default)markierte) und die Groesse pruefen. Ohne diesen Claim gibt es keinen dauerhaften Speicher fuer Produkt- und Erntenfotos. -
image:in05-app-deployment.yamlzeigt bereits aufgit.encry.de/nick.rosenloecher/wabenhain— nur noch den Tag pruefen (:latestpasst fuer den jeweils neuestenmain-Build). In06-ingress.yamlshop.example.comdurch die echte Domain ersetzen (zweimal:host:undtls.hosts).
Deployment
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/01-configmap.yaml
kubectl apply -f k8s/02-secret.yaml
# Nur EINE der beiden Datenbanken anwenden, passend zu DATABASE_PROVIDER:
kubectl apply -f k8s/03-postgres.yaml
# kubectl apply -f k8s/03-mysql.yaml
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
# Persistenter Speicher fuer hochgeladene Bilder — MUSS vor dem Deployment
# angewendet werden, sonst bleibt der App-Pod in "Pending" haengen.
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.yaml
# Taegliche Datenbanksicherung (siehe "Sicherung und Wiederherstellung").
kubectl apply -f k8s/07-backup-cronjob.yaml
Migrationen + Seed (nur Admin-Konto, KEINE Beispieldaten — siehe Punkt 2
oben) laufen automatisch beim Container-Start —
kubectl rollout status deployment/wabenhain-app -n wabenhain zeigt, wann
der erste Pod bereit ist.
Bei einem neuen Image (Redeploy)
kubectl rollout restart deployment/wabenhain-app -n wabenhain
Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods (idempotent, siehe oben).
Das Deployment nutzt dabei die Strategie Recreate, nicht das
Kubernetes-Standardverhalten RollingUpdate: der alte Pod wird zuerst
beendet, dann der neue gestartet. Das ist mit dem uploads-Volume zwingend —
ein ReadWriteOnce-Claim kann immer nur an einen Pod gebunden sein, ein
parallel hochfahrender neuer Pod bliebe mit
Multi-Attach error for volume in ContainerCreating haengen und der
Rollout liefe in den Timeout. Der Preis sind wenige Sekunden Ausfall pro
Redeploy.
Bekannte Grenzen dieser Manifeste
replicas: 1in05-app-deployment.yaml, bewusst nicht mehr: das Login-/2FA-/Checkout-Rate-Limiting (lib/rate-limit.ts) haelt seinen Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer horizontale Skalierung zuerstlib/rate-limit.tsauf Redis o.ae. umstellen.- Kein
HorizontalPodAutoscaler, keinPodDisruptionBudget, keineNetworkPolicy— je nach Cluster/Anforderungen ergaenzen. - Die Postgres-/MySQL-StatefulSets sind Single-Instance ohne
Replikation. Eine taegliche Sicherung gibt es inzwischen
(
07-backup-cronjob.yaml, siehe "Sicherung und Wiederherstellung"), sie liegt aber im selben Cluster und ersetzt damit weder Replikation noch eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen —DATABASE_URLin02-secret.yamleinfach auf den externen Endpunkt zeigen lassen,03-postgres.yaml/03-mysql.yamldann nicht anwenden.
Sicherung und Wiederherstellung
07-backup-cronjob.yaml legt jede Nacht um 03:15 Uhr eine
gzip-komprimierte pg_dump-Sicherung auf einem eigenen Volume ab und
haelt sie 30 Tage vor. Ohne diese Sicherung liegen Bestellungen,
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
jedes Netz — und Rechnungen unterliegen in Deutschland einer
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
Sicherungen ansehen:
kubectl get cronjob wabenhain-db-backup -n wabenhain
kubectl logs -n wabenhain -l job-name --tail=50 # Ausgabe des letzten Laufs
Sofort eine Sicherung ausloesen (etwa vor einem riskanten Eingriff):
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
Aus dem Cluster herausziehen. Die Sicherung im Cluster schuetzt gegen Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten Clusters oder Standorts. Deshalb regelmaessig (oder per eigenem Cron auf einem Rechner ausserhalb) kopieren:
POD=$(kubectl get pod -n wabenhain -l job-name -o name | head -1)
kubectl exec -n wabenhain "$POD" -- ls -1 /backup
kubectl cp wabenhain/"${POD#pod/}":/backup/wabenhain-2026-01-31_0315.sql.gz ./wabenhain-2026-01-31.sql.gz
Wiederherstellen. Vorher die App anhalten, damit waehrend des
Einspielens niemand schreibt — --clean --if-exists im Dump loescht die
vorhandenen Tabellen zuerst:
kubectl scale deployment/wabenhain-app -n wabenhain --replicas=0
gunzip -c wabenhain-2026-01-31.sql.gz | kubectl exec -i -n wabenhain wabenhain-postgres-0 -- psql -U wabenhain -d wabenhain
kubectl scale deployment/wabenhain-app -n wabenhain --replicas=1
Der App-Container fuehrt beim naechsten Start ohnehin
prisma migrate deploy aus (siehe Dockerfile-CMD) — ein Dump aus einem
aelteren Schemastand wird dadurch automatisch nachgezogen.
Eine ungepruefte Sicherung ist keine Sicherung. Einmal nach der Einrichtung und danach gelegentlich einen Wiederherstellungsversuch gegen eine Wegwerf-Datenbank fahren, statt sich erst im Ernstfall darauf zu verlassen.