跳到正文

可复现构建 ​

同一份 commit,两个人构建出不一样的结果,或者昨天能跑今天不能跑,说明有输入没被记录下来。可复现要做的就是把这些输入全部写下来。

输入不止源码 ​

你的代码坐在一摞抽象层上:解释器、直接依赖、传递依赖、系统库、编译器、内核、硬件。任何一层不同,行为都可能不同。

层例子记在哪
应用代码git commit版本控制
直接依赖requests>=2.32清单文件
传递依赖urllib3 2.2.3锁文件
解释器CPython 3.12.7requires-python
系统库OpenSSL、libpq容器镜像
编译器构建时的 gcc 版本镜像或 Nix

清单文件写的是范围,范围是给解算器看的,不是给复现看的。范围每次解算都可能落到不同版本,这就要求锁文件。

「可复现」有三个强度,混用时容易高估自己:同一份输入能重新构建出能跑的产物;同一份输入在不同机器上得到行为一致的产物;同一份输入得到逐位相同的字节。前两个靠锁文件基本够,第三个要封闭构建。

锁文件 ​

锁文件记录解算之后的精确结果,包括每个包的版本、下载地址和校验和。把某个依赖钉成精确版本叫 pinning,它只锁住一个点;锁文件锁的是整张解算结果。

toml
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,直接重新解算覆盖:

bash
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 定期跑,比一年想起来查一次可靠。

构建可复现还不够,产物要留档。只保留最新版镜像的团队,出事时想回滚却发现旧镜像已被覆盖,回滚预案写在文档里也没用。