"Video-Upload: unerwarteter Fehler. Error: aborted { code: ECONNRESET }"
verdeckte die eigentliche Aussage: nicht die Verarbeitung ist gescheitert,
die Datei ist gar nicht erst vollstaendig angekommen. Der Fall landete in
der allgemeinen Auffanglinie und sah damit aus wie ein Programmfehler.
Ein Abbruch der Verbindung wird jetzt erkannt und mit Mengenangabe geloggt:
[upload-video] Verbindung abgebrochen nach 41943040 von angekuendigten
78643200 Bytes.
Genau diese Zahlen trennen die beiden moeglichen Ursachen. Bricht es immer
bei derselben BYTEZAHL ab, liegt eine Groessengrenze auf dem Weg. Bricht es
nach derselben ZEIT ab, ist es eine Zeitgrenze — und davon liegen mehrere
hintereinander, allen voran Node selbst: dessen requestTimeout beendet einen
Request nach fuenf Minuten und laesst sich nicht konfigurieren. Das ist die
harte Obergrenze fuer die Dauer eines Uploads, unabhaengig davon, dass
MAX_UPLOAD_VIDEO_BYTES 100 MB erlaubt.
Statt eines 500ers antwortet die Route mit 408 und einer Meldung, die zum
Wiederholen und zum vorherigen Verkleinern raet. Die Antwort erreicht die
Kundschaft meist nicht mehr — die Verbindung ist ja weg —, ist aber richtig
fuer den Fall, dass nur eine Zwischenstation aufgegeben hat.
k8s/README.md bekommt dazu einen Abschnitt mit der Tabelle der Zeitgrenzen
und dem ffmpeg-Aufruf, mit dem sich ein Hero-Video vorab auf genau das Mass
bringen laesst, auf das der Server es ohnehin umrechnet. Was darueber
hinausgeht, wird nur uebertragen, um anschliessend weggerechnet zu werden.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
384 lines
17 KiB
Markdown
384 lines
17 KiB
Markdown
# Wabenhain in Kubernetes
|
||
|
||
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
|
||
anwendbar. Nummerierung = empfohlene Reihenfolge.
|
||
|
||
## Einmalige Vorbereitung
|
||
|
||
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` baut bei jedem
|
||
Push auf `main` das Image `<registry>/<owner>/wabenhain:latest` (+
|
||
`:sha-<kurzhash>`, `:vX.Y.Z` bei Tags) in der Gitea-Container-Registry.
|
||
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` — nur noch den Tag pruefen
|
||
(`:latest` passt fuer den jeweils neuesten `main`-Build). 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)
|
||
|
||
```bash
|
||
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
|
||
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`, keine
|
||
`NetworkPolicy` — 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_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.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:
|
||
|
||
```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 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:**
|
||
|
||
```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 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```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.
|