一句话:一个“应该 10 分钟搞定”的 Python 3.12 升级 + pip → uv 迁移,在一台已 EOL 的 CentOS 7(glibc 2.17)服务器上,变成了一场关于 wheel / manylinux / glibc ABI 兼容性的深度排查。这篇文章讲清楚“为什么 wheel 明明存在却装不上”,以及 uv 如何把依赖治理变成可复现、可自愈的工程能力。

一、一个“应该 10 分钟”的任务

我们有一个生产中的 AI 电商 Agent 服务:FastAPI + LangGraph,接了多家 LLM,挂着 Milvus 向量库。依赖一直靠手维的 requirements.txt。这次要做两件看起来很常规的事:

  1. 运行时 Python 从 3.11 升到 3.12;
  2. 依赖管理从 pip 迁到 uv

如果你是前端,可以用这套对照建立直觉:

Python / uv 前端 / npm 作用
pyproject.toml package.json 声明依赖、版本约束、镜像源
uv.lock package-lock.json 锁定全量传递依赖到精确版本
uv sync --frozen npm ci 严格按锁文件还原环境,不重新解析
uv add xxx npm install xxx 加依赖并更新锁文件

为什么要迁?手维 requirements.txt 没有锁文件,依赖树依赖安装顺序和环境,环境之间会漂移——典型的“我本地能跑,上线出错”。

二、第一面墙:wheel 存在,却“装不上”

迁好 uv、第一次在服务器上 uv sync 就卡住了:

error: Distribution `onnxruntime==1.26.0` can't be installed because it doesn't have a source distribution or wheel for the current platform
hint: You're on Linux (`manylinux_2_17_x86_64`), but `onnxruntime` only has wheels for: manylinux_2_27 / manylinux_2_28 ...

要看懂这条报错,得先补三个概念:

  • wheel 是 Python 的预编译二进制包,文件名自带“出生证”,例如 onnxruntime-1.17.0-cp312-cp312-manylinux_2_27_x86_64.whl:cp312 = 给 Python 3.12 编的,manylinux_2_27 = 要求 glibc ≥ 2.27
  • manylinux 是一套“在老 Linux 上也能跑”的兼容标签(PEP 600),manylinux_2_X硬性要求 glibc ≥ 2.X,不是“建议”。manylinux2014 = glibc 2.17。
  • glibc 只向后兼容:高 glibc 的机器能装为低 glibc 编的 wheel,反过来不行。

我们的服务器是 CentOS 7 = glibc 2.17(ldd --version 实测)。而 onnxruntime 凡是带 cp312 的版本,Linux x86_64 包全是 manylinux_2_27——于是装不上。

更要命的是:

“cp312 + glibc 2.17” 这个组合根本不存在。 能装 CentOS 7 的 onnxruntime 版本(1.16.3)没有 cp312 包;有 cp312 的版本(1.17.0 起)又要 glibc ≥ 2.27。降版本也救不了。

那源码编译呢?查了工具链:gcc 4.8.5(2015 年),/opt/rh 空、无 devtoolset。pandas 要 Cython + meson、rapidfuzz 要 C++17——这把老 gcc 编不动。源码这条路也堵死了。

三、关键转折:这不是技术问题,是产品问题

技术层已经是死路。这时候最重要的判断是:别在死路上继续硬磕,退一层看约束本身能不能松动。

我去看 onnxruntime 是被谁拉进来的——是项目里的图片/文档 OCR 功能(rapidocr-onnxruntime / rapidocr-paddle,底层都依赖微软的推理引擎 onnxruntime)。

于是问题从“怎么在 glibc 2.17 上装 onnxruntime”变成了一个产品 go/no-go 判断:这个 OCR 功能还在用吗?

确认它已废弃之后,在 pyproject.toml 注释掉这两个 OCR 包,onnxruntime 随之移出整个依赖树。3.12 在 onnxruntime 这一处解除。

这一步是整件事最关键的工程判断:一个约束在当前技术层无解时,正确动作往往不是更努力地解技术题,而是退一层问“这个需求是不是本来就可以不要”。识别 go/no-go,比死磕更值钱。

四、第二面墙:onnxruntime 不是唯一的雷

移除 OCR 后再跑 uv sync,又撞上 faiss-cpu(它连 sdist 源码包都没有,直接硬报错)。进一步把 uv.lock 按目标平台(cp312 / glibc 2.17 / x86_64)整体扫一遍,发现还有 pandas / greenlet / rapidfuzz / ujson 的新版同样缺 glibc 2.17 的 wheel。

这次我没有逐个 SSH 上去救火,而是把平台约束固化进依赖声明,用两类不同的武器:

处理 为什么
faiss-cpu(经 milvus-lite 拉入) override-dependencies 用永假 marker 整条移出依赖树 依赖链是 pymilvus → milvus-lite(本地嵌入式) → faiss-cpu;本项目只连远程 Milvus、全仓库无 import faiss,这条链从没用到
pandas <2.3 → 2.2.3 2.2.x 是最后一个发 manylinux2014 wheel 的
greenlet <3.2 → 3.1.1 SQLAlchemy 依赖;3.2 起 wheel 抬到 glibc 2.24
rapidfuzz <3.13 → 3.12.2 unstructured 依赖;3.13 起抬到 glibc 2.27
ujson <5.11 → 5.10.0 pymilvus 依赖;新版抬到 glibc 2.24

