本地 pip install -r requirements.txt 一切正常,镜像构建到装依赖那层就卡住,报一个从没见过的 ResolutionImpossible;或者装上了但跑起来行为变了,因为服务器上多装了一个你没声明的包。问题不是"环境脏",而是依赖解析、锁定与平台差异三件事没有分别落到文件。
1. 依赖解析到底在解什么
解析器要解的是一道约束满足题:给定所有包声明的版本区间(>=、<、~=、!=、额外的 marker 与 extra),找出一组两两兼容的具体版本。算法本质是回溯搜索:先挑一个候选,往下推,冲突就回退换下一个。
| 阶段 | 发生什么 | 典型报错 |
|---|---|---|
| 收集约束 | 读 Requires-Dist 与 wheel 元数据 |
No matching distribution |
| 候选裁剪 | 按 Python 版本、平台、ABI 过滤 | Ignored the following versions |
| 回溯求解 | 冲突时回退上一条选择 | ResolutionImpossible |
| 写锁定结果 | 输出可直接安装的精确集合 | —— |
看冲突信息的方法:不要只看最后一行,往上找第一个 The conflict is caused by,那里才是根因。
The conflict is caused by:
The user requested urllib3<2
botocore 1.34.x depends on urllib3<1.27
读法:你的直接约束与某个间接依赖的约束区间没有交集。解法不是硬改版本号,而是找到那个要求 <1.27 的库,看能否升到放宽约束的版本。
2. requirements.txt 与 pyproject.toml 的分工
| 文件 | 表达什么 | 谁维护 | 变更频率 |
|---|---|---|---|
pyproject.toml |
抽象依赖、版本区间、extra | 人 | 低 |
| 锁定文件 | 全量精确版本 + hash | 工具生成 | 高 |
requirements.txt |
传统入口,可直接写精确版也可写区间 | 人 | 中 |
pyproject.toml 写"我需要什么"(requests>=2.28,<3),锁定文件写"这次装了什么"(requests==2.31.0 + 全部间接依赖 + hash)。两者职责不同,混用是绝大多数"环境漂移"的来源。
[project]
name = "clarity-tools"
requires-python = ">=3.11,<3.13"
dependencies = ["httpx>=0.27,<0.29", "pydantic>=2.5,<3"]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.4", "mypy>=1.10"]
$ pip freeze > requirements.lock.txt # 简单,但会混入本地临时装的包
$ uv pip compile pyproject.toml -o requirements.txt # 从抽象依赖生成精确集合
$ poetry lock --no-update # 只补锁,不动已锁版本
pip freeze 导出的是当前环境里的一切,包括顺手装的调试工具和 -e 本地路径,换环境不可复现。uv pip compile 或 poetry lock 从抽象约束出发解一次,更可靠。
3. 虚拟环境与全局环境的边界
| 对象 | venv 是否隔离 | 说明 |
|---|---|---|
| 第三方包 | 是 | site-packages 指向 venv 内部 |
| 解释器版本 | 否 | base_prefix 仍指系统 Python |
| 系统包管理器装的包 | 否(部分发行版) | dist-packages 仍在 sys.path |
PATH 上的其他 python |
否 | 谁在前谁生效 |
环境变量(代理、PYTHONPATH) |
否 | venv 不碰 os.environ |
用 python -m pip 而不是裸 pip,就是为了保证 pip 作用于当前解释器,而不是 PATH 上先命中的另一个。
$ python -m venv .venv && . .venv/bin/activate
$ python -c "import sys; print(sys.executable, sys.prefix, sys.base_prefix)"
$ python -m pip install -r requirements.txt # 绝不用裸 pip
conda 与 venv 别混用:conda 激活会改 PATH 与 CONDA_PREFIX,在 conda 环境里再建 venv 容易出现 pip 与 python 来自不同前缀。
4. 可复现构建三要素
| 要素 | 锁什么 | 不锁的后果 |
|---|---|---|
| 版本 | 全部直接+间接依赖的精确版本 | 隔天构建装上不同版本 |
| 来源 | index URL、trusted-host、hash |
私有源与公网源混用,装到同名包 |
| 解释器 | requires-python + 基础镜像 tag |
换镜像就换次版本,行为差异 |
三条缺一不可。只锁版本不锁来源,同名包可能来自不同源;只锁来源不锁解释器,3.11 与 3.12 的 C 扩展 ABI 不同,wheel 直接不可用。
[tool.uv]
index-url = "https://pypi.tuna.tsinghua.edu.cn/simple"
extra-index-url = ["https://pypi.org/simple"]
[[tool.uv.index]]
name = "private"
url = "https://PRIVATE_INDEX_URL/simple"
explicit = true # 只有显式声明来源的包才从这里装,避免同名包投毒
5. 私有源与认证信息
# ~/.config/pip/pip.conf 或 /etc/pip.conf
[global]
index-url = https://PRIVATE_INDEX_URL/simple
extra-index-url = https://pypi.org/simple
trusted-host = PRIVATE_HOST
timeout = 30
retries = 3
| 放认证的方式 | 风险 |
|---|---|
写进 pip.conf |
文件进仓库即泄露 |
环境变量 PIP_INDEX_URL |
CI 日志可能打印 |
~/.netrc(权限 600) |
需随镜像注入 |
底线:认证信息只走环境变量或密钥挂载,不进任何被提交的文件。
6. "本地能装、服务器不能装"
| 差异 | 表现 | 根因 |
|---|---|---|
| wheel 与 sdist | 本地秒装,服务器开始编译 | 本地有预编译 wheel,服务器平台没有 |
| 编译依赖 | fatal error: Python.h |
缺 build-essential / python3-dev |
| glibc 版本 | manylinux_2_28 wheel 不被接受 |
基础镜像太老 |
| CPU 架构 | aarch64 上找不到 wheel |
索引里没有对应产物 |
| musl vs glibc | Alpine 上装不上 | 需要 musllinux wheel |
$ pip download -r requirements.txt -d wheelhouse --only-binary=:all: --platform manylinux2014_x86_64
$ pip install --no-index --find-links wheelhouse -r requirements.txt
$ auditwheel show dist/*.whl # 看 wheel tag 是否合法
先做一次"能否只装 wheel"的预检(--only-binary=:all:),通过说明服务器不需要编译器;不通过就得补齐构建链,或选一个提供对应 wheel 的版本。
7. 依赖树膨胀与冲突排查
$ pip check # 只报"装完之后不满足声明"的冲突
$ pipdeptree --warn silence -p fastapi # 反查谁引入了某个包
$ pipdeptree --reverse --packages urllib3
$ uv pip tree # 新版 uv 自带
| 现象 | 含义 | 处理 |
|---|---|---|
| 同一个包出现多个版本 | 有库把依赖 vendor 进来了 | 找它,看能否升级 |
| 依赖树深到十几层 | 引入了大而全的框架包 | 换成轻量替代或拆 extra |
pip check 通过但运行报错 |
版本满足声明但行为变了 | 用锁定文件回退,看 changelog |
冲突的本质永远是:两个约束区间的交集为空。pip check 只检查已装集合是否满足声明。
8. 容器镜像的分层与体积
| 手段 | 效果 | 代价 |
|---|---|---|
| 多阶段构建 | 编译工具不进最终镜像 | 构建时间变长 |
--no-cache-dir |
不写 ~/.cache/pip |
重装时无缓存 |
| 先拷依赖文件再拷代码 | 依赖层可复用 | 顺序写错就全量重装 |
| wheelhouse 预下载 | 构建时零网络 | 需维护产物 |
基础镜像 -slim |
体积显著下降 | 可能需要补系统库 |
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /install /usr/local
COPY . /app
RUN useradd -m app && chown -R app /app
USER app
CMD ["python", "-m", "clarity_tools"]
COPY requirements.txt 必须单独一层——代码改动不应让依赖层失效,否则改一行代码就要重装依赖。
9. 构建自己的包
[project]
name = "clarity-tools"
version = "1.4.0" # 或 dynamic = ["version"] 读 __version__
dependencies = ["httpx>=0.27,<0.29"]
[project.scripts]
clarity = "clarity_tools.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
| 部分 | 作用 | 常见错误 |
|---|---|---|
project.scripts |
生成命令行入口 | 路径写成文件而非 模块:函数 |
build-system |
指定构建后端 | 漏写,pip 回退到旧 setup.py 逻辑 |
version |
语义化版本 | 手改导致同版本号内容不同 |
dynamic |
从代码读版本 | 两处各写一份,必然不一致 |
版本号:破坏性改 MAJOR,加功能改 MINOR,修 bug 改 PATCH;0.x 阶段可任意破坏。
10. 发布与回滚
$ python -m build # 产出 sdist + wheel
$ twine check dist/*
$ twine upload --repository testpypi dist/* # 先测测试源
$ twine upload dist/* # 再发正式源
$ twine upload --repository pypi --skip-existing dist/* # 幂等重发
| 情况 | 做法 | 为什么 |
|---|---|---|
| 发错版本 | 只能 yank,不能删同名版本重发 |
已安装的 hash 会失配 |
| 严重 bug | 发 PATCH+1 并 yank 旧版 |
升级即可,不必清缓存 |
| 需要撤回 | pip install pkg==上一版 |
回滚靠版本,不靠删包 |
yanked 的语义是"仍在索引里,但新解析不会选它"——已锁定的环境不受影响,这比删除安全。
11. 常见坑
| 坑 | 后果 | 改法 |
|---|---|---|
| 直接往系统环境装包 | 系统工具链被污染 | 一律 venv 或容器 |
| 忘记提交锁定文件 | 每次构建装到不同版本 | 锁定文件进仓库并 review 变更 |
用 == 把间接依赖也钉死 |
上游升级后被锁死,无法收安全补丁 | 只钉直接依赖,间接交给锁定文件 |
| CI 里装最新版 | 今天绿明天红 | CI 用 --no-deps + 锁定文件 |
把 .venv 提交进仓库 |
体积暴涨、跨平台不可用 | gitignore 掉 |
构建时 pip install -U pip |
解析器行为随版本变 | 固定 pip/uv 版本 |
12. 边界:什么时候不必这么严
| 场景 | 简化做法 |
|---|---|
| 一次性分析脚本 | 直接装,不锁 |
| 内部工具、单使用者 | 锁定文件 + requires-python 够用 |
| 长期分发的库 | 锁构建后端 + 测多版本矩阵 |
| 不可变部署(镜像即产物) | 镜像 digest 就是锁定 |
判断标准:这份环境要被重建几次? 一次就够的,锁定成本高于收益;要重建几十次的,一次漂移就够赔。
13. 自测
ResolutionImpossible的根因该从报文的哪一行读起?pip freeze导出的文件为什么换台机器不一定能复现?- 为什么必须用
python -m pip而不是裸pip? - 可复现构建的三要素分别锁什么?
- 服务器上装包开始编译,最可能缺的是哪一类东西?
yank一个版本与删除一个版本,对已锁定的环境有什么不同影响?