fix: Video-Upload startet den Pod nicht mehr neu

Ein Video ueber 50 MB im Admin hochzuladen, liess den Pod mitten im Upload
neu starten. Ursache war nicht die Dateigroesse, sondern dass ffmpeg sich
nach den Kernen des KNOTENS bemessen hat statt nach dem CPU-Anteil des Pods.

x264 waehlt seine Threadzahl standardmaessig als das 1,5-fache der vom
Betriebssystem gemeldeten Kerne. In einem Container sind das die Kerne des
Knotens: auf einer 16-Kern-Maschine rund 24 Encoder-Threads mit je eigenen
Bildpuffern, bei 4K-Material rund 12 MB je Bild. Das hatte zwei Folgen, die
von aussen beide gleich aussehen:

1. Der Speicherbedarf sprengte das Speicherlimit des Pods, der Kernel
   beendete den Container (OOMKilled).
2. ffmpeg belegte alle Kerne, der Node-Prozess kam nicht mehr zum Zug und
   beantwortete die Liveness-Probe nicht rechtzeitig. Kubelet wertete das
   als haengenden Prozess und startete den Container neu.

Dekoder-, Filter- und Encoder-Threads sind jetzt gedeckelt (Vorgabe 2, ueber
VIDEO_TRANSCODE_THREADS auf bis zu 8 anhebbar). Der Verbrauch ist damit
konstant und unabhaengig davon, auf welchem Knoten der Pod laeuft. Der
"-threads"-Deckel steht bewusst auch VOR "-i": ffmpeg ordnet Optionen der
naechsten Datei zu, ein Deckel erst hinter "-i" wuerde nur den Encoder
begrenzen und das Dekodieren eines 4K-Videos unbegrenzt lassen.

Zusaetzlich "-nostdin" und stdio ["ignore","ignore","pipe"]: der
Kindprozess bekam von spawn() eine Standardeingabe, die niemand bedient,
und eine unbeachtete stdout-Pipe, die volllaufen kann. Beides konnte
ffmpeg bis zum Zeitlimit blockieren.

Die Argumentliste ist als buildFfmpegArgs() ausgelagert und getestet, damit
die Begrenzungen pruefbar sind, ohne ffmpeg wirklich zu starten.

Beide Probes bekommen ausserdem timeoutSeconds: 5. Ohne den Eintrag gilt
Kubernetes' Vorgabe von einer Sekunde — zu knapp, solange sich der
Node-Prozess die CPU mit einem laufenden Transcode teilt. k8s/README.md
beschreibt die Diagnose fuer den Fall, dass im Cluster noch eine aeltere,
von Hand gepflegte Fassung des Deployments laeuft.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 01:01:37 +02:00
1 parent 78c19f1b73
commit b1384c3695
5 files changed
+270 -19

No files matched your search

+12
View File
@@ -193,3 +193,15 @@ ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
# PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath # PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath
# zeigen. # zeigen.
UPLOAD_DIR=./uploads 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=
+13
View File
@@ -86,6 +86,11 @@ spec:
port: 3000 port: 3000
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 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): # Bewusst der DB-FREIE Endpunkt (siehe app/api/health/route.ts):
# eine Liveness-Probe an der Datenbank wuerde aus einer # eine Liveness-Probe an der Datenbank wuerde aus einer
# voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil # voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil
@@ -106,6 +111,14 @@ spec:
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
failureThreshold: 5 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 # Bemessen nach dem teuersten Vorgang der Anwendung, dem
# Admin-Video-Upload: Node laedt den multipart-Body beim # Admin-Video-Upload: Node laedt den multipart-Body beim
# request.formData() vollstaendig in den Speicher, und die Datei # request.formData() vollstaendig in den Speicher, und die Datei
+54
View File
@@ -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 (`lib/upload-health.ts`). Dieser Fall faellt damit sofort auf, statt erst
dann, wenn Kundschaft im Shop leere Flaechen sieht. 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 ## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine `07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
+86
View File
@@ -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");
});
});
+105 -19
View File
@@ -43,8 +43,102 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
* Reverse-Proxy). * Reverse-Proxy).
*/ */
export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> { export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
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", "-y",
"-nostdin",
// Dekoder- und Filterbegrenzung, siehe Kommentar oben: muss vor "-i" stehen.
"-threads",
threadCount,
"-filter_threads",
threadCount,
"-i", "-i",
inputPath, inputPath,
"-an", "-an",
@@ -52,6 +146,10 @@ export async function transcodeVideoForWeb(inputPath: string, outputPath: string
"scale='min(1920,iw)':-2", "scale='min(1920,iw)':-2",
"-c:v", "-c:v",
"libx264", "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 // "veryfast" statt "medium": x264-Presets tauschen Encodier-GESCHWINDIGKEIT
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das // gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit // steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
@@ -75,28 +173,16 @@ export async function transcodeVideoForWeb(inputPath: string, outputPath: string
"+faststart", "+faststart",
outputPath, 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<void> { function runFfmpeg(binary: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => { 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 = ""; let stderr = "";
const timer = setTimeout(() => { const timer = setTimeout(() => {
proc.kill("SIGKILL"); proc.kill("SIGKILL");