From a78c1cd2355d7fdb0476e60f5a9f3cb26bc5e72b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 15 Sep 2026 10:13:06 +0200 Subject: [PATCH] fix: hochgeladene Bilder ueberleben einen Redeploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das uploads-Volume im App-Deployment war ein emptyDir-Platzhalter. Der ist an die Lebensdauer des Pods gebunden: bei jedem Neustart, jedem Redeploy und jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt-, Ernten- und Inhaltsfotos weg. Die Datenbank behaelt dabei ihre Bildpfade — die Seite haette danach ueberall kaputte Bilder gezeigt, ohne dass irgendwo ein Fehler aufgetaucht waere. Neu: k8s/04-uploads-pvc.yaml (die Nummer war in der Reihenfolge noch frei) mit einem PersistentVolumeClaim, den das Deployment jetzt bindet. storageClassName ist bewusst auskommentiert, damit das Manifest ohne Aenderung gegen die Default-StorageClass des Clusters anwendbar bleibt. Damit einher geht zwingend "strategy: Recreate": Kubernetes' Standard "RollingUpdate" startet den neuen Pod, BEVOR der alte beendet ist. Ein ReadWriteOnce-Claim kann aber nur an einen Pod gebunden sein — der neue Pod bliebe mit "Multi-Attach error for volume" in ContainerCreating haengen, und das im k8s/README.md dokumentierte "kubectl rollout restart" liefe schlicht in den Timeout. Recreate kostet wenige Sekunden Ausfall pro Redeploy und sorgt nebenbei dafuer, dass nie zwei Pods gleichzeitig laufen, die sich sonst die im Prozessspeicher gehaltenen Rate-Limits teilen wuerden. Alle Manifeste gegen einen YAML-Parser geprueft, zusaetzlich strukturell: claimName trifft den PVC-Namen, PVC liegt im selben Namespace, und der mountPath stimmt mit UPLOAD_DIR aus 01-configmap.yaml ueberein. preflight: NEXT_PUBLIC_SITE_URL wird jetzt auch gegen "example.com" geprueft. Die ausgelieferte Vorgabe ist "shop.example.com" — blieb sie stehen, zeigten Sitemap, Canonicals und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain, ohne dass die bisherigen HTTPS-/localhost-Pruefungen angeschlagen haetten. Co-Authored-By: Claude Opus 5 (1M context) --- k8s/04-uploads-pvc.yaml | 40 ++++++++++++++++++++++++++++++++++++++ k8s/05-app-deployment.yaml | 40 ++++++++++++++++++++++++++------------ k8s/README.md | 21 +++++++++++++++++++- scripts/preflight.mjs | 5 +++++ 4 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 k8s/04-uploads-pvc.yaml diff --git a/k8s/04-uploads-pvc.yaml b/k8s/04-uploads-pvc.yaml new file mode 100644 index 0000000..9a672fc --- /dev/null +++ b/k8s/04-uploads-pvc.yaml @@ -0,0 +1,40 @@ +# Persistenter Speicher fuer hochgeladene Bilder und Videos (Produkt-, +# Ernten- und Inhaltsfotos aus dem Admin-Bereich, siehe +# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts). +# +# WARUM EIGENES MANIFEST: 05-app-deployment.yaml hatte hier bisher nur einen +# "emptyDir"-Platzhalter. Der ist an die Lebensdauer des Pods gebunden — +# jeder Neustart, jeder Redeploy und jede Node-Verlagerung loescht saemtliche +# hochgeladenen Bilder. Die Datenbank behaelt dabei die Bildpfade, die +# Dateien dahinter sind aber weg: die Seite zeigt danach ueberall kaputte +# Bilder statt der Produktfotos, ohne dass irgendwo ein Fehler auftaucht. +# +# ANZUPASSEN VOR DEM ANWENDEN: +# - storageClassName: auf eine im Cluster vorhandene StorageClass setzen +# ("kubectl get storageclass" zeigt die verfuegbaren; die als +# "(default)" markierte wird verwendet, wenn das Feld ganz entfernt +# wird). Die Zeile ist bewusst auskommentiert, damit das Manifest ohne +# Aenderung gegen die Default-StorageClass anwendbar bleibt. +# - storage: Groesse nach erwartetem Bildbestand. 5Gi reichen fuer einige +# tausend optimierte Produktfotos; das Hero-Video (bis 100 MB roh, siehe +# MAX_UPLOAD_VIDEO_BYTES in lib/uploads.ts) faellt dabei kaum ins +# Gewicht. +# +# ZUGRIFFSMODUS: ReadWriteOnce genuegt, weil das Deployment mit genau einer +# Replica und der Strategie "Recreate" laeuft (siehe 05-app-deployment.yaml) +# — es greift also nie mehr als ein Pod gleichzeitig darauf zu. Soll spaeter +# hochskaliert werden, braucht es hier ReadWriteMany (NFS/SMB/CephFS) UND +# einen gemeinsamen Rate-Limit-Store (siehe Kommentar zu replicas in +# 05-app-deployment.yaml). +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wabenhain-uploads + namespace: wabenhain +spec: + accessModes: + - ReadWriteOnce + # storageClassName: "dein-storage-class" + resources: + requests: + storage: 5Gi diff --git a/k8s/05-app-deployment.yaml b/k8s/05-app-deployment.yaml index bf64df0..42aef67 100644 --- a/k8s/05-app-deployment.yaml +++ b/k8s/05-app-deployment.yaml @@ -19,6 +19,23 @@ spec: # ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst # lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen. replicas: 1 + # PFLICHT in Verbindung mit dem uploads-Volume (siehe 04-uploads-pvc.yaml): + # die Standardstrategie "RollingUpdate" startet den NEUEN Pod, bevor der + # alte beendet ist. Ein ReadWriteOnce-Volume kann aber immer nur an einen + # Pod gebunden sein — der neue Pod bliebe in ContainerCreating mit + # "Multi-Attach error for volume" haengen, und ein + # "kubectl rollout restart" (der im k8s/README.md dokumentierte Weg fuer + # ein neues Image) liefe schlicht in den Timeout. + # + # "Recreate" beendet den alten Pod zuerst. Das kostet waehrend des + # Redeploys ein paar Sekunden Ausfall — bei einer Replica ist der + # Unterschied ohnehin gering, und der Container migriert beim Start erst + # die Datenbank, bevor er Verkehr annimmt. Der Nebeneffekt ist erwuenscht: + # es laufen nie zwei Pods gleichzeitig, die sich sonst die im + # Prozessspeicher gehaltenen Rate-Limits teilen wuerden (siehe Kommentar + # zu replicas oben). + strategy: + type: Recreate selector: matchLabels: app: wabenhain-app @@ -116,19 +133,18 @@ spec: limits: memory: 1Gi volumes: - # Platzhalter — die Betreiberin ergaenzt hier ihre eigene - # Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen - # ein SMB-/NFS-Backend oder einen hostPath gebunden ist), etwa: - # - name: uploads - # persistentVolumeClaim: - # claimName: wabenhain-uploads-pvc + # Bindet den PersistentVolumeClaim aus 04-uploads-pvc.yaml — dieses + # Manifest MUSS vor dem Deployment angewendet werden, sonst bleibt + # der Pod in "Pending" (unaufloesbare Volume-Referenz). + # + # Vorher stand hier ein emptyDir-Platzhalter. Das ist kein + # persistenter Speicher: bei jedem Pod-Neustart, jedem Redeploy und + # jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt- und + # Erntenfotos weg, waehrend die Datenbank ihre Pfade behielt — die + # Seite zeigte danach ueberall kaputte Bilder, ohne Fehlermeldung. - name: uploads - # CHANGE-ME: obige emptyDir-Zeile durch die echte Volume-Quelle - # ersetzen. emptyDir ist NICHT persistent (Inhalt geht bei jedem - # Pod-Neustart verloren) und dient hier nur als Platzhalter, damit - # dieses Manifest ohne weitere Aenderung "kubectl apply"-faehig - # bleibt. - emptyDir: {} + persistentVolumeClaim: + claimName: wabenhain-uploads --- apiVersion: v1 kind: Service diff --git a/k8s/README.md b/k8s/README.md index ed07052..9f19772 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -42,7 +42,13 @@ anwendbar. Nummerierung = empfohlene Reihenfolge. 4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und `NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen. -5. `image:` in `05-app-deployment.yaml` zeigt bereits auf +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 @@ -61,6 +67,10 @@ kubectl apply -f k8s/03-postgres.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 ``` @@ -79,6 +89,15 @@ 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 diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index 85e4478..77168ac 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -78,6 +78,11 @@ must( siteUrl.startsWith("https://") && !siteUrl.includes("localhost"), "Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap." ); +must( + "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter", + !siteUrl.includes("example.com") && !siteUrl.includes("example.org"), + "k8s/01-configmap.yaml und k8s/06-ingress.yaml liefern \"shop.example.com\" als Vorgabe aus. Bleibt der Wert stehen, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain." +); // --- Zahlung --------------------------------------------------------------- must(