Files
Wabenhain/k8s
Nick SzaunigandClaude Opus 5 c8ddcec04c feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.

Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.

Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.

lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.

Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.

Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:06:33 +02:00
..

Wabenhain in Kubernetes

Rohe Manifeste, mit kubectl apply -f k8s/<datei> einzeln oder als Ordner anwendbar. Nummerierung = empfohlene Reihenfolge.

Einmalige Vorbereitung

  1. Gitea Actions: .gitea/workflows/docker-build.yml baut bei jedem Push auf main das Image <registry>/<owner>/wabenhain:latest (+ :sha-<kurzhash>, :vX.Y.Z bei Tags) in der Gitea-Container-Registry. Ein einzelnes Image reicht fuer 05-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_TOKEN in Gitea anlegen (Personal Access Token mit package-Scope, unter Benutzereinstellungen → Anwendungen → Access Tokens erzeugen, dann unter Repo-Einstellungen → Actions → Secrets als REGISTRY_TOKEN hinterlegen). Der automatisch von Gitea Actions bereitgestellte GITEA_TOKEN reicht dafuer aktuell NICHT aus (fehlendes Package-Write-Recht, Stand heute).

  2. 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 generiert scripts/generate-prisma-clients.mjs (Teil von pnpm build, laeuft also automatisch bei jedem Image-Build) BEIDE Varianten ins selbe Image; die Wahl zwischen ihnen passiert zur Laufzeit ueber DATABASE_PROVIDER, siehe src/lib/prisma.ts. Ein Image reicht also fuer Postgres UND MySQL — nur DATABASE_PROVIDER/DATABASE_URL in 01-configmap.yaml/ 02-secret.yaml muessen zur tatsaechlich gewaehlten Datenbank passen. Beim Container-Start fuehrt derselbe Prozess erst prisma migrate deploy und prisma db seed aus, BEVOR der Next.js-Server startet (siehe Dockerfile-CMD in der runner-Stage) — beide Schritte sind idempotent und bei jedem Neustart gefahrlos, kein separater Migrations-Job noetig.

  3. k8s/02-secret.example.yaml nach k8s/02-secret.yaml kopieren (per .gitignore ausgeschlossen — NIE mit echten Werten committen) und ausfuellen. Alternativ direkt per kubectl create secret, siehe Kommentar in der Beispieldatei.

  4. 01-configmap.yaml: DATABASE_PROVIDER (postgresql/mysql) und NEXT_PUBLIC_SITE_URL (echte Domain) anpassen.

  5. 04-uploads-pvc.yaml: storageClassName auf 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.

  6. image: in 05-app-deployment.yaml zeigt bereits auf git.encry.de/nick.rosenloecher/wabenhain — nur noch den Tag pruefen (:latest passt fuer den jeweils neuesten main-Build). In 06-ingress.yaml shop.example.com durch die echte Domain ersetzen (zweimal: host: und tls.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: 1 in 05-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 zuerst lib/rate-limit.ts auf Redis o.ae. umstellen.
  • Kein HorizontalPodAutoscaler, kein PodDisruptionBudget, keine NetworkPolicy — 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_URL in 02-secret.yaml einfach auf den externen Endpunkt zeigen lassen, 03-postgres.yaml/03-mysql.yaml dann nicht anwenden.

Bilder/Videos verschwinden nach jedem Redeploy

Symptom: nach einem Redeploy sind alle hochgeladenen Produkt-, Ernten- und Inhaltsbilder weg und muessen neu hochgeladen werden.

Ursache: das Volume uploads im App-Deployment ist noch ein emptyDir. Das ist an die Lebensdauer des Pods gebunden — jeder Neustart loescht es. Die Datenbank behaelt dabei ihre Bildpfade, die Dateien dahinter sind aber fort; im Shop erscheinen deshalb leere Flaechen statt einer Fehlermeldung.

Wichtig: Die Gitea-Pipeline baut nur das Image. Sie wendet keine Kubernetes-Manifeste an. Ein kubectl rollout restart holt zwar das neue Image, laesst die Deployment-Definition im Cluster aber unveraendert — die Korrektur unten muss deshalb einmal von Hand angewendet werden:

# 1. Aktuellen Zustand pruefen — steht hier "emptyDir", ist genau das die Ursache:
kubectl get deployment wabenhain-app -n wabenhain   -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
"}'

# 2. Dauerhaften Speicher anlegen und Deployment aktualisieren:
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml

# 3. Gegenprobe — jetzt muss dort ein persistentVolumeClaim stehen:
kubectl get deployment wabenhain-app -n wabenhain   -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
"}'
kubectl get pvc wabenhain-uploads -n wabenhain

Danach ueberstehen Uploads jeden Redeploy. Bereits verlorene Dateien lassen sich nicht wiederherstellen — sie muessen einmalig neu hochgeladen werden.

Das Deployment nutzt dabei strategy: Recreate (siehe Abschnitt oben): ohne das bliebe der neue Pod beim Redeploy an einem Multi-Attach error for volume haengen, weil ein ReadWriteOnce-Claim nur an einen Pod gebunden sein kann.

Kontrolle im laufenden Betrieb: Das Admin-Dashboard gleicht bei jedem Aufruf ab, ob die in der Datenbank hinterlegten Bild-/Videopfade als Dateien vorliegen, und zeigt sonst eine Warnung mit Anzahl und Beispielpfaden (lib/upload-health.ts). Dieser Fall faellt damit sofort auf, statt erst dann, wenn Kundschaft im Shop leere Flaechen sieht.

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.