长视频处理 OOM 排查实战:从 SIGKILL 到流式加载的完整链路
returncode=-9 不只是”内存不足”——当 torchaudio.load() 吃掉 2 倍文件内存、tmpfs 驻留 184MB WAV、ffmpeg capture_output 再补一刀、4GB 容器限制贴脸,崩溃只是时间问题。
背景:一个监控视频处理脚本
cam_filter_v3.py 是一个处理家庭摄像头视频的脚本。它的核心流程是:
- 用 ffmpeg 把视频的音频轨道提取为 WAV
- 用 Silero VAD(语音活动检测)分析音频,找出有人声的片段
- 用 YOLOv8n 做视觉检测,找出有活动的画面
- 合并 VAD 和 YOLO 结果,用 ffmpeg 裁剪出有效片段
脚本跑在 CI 容器里——2vCPU、4GB 内存、无 swap。处理短片段(10-15 分钟)一直正常,直到某天 CI 日志里出现了这么一行:
[00:39:38 +2.0m] 处理失败: returncode=-9
没有 Traceback,没有报错信息,没有 Python 异常——进程静悄悄地消失了。
症状诊断:读懂 exit code
returncode=-9 在 Linux 中有明确含义:正常退出码是 0-127,负数代表进程被信号杀死。-9 对应 SIGKILL,Linux 内核最暴力的终止手段。SIGKILL 几乎只来自一个地方——OOM Killer。
快速诊断命令:
dmesg | grep -i oom | tail -5
# 应看到类似:
# Memory cgroup out of memory: Killed process N (python)
确认是 OOM,但为什么?97 分钟的 4K 视频在处理音频时就爆了。
第一轮 OOM:torchaudio.load() 全量加载
直接原因很明确:
import torchaudio
# 全量加载 WAV 到内存
wav, sr = torchaudio.load(tmp_path) # ← 96min 视频 → 372MB tensor
torchaudio.load() 把整个 WAV 文件解码为 float32 tensor。WAV 文件 186MB,float32 tensor 需要 2 倍内存(4 字节 vs 原始 int16 的 2 字节),即约 372MB。叠加 PyTorch、OpenCV、Ultralytics 的基线占用(500MB-1GB),4GB 容器直接过线。
修法:soundfile 流式读取
import soundfile as sf
block_size = 16000 * 10 # 10秒音频块
with sf.SoundFile(tmp_path, 'r') as f:
while True:
data = f.read(block_size, dtype='float32')
if len(data) == 0:
break
# 逐块送 VAD,单次占 ~640KB
每次内存占用从 372MB 陡降到 ~640KB。提交上线,CI 通过。
第二轮 OOM:你以为修好了
两周后,同一套代码处理了一批新的视频。CI 又崩了。这次规律非常明确:
| 文件 | 时长 | FPS | 结果 |
|---|---|---|---|
| 97 min 4K | 5813s | 48 | ❌ OOM |
| 95 min 4K | 5721s | 48 | ❌ OOM |
| 14 min 4K | 840s | 10 | ✅ |
| 10 min 4K | 637s | 10 | ✅ |
只有 48fps 的超长视频 失败,10fps 短片段全部正常。流式读取只占 ~640KB,问题在哪?
三源叠加的根因
深入排查发现,内存压力来自三个叠加源:
压力源 1:ffmpeg capture_output=True
subprocess.run([
"ffmpeg", "-i", str(self.video_path),
tmp_path
], capture_output=True) # ← 全量缓存 ffmpeg 的 stderr
48fps 视频解码时,ffmpeg 的进度输出量远大于 10fps,全部保留在 subprocess.CompletedProcess 对象中。
压力源 2:tmpfs 隐形消耗
tempfile.NamedTemporaryFile 默认写入 /tmp。在 Docker 容器中,/tmp 是 tmpfs(内存文件系统)。这里的文件占的是内存,计入 cgroup 限制。一个 97 分钟的 16kHz WAV = 184MB,全在内存里。
压力源 3:Python 生态基线
python:3.10-slim + PyTorch + OpenCV + Ultralytics + Silero VAD 导入后占用约 500MB-1GB。
叠加效果:
容器限制: 4GB(无 swap)
PyTorch/OpenCV 基线: ~800MB
tmpfs WAV 文件: ~184MB
ffmpeg 进度缓存: ~100MB(48fps)
CI runner 开销: ~200MB
━━━━━━━━━━━━━━━━━━━━━━━━
剩余可用: ~2.7GB
2.7GB 看似不少,但 48fps 视频的处理压力是 10fps 的 4.8 倍,加上 PyTorch 计算图、模型权重、多帧缓冲区,内存峰值在长处理过程中频繁越线。
修复方案的演进
问题定位后,讨论了多个方案:
| 方案 | 思路 | 代价 |
|---|---|---|
| A | capture_output 改文件重定向 | 修复快,但只砍掉一个压力源 |
| B | VAD 放子进程,主进程回收内存 | 复杂度高,跨进程通信 |
| C | 加 --skip-vad 标志,OOM 后自动降级 |
降级后纯 YOLO,少一道路由 |
| D | 源头切分视频(≤30min/片) | 修改录制端,周期长 |
| E | 容器内存升到 8G | 治标不治本,CI 配额限制 |
真实方案:C + A
最终用户的方案是 C + A 组合:
给 cam_filter_v3.py 加 --skip-vad 标志,让 ci_process_assets.py 在 VAD 失败时自动重试并跳过 VAD:
try:
run_cam_filter(input_path, output_dir) # 全功能模式
except OomError: # returncode=-9
print("VAD OOM,自动降级到 --skip-vad 重试")
run_cam_filter(input_path, output_dir, skip_vad=True) # 仅 YOLO
同时做了方案 A:capture_output=True → 重定向到日志文件。
这样正常视频仍然全功能检测,超长视频遇到 OOM 自动降级到纯 YOLO,不中断整个批处理流程。对于监控场景,有人走过时画面变化就能触发 YOLO,VAD 更多是锦上添花。
验证:批量成功
修复后,CI 一次性成功处理了 85 个文件,全部归档到 NAS,零报错。单个文件约 128MB,传输速率约 30 秒/个,全程无中断——对比之前跑 2 分钟就崩,这是质的区别。
后话:完整流水线
这个脚本最终集成到了青龙面板的定时任务体系:
- Task4:归档+上传(禁用,每月 21 号)
- Task5:仅归档(启用,手动触发)
- Task6:仅上传(启用,手动触发)
- Task7:GPU 归档+上传(禁用)
每轮处理结果通过 ntfy 推送通知,失败自动降级后也有通知,整个链路算是跑通了。
排查 CLI 速查
# 确认 OOM Killer
dmesg | grep -i oom | tail -5
# 查看容器内存限制
docker inspect <container> | grep -i memory
# 检查 tmpfs 占用(容器内)
df -h /tmp # tmpfs 文件系统大小
# exit code 速查表
echo $? # 0-127 = 正常; -9 = SIGKILL(OOM); -11 = SIGSEGV
总结
一个 returncode=-9 背后往往不是单一问题。这次排查从最明显的 torchaudio.load() 开始,修掉了 372MB 的 tensor 占用。但真正的根因是三源叠加——tmpfs 占了 184MB、capture_output 缓存了过量日志、48fps 放大了每项开销。任何一个单独看都不致命,加起来就要命。
最终的 --skip-vad 自动降级方案比硬扛 OOM 聪明得多:不要跟资源瓶颈较劲,承认边界,优雅降级。这也是一次很好的 lesson——写代码时要有失败预案,而不是假设一切完美运行。