Der Kommentar am fsGroup-Feld behauptete, Kubernetes ordne damit JEDES gemountete Volume der Gruppe 1001 zu. Das stimmt nur fuer blockbasierten Speicher. Bei SMB/CIFS und NFS wird fsGroup ignoriert: diese Protokolle kennen keinen per-Datei-Unix-Besitz, den der Knoten nachtraeglich aendern koennte. Ohne uid/gid in den mountOptions erscheint dort alles als root:root, und der als uid 1001 laufende Container scheitert bei jedem Upload mit EACCES — also genau die Meldung "Die Datei konnte nicht gespeichert werden", ohne dass am Deployment etwas falsch aussieht. k8s/README.md bekommt dafuer einen eigenen Abschnitt: Diagnose im laufenden Pod, die noetigen mountOptions (uid=1001, gid=1001 zusaetzlich zu dir_mode/file_mode) und der Hinweis, dass Aenderungen daran erst beim naechsten Einhaengen greifen, ein Rollout-Restart also dazugehoert. Ebenfalls vermerkt: noperm umgeht die Rechtepruefung, statt den Besitz richtigzustellen — es ersetzt uid/gid nicht. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
15 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). Die Domain steht in06-ingress.yaml(host:undtls.hosts) und in01-configmap.yaml(NEXT_PUBLIC_SITE_URL) bereits alswabenhain.com— beide Stellen muessen uebereinstimmen, sonst zeigen Sitemap, Canonical-Tags und die Links in allen Bestellbestaetigungen woandershin.
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.
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.
Server startet beim Video-Upload neu
Symptom: Ein Video im Admin hochladen, und der Pod startet mittendrin neu. Kleine Bilder gehen problemlos durch.
Ursache ist fast immer, dass im Cluster noch eine ältere, von Hand gepflegte Fassung des Deployments läuft und nicht die aus diesem Verzeichnis. Zuerst den Ist-Zustand feststellen:
# Abbruchgrund des letzten Containerlaufs:
kubectl -n wabenhain get pods -l app=wabenhain-app \
-o jsonpath='{.items[0].status.containerStatuses[0].lastState.terminated.reason}{"\n"}'
# Die drei Werte, auf die es ankommt:
kubectl -n wabenhain get deploy wabenhain-app \
-o jsonpath='limit={.spec.template.spec.containers[0].resources.limits.memory}{"\n"}liveness={.spec.template.spec.containers[0].livenessProbe.httpGet.path}{"\n"}timeout={.spec.template.spec.containers[0].livenessProbe.timeoutSeconds}{"\n"}'
Erwartet wird limit=1Gi, liveness=/api/health, timeout=5. Steht dort
512Mi, / oder ein leerer Timeout, ist genau das die Ursache — es gibt zwei
Wege, wie das den Container umbringt:
liveness=/: Die Startseite istforce-dynamic, fragt die Datenbank ab und rendert React vollständig. Ohne eigenentimeoutSecondsgilt Kubernetes' Vorgabe von einer Sekunde. Während ffmpeg das Video umrechnet, teilt sich der Node-Prozess die CPU — die Seite antwortet nicht rechtzeitig, die Probe schlägt fehl, und nachfailureThreshold × periodSecondsstartet kubelet den Container neu./api/healthist stattdessen datenbankfrei und billig.limit=512Mi: Node-Server und ffmpeg teilen sich dasselbe Speicherbudget. Bei einem 4K-Video reicht das nicht, der Kernel beendet den Container (reason=OOMKilled).
Behoben wird beides, indem die Fassung aus diesem Verzeichnis ausgerollt wird:
kubectl apply -f k8s/05-app-deployment.yaml
kubectl -n wabenhain rollout status deploy/wabenhain-app
Wird das Deployment über die Rancher-Oberfläche gepflegt, überschreibt das
nächste Speichern dort ein kubectl apply wieder. Dann die Werte in Rancher
selbst nachziehen (Speicherlimit, beide Probe-Pfade, timeoutSeconds) oder das
Manifest als Quelle übernehmen.
Die Anwendungsseite ist unabhängig davon abgesichert: ffmpeg läuft mit
gedeckelter Threadzahl (VIDEO_TRANSCODE_THREADS, Vorgabe 2), damit sein
Speicher- und CPU-Bedarf nicht mehr von der Größe des Knotens abhängt — siehe
src/lib/video-transcode.ts.
Zusätzlich prüfen: Hat das Deployment gar kein uploads-Volume, ist ein
erfolgreicher Upload beim nächsten Neustart trotzdem weg. Siehe
„Bilder/Videos verschwinden nach jedem Redeploy" oben.
Uploads lassen sich nicht speichern
Symptom: Im Admin erscheint „Die Datei konnte nicht gespeichert werden. Bitte
informiere die Technik." Im Pod-Log steht dahinter ein EACCES.
Ursache ist fast immer der Besitz des gemounteten Volumes. Der Container läuft
als nextjs, uid 1001, Gruppe nodejs gid 1001 (siehe Dockerfile). Das
Image setzt zwar chown nextjs:nodejs /app/uploads, aber ein darübergelegtes
Volume überschreibt das vollständig.
Zuerst feststellen, was der Container tatsächlich sieht:
kubectl -n wabenhain exec deploy/wabenhain-app -- sh -c \
'id; ls -ld /app/uploads /app/invoices; \
touch /app/uploads/.probe && echo SCHREIBEN_OK || echo SCHREIBEN_FEHLT'
Der Fix hängt am Speicher-Backend — die beiden Wege gelten für verschiedene Fälle, nicht alternativ:
Blockspeicher (die meisten CSI-Treiber)
securityContext.fsGroup: 1001 im Deployment. Kubernetes ordnet das Volume
dann der Gruppe 1001 zu und macht es gruppenschreibbar. Steht bereits in
05-app-deployment.yaml.
SMB/CIFS und NFS
Hier wird fsGroup ignoriert. Diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachträglich ändern könnte; ohne
Zuordnung erscheint alles als root:root, und der Container scheitert trotz
fsGroup mit EACCES. Die Zuordnung gehört in die mountOptions des
PersistentVolume:
spec:
mountOptions:
- vers=3.0
- uid=1001 # muss zum Containerbenutzer passen
- gid=1001
- dir_mode=0770
- file_mode=0660
- mfsymlinks
- nobrl # keine Byte-Range-Locks über SMB
dir_mode/file_mode allein genügen nicht: sie bestimmen nur, welche Rechte
angezeigt werden — wem die Datei gehört, entscheiden uid/gid.
noperm (der Client prüft Rechte gar nicht erst) wirkt in vielen Aufbauten
ebenfalls, ist aber die schwächere Zusicherung: es umgeht die Prüfung, statt
den Besitz richtigzustellen. uid/gid also auch dann setzen, wenn noperm
bereits gesetzt ist.
Änderungen an mountOptions greifen erst beim nächsten Einhängen. Nach dem
Anpassen des PV den Pod neu starten:
kubectl -n wabenhain rollout restart deploy/wabenhain-app
Lässt sich das PV nicht mehr ändern (bei bereits gebundenen PVs je nach
Kubernetes-Version), PVC und PV löschen und neu anlegen. Bei
persistentVolumeReclaimPolicy: Retain bleiben die Daten auf der Freigabe
erhalten.
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.