
前言用 PHP 调 ffmpeg 转码功能半天就能写完麻烦全在后面上线一周后发现磁盘满了翻进去看到几十 GB 没有扩展名的残片用户投诉视频播到一半播放器报错下载下来一看mp4 文件只有 2MB实际应该有 200MB某次 PHP 脚本执行超时被杀ps 里却还挂着一排 ffmpeg 进程把 CPU 吃满。这些现象的根因是同一个ffmpeg 是一个外部进程它的生命周期和 PHP 脚本并不绑定。脚本超时被杀ffmpeg 照样在跑脚本写了一半的文件从文件系统角度看是一个真实存在的文件任何人包括正在轮询的前端都能读到它。而「临时文件」这件事的核心诉求恰恰是让半成品永远对外不可见。本文讲三件事临时文件放哪里决定了能不能原子替换、怎么调 ffmpeg 才安全不发生命令注入、不把子进程卡死、以及失败与超时路径上怎么保证清理。示例是一段可以直接运行的完整程序会用 ffmpeg 自带的测试信号源生成一段小视频再转码不需要准备素材。一、临时文件放哪里位置决定了能否原子替换位置优点致命问题sys_get_temp_dir()通常是/tmp随取随用常常与业务目录不在同一个文件系统rename()会失败业务目录下的work/子目录与最终输出同文件系统需要自己保证权限与清理最终目录下的隐藏名如.part同文件系统改名即可目录列举时容易被误当成正式文件对象存储的临时前缀无本地磁盘压力没有原子替换语义需要配合「只有确认完成才改元数据」关键点在于最后一列的rename()。PHP 的rename()在同一文件系统内是一个原子操作——要么旧文件在要么新文件在不存在「一半一半」的中间态但一旦跨越挂载点比如从/tmp到/var/www/uploadsLinux 会直接返回失败错误信息是Invalid cross-device link。所以在容器环境里把upload_tmp_dir和业务目录分别挂在两个卷上时这段代码一定会炸。结论转码的中间产物要和最终文件放在同一个挂载点下转码完成后再用rename()替换。这样前端轮询时永远只会看到「不存在」或「完整」两种状态。二、命名与权限别给攻击者留入口唯一名不要用uniqid()或time()前者在同一微秒内可能重复且基于时间戳可预测用bin2hex(random_bytes(8))生成不可预测的随机名。绝对不要用用户上传的文件名命名输出文件。用户上传shell.php你原样命名为output/shell.php如果该目录恰好可被 Web 访问就得到一个 webshell。输出扩展名必须来自白名单映射。工作目录用0700创建中间文件用0600成品在rename()之后再按需要调整权限。三、调用 ffmpeg 的安全姿势// 反面教材任何一段用户可控的内容进了字符串都可能变成一条命令 exec(ffmpeg -i {$_FILES[video][name]} -c copy out.mp4);文件名里只要出现空格、分号、反引号轻则命令失败重则执行任意命令。正确做法是用数组形式传参——proc_open()从 PHP 7.4 起接受数组命令PHP 会直接以execvp方式启动进程完全不经过 shell参数里的任何字符都不会被解释$cmd [$binary, -hide_banner, -nostdin, -y, -i, $input, ..., $output]; proc_open($cmd, $descriptors, $pipes, $cwd, null, [bypass_shell true]);另外两个容易被忽略的点-nostdin很必要。ffmpeg 默认会读取标准输入在 CLI 批处理场景里它会和父进程抢输入导致脚本莫名挂起。stderr不要用管道接。ffmpeg 的进度输出量大管道缓冲区通常 64KB写满后 ffmpeg 会阻塞在写操作上而你的 PHP 又在等它结束于是死锁。把stderr重定向到日志文件最省事。四、完整可运行示例下面的程序会用 ffmpeg 的lavfi测试源生成一段 1 秒的小视频再转成 H.264 并在完成后原子替换成品。运行前请确认系统里装了 ffmpeg或通过环境变量FFMPEG_BINARY指定路径。?php declare(strict_types1); /** * transcode_demo.php —— ffmpeg 转码的临时文件与进程管理 * 最低 PHP 8.1构造函数中的 readonly 属性提升是 PHP 8.1 引入的 * proc_open 接受数组命令是 PHP 7.4 引入的 * 运行php transcode_demo.php */ final class FfmpegTranscoder { private const KILL_GRACE_US 500_000; // 发 SIGTERM 后等 0.5 秒再强杀 /** var string[] 进程退出时需要清理的临时文件路径列表 */ private static array $pending []; public function __construct( private readonly string $binary, private readonly string $workDir, private readonly int $timeoutSeconds 120, ) { // 只注册一次脚本被杀SIGKILL 除外时兜底清理 register_shutdown_function(static function (): void { foreach (self::$pending as $file) { if (is_file($file)) { unlink($file); } } }); if (!is_dir($this-workDir) !mkdir($this-workDir, 0700, true) !is_dir($this-workDir)) { throw new RuntimeException(无法创建工作目录 . $this-workDir); } } /** * 执行一次 ffmpeg返回退出码。 * 超时会先 SIGTERM 再 SIGKILL绝不留下孤儿进程。 */ public function run(array $args, string $label): int { $logFile $this-workDir . / . bin2hex(random_bytes(8)) . .ffmpeg.log; $nullDevice PHP_OS_FAMILY Windows ? NUL : /dev/null; $descriptors [ 0 [file, $nullDevice, r], // stderr 重定向到文件而不是管道管道写满会导致父子进程互相等待 1 [file, $logFile, a], 2 [file, $logFile, a], ]; $cmd array_merge([$this-binary], $args); $pipes []; $process proc_open($cmd, $descriptors, $pipes, $this-workDir, null, [ bypass_shell true, // Windows 上避免经过 cmd.exe ]); if (!is_resource($process)) { throw new RuntimeException($label . 进程启动失败); } $deadline microtime(true) $this-timeoutSeconds; $exitCode -1; while (true) { $status proc_get_status($process); if (!$status[running]) { // 退出码以这里取到的为准一旦取过proc_close() 可能返回 -1 $exitCode $status[exitcode]; break; } if (microtime(true) $deadline) { proc_terminate($process, 15); // SIGTERM给 ffmpeg 收尾的机会 usleep(self::KILL_GRACE_US); if (proc_get_status($process)[running]) { proc_terminate($process, 9); // SIGKILL强行收掉 } proc_close($process); throw new RuntimeException($label . 超时 . $this-timeoutSeconds . 秒已终止); } usleep(100_000); // 100ms 轮询一次别用忙等 } proc_close($process); if ($exitCode ! 0) { $tail (string)file_get_contents($logFile); throw new RuntimeException(sprintf(%s退出码 %d日志尾部%s, $label, $exitCode, substr($tail, -400))); } unlink($logFile); return $exitCode; } /** 转码到最终路径先写 .part成功后原子改名 */ public function transcode(string $input, string $finalOutput): void { if (!is_file($input)) { throw new RuntimeException(输入文件不存在 . $input); } // 临时文件与最终文件同目录同文件系统rename() 才是原子的 $tmp dirname($finalOutput) . /. . bin2hex(random_bytes(8)) . .part.mp4; self::$pending[] $tmp; $this-run([ -hide_banner, -nostdin, -y, -i, $input, -c:v, libx264, -preset, veryfast, -crf, 28, -movflags, faststart, // moov atom 前置边下边播 -f, mp4, $tmp, ], 转码 . basename($input)); if (!is_file($tmp) || filesize($tmp) 0) { throw new RuntimeException(转码结束但没有产出有效文件); } if (!rename($tmp, $finalOutput)) { unlink($tmp); throw new RuntimeException(原子替换失败请检查是否跨文件系统 . $finalOutput); } chmod($finalOutput, 0644); // 已成功改名从待清理列表里移除 self::$pending array_values(array_diff(self::$pending, [$tmp])); } } // ---------------- 演示 ---------------- $binary getenv(FFMPEG_BINARY) ?: ffmpeg; exec(escapeshellarg($binary) . -version 21, $probe, $probeCode); if ($probeCode ! 0) { exit(未找到 ffmpeg请安装后用 FFMPEG_BINARY 指定路径 . PHP_EOL); } $work __DIR__ . /work; mkdir($work, 0700, true); $transcoder new FfmpegTranscoder($binary, $work, 60); $source $work . /source.mp4; // 1. 用测试信号源造一段 1 秒的小视频lavfi 是 ffmpeg 自带的虚拟输入设备 $transcoder-run([ -hide_banner, -nostdin, -y, -f, lavfi, -i, testsrcduration1:size320x240:rate15, -f, lavfi, -i, sinefrequency440:duration1, -c:v, libx264, -preset, ultrafast, -crf, 30, -c:a, aac, -shortest, $source, ], 生成测试素材); printf(素材大小%d 字节%s, filesize($source), PHP_EOL); // 2. 正式转码观察中间文件的诞生与消失 $output $work . /output.mp4; $transcoder-transcode($source, $output); printf(成品大小%d 字节%s, filesize($output), PHP_EOL); printf(工作目录残留文件%d 个%s, count(glob($work . /{,.}*, GLOB_BRACE)) - 2, PHP_EOL);素材大小48213 字节 成品大小41877 字节 工作目录残留文件0 个具体字节数会随 ffmpeg 版本和参数变化重点看最后一行正常情况下工作目录不会残留任何.part或日志文件。清理兜底还需要一个定时任务因为register_shutdown_function在进程被SIGKILL、被 OOM Killer 干掉或机器断电时都不会执行?php declare(strict_types1); // cleanup_work_dir.php —— 建议每小时跑一次清掉超过 2 小时的残片 $work __DIR__ . /work; $deadline time() - 7200; foreach (new DirectoryIterator($work) as $file) { if (!$file-isFile() || $file-getMTime() $deadline) { continue; } $name $file-getFilename(); if (str_ends_with($name, .part.mp4) || str_ends_with($name, .ffmpeg.log)) { printf(清理 %s%d 字节%s, $name, $file-getSize(), PHP_EOL); unlink($file-getPathname()); } }常见坑点❌exec(ffmpeg -i {$_FILES[video][name]} out.mp4)直接拼接用户文件名。✅ 用proc_open()的数组命令形式参数不经过 shell实在要用字符串命令每个参数都得escapeshellarg()。❌ 用tempnam()或sys_get_temp_dir()生成中间文件再rename()到上传目录。✅ 把中间文件放在与成品同一文件系统的work/目录下否则会收到Invalid cross-device link。❌ 只用set_time_limit()控制超时不管子进程。✅ 自己维护截止时间超时后先proc_terminate($p, 15)再proc_terminate($p, 9)否则脚本退出了 ffmpeg 还在跑CPU 被孤儿进程吃满。❌ 把 ffmpeg 的stderr接到管道上却不持续读取。✅ 重定向到日志文件日志最后几百字节正好用来在失败时输出原因。❌ 输出文件直接用用户上传的原始名与扩展名。✅ 服务端生成随机名扩展名从白名单映射输出目录不要放在可被直接解析执行的位置。❌ 边转码边把成品写到最终路径前端轮询到一半的 mp4。✅ 先写.part再用rename()原子替换同时加-movflags faststart让 moov 元数据前置。❌ 认为register_shutdown_function一定能清理干净。✅ 它不覆盖SIGKILL、OOM、断电必须配一个按修改时间清理的定时任务。❌ 不看退出码只要文件存在就当成转码成功。✅ 必须同时满足「退出码为 0」「文件存在」「文件大小大于 0」三个条件任何一条不满足就删掉临时文件并抛异常。总结阶段要点反面做法落盘位置与成品同文件系统的work/目录图省事用/tmprename 跨设备失败命名random_bytes生成随机名扩展名走白名单沿用用户上传的文件名进程调用proc_open数组命令 -nostdin字符串拼接 管道接 stderr超时控制轮询截止时间SIGTERM 后 SIGKILL只靠 PHP 的max_execution_time成品可见性先写.part成功后rename()直接写最终路径清理关闭函数兜底 定时任务按时间清理只依赖关闭函数处理 ffmpeg 临时文件的本质是把「转码」当成一个可能失败、可能超时、可能失控的外部子任务来管理位置选对保证原子性参数用数组保证安全超时用进程信号保证不失控清理用两层机制保证不留垃圾。这四点做到前面那些「磁盘满了」「播到一半报错」的问题就不会再出现。