Files
Wabenhain/k8s
Nick Szaunig cd8e307faf perf: hochgeladene Bilder und Videos schnell und vollstaendig ausliefern
Vier Befunde aus der Pruefung der Upload-Kette, alle am gemeinsamen
Datenpfad lib/uploads.ts und app/uploads/[...path]/route.ts:

1. Kein Range-Support. Die Ausliefer-Route beantwortete auch einen
   Range-Request mit einer vollstaendigen 200-Antwort. Ein <video> laedt
   sein Material grundsaetzlich per Range; iOS-Safari schickt zuerst die
   Probe "bytes=0-1" und spielt bei einer 200-Antwort gar nicht ab.
   Springen im Hero-Video war ebenfalls unmoeglich. Die Route beantwortet
   Bereichsanfragen jetzt mit 206 samt Content-Range, meldet
   Accept-Ranges und antwortet auf einen unerfuellbaren Bereich mit 416.

2. Ganze Datei im Speicher. readFile() las jede Datei vollstaendig in den
   RAM — bei einem Hero-Video bis MAX_UPLOAD_VIDEO_BYTES pro
   gleichzeitigem Zugriff. Ersetzt durch einen Stream von der Platte,
   inklusive Content-Length (vorher chunked, ohne Groessenangabe).

3. Keine bedingten Anfragen. Ohne ETag lud der Browser bei jedem Reload
   den vollen Bildinhalt erneut. ETag aus Groesse + Aenderungszeit, 304
   auf If-None-Match.

4. Bilder wurden in Originalaufloesung gespeichert. Ein 24-MP-Foto lag
   mit 6000x4000 und 9 MB auf dem Volume, und next/image musste dieses
   Bild fuer JEDE ausgelieferte Breite komplett neu dekodieren.
   reencodeImage() begrenzt jetzt auf MAX_IMAGE_DIMENSION (2560px lange
   Kante, Seitenverhaeltnis erhalten, kleinere Bilder werden nie
   hochskaliert).

Zusaetzlich MAX_UPLOAD_BYTES von 8 auf 25 MB angehoben: 8 MB lagen unter
dem, was aktuelle Handykameras routinemaessig erzeugen — ein normales
Foto brach mit "Die Datei ist zu gross" ab, obwohl es serverseitig
ohnehin verkleinert wird. Gegen eine Dekompressionsbombe (kleine Datei,
die sich zu Milliarden Pixeln entpackt) bekommt sharp mit
MAX_INPUT_PIXELS eine ausdrueckliche Obergrenze von 100 MP, die die
Byte-Grenze allein nicht abdeckt.

Gemessen an einem 10,17-MB-Foto mit 6000x4000: vorher mit 413 abgelehnt,
jetzt als 2560x1707 / 1,88 MB gespeichert; next/image kalt fuer 640px von
180 ms auf 65 ms.

Der Kommentar zur proxy-body-size in k8s/06-ingress.yaml nennt jetzt das
neue Bild-Limit; die Annotation selbst (110m) deckt es unveraendert ab.
2026-09-13 23:16:23 +02:00
..
2026-08-19 07:26:47 +02:00

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. 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 (zweimal: host: und tls.hosts).

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

kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.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)

kubectl rollout restart deployment/wabenhain-app -n wabenhain

Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods (idempotent, siehe oben).

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/ Backup-Automation. Fuer produktiven Betrieb 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.