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(