nixpkgs的圣杯:版本范围
摘要
本文介绍了nixpkgs的版本范围支持,这是一个以前缺乏此类功能的包管理器,使用求解器和历史数据来实现曾经无法表达的依赖约束。
<p><a href="https://lobste.rs/s/echox4/holy_grail_nixpkgs_version_ranges">评论</a></p>
查看缓存全文
缓存时间: 2026/09/02 21:56
# Nixpkgs的圣杯:版本范围
来源:https://fzakaria.com/2026/09/01/the-holy-grail-of-nixpkgs-version-ranges
> **更新**:您现在可以在 fzakaria.github.io/grail (https://fzakaria.github.io/grail/) 上实时体验此功能,在浏览器中查询nixpkgs的版本范围——这是同一个求解器,已编译为 WebAssembly。
自推出 nixmultiverse.com (https://nixmultiverse.com/) 以来,我就知道这里面蕴含着无穷的探索可能。其中最令人兴奋的,就是能够提出一些关于 nixpkgs 历史的、此前无法解答的问题。
我在之前的一篇文章(https://fzakaria.com/2026/08/09/nixpkgs-multiverse-every-version-that-ever-existed)中展示的 `mvs` 工具,已经为此类自省提供了基本支持:计算出满足一组包精确版本约束所需的最少 nixpkgs 数量。真正的魔力在于,当你能够询问关于*版本范围*的问题时。🧙
Nixpkgs 是一个*无版本*的包管理器。每个属性只有一个版本,而该版本由你当前所在的修订版(revision)决定。这是一个追求简洁的优秀设计,但有时会以牺牲兼容性为代价。
为 Nixpkgs 添加版本范围支持需要付出什么努力?
印第安纳·琼斯表情包:版本范围
*nixpkgs 直到现在才拥有版本范围!*😈
```bash
# 一个同时满足两个约束的修订版
$ grail solve 'python3@>=3.10 ^[email protected].*'
1 修订版
2022-09-12-5f326e2a403e (2022-09-12, r852)
python3 3.10.6
openssl 1.1.1q
glibc: 2.35
```
```nix
grail.lib.${system}.mkDerivation {
pname = "demo";
specs = "python3@>=3.10 ^[email protected].*";
dontUnpatch = true;
installPhase = ''
{ python3 --version; openssl version; } | tee $out
'';
}
```
这个例子曾被认为是 Nixpkgs 中*无法表达的*。Nixpkgs 对每个属性基本上只有一个版本,因此没有可供“范围化”的对象。这正是 Nix 不需要依赖求解器的原因:求解器负责在版本间选择,而这里根本没有版本可选。
然而,如果我们想按照原作者的意图可靠地构建软件,允许作者表达一组已知可工作的版本范围会很有帮助。这就是 nixpkgs 的“圣杯”:版本范围。
几乎所有其他软件包生态系统都支持版本范围(例如 npm、cargo、pip)。令人惊讶的是,即使是与 Nix 类似的*基于存储*的包管理器也支持版本范围。Spack (https://spack.io/) 是这方面最著名的例子,它是一个用于高性能计算(HPC)的*基于存储*的包管理器。Spack 拥有丰富的规范语言(我会借鉴它),允许你为包配方表达约束条件。他们为此撰写了一篇精彩的论文“使用答案集编程解决HPC依赖问题”(https://arxiv.org/abs/2210.08404),该论文于 2022 年在超级计算大会(SuperComputing 2022)上展示,同年我展示了“绘制HPC依赖混乱地图”(https://arxiv.org/abs/2211.05118)。Spack 的“终身仁慈独裁者”(BDFL)Todd Gamblin 也是我的博士论文委员会成员。
> **剧透**:对于那些已经在思考“glibc 兼容性怎么办!?”并认为这将以绝望告终的极客们🤓,请放心!Spack 有一个 `libc_compatibility.lp` 文件,帮助其求解器推理不同 glibc 版本的兼容性。继续阅读以了解更多。
nixpkgs-multiverse (https://fzakaria.com/2026/08/09/nixpkgs-multiverse-every-version-that-ever-existed) 的引入,恢复了 Nixpkgs 中每一个被删除的版本。在 1,541 个 nixos-unstable 修订版中,有超过 309,000 个包版本,几乎每一个都是缓存命中。当版本成为一种*选择*,求解就真正成为可能。我得以将 Nix 认为自己永远不需要的求解器交到它手中。😈
这个“多元宇宙”已经能够解决精确的版本锁定问题,并且已被证明是高效的 (https://fzakaria.com/2026/08/17/nixpkgs-multiverse-the-fewest-nixpkgs#pins-are-intervals),时间复杂度为 O(n log n)。然而,其解决方案有一些限制。为了求解版本范围,问题变成了一个布尔可满足性问题,因此是 NP-难的。是时候使用一个真正的求解器了!
grail (https://github.com/fzakaria/grail) 是一个绑定到求解器的小型查询语言。其语法毫不掩饰地借鉴了 Spack 风格:`@` 接受一个范围,`^` 将约束链接成一个*共存组*,这意味着它们必须在一个共享的修订版上解析。
以下是查询语言的简要入门:
该语法的 BNF 定义可在 docs/grammar.md (https://github.com/fzakaria/grail/blob/main/docs/grammar.md) 找到。还支持通过 `--one-glibc` 来指定*日期范围*和*glibc 时代*。
```bash
# 独立约束:跨修订版最小化
$ grail solve 'ffmpeg@4.* ripgrep@>=14'
# 一个共存组:一个修订版满足所有约束
$ grail solve 'python3@>=3.10 ^nodejs@>=20 ^go@>=1.21 ^ruby@>=3.2'
1 修订版
2026-08-31-34ab99075ac4 (2026-08-31, r1540)
python3 3.14.7
nodejs 24.19.0
go 1.26.7
ruby 3.4.9
glibc: 2.42
```
你可以使用 `--viz` 可视化 SAT 求解器的计划。这是 `ffmpeg@4.* ripgrep@>=14 ^bat`:求解器将 ripgrep 和 bat 合并到尖端(tip),并给 ffmpeg 4 分配了自己的 2023 年世界:
```bash
$ grail solve 'ffmpeg@4.* ripgrep@>=14 ^bat' --viz plan.svg
```
由 clingraph 绘制的求解计划:ripgrep 和 bat 共享尖端修订版,ffmpeg 4 获得自己的 2023 年修订版 (https://fzakaria.com/assets/images/grail-plan.svg)
我使用的 SAT 求解器也会尽职地告诉我一组约束何时不可满足,以及原因,细节出乎意料地丰富:
```bash
$ grail solve '[email protected].* ^postgresql@13.*'
不可满足:[email protected].* 和 postgresql@13.* 从未重叠:
[email protected].* 最后存在时间是 2021-07-18 (r621),
postgresql@13.* 首次存在时间是 2021-08-01 (r625)
```
因此,根据 nixmultiverse.com (https://nixmultiverse.com/) 索引,对于 Python 3.8 和 PostgreSQL 13,没有任何单个 Nixpkgs 修订版能同时满足这两个约束。
然而,之前开头的查询 `python3@>=3.10 ^[email protected].*` 是满足的。将其生命周期绘制在整个索引上就能看出原因。阴影区域表示重叠的 88 天。
nixos-unstable 中 python3 和 openssl 的版本生命周期,共存窗口阴影显示 (https://fzakaria.com/assets/images/grail-lifetimes.svg)
## § (https://fzakaria.com/2026/09/01/the-holy-grail-of-nixpkgs-version-ranges#a-five-minute-tour-of-asp) ASP 五分钟入门
使用的 SAT 求解器是答案集编程 (https://en.wikipedia.org/wiki/Answer_set_programming) (ASP),具体是 clingo (https://potassco.org/clingo/)。
答案集编程看起来像 Prolog,但思维像 SAT。你编写事实、规则和约束;clingo 将它们实例化(ground)并搜索*稳定模型*:所有条件都成立的赋值。
事实只是行数据,从多元宇宙的 `history.json` 中仅为查询指定的属性发出:
```prolog
% s0 是查询的第一个规范:python3@>=3.10
attrname(s0, "python3").
% 通过了范围检查;47 是 compareVersions 排名
allowed(s0, "3.10.6", 47).
% 一段生命周期,使用修订版偏移量表示
run(s0, "3.10.6", 835, 864).
% glibc 2.35 统治 r824..r1005
glibcera(9, 824, 1005).
```
在我们编码了事实之后,我们编写规则来表达约束和策略。
```prolog
% 选择规则:每个规范恰好选择一个允许的版本
1 { pick(S, V) : allowed(S, V, _) } 1 :- spec(S).
% 选择规则:将每个共存组放置在一个候选修订版上
1 { at(G, R) : possible(G, R) } 1 :- group(G, _).
% 完整性约束:如果某个成员的选定版本在组的选定修订版时不存在,则杀死每个模型
:- at(G, R), group(G, S), pick(S, V), not alive(S, V, R).
% 策略,最高优先级(@4)优先
#minimize { 1@4, R : used(R) }. % 最少修订版数
#maximize { K@3, S : pick(S, V), allowed(S, V, K) }. % 然后是最新的版本
#minimize { 1@2, K : usedglibc(K) }. % 然后是最少的 glibc 时代
#maximize { R@1, G : at(G, R) }. % 然后是最新的构建
```
> **注意**:LLMs 非常擅长编写 ASP,此时我们可以利用工具本身来可验证地证明求解器的正确性。
我们只需要为查询请求的属性放置事实,因此保持了求解器的长度和运行时间在合理范围内(亚秒级)。ASP 编码灵感来自 Spack (https://spack.io/),后者使用相同的 clingo 求解器来推理版本范围,但用于解决完整的依赖图。
## § (https://fzakaria.com/2026/09/01/the-holy-grail-of-nixpkgs-version-ranges#its-just-a-lock-file) 它只是一个锁文件
`grail lock` 为已求解的约束写入一个 `multiverse.lock` 文件,可供 nixpkgs-multiverse (https://github.com/fzakaria/nixpkgs-multiverse) 使用。
通过 `mvs` 的所有下游功能之所以能工作,是因为锁文件格式相同:`readLock`、NixOS/darwin/home-manager 模块、`mvs lock status`。
```bash
$ grail lock 'python3@>=3.10 ^[email protected].*'
写入了 multiverse.lock (1 组, 1 个修订版)
# 真正的 mvs,读取另一个工具写的锁
$ mvs lock status
属性 锁定版本 最新版本 落后
openssl 1.1.1q 3.6.3 17 个版本,1449 天
python3 3.10.6 3.14.7 29 个版本,1449 天
```
## § (https://fzakaria.com/2026/09/01/the-holy-grail-of-nixpkgs-version-ranges#mkderivation-with-version-ranges) 带版本范围的 mkDerivation
那么,直接在 derivation 中指定版本范围呢?
解析器本身就是一个 derivation:clingo 在构建沙箱内运行,计划通过 import-from-derivation (https://nix.dev/manual/nix/2.31/language/import-from-derivation) 返回到 eval,多元宇宙的 `mv.at` (https://nixmultiverse.com/docs/nix-api#nested-attributes-are-one-key) 将选定的 nixpkgs 具体化。
```nix
grail.lib.${system}.mkDerivation {
pname = "demo";
specs = "python3@>=3.10 ^[email protected].*";
dontUnpack = true;
installPhase = ''
{ python3 --version; openssl version; } | tee $out
'';
}
```
```bash
$ nix build github:fzakaria/grail#demo
$ cat result
Python 3.10.6
OpenSSL 1.1.1q 5 Jul 2022
glibc ldd (GNU libc) 2.35
```
每个输入都可以从 cache.nixos.org (https://cache.nixos.org/) 替换,因为 nixmultiverse.com (https://nixmultiverse.com/) 仅索引 Hydra 构建的频道更新。
这个 derivation 没有锁定任何 Nixpkgs;它陈述了约束,然后由求解器专门选择了一个。
从查询到事实到 clingo 到计划再到锁文件和 mkDerivation 的 grail 流程 (https://fzakaria.com/assets/images/grail-pipeline.svg)
## § (https://fzakaria.com/2026/09/01/the-holy-grail-of-nixpkgs-version-ranges#what-about-glibc) glibc 怎么办?
“共存组”,即 `^` 符号,从单个修订版获取其 `stdenv`,因此编译器、glibc 和输入构成一个单一、连贯的世界。这是传统的 Nixpkgs 模型,也是安全的默认设置。
在一个*构建*中混合修订版是危险所在(龙出没的地方)。如果不小心,你可能会在一个进程中出现两个不同的 glibc 版本。
关于 glibc 的恐慌冷静表情包
幸运的是,对于没有如此“清教徒式”需求的非 Nix 用户,`glibc` 通过符号版本控制保持向后兼容:一个要求最多 `GLIBC_2.27` 的目标文件可以愉快地与 2.38 链接。每个二进制文件在其 `.gnu.version_r` 部分都包含一条友好的说明,准确标明它支持的最低版本。
`grail` 附带了一个提取器,将它们转化为事实:
```prolog
$ elf_facts.py $(readlink -f $(command -v python3)) python3
needs("python3", "libc.so.6").
verneed("python3", "libc.so.6", "GLIBC_2.2.5").
verneed("python3", "libc.so.6", "GLIBC_2.34").
interp("python3", "/nix/store/...-glibc-2.40-224/lib/ld-linux-x86-64.so.2").
% 最大 glibc 需求:GLIBC_2.34
```
这编码了这样一个事实:`python3` 链接到了 glibc 2.40,但*最多要求* 2.34。求解器可以推理这个事实,并将其混合到 2021 年的任何世界中,这比其 `RUNPATH` 所允许的早了六个时代。Spack 的 `libc_compatibility.lp` 编码了此规则的前向版本,用于针对主机 libc 重用二进制文件;`grail` 将相同的思想在多元宇宙十四年的历史中向后追溯。
发现这个 glibc 最低版本是出奇地可行。计算这些事实的成本比听起来要低。存储路径是不可变的,因此关于它的任何事实只需计算一次并永久缓存。粗粒度的事实已经免费获得:narinfo 的 `References` 列出了路径链接的 glibc,多元宇宙已经爬取过这些信息。`.gnu.version_r` 事实是几 MiB 的流式 NAR,仅在求解器需要时才按需获取。
## § (https://fzakaria.com/2026/09/01/the-holy-grail-of-nixpkgs-version-ranges#where-this-goes) 未来之路
我一直在通过独立的实用程序 grail (https://github.com/fzakaria/grail) 探索这个领域,但一旦我确定了用户体验,下一步就是将其集成到 nixmultiverse.com (https://nixmultiverse.com/) 中。
我甚至可以将求解器直接集成到网站 nixmultiverse.com (https://nixmultiverse.com/) 中,因为 clingo (https://github.com/potassco/clingo) 可以编译为 WebAssembly。一个求解器页面,查询“这五样东西上次共存是什么时候?”可以是一个 URL 查询参数 🤯。
Spack (https://spack.io/) 已经证明我们不必害怕版本范围。是的,*可能有龙*(危险),但这对于 Nixpkgs 来说一直如此。专家工具很锋利,可能会割伤你,但也能赋予你强大的力量。
我只是重新将我个人的地狱带到了 Nixpkgs:钻石依赖问题 (https://fzakaria.com/2024/07/02/reproducibility-in-disguise),但至少我现在有了推理它的工具。
代码位于 github.com/fzakaria/grail (https://github.com/fzakaria/grail),去探索 Nixpkgs 的多元宇宙吧!
相似文章
nixpkgs-multiverse: every version that ever existed
nixpkgs-multiverse is a Nix flake that provides access to every version of every package that ever existed in Nixpkgs, allowing users to easily pin and mix historical package versions without multiple flake inputs.
nixpkgs-multiverse:快速模式
nixpkgs-multiverse 引入了一种快速模式,跳过 Nixpkgs 评估并直接获取包存储路径,从而可以更快地访问历史包版本。
自行过期的 Nix 覆盖
一篇博客文章,演示了一种 Nix 模式,使包覆盖在过时时发出警告,基于评估期间的条件版本或元数据检查。
GuixPkgs:每个 Guix 包,作为 Nix flake
GuixPkgs 是一个项目,它将每个 GNU Guix 包以 Nix flake 的形式提供,使用户能够在单个 flake 中混合使用 Guix 和 Nixpkgs 包。它使用 guix-transfer 将 Guix 派生转换为 Nix 派生,并提供二进制缓存以避免完全重建 Guix 引导过程。
Nix Flakes 及其在 Guix 中的对应物
详细比较 Nix Flakes 与 Guix 包管理系统中的对应物,涵盖依赖声明、锁定、纯净性、输出、开发环境和系统配置。