Files
Wabenhain/k8s
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:07:30 +02:00
..

Wabenhain in Kubernetes

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

Woher jeder Wert in 02-secret.yaml und 01-configmap.yaml kommt (Entra ID, Microsoft 365, Stripe, optionale Dienste) und wie man danach prueft, dass alles laeuft: ../docs/EINRICHTUNG.md.

Einmalige Vorbereitung

  1. Gitea Actions: .gitea/workflows/docker-build.yml prueft bei jedem Push auf main lint, audit, typecheck und test und baut erst danach das Image <registry>/<owner>/wabenhain:latest (+ :sha-<kurzhash>, :vX.Y.Z bei Tags) in der Gitea-Container-Registry. Daneben entsteht mit denselben Tags wabenhain-tools fuer die CronJobs in 07-backup-cronjob.yaml. 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 und ist auf einen festen Build gepinnt (:sha-<commit>@sha256:<digest>), nie auf :latest — siehe „Bei einem neuen Image“ unten. Die Domain steht in 06-ingress.yaml (host: und tls.hosts) und in 01-configmap.yaml (NEXT_PUBLIC_SITE_URL) bereits als wabenhain.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)

Das Deployment ist auf Tag und Digest gepinnt. Ein rollout restart holt deshalb kein neues Image, sondern startet nur denselben Build neu (das genuegt nach einer Aenderung an Secret oder ConfigMap). Einen neuen Build rollt man so aus:

# Digest des gewuenschten Builds aus der Gitea-Registry (Paket "wabenhain",
# Tag sha-<kurzer Commit>) uebernehmen:
kubectl -n wabenhain set image deploy/wabenhain-app \
  app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
kubectl -n wabenhain rollout status deploy/wabenhain-app

Danach dieselbe Zeile in 05-app-deployment.yaml nachziehen und committen, damit Repo und Cluster nicht auseinanderlaufen. Laufen die CronJobs aus 07-backup-cronjob.yaml, gehoert ihr wabenhain-tools-Image auf denselben Build (sie nutzen denselben Prisma-Client und muessen zum Datenbankschema passen).

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. Die NetworkPolicys (default-deny-ingress, allow-same-namespace, allow-from-traefik) und die Traefik-Middleware cloudflare-only liegen nicht hier, sondern in den Cluster-Manifesten (Haertung vom 2026-10-12, Ordner rke2-cluster/, nicht unter Git).
  • Repo und Cluster weichen voneinander ab (Stand 2026-10-16): Im Cluster sind wabenhain-uploads (5Gi) und wabenhain-invoices (50Gi) ReadWriteMany auf SMB mit eigenen PersistentVolumes, hier stehen ReadWriteOnce-Claims gegen die Default-StorageClass (5Gi/2Gi). STRIPE_PUBLISHABLE_KEY liegt im Cluster im Secret, hier in der ConfigMap (beides funktioniert, weil die App beide Quellen per envFrom liest). Vor einem kubectl apply auf ein bestehendes Cluster die Unterschiede pruefen.
  • 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 auf derselben Storage Box wie die Datenbank 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.

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 ist force-dynamic, fragt die Datenbank ab und rendert React vollständig. Ohne eigenen timeoutSeconds gilt 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 nach failureThreshold × periodSeconds startet kubelet den Container neu. /api/health ist 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.1.1
    - seal              # SMB-Verschluesselung, die Storage Box liegt im Internet
    - 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.

Video-Upload bricht ab (ECONNRESET)

Symptom im Pod-Log:

[upload-video] Verbindung abgebrochen nach 41943040 von angekündigten 78643200 Bytes.

Das ist kein Fehler der Verarbeitung — die Datei ist gar nicht erst vollständig angekommen. Die Zahlen im Log entscheiden, wo zu suchen ist:

  • Immer bei ungefähr derselben BYTEZAHL → eine Größengrenze auf dem Weg. Traefik begrenzt von sich aus nichts; prüfen, ob eine buffering-Middleware mit maxRequestBodyBytes am Ingress hängt.
  • Immer nach ungefähr derselben ZEIT → eine Zeitgrenze. Davon liegen mehrere hintereinander:
Grenze Vorgabe Wo einstellbar
Node requestTimeout 300 s nicht konfigurierbar — harte Obergrenze für die Dauer eines Uploads
Traefik respondingTimeouts.readTimeout je nach Version entryPoints.websecure.transport.respondingTimeouts in der Traefik-Konfiguration
Browser bzw. Netz dazwischen — —

Die 300 Sekunden von Node sind die eigentliche Obergrenze: Was in dieser Zeit nicht durch die Leitung passt, lässt sich nicht hochladen — unabhängig davon, dass MAX_UPLOAD_VIDEO_BYTES 100 MB erlaubt. Beispiel: 80 MB über eine Upload-Leitung mit 5 Mbit/s brauchen rund 130 Sekunden und gehen durch; bei 2 Mbit/s sind es über 5 Minuten, und der Upload stirbt zuverlässig.

Praktisch heißt das: das Hero-Video vor dem Hochladen verkleinern. Der Server rechnet es ohnehin auf höchstens 1920 px Breite und CRF 26 um (siehe src/lib/video-transcode.ts) — was darüber hinausgeht, wird nur übertragen, um anschließend weggerechnet zu werden. Ein vorab auf diese Größe gebrachtes Video lädt um ein Vielfaches schneller hoch:

ffmpeg -i original.mp4 -an -vf "scale='min(1920,iw)':-2" \
  -c:v libx264 -preset veryfast -crf 26 -movflags +faststart hero.mp4

Sicherung und Wiederherstellung

07-backup-cronjob.yaml legt jede Nacht um 03:15 Uhr (Zeitzone des Clusters, meist UTC) eine gzip-komprimierte pg_dump-Sicherung und ein rechnungen-<Zeitstempel>.tar.gz der Rechnungsablage auf einem eigenen Volume ab und haelt beides 30 Tage vor. Das Volume ist ein SMB-PV auf der Storage Box (//u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain, Unterordner db-backup). Im Cluster ist der Job seit 2026-10-10 angelegt; die letzten drei Laeufe waren erfolgreich, zuletzt am 2026-10-16 um 03:15 UTC. 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 schuetzt gegen Volume-Verlust und Bedienfehler, nicht gegen den Ausfall der Storage Box oder des Standorts. Deshalb regelmaessig (oder per eigenem Cron auf einem Rechner ausserhalb) an einen zweiten Ort kopieren. Die Dateien liegen auf der Storage Box unter Server/Disks/Customer/Wabenhain/db-backup/ und lassen sich dort direkt abholen (SMB, SFTP oder rsync der Storage Box). Ein kubectl exec in den Sicherungs-Pod geht nicht: der Pod ist nach dem Lauf beendet (Completed).

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.