可复现构建
同一份 commit,两个人构建出不一样的结果,或者昨天能跑今天不能跑,说明有输入没被记录下来。可复现要做的就是把这些输入全部写下来。
输入不止源码
你的代码坐在一摞抽象层上:解释器、直接依赖、传递依赖、系统库、编译器、内核、硬件。任何一层不同,行为都可能不同。
| 层 | 例子 | 记在哪 |
|---|---|---|
| 应用代码 | git commit | 版本控制 |
| 直接依赖 | requests>=2.32 | 清单文件 |
| 传递依赖 | urllib3 2.2.3 | 锁文件 |
| 解释器 | CPython 3.12.7 | requires-python |
| 系统库 | OpenSSL、libpq | 容器镜像 |
| 编译器 | 构建时的 gcc 版本 | 镜像或 Nix |
清单文件写的是范围,范围是给解算器看的,不是给复现看的。范围每次解算都可能落到不同版本,这就要求锁文件。
「可复现」有三个强度,混用时容易高估自己:同一份输入能重新构建出能跑的产物;同一份输入在不同机器上得到行为一致的产物;同一份输入得到逐位相同的字节。前两个靠锁文件基本够,第三个要封闭构建。
锁文件
锁文件记录解算之后的精确结果,包括每个包的版本、下载地址和校验和。把某个依赖钉成精确版本叫 pinning,它只锁住一个点;锁文件锁的是整张解算结果。
version = 1
requires-python = ">=3.11"
[[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/...", hash = "sha256:..." }
wheels = [
{ url = "https://files.pythonhosted.org/...", hash = "sha256:..." },
]uv lock 生成它,uv sync --locked 使用它:锁文件与清单不一致时直接报错,而不是悄悄重新解算。CI 里用 --locked,本地开发想升级时才去掉。
| 角色 | 依赖声明策略 | 理由 |
|---|---|---|
| 库 | 宽范围,如 >=1.24 | 上游项目可能还要装别的包 |
| 应用 | 精确版本或锁文件 | 所有人跑同一份解算结果 |
库如果把 numpy 钉死在 1.26.4,任何同时依赖 numpy 2.x 的项目都没法装它。应用是依赖链的终点,钉死不会伤害别人,只会保护自己。
锁文件也是文件,会冲突。两个分支同时改依赖时不要手改那段 TOML,直接重新解算覆盖:
uv lock # 按当前 pyproject 重新解算并覆盖
uv sync --locked # CI 里用这个,锁文件过期就失败库通常不提交锁文件,依赖归属由使用者决定;应用一定提交,否则 CI 与本地就是两套世界。
封闭构建
锁文件只覆盖包管理器管辖的范围。apt-get install libpq-dev 装到什么版本、镜像里的基础系统什么时候更新过,锁文件管不着。
Nix 和 Bazel 把编译器、系统库、构建脚本也当作输入,全部按内容哈希寻址,目标是同一份输入在不同机器、不同时间构建出逐位相同的结果。这种做法叫 hermetic build,代价是要重写构建描述。
时间戳也会破坏复现性:同样的源码在不同时刻构建,归档里的 mtime 不同,哈希就不同。可复现构建社区用
SOURCE_DATE_EPOCH把时间戳钉成一个固定值。
容器不是封闭构建。FROM python:3.12-slim 每次构建都可能拉到不同的镜像内容,要固定就写 digest:python:3.12-slim@sha256:...。同理,apt-get update 拿到的索引也会随时间变化。
同样的思路可以推到整台机器:NixOS 用声明式配置描述系统,新机器靠同一份配置重建,不需要记忆装过什么。
持续对抗
不升级会积累已知漏洞,升级会引入破坏,这对矛盾没有终点。
- CI 里定期用最新依赖跑一遍测试,把「什么时候坏」提前到合并前
- Dependabot 之类工具盯上游新版本,自动开 PR
- 每次升级都留回滚路径,出问题先退版本再查原因
- 锁文件更新要单独成一个 commit,方便
git revert pip freeze只是当前环境的快照,缺哈希、缺平台信息,不能当锁文件用
依赖的安全问题通过公告披露,pip-audit 能拿当前环境对着漏洞库扫一遍。放进 CI 定期跑,比一年想起来查一次可靠。
构建可复现还不够,产物要留档。只保留最新版镜像的团队,出事时想回滚却发现旧镜像已被覆盖,回滚预案写在文档里也没用。