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]>
87 lines
3.5 KiB
TypeScript
87 lines
3.5 KiB
TypeScript
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");
|
|
});
|
|
});
|