Files
Wabenhain/k8s/README.md
T
Nick SzaunigandClaude Opus 5.5 96c101804d
Docker Image bauen und veroeffentlichen / lint (push) Successful in 1m41s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 1m42s
Docker Image bauen und veroeffentlichen / test (push) Successful in 2m40s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 8m21s
docs: README, k8s/README und EINRICHTUNG auf Stand main (2026-10-16)
README: Abschnitt Projektstand (Issues #34, #33), veraltete Aussagen zu
Platzhaltern und Rechnungsnummern korrigiert, CI-Gate, Rollout per set
image, Client-IP hinter Cloudflare, VAT_RATE_PERCENT_SONSTIGES, Teststand.
k8s/README: Image-Pinning, Abweichungen Repo/Cluster, Sicherung auf der
Storage Box, SMB 3.1.1 mit seal. EINRICHTUNG: APICORE_WEBHOOK_SECRET,
ERP_EXPORT_TOKEN, Abschnitt Neuen Build ausrollen. .env.example:
DATABASE_POOL_SIZE.

Refs #32

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

422 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../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
```bash
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:
```bash
# 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
`NetworkPolicy`s (`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:
```bash
# 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:
```bash
# 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:
```bash
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:
```bash
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:
```yaml
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:
```bash
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:
```bash
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:**
```bash
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):
```bash
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:
```bash
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.