Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt. Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert: invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war ohnehin das falsche Konzept. INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR: app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung. Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn keine da ist — also bei Rechnungen aus der Zeit davor. Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und ein Wiederholungslauf aenderte daran nichts. Sicherung: der taegliche CronJob packt das Verzeichnis als rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt der Sicherungs-Pod lautlos in "Pending". Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig, aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine Umsatzsteuer aus. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
182 lines
8.8 KiB
YAML
182 lines
8.8 KiB
YAML
# image: unten mit der tatsaechlichen Registry/Tag aus der Gitea-Actions-
|
|
# Pipeline ersetzen. Kein separater Migrations-Job mehr noetig: der
|
|
# Container fuehrt "prisma migrate deploy" + "prisma db seed" selbst aus,
|
|
# BEVOR er den Server startet (siehe Dockerfile-CMD in der runner-Stage) —
|
|
# beide Schritte sind idempotent, laufen also bei jedem Neustart gefahrlos
|
|
# erneut. Readiness-/Liveness-Probes greifen automatisch erst, sobald Port
|
|
# 3000 offen ist, also nach abgeschlossener Migration/Seed.
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: wabenhain-app
|
|
namespace: wabenhain
|
|
spec:
|
|
# ACHTUNG bevor du hochskalierst: lib/rate-limit.ts haelt Login-/2FA-/
|
|
# Checkout-Limits aktuell im Prozessspeicher, nicht in einem gemeinsamen
|
|
# Store. Mit replicas > 1 verteilt sich derselbe Nutzer per Load-Balancing
|
|
# auf mehrere Prozesse mit je eigenem Zaehler — das Limit wird effektiv um
|
|
# den Skalierungsfaktor schwaecher (2 Replicas = 2x mehr Versuche moeglich),
|
|
# 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
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: wabenhain-app
|
|
spec:
|
|
# fsGroup=1001 muss zur "nodejs"-Gruppe (gid 1001) passen, die das
|
|
# Dockerfile fuer den non-root "nextjs"-Laufzeitbenutzer anlegt: ohne
|
|
# dieses Feld mountet Kubernetes das "uploads"-Volume (egal ob emptyDir
|
|
# oder ein echtes PVC) mit root:root-Besitz — das ueberschreibt den im
|
|
# Image bereits per chown gesetzten Besitzer von /app/uploads
|
|
# vollstaendig, und der nicht-root laufende Container koennte trotzdem
|
|
# keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup
|
|
# sorgt dafuer, dass Kubernetes JEDES gemountete Volume zusaetzlich der
|
|
# Gruppe 1001 zuordnet und gruppen-schreibbar macht.
|
|
securityContext:
|
|
fsGroup: 1001
|
|
containers:
|
|
- name: app
|
|
image: git.encry.de/nick.rosenloecher/wabenhain:latest
|
|
ports:
|
|
- containerPort: 3000
|
|
envFrom:
|
|
- configMapRef:
|
|
name: wabenhain-config
|
|
- secretRef:
|
|
name: wabenhain-secrets
|
|
volumeMounts:
|
|
# Muss auf denselben Pfad zeigen wie UPLOAD_DIR in
|
|
# 01-configmap.yaml. Das "uploads"-Volume selbst wird HIER
|
|
# bewusst nicht definiert (kein PV/PVC in diesem Manifest) — das
|
|
# haengt vom tatsaechlichen Storage-Backend ab (SMB/NFS/
|
|
# hostPath/...), das die Betreiberin bereitstellt. Unten im
|
|
# "volumes:"-Block einen passenden Eintrag mit demselben Namen
|
|
# ("uploads") ergaenzen, z.B. ueber eine
|
|
# persistentVolumeClaim.claimName, sonst startet der Pod nicht
|
|
# (fehlende Volume-Referenz).
|
|
- name: uploads
|
|
mountPath: /app/uploads
|
|
# Rechnungsablage, getrennt von den Uploads — auf dieses
|
|
# Verzeichnis zeigt bewusst KEINE oeffentliche Route. Muss zu
|
|
# INVOICE_DIR in 01-configmap.yaml passen, ausfuehrliche
|
|
# Begruendung in 04-uploads-pvc.yaml.
|
|
- name: invoices
|
|
mountPath: /app/invoices
|
|
# Prueft die Datenbank mit (siehe app/api/health/ready/route.ts).
|
|
# Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem
|
|
# Load-Balancing, startet ihn aber nicht neu.
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /api/health/ready
|
|
port: 3000
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 10
|
|
# Ohne diesen Eintrag gilt Kubernetes' Vorgabe von EINER Sekunde.
|
|
# Waehrend ein Admin-Video umgerechnet wird, teilt sich der
|
|
# Node-Prozess die CPU mit ffmpeg — eine Antwort kann dann laenger
|
|
# als eine Sekunde brauchen, ohne dass irgendetwas kaputt waere.
|
|
timeoutSeconds: 5
|
|
# Bewusst der DB-FREIE Endpunkt (siehe app/api/health/route.ts):
|
|
# eine Liveness-Probe an der Datenbank wuerde aus einer
|
|
# voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil
|
|
# der neu startende Container laut Dockerfile-CMD zuerst
|
|
# "prisma migrate deploy" gegen dieselbe gestoerte Datenbank faehrt.
|
|
# Beide Probes zeigten vorher auf "/" — also auf eine
|
|
# force-dynamic-Seite mit DB-Abfragen und vollem React-Rendering
|
|
# alle 10 bzw. 30 Sekunden.
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /api/health
|
|
port: 3000
|
|
# initialDelaySeconds/failureThreshold grosszuegiger als sonst
|
|
# ueblich: der Container migriert/seedet die DB selbst vor dem
|
|
# Serverstart (siehe Kommentar oben) — genug Spielraum, damit ein
|
|
# laengerer erster Migrationslauf nicht faelschlich als haengender
|
|
# Prozess gewertet und der Container neu gestartet wird.
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 30
|
|
failureThreshold: 5
|
|
# Siehe Begruendung an der Readiness-Probe oben. Hier wiegt es
|
|
# schwerer: eine fehlgeschlagene LIVENESS-Probe laesst Kubernetes
|
|
# den Container neu starten. Genau das passierte beim
|
|
# Admin-Video-Upload, solange die Probe mit der Vorgabe von einer
|
|
# Sekunde gegen "/" lief — also gegen die Startseite samt
|
|
# Datenbankabfragen und vollem React-Rendering, waehrend ffmpeg
|
|
# gleichzeitig die CPU belegte.
|
|
timeoutSeconds: 5
|
|
# Bemessen nach dem teuersten Vorgang der Anwendung, dem
|
|
# Admin-Video-Upload: Node laedt den multipart-Body beim
|
|
# request.formData() vollstaendig in den Speicher, und die Datei
|
|
# darf bis zu 100 MB gross sein (MAX_UPLOAD_VIDEO_BYTES in
|
|
# lib/uploads.ts). Zusammen mit dem Grundverbrauch des
|
|
# Next.js-Servers sprengte das ein Limit von 512Mi — der Kernel
|
|
# beendet den Prozess dann mitten im Upload (OOMKilled), und im
|
|
# Browser kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an
|
|
# ("Unexpected token '<', "<!DOCTYPE "... is not valid JSON").
|
|
#
|
|
# Die Route selbst haelt inzwischen nur noch den Body im Speicher
|
|
# und streamt alles Weitere ueber die Platte (siehe
|
|
# app/api/admin/upload-video/route.ts) — 1Gi gibt darueber hinaus
|
|
# Reserve, damit ein zweiter gleichzeitiger Zugriff den Pod nicht
|
|
# kippt.
|
|
#
|
|
# ffmpeg ist CPU-gebunden: mit nur 100m dauert das Umrechnen ein
|
|
# Vielfaches und laeuft eher in ein Proxy-Zeitlimit. Bewusst KEIN
|
|
# CPU-Limit, nur ein hoeherer Request — ein Limit wuerde den
|
|
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
|
|
resources:
|
|
requests:
|
|
cpu: 500m
|
|
memory: 512Mi
|
|
limits:
|
|
memory: 1Gi
|
|
volumes:
|
|
# 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
|
|
persistentVolumeClaim:
|
|
claimName: wabenhain-uploads
|
|
- name: invoices
|
|
persistentVolumeClaim:
|
|
claimName: wabenhain-invoices
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: wabenhain-app
|
|
namespace: wabenhain
|
|
spec:
|
|
selector:
|
|
app: wabenhain-app
|
|
ports:
|
|
- port: 80
|
|
targetPort: 3000
|