后四个用 constraint-dependencies 钉版本。这里有个常被混淆的区别:

  • constraint-dependencies(约束):“如果用到这个包,版本必须满足约束”。它不会主动引入任何包,只在已有依赖被解析到时限制版本范围。≈ npm 的 overrides 里“收窄版本”。
  • override-dependencies(覆盖):强行改写依赖树里别人对某个包的声明。配合永假 marker(sys_platform == 'never',任何平台都不成立)就能把一整条用不到的链整个摘掉

结果:uv sync --frozen 秒级装完、零 C 编译,不用 Docker、不用升 OS。

一个精度修正(也是诚实):严格说不是“全程纯 wheel”——161 个安装包里 156 个是预编译 wheel,另外 5 个是纯 Python 的 sdist(oss2 / aliyun-python-sdk-core / crcmod / memoization / langdetect),会现场轻量构建,但不需要编译器。准确表述是“零 C 编译”,而不是“纯 wheel”。技术写作里,这种精度比漂亮的结论更重要。

五、为什么是 uv,不是继续用 pip

依赖装通只是结果,选 uv 是因为它把“可复现”做成了可纳入流水线的工程能力:

  • 锁文件可复现:任意环境按 uv.lock 得到完全一致的依赖树,消除漂移。
  • uv sync --frozen 是幂等的:它拿 .venvuv.lock 比对,一致就秒级 no-op,不一致才装差异——所以可以每次部署都跑。pip 不敢这么用,因为它没变也要全跑一遍、很慢。(就是 npm ci 之于 npm install 的关系。)
  • 配置收敛:Python 版本约束(requires-python = ">=3.12,<3.13")、镜像源(清华)、平台约束全部声明在 pyproject.toml 一处。

这一条直接带来了“依赖自愈”:把 uv sync --frozen --no-dev 加进部署流水线,以后本地 uv add 改依赖、提交 lock,服务器部署时自动跟上,不用再 SSH 上去手动装。

六、上线:灰度切换 + 一个进程管理的经典坑

这套服务的部署很“传统”:Jenkins 只 rsync 源码,supervisor 托管进程,依赖装在服务器本地的 venv 里(不进 git)。

Jenkins: rsync 源码

uv sync --frozen(依赖自愈)

restart.sh: supervisorctl restart

.venv/bin/python start.py(uvicorn 多 worker)

灰度切换:uv 在用户空间装独立的 3.12(不碰系统 python),新建 .venv 与旧 venv 并存。切换只需把 supervisor 配置里的 command / PATHvenv 指向 .venv——一个原子、可逆的动作。回滚 = 还原 conf 备份。近似蓝绿部署。

然后踩了个经典坑:切到 .venv 重启后,服务开始 flapping——status 一会儿 RUNNING、pid 反复变,最后 FATAL。排查:

  • ss -ltnp | grep :8000 → 8 个 python 还占着 8000;
  • ps 看它们 PPID=1、cmdline 是 multiprocessing-fork孤儿 worker

根因:uvicorn 多 worker 模式下,master 会 spawn 出多个 worker;而 supervisor 默认只给 master 发停止信号、不回收 worker。重启时旧 worker 变孤儿继续占 8000 → 新 master bind 失败退出 → autorestart 反复重试到 FATAL。(旧的单进程老代码不会暴露这个问题,是多 worker 才真正激活的。而且 stderr_logfile=none 还把 address already in use 这条错给吞了,排查时一度看不到。)

解法:supervisor conf 加 stopasgroup=true + killasgroup=true,让它按进程组整组回收 master + worker。一次性清孤儿用 pkill -f 精确匹配本 app 的 .venv 路径(不误伤同机其他服务)。

七、最后一公里:测试机和生产机还不一样

测试机是 CentOS 7(glibc 2.17),生产机却是 Ubuntu 24.04(glibc 2.39)。glibc 2.39 向后兼容那些为 2.17 钉的 manylinux2014 wheel,所以生产其实更好装。但关键纪律是:

生产必须 uv sync --frozen,禁止重新解析。否则在 glibc 更高的 Ubuntu 上,解析器会去挑更新的 wheel,装出一套和测试机不一致的依赖——“测试通过”就失去意义了。锁文件的价值,正是在这种两机环境不同的时候才真正公现。

八、沉淀与诚实的技术债

几条可复用的原则:

  1. 约束在哪一层无解,就退一层(技术 → 产品的 go/no-go)。
  2. 把平台约束固化进 lock,而不是靠人肉 SSH 上去手动救火。
  3. 灰度 + 原子切换 + 可回滚,把上线的爆炸半径压到最小。
  4. 锁文件驱动部署 = 依赖自愈,把“别忘了手动装依赖”这种人因风险从流程里删掉。

留下的技术债也如实记:把 4 个包钉旧版,是在迁就一台已 EOL 的 CentOS 7;长期干净解是容器化(python:3.12 镜像自带 glibc 2.36)或升级 OS。这些作为独立事项留待后续,不混进这次迁移。

写在最后(AI 工程视角):AI 项目的依赖天然更重——torch / onnxruntime / faiss 这类 ML 原生包对 glibc、CUDA、编译工具链极其敏感。所以在 AI 工程里,“可复现的依赖治理”不是锦上添花,而是地基:模型能不能在目标机器上一致地跑起来,往往不取决于模型本身,而取决于你有没有把环境锁死。这也是我做完这件事最大的体会。

更多推荐