2634 字
约 8 分钟
14
S10-J05 · Python 环境与打包进阶:依赖解析、虚拟环境与可复现构建

本地 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. 自测

  1. ResolutionImpossible 的根因该从报文的哪一行读起?
  2. pip freeze 导出的文件为什么换台机器不一定能复现?
  3. 为什么必须用 python -m pip 而不是裸 pip?
  4. 可复现构建的三要素分别锁什么?
  5. 服务器上装包开始编译,最可能缺的是哪一类东西?
  6. yank 一个版本与删除一个版本,对已锁定的环境有什么不同影响?
S10-J05 · Python 环境与打包进阶:依赖解析、虚拟环境与可复现构建
https://www.clxhxhhr.top/posts/6067/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。