「烤肉」是我攒了挺久的一条流水线:YouTube 视频下下来,机器翻译配中文字幕,硬编码烧进画面,传到 B 站。跑在 Mac mini 上的常驻服务,之前写过服务化和 YouTube 403 排查的过程。这次的起因很简单——「修一下烤肉服务」,外加一句「压制质量可以调到最高,因为不缺时间」。结果这句话在接下来几个小时里连环引爆了三个故障,外加一个可以预见但没预见到的死循环。记录下来,主要是给以后类似场景一个参照。
起点:7 个 403,都是旧闻
先看 daemon 日志,一眼扫到 7 个 job 卡在 failed,错误都一样:
RuntimeError: Expected video file not found after download: ...(tried both network stacks; last error: ERROR: unable to download video data: HTTP Error 403: Forbidden).If this is an HTTP 403 on the stream, the video is SABR/sign-in gated for this IP: supply YouTube cookies...这套流水线已经踩过 YouTube cookies 过期的坑,专门建了个 launchd job(app.lishuyu.kaorou.cookies,每 6 小时跑一次,从登录中的 Chrome 自动刷新 yt-cookies.txt)。查了一下 cookies 文件的 mtime,17:22 刚刷新过,比这批失败的 job 晚。也就是说这批失败发生在 cookies 过期窗口,修复动作已经跑过了,只是失败的 job 不会自己复活——重试一遍就行:
for id in 76 91 114 116 117 118 119; do ./service/kaorou-env.sh retry $id; done这一步没什么技术含量,纯粹是”自愈机制生效了但没人去按重试键”。真正的故事从下一句话开始。
故障一:Homebrew 把 ffmpeg 的依赖清没了
用户那句”质量拉满”落地成两处改动:hevc_videotoolbox 的 -q:v 从 75 提到 100(后来证明是个坑,下面细说),-maxrate 从写死的 15M 改成按视频时长和剩余磁盘动态算。改完 push、跑测试、部署、soft-restart,一切正常。
一小时后巡检,daemon 日志里刷屏:
ERROR [burn] yt2bili.pipeline: ffmpeg stderr: dyld[21213]: Library not loaded: /opt/homebrew/opt/libxcb/lib/libxcb.1.dylibffmpeg -version 直接报同样的错。这不是我们改的代码,是环境本身坏了。用 otool -L 把 ffmpeg 和 ffprobe 依赖的所有 homebrew 库过一遍,挑出磁盘上不存在的:
for bin in ffmpeg ffprobe; do otool -L /opt/homebrew/Cellar/ffmpeg/8.1_1/bin/$bin | awk '{print $1}' | grep '^/opt/homebrew'done | sort -u | while read lib; do [ -e "$lib" ] || echo "$lib"; done一次性列出 19 个缺失的库:libxcb、harfbuzz、x264、x265、libass、aom、dav1d……几乎是 ffmpeg 整个多媒体依赖树。
这台机器的 ffmpeg 来自第三方 tap homebrew-ffmpeg/ffmpeg(源码编译版,带的 codec 比官方仓库全)。查了一下才知道 Homebrew 6.0 起有个叫 Tap Trust 的机制:非官方 tap 默认不被信任,加载它的 formula 会直接拒绝:
Error: Refusing to load formula homebrew-ffmpeg/ffmpeg/ffmpeg from untrusted taphomebrew-ffmpeg/ffmpeg. Run `brew trust --formula homebrew-ffmpeg/ffmpeg/ffmpeg`or `brew trust homebrew-ffmpeg/ffmpeg` to trust it.这台机器上的 Homebrew 版本正好是 6.0.6,踩线中招。
我的推断是:untrusted tap 让 ffmpeg 这个 formula 对 brew 不可见,它声明的依赖关系自然也不可见,于是某次 brew autoremove(官方文档:只清理”仅作为依赖安装、现在没人依赖”的库)把这些库当成孤儿清掉了。这条因果链没有在 Homebrew 官方文档里找到直接背书,只是自己的推断——但两个已知事实撑得住:untrusted tap 拒绝加载确实存在(刚验证过),autoremove 确实只清理”安装为依赖”标记的库(而不是显式 brew install 过的)。这个组合足够解释现象,即使我没法证明这就是这次的真实触发路径。
这不是第一次撞见这类问题——一个多月前同一台环境也发生过 ffmpeg 依赖集体消失,当时排查了一圈,怀疑是某个并发 session 跑了清缓存脚本,但没坐实根因。这次算是把机制补上了一半。
修复两步:
brew install aom dav1d fontconfig freetype harfbuzz lame libass libogg \ libvmaf libvorbis libvpx libx11 opus snappy svt-av1 theora x264 x265brew trust homebrew-ffmpeg/ffmpeg显式 install 会把这些库标记为”用户请求”,autoremove 不会再动它们;trust 让整棵依赖树对 brew 恢复可见,双保险。装完拿真实的游戏录屏源跑了一遍完整的 -q:v 100 -maxrate 20M 编码验证链路通了,才把之前 retry 过的 job 重新入队。
故障二:q 100 让 maxrate 形同虚设
11 个 job 重新排队,其中一个(4843 秒的视频,动态算出的 maxrate cap 是 40M)烧到一半,磁盘监控从 4GB 直接报到 0GB。
du -sh ~/.local/state/yt2bili/*/ | sort -rh | head -3# 48G .../YK7Tuejs8Qg/ ← 这个 job 的 partial 文件那个 partial 文件本该在 40M 码率上限下,4843 秒撑死也就 24GB,结果已经烧到 43GB 还没完。先救火:pause 掉所有待烧的 job,杀掉 ffmpeg 进程,磁盘瞬间回到 55GB(partial 文件被自动清理逻辑收走了)。
现场用同一段真实源做 A/B 测试,固定 -maxrate 20M,只扫 -q:v:
for q in 100 95 90 85 80 75; do ffmpeg -v error -ss 600 -t 60 -i "$src" -an \ -c:v hevc_videotoolbox -q:v $q -maxrate 20M -bufsize 40M -tag:v hvc1 -y /tmp/qtest_$q.mp4 ffprobe -v error -show_entries format=bit_rate -of default=noprint_wrappers=1:nokey=1 /tmp/qtest_$q.mp4done结果一目了然:
| q | 实测码率 |
|---|---|
| 100 | 284 Mbps |
| 95 | 13 Mbps |
| 90 | 13 Mbps |
| 85 | 13 Mbps |
| 80 | 13 Mbps |
| 75 | 13 Mbps |
q:v 只要低于 100,-maxrate 就老老实实生效;一到 100,videotoolbox 直接切换成一种不受码率上限约束的纯质量模式,20M 的 cap 形同虚设。搜了一圈没找到 Apple 或 ffmpeg 官方文档明确写过”q
默认值从 100 改回 95,代码注释里把这组实测数据焊死,测试里加了一条”永远不允许配成 100”的断言。部署、重烧,同一个 job 4843 秒的视频最终产出 15GB,平均码率 25.9 Mbps——码率上限真正生效,而且比故障前的固定 15M 配置提升了 1.7 倍。
故障三:连续五次 403 的视频,根子在 SABR
修完前两个故障,队列里还有三个 job(都是频道的直播回放,was_live)连续 4-5 次重试全部 403。手动复现,把这三个视频的所有 DASH 格式挨个试了一遍——1440p、1080p,AV1、VP9、H264,全部 403:
[info] daeuNCNDEBc: Downloading 1 format(s): 400ERROR: unable to download video data: HTTP Error 403: ForbiddenCookies 是新鲜的,PO-Token 服务器也正常应答,问题不在认证层。翻了一下 yt-dlp 的 issue 列表,#15689 标题就是”YouTube is forcing SABR streaming for this client”——SABR 是 YouTube 这两年逐步推的一种服务端流协议,把原来直接可下载的 DASH adaptive format 换成需要逐分片签名验证的流。对特定 IP/视频组合,DASH 通道会被整体挡住,即便认证信息完全有效。
抱着”死马当活马医”的心态试了同一个视频的 HLS(m3u8)变体:
yt-dlp -f 301 --test "https://www.youtube.com/watch?v=daeuNCNDEBc"# [download] 100.0% of ~ 1.00KiB ...直接通了。DASH 和 HLS 是两条独立的分发路径,SABR 只锁死了 DASH 那条,HLS 完全没受影响。
于是给下载逻辑加了一个 format 阶梯:DASH 在双网络栈上都失败后,检查 info 里有没有满足分辨率门槛的 HLS 渲染,有就用 best[protocol*=m3u8][height>=1080] 再走一遍双栈;没有 HLS 候选就维持原来的失败路径,不多打无谓的请求。三个卡了一周的视频一次性全部下载成功,只是分辨率锁定在 HLS 能给出的最高档(这三个视频是 1080p,不是 DASH 原本能给的 1440p——SABR 封锁下能拿到的上限)。
一个隐藏的死循环:fail 终态会清空工作目录
抢救磁盘时 pkill 掉的那个 job(#91)被算进了它的失败次数,攒够 max_attempts 转成终态 failed。终态 fail 会触发无痕清理,把整个工作目录(源文件、翻译缓存全部)删掉——这是”无痕”这个设计特性本身的正常行为,不是 bug。
问题在于 retry 命令只重置 status/attempts,不回退 stage。这个 job 的 stage 还停在 translated,retry 之后 burn worker 一认领,直接找不到源文件,秒失败,attempts 又清零重新攒,周而复始。retry 和”目录还在”这个前提是绑定的,一旦目录被清空,retry 本身就成了死循环的一部分。
修法是绕开 retry,直接把这个 job 的 stage 手动回卷到 pending,从下载开始整个重跑一遍:
UPDATE jobs SET stage='pending', status='queued', attempts=0, error=NULL, detect_status='queued' WHERE id=91;翻译缓存没了,LLM 费用要重花一次,没有更省的路——目录都不在了。这个坑记进了运维笔记,下次遇到”retry 了但秒 fail”先看一眼工作目录还在不在,而不是无脑再点重试。
最后一步:别让下载跑在消费前面
四个故障处理完,队列一口气清空跑完了 13 个视频。用户又提了一个新要求:下载不要把整个队列的源都提前拉下来,同一时刻最多保留 2 个已下载的视频。
这其实是这次连环事故的根因总结——retry 风暴那次,13 个 job 同时在飞,下载 worker 十几分钟就把 52GB 磁盘吃到 0.5GB。之前只有一个基于磁盘水位的保护(低于某个阈值才限流),这次改成一条无条件的数量闸门:只要”已下载但还没上传完成”(downloaded 到 submitting 之间任意 stage)的 job 数量达到上限,下载 worker 就暂停拉新源,跟磁盘还剩多少无关。
DOWNLOAD_LOOKAHEAD = int(os.environ.get("KAOROU_DOWNLOAD_LOOKAHEAD", "2"))吞吐上不吃亏——burn 阶段本来就是单 worker 串行的,2 个 in-flight 已经够喂饱下载之后的每一道工序,多预取只会变成纯粹的磁盘压力,换不来任何速度。
收尾
四个故障、一个死循环,串起来看有个共性:每一个都不是代码逻辑错了,而是某个此前一直成立的隐含假设突然不成立——cookies 会自动续期但没人按重试;ffmpeg 的依赖树对包管理器一直可见直到 tap 信任策略变了;-maxrate 一直管用直到 -q:v 顶到 100;DASH 一直能下直到这个 IP 被 SABR 点名;retry 一直等价于”重来一次”直到工作目录被清空。排查的过程基本是同一套动作:找到失效的那个假设,用真实数据(otool -L 列缺失库、A/B 扫码率、手动复现 403)把边界钉死,而不是猜一个”看起来该管用”的修复就交差。
这条流水线现在稳定跑着,压制质量比修复前提高了 1.6-2.7 倍(取决于视频时长),磁盘再也不会被自己的下载队列吃满。