长视频处理 OOM 排查实战:从 SIGKILL 到流式加载的完整链路

2026-06-04

长视频处理 OOM 排查实战:从 SIGKILL 到流式加载的完整链路

returncode=-9 不只是”内存不足”——当 torchaudio.load() 吃掉 2 倍文件内存、tmpfs 驻留 184MB WAV、ffmpeg capture_output 再补一刀、4GB 容器限制贴脸,崩溃只是时间问题。

背景:一个监控视频处理脚本

cam_filter_v3.py 是一个处理家庭摄像头视频的脚本。它的核心流程是:

  1. 用 ffmpeg 把视频的音频轨道提取为 WAV
  2. 用 Silero VAD(语音活动检测)分析音频,找出有人声的片段
  3. 用 YOLOv8n 做视觉检测,找出有活动的画面
  4. 合并 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 容器中,/tmptmpfs(内存文件系统)。这里的文件占的是内存,计入 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——写代码时要有失败预案,而不是假设一切完美运行