我最不引以为豪的提交之一,字面写着"修掉这堆破依赖,build 不报错,部署就炸"。写的时候我很累,但这句话把问题描述得很准:build 过了,测试过了,部署照样挂。
这不是罕见个案。在一个处理文档的 Python 服务里,我同一天有连着六个提交,全叫 "try to deploy on railway",名字想不出来了就编号 1 和 2。每一个都是对着一个我无法复现的环境盲打。这不是运气不好:这是一类有自己形状的故障。
依赖会因为谁来安装而解析出不同结果
那六个提交的主角是 PyTorch。本地我已经有缓存,能起来。在容器里,uv 解析的是 PyPI 上的 torch>=2.0.0,它拖着整套 CUDA:nvidia-cublas-cu12 一个 wheel 就是 594 MB,后面还跟着 cuda-cupti、cuda-nvrtc 之类。build 甚至不是因为我的错误而失败,它是被空间和时间耗死的。在一台永远不会见到 GPU 的机器上。
修好它的办法是不再去要那个包,而是去指定索引:
dependencies = ["torch==2.9.1+cpu", "torchvision==0.24.1+cpu"]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
torch = { index = "pytorch-cpu" }
让我多花两次尝试的细节是:用 torch>=2.0.0 指向 CPU 索引时,lockfile 最后会有两条记录,sys_platform == 'darwin' 用 2.9.1,其余用 2.9.1+cpu,因为那个索引在 macOS 上不发带 +cpu 后缀的版本。能跑,但这意味着你在 Mac 上装的和在容器里装的不是同一个产物。把带后缀的确切版本钉死,就关上了这扇门。
另一个变体更蠢也更常见:代码里用到、但 package.json 里没有的依赖。比如 puppeteer-core 出现在一个叫 "adds missing dependency" 的提交里,而三周前还有一个叫 "add missing dependencies"。本地能跑是因为那个包作为别的东西的传递依赖已经在 node_modules 里。在一个干净安装的容器里,不行。
你复现不了的那部分环境:系统库
这一类里我付出代价最大的故障,出在一个桌面转录应用上。用 uv run 时麦克风完全正常,装成 .deb 就不行。错误是 Invalid sample rate [PaErrorCode -9997]。
原因:PyInstaller 打包进去的是 sounddevice wheel 自带的 PortAudio,它编译时没有 PulseAudio 后端,还有一个缺了 alsa-lib 模块的 libasound.so.2。在 bundle 内部,pulse、pipewire 和 default 这些 PCM 都不见了,只剩下独占访问的 hw:。而麦克风已经被 PipeWire 占着,于是录音失败。用 uv run 看不到,因为那时链接的是系统的 PortAudio,它有 PulseAudio。
解决办法是把 libportaudio、libasound 和 libjack 排除出 bundle。然后是第二部分,也是真正有教益的部分:下一个版本在任何现代发行版上都起不来。
OSError: cannot load library 'libportaudio.so.2': .../_internal/libstdc++.so.6:
version `GLIBCXX_3.4.32' not found (required by /usr/lib/x86_64-linux-gnu/libjack.so.0)
加载系统的 PortAudio 时,系统的 libjack 也被牵进来,而它在新发行版上需要 GLIBCXX_3.4.32。但 PyInstaller 仍然打包了 libstdc++.so.6,而 bundle 在链接器搜索路径里排在前面,于是赢的是 build runner 那份拷贝,上限 GLIBCXX_3.4.30。我从中得到的规则是:排除一个库,两个方向都要切干净。 如果某个依赖解析到系统上,它拖着的一切也必须解析到系统上。
为什么绿色的 build 几乎什么都不说明
一次 build 验证的是:在那台机器、那个时刻,用解析器选出的依赖,代码能编译。它不验证程序能启动,不验证它能找到自己的原生库,也不验证进程会在 runtime 转发流量的地方监听。
最后这条我也付过账。在一个内部工具上,我在 Procfile 和连接串上耗了一下午:next start 不加 -H 0.0.0.0 就不接受容器外的流量;web: pnpm next start -p $PORT 改成了 web: pnpm start -- -p $PORT 好让那个 flag 传进去;顺带还得把 channel_binding 从 DATABASE_URL 里去掉,因为不是所有 runtime 都支持。三个修复,同一天,本地一个都看不见:本地服务器监听 localhost,而你也在 localhost。
真能抓住这个 bug 的测试
libstdc++ 那个案例的重点不是修复,而是冒烟测试为什么没发现它:它跑在构建用的同一个 runner 上。在那里,系统的 libjack 和打包进去的 libstdc++ 一样老,所以正好对得上,应用能起来。故障只在比构建机更新的发行版上出现。也就是,在用户那里。
我今天放进 CI 的东西,按救过我的次数排序:
- 启动产物,而不只是构建它。 用
docker run跑真实容器并请求一次/health。绿色的 build 和活着的进程是两回事。 - 在和构建环境不同的地方启动它。 这里就是一个只安装声明的
Depends的ubuntu:24.04容器,顺带还能检查那份清单是否完整。 - 一项静态的 ABI 检查。 一个用
objdump的脚本,确保 C++ 运行时没有随 bundle 一起走,并把系统库要求的东西和 bundle 提供的东西对比。它不能是间歇性的,而且在那台复现不了 bug 的老 runner 上也要失败。 - 干净安装,不用缓存。 这是让那个你碰巧拥有的依赖现形的唯一办法。
"在我本地能跑"的意思是:它在你的系统库、你的包缓存、你的发行版版本和你的网络接口下能跑。这四样生产环境一样都不和你共享。
