wheel 明明存在,为什么装不上?—— 一次 Python 3.12 升级里的 glibc/manylinux 课与 uv 依赖治理实战
一句话:一个“应该 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。这次要做两件看起来很常规的事:
- 运行时 Python 从 3.11 升到 3.12;
- 依赖管理从 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是幂等的:它拿.venv和uv.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)。
灰度切换:uv 在用户空间装独立的 3.12(不碰系统 python),新建 .venv 与旧 venv 并存。切换只需把 supervisor 配置里的 command / PATH 从 venv 指向 .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,装出一套和测试机不一致的依赖——“测试通过”就失去意义了。锁文件的价值,正是在这种两机环境不同的时候才真正公现。
八、沉淀与诚实的技术债
几条可复用的原则:
- 约束在哪一层无解,就退一层(技术 → 产品的 go/no-go)。
- 把平台约束固化进 lock,而不是靠人肉 SSH 上去手动救火。
- 灰度 + 原子切换 + 可回滚,把上线的爆炸半径压到最小。
- 锁文件驱动部署 = 依赖自愈,把“别忘了手动装依赖”这种人因风险从流程里删掉。
留下的技术债也如实记:把 4 个包钉旧版,是在迁就一台已 EOL 的 CentOS 7;长期干净解是容器化(python:3.12 镜像自带 glibc 2.36)或升级 OS。这些作为独立事项留待后续,不混进这次迁移。
写在最后(AI 工程视角):AI 项目的依赖天然更重——torch / onnxruntime / faiss 这类 ML 原生包对 glibc、CUDA、编译工具链极其敏感。所以在 AI 工程里,“可复现的依赖治理”不是锦上添花,而是地基:模型能不能在目标机器上一致地跑起来,往往不取决于模型本身,而取决于你有没有把环境锁死。这也是我做完这件事最大的体会。
更多推荐
所有评论(0)