diff --git a/.env.example b/.env.example index 5806689..302a60e 100644 --- a/.env.example +++ b/.env.example @@ -193,3 +193,15 @@ ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback # PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath # zeigen. UPLOAD_DIR=./uploads + +# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf +# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8. +# +# NICHT hochsetzen, um den Upload zu beschleunigen, ohne gleichzeitig das +# Speicherlimit des Pods zu pruefen: x264 haelt pro Thread eigene Bildpuffer, +# bei 4K-Material rund 12 MB je Bild. Ohne Deckel richtet sich ffmpeg nach den +# Kernen des KUBERNETES-KNOTENS statt nach dem CPU-Anteil des Pods — auf einem +# grossen Knoten sprengt das sowohl den Speicher des Pods als auch die +# Liveness-Probe, und der Container wird mitten im Upload neu gestartet. +# Ausfuehrliche Begruendung in src/lib/video-transcode.ts. +VIDEO_TRANSCODE_THREADS= diff --git a/k8s/05-app-deployment.yaml b/k8s/05-app-deployment.yaml index 42aef67..85964e1 100644 --- a/k8s/05-app-deployment.yaml +++ b/k8s/05-app-deployment.yaml @@ -86,6 +86,11 @@ spec: 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 @@ -106,6 +111,14 @@ spec: 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 diff --git a/k8s/README.md b/k8s/README.md index e253a5a..930613e 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -165,6 +165,60 @@ 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. + ## Sicherung und Wiederherstellung `07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine diff --git a/src/lib/video-transcode.test.ts b/src/lib/video-transcode.test.ts new file mode 100644 index 0000000..a6f1b0b --- /dev/null +++ b/src/lib/video-transcode.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from "vitest"; +import { buildFfmpegArgs, transcodeThreadCount, MAX_TRANSCODE_THREADS } from "./video-transcode"; + +/** Alle Werte, die im Argument-Array direkt auf das gesuchte Flag folgen. */ +function valuesAfter(args: string[], flag: string): string[] { + return args.flatMap((arg, index) => (arg === flag ? [args[index + 1]] : [])); +} + +describe("transcodeThreadCount", () => { + test("nutzt die Vorgabe, wenn nichts konfiguriert ist", () => { + // Arrange & Act + const threads = transcodeThreadCount(undefined); + + // Assert: eine feste, kleine Zahl — bewusst NICHT die Kernzahl des + // Knotens, siehe Begruendung in video-transcode.ts. + expect(threads).toBe(2); + }); + + test("uebernimmt einen gueltigen konfigurierten Wert", () => { + expect(transcodeThreadCount("4")).toBe(4); + }); + + test("faellt bei unsinnigen Werten auf die Vorgabe zurueck", () => { + // Arrange & Act & Assert: eine kaputte Env-Var darf den Upload nicht in + // einen unbegrenzten ffmpeg-Lauf kippen lassen. + expect(transcodeThreadCount("")).toBe(2); + expect(transcodeThreadCount("0")).toBe(2); + expect(transcodeThreadCount("-3")).toBe(2); + expect(transcodeThreadCount("2.5")).toBe(2); + expect(transcodeThreadCount("viele")).toBe(2); + }); + + test("deckelt nach oben, damit die Obergrenze nicht umgangen werden kann", () => { + expect(transcodeThreadCount("999")).toBe(MAX_TRANSCODE_THREADS); + }); +}); + +describe("buildFfmpegArgs", () => { + test("begrenzt Dekoder-, Filter- und Encoder-Threads auf denselben Wert", () => { + // Arrange + const threads = 2; + + // Act + const args = buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", threads); + + // Assert: ohne diese Deckel waehlt x264 seine Threadzahl nach den Kernen + // des KNOTENS, nicht nach dem CPU-Anteil des Pods — genau das sprengte + // das Speicherlimit und blockierte die Liveness-Probe. + expect(valuesAfter(args, "-threads")).toEqual(["2", "2"]); + expect(valuesAfter(args, "-filter_threads")).toEqual(["2"]); + }); + + test("setzt den Dekoder-Deckel vor die Eingabedatei", () => { + // Arrange & Act + const args = buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", 2); + + // Assert: ffmpeg ordnet Optionen der NAECHSTEN Datei zu. Ein "-threads" + // erst nach "-i" gilt nur noch dem Encoder — das Dekodieren eines + // 4K-Videos bliebe unbegrenzt. + const firstThreads = args.indexOf("-threads"); + const input = args.indexOf("-i"); + expect(firstThreads).toBeGreaterThanOrEqual(0); + expect(firstThreads).toBeLessThan(input); + }); + + test("laesst ffmpeg nie auf die Standardeingabe warten", () => { + // Arrange & Act & Assert: der Kindprozess bekommt eine Pipe als stdin, + // die niemand bedient — ohne -nostdin kann ffmpeg dort auf + // Tastatureingaben warten und der Upload haengt bis zum Zeitlimit. + expect(buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", 2)).toContain("-nostdin"); + }); + + test("behaelt die web-optimierenden Einstellungen bei", () => { + // Arrange & Act + const args = buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", 2); + + // Assert: Tonspur weg, auf 1920 begrenzt, faststart — die Gruende stehen + // am Modulkommentar in video-transcode.ts. + expect(args).toContain("-an"); + expect(valuesAfter(args, "-vf")).toEqual(["scale='min(1920,iw)':-2"]); + expect(valuesAfter(args, "-movflags")).toEqual(["+faststart"]); + expect(valuesAfter(args, "-crf")).toEqual(["26"]); + expect(valuesAfter(args, "-i")).toEqual(["/tmp/in.mp4"]); + expect(args[args.length - 1]).toBe("/tmp/out.mp4"); + }); +}); diff --git a/src/lib/video-transcode.ts b/src/lib/video-transcode.ts index e2dc9fa..64be0f1 100644 --- a/src/lib/video-transcode.ts +++ b/src/lib/video-transcode.ts @@ -43,8 +43,102 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000; * Reverse-Proxy). */ export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise { - const args = [ + const args = buildFfmpegArgs( + inputPath, + outputPath, + transcodeThreadCount(process.env.VIDEO_TRANSCODE_THREADS) + ); + + try { + // Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add + // ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter + // /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur. + await runFfmpeg("ffmpeg", args); + } catch (error: unknown) { + // ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein + // lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per + // npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev" + // ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine + // tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert + // durchschlagen, nicht stillschweigend erneut versucht werden. + const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT"; + if (!isMissingBinary || !ffmpegStaticPath) throw error; + await runFfmpeg(ffmpegStaticPath, args); + } +} + +/** + * Hoechstzahl an Threads, die sich ueber VIDEO_TRANSCODE_THREADS einstellen + * laesst. Der Deckel ist Absicht: die Variable soll den Transcode auf einem + * groesseren Knoten beschleunigen koennen, aber nicht die Begrenzung + * aushebeln, um die es hier ueberhaupt geht. + */ +export const MAX_TRANSCODE_THREADS = 8; + +/** + * Vorgabe, wenn nichts konfiguriert ist. Zwei Threads lassen auf jedem + * realistischen Knoten genug Rechenzeit fuer den Node-Prozess uebrig, damit + * der die Liveness-Probe weiter bedienen kann. + */ +const DEFAULT_TRANSCODE_THREADS = 2; + +/** + * Liest die gewuenschte Threadzahl aus der Umgebung und faellt bei allem, + * was keine sinnvolle ganze Zahl ist, auf die Vorgabe zurueck. + * + * Bewusst KEINE Ableitung aus os.cpus(): in einem Container meldet das die + * Kerne des KNOTENS, nicht den CPU-Anteil des Pods. Genau diese Verwechslung + * ist die Ursache des Absturzes, den diese Funktion verhindert — sie darf + * nicht durch die Hintertuer zurueckkommen. + */ +export function transcodeThreadCount(raw: string | undefined): number { + const parsed = Number(raw); + if (!raw || !Number.isInteger(parsed) || parsed < 1) return DEFAULT_TRANSCODE_THREADS; + return Math.min(parsed, MAX_TRANSCODE_THREADS); +} + +/** + * Baut die ffmpeg-Argumentliste. Ausgelagert und exportiert, damit die + * Begrenzungen pruefbar sind, ohne ffmpeg wirklich zu starten. + * + * Die drei Thread-Deckel sind der eigentliche Fix gegen den Pod-Absturz beim + * Video-Upload: + * + * x264 waehlt seine Threadzahl sonst als das 1,5-fache der Kerne, die das + * Betriebssystem meldet — in Kubernetes sind das die Kerne des KNOTENS, nicht + * der CPU-Anteil des Pods. Auf einem 16-Kern-Knoten sind das rund 24 + * Encoder-Threads mit je eigenen Bildpuffern; bei 4K-Material ist ein + * einzelnes Bild rund 12 MB gross. Zwei Folgen, beide toedlich: + * + * 1. Der Speicherbedarf sprengt das Speicherlimit des Pods, der Kernel + * beendet den Container (OOMKilled) — mitten im Upload. + * 2. ffmpeg belegt alle Kerne, der Node-Prozess kommt nicht mehr zum Zug und + * beantwortet die Liveness-Probe nicht rechtzeitig. Kubelet wertet das als + * haengenden Prozess und startet den Container neu. + * + * Beides sieht von aussen gleich aus: "der Server stuerzt beim Video-Upload + * ab". Mit gedeckelten Threads bleibt der Verbrauch konstant und unabhaengig + * davon, auf welchem Knoten der Pod gerade laeuft. + * + * "-threads" muss VOR "-i" stehen, um auch das Dekodieren zu begrenzen: + * ffmpeg ordnet Optionen jeweils der naechsten Datei zu. Ein "-threads" erst + * hinter "-i" gilt nur dem Encoder, und das Dekodieren eines 4K-Videos + * bliebe unbegrenzt. + * + * "-nostdin" verhindert, dass ffmpeg auf Tastatureingaben wartet: der + * Kindprozess bekommt von spawn() eine Pipe als Standardeingabe, die niemand + * bedient. + */ +export function buildFfmpegArgs(inputPath: string, outputPath: string, threads: number): string[] { + const threadCount = String(threads); + return [ "-y", + "-nostdin", + // Dekoder- und Filterbegrenzung, siehe Kommentar oben: muss vor "-i" stehen. + "-threads", + threadCount, + "-filter_threads", + threadCount, "-i", inputPath, "-an", @@ -52,6 +146,10 @@ export async function transcodeVideoForWeb(inputPath: string, outputPath: string "scale='min(1920,iw)':-2", "-c:v", "libx264", + // Encoder-Begrenzung (x264). Ohne sie bliebe der Encoder unbegrenzt, + // auch wenn das Dekodieren oben schon gedeckelt ist. + "-threads", + threadCount, // "veryfast" statt "medium": x264-Presets tauschen Encodier-GESCHWINDIGKEIT // gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das // steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit @@ -75,28 +173,16 @@ export async function transcodeVideoForWeb(inputPath: string, outputPath: string "+faststart", outputPath, ]; - - try { - // Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add - // ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter - // /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur. - await runFfmpeg("ffmpeg", args); - } catch (error: unknown) { - // ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein - // lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per - // npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev" - // ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine - // tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert - // durchschlagen, nicht stillschweigend erneut versucht werden. - const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT"; - if (!isMissingBinary || !ffmpegStaticPath) throw error; - await runFfmpeg(ffmpegStaticPath, args); - } } function runFfmpeg(binary: string, args: string[]): Promise { return new Promise((resolve, reject) => { - const proc = spawn(binary, args); + // stdin bewusst "ignore": ffmpeg soll dort nie auf eine Eingabe warten + // (zusaetzlich zu -nostdin). stdout ebenfalls "ignore" — die Ausgabe geht + // in eine Datei, ffmpeg schreibt dorthin nichts; eine unbeachtete Pipe + // koennte dagegen volllaufen und den Prozess blockieren. Nur stderr wird + // gelesen, dort steht die Diagnose. + const proc = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] }); let stderr = ""; const timer = setTimeout(() => { proc.kill("SIGKILL");