Skip to content

필사 모드: Nix 2.35 更懒惰地把 flake 源复制到 store 了 — 一个持续 6 年 8 个月的 issue,以及上游没有选 lazy trees 而选的那条路

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 为什么 nix build 会复制你的整个仓库

如果你在一个用了 flake 的仓库里改了一个文件,然后跑过 nix build,你应该见过这个动作。

$ vim README.md          # 改了一个字符
$ nix build .#myapp
copying '/home/me/big-monorepo' to the store...

不管构建到底需不需要 README,整个源码树都会被复制到 /nix/store。下次再改一个字符,又复制一遍。如果这棵树是个几个 G 的 monorepo,这就不是闹着玩的了。

重要的是,这不是 bug,而是设计。照搬 Nix 2.35.0 发布说明的原话 — 历史上,flake 的源码树为了保证确定性、hermetic 的求值,一直被 eager 地取入 store 并从那里求值,即便最终的 store 对象根本没被用作 derivation 的输入。这样实现起来简单,但让 flake 在大仓库上没法用,而且每次源码树变化都会产生不必要的写入。

本文讲的是,修好这个设计为什么花了 6 年 8 个月,Nix 2.35.0 实际修了什么,以及它还没修好什么。最后这一点最重要。

一个持续 2,437 天的 issue — #3121

issue NixOS/nix#3121 "Copy local flakes to the store lazily" 于 2019 年 10 月 7 日开启,开的人是 Eelco Dolstra — Nix 的创造者本人。

这个 issue 在 2026 年 6 月 9 日关闭。2,437 天,大约 6 年 8 个月,69 条评论。关闭它的人不是 Dolstra,关闭时留的评论只有五个词 — "Completed in #15711"。

#15711 不是 Dolstra 的 PR。故事从这里开始。

lazy trees — 两次尝试

Dolstra 给出的答案是 lazy trees。想法是这样的:不把源码树复制进 store 再从那里读,而是造一个虚拟文件系统,让求值器看起来「就像它在 store 里一样」。只有真正需要某个文件的时候,才读需要的那部分。构建 nixpkgs#hello 没理由把整个 nixpkgs 都复制一遍。

PR NixOS/nix#6530 "Lazy trees" 于 2022 年 5 月 12 日开启,一路长到 402 个提交、56 个文件、+1363/-364。然后在 2025 年 5 月 18 日,没有合并就被关闭了。1,102 天,整整 3 年。

关闭前一天,2025 年 5 月 17 日,Dolstra 开了 PR #13225「Lazy trees v2」。这个小得多 — 1 个提交、41 个文件、+539/-133。截至本文写作的 2026 年 7 月 16 日,这个 PR 仍然开着,GitHub 报告的可合并状态是 dirty(冲突),最后一次更新是在 2025 年 10 月 9 日。16 条评论、17 条审阅意见,停滞了 9 个月。

这里有件事要小心。PR 为什么不动,我不知道。总结审阅争论的内容然后写「是谁挡住了它」,超出了本文的范围,也不是可验证的事实。能确认的只有这些 — 它开着,处于冲突状态,9 个月没有更新。

与此同时,Determinate Nix 就这么发布了

Dolstra 是 Determinate Systems 的联合创始人。而 Determinate Systems 有自己的 Nix 发行版,叫 Determinate Nix。

2025 年 5 月 15 日,Determinate Systems 的 Luc Perkins 宣布 lazy trees 已经进了 Determinate Nix 3.5.2。它基于上游的 Nix 2.28.3,一开始是可选启用(opt-in)的。

# /etc/nix/nix.custom.conf
lazy-trees = true

同一篇文章里有这么一句 — 「我们的联合创始人 Eelco Dolstra 目前开着一个 PR,想把 lazy trees 带入上游 Nix,希望它能尽快合并」。那个 PR 就是 #13225。1 年 2 个月过去了,它依然开着。

两个月后,2025 年 7 月 11 日,Graham Christensen 宣布了 Determinate Nix 3.8.0,写道上一版已经把 lazy trees 放量给了 5% 的 Determinate Nix 用户,而且因为这 5% 的放量效果不错,现在要扩大到 20%。

按百分比分阶段放量的方式来发布一个开源包管理器 — 这是相当新鲜的景象。用户可以敲 determinate-nixd version 来确认自己是不是落在那 20% 里,同一篇文章还说,想退出(opt-out)可以联系 support@determinate.systems

也有性能数字,不过这些是厂商自测。3.5.2 的公告以在 Nixpkgs 仓库内求值为例,说他们「经常见到(frequently seen)」wall time 减少 3 倍以上、磁盘使用减少 20 倍以上,还补充说「有时降幅会大得多」。这里缺失的东西,我照实写出来 — 硬件、测量方法、对比的基准版本、跑了几次、「经常」具体是多少个百分比,全都没有。方向大体可信,但不要把那些倍数原样搬到你自己的仓库上。

核心 — lazy-locks,以及彻底懒惰的代价

现在是这个故事技术上的核心部分。看 Determinate Nix 源码(以 v3.21.7 为准)里的配置定义,会看到两个并排的选项。

Setting<bool> lazyTrees{
    this, false, "lazy-trees",
    R"(
      If set to true, flakes and trees fetched by builtins.fetchTree are only
      copied to the Nix store when they're used as a dependency of a derivation.
      This avoids copying (potentially large) source trees unnecessarily.
    )"};

Setting<bool> lazyLocks{
    this, false, "lazy-locks",
    R"(
      If enabled, Nix only includes NAR hashes in lock file entries if they're
      necessary to lock the input (i.e. when there is no other attribute that
      allows the content to be verified, like a Git revision).
      This is not backward compatible with older versions of Nix.
      If disabled, lock file entries always contain a NAR hash.
    )"};

lazy-locks 文档里那句话 — "This is not backward compatible with older versions of Nix" — 说明了一切。

逻辑是这样的。flake.lock 里的每一条记录都带一个 narHash。这是输入源码树序列化成 NAR 之后的哈希。要算出这个哈希,就得读完整棵树。也就是说,只要还决定把 narHash 放进 lock 文件,懒惰程度就有个天花板 — 哪怕只需要一个文件,也得把整棵树遍历一遍。

所以,要真正彻底懒惰,就得把 narHash 从 lock 文件里去掉。这就是 lazy-locks = true。而这样生成的 lock 文件,旧版 Nix 读不了。团队里有一个人开了这个选项、提交了 flake.lock,没开这个选项的人的构建就会坏掉。

还有一点值得记下来 — 这两个选项在代码里的默认值都是 false。Determinate 放量给的那 20%,用的不是代码默认值,而是他们自家守护进程管理的配置里开启的。

Lix 的答案 — 不修,冻结

还有第三种实现。2024 年分叉出来的 Lix

Lix wiki 的 Flakes feature freeze 文档(写于 2025 年 10 月 5 日,2026 年 2 月 1 日更新)写道,核心团队决定把 flake 的功能集合和语义冻结在当前这个时间点 — 除了修 bug 之外不再加新功能,不加新的输入类型,也不给现有的 fetcher 加新功能,也不做以 flake 为中心的求值器改动。给出的理由是 flake 的实现代码极度脆弱,维护负担很重。

同一份文档里有一句在本文语境下最尖锐的话。它指出 flake 的锁定设计是 unsound 的,要点是这样的 — lock 文件里的东西哪怕能取到,只要它不是这个特定的 Nix 实现针对同一个输入本应生成出来的值,锁的比对就会失败。所以 libfetchers 的输入格式或 lock 文件格式一旦出现任何 divergence,就会造成实现之间的不兼容。

Determinate 的 lazy-locks 正是这样一种 divergence。Lix wiki 把这一类问题作为一般性问题指了出来,Determinate 把它放在一个可选开关(opt-in flag)背后,而上游则干脆决定不做。同一个问题,三个不同的答案。

Nix 2.35 实际做了什么

Nix 2.35.0 于 2026 年 7 月 13 日打标签,并在同一天公告。(发布说明文档顶部写的日期是 2026-06-22 — 剪辑说明文稿的那天和实际打标签的那天差了 3 周。标签和公告同一天,即 2026 年 7 月 13 日,才是实际的发布日期。)

被合并的是 PR #15711 "Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)",作者是 Sergei Zimmerman(@xokdvium),2026 年 4 月 19 日开启,4 月 27 日合并。3 个提交、14 个文件,+180/-37

回想一下 lazy trees v1 是 402 个提交、56 个文件、+1363/-364 — 这不是一个数量级。

PR 正文里把思路写得很直白。

3rd time's the charm! (...) This repurposes a slightly less lazy (but also more deterministic) approach than determinate nix has taken. We do still pay to cost of hashing an input once to compute the store path and narHash daemon-client-side.

「比 Determinate nix 采用的方案稍微没那么懒、但更确定性」。而且,「为了算出 store path 和 narHash 而对输入哈希一次的成本」依然要付。

具体是怎么运作的,发布说明里写了 — 为了减少复制的次数,Nix 现在会先哈希、后复制,并假设 .outPath 最终不会出现在某个 derivation 的属性里,因此实际上不需要把它取进 store。如果这个假设错了,就要付出一点额外工作的代价,但在典型的使用场景下,总体工作量是减少的。

换句话说,消失的是复制(copy),不是遍历(walk)和哈希计算。

而且这也不是和 Dolstra 无关地进行的。Zimmerman 在 2026 年 4 月 19 日于 issue #3121 上写道 — 「在 #15711 里试了一下。@edolstra 做了大量让这个 PR 成为可能的好工作,我只是把碎片拼在了一起。」事实上,这个 PR 所依赖的 PR #14050 "Fix fetchToStore caching",正是 Dolstra 在 2025 年 10 月 6 日合并进上游的。

fetchTarball 那边也是同样的套路。Determinate Nix 3.21.0(2026 年 5 月 22 日)上线了「Lazy fetchTarball」,5 天后的 5 月 27 日,上游的 PR #15920 "fetchTarball: Use the tarball fetcher" 合并。两边的作者都是 Dolstra。(我不会断定这两个补丁是同一件事 — 我确认的只有作者和日期。)

发布说明里有一条性能数字 — 这项改动预计会(expected to)大幅降低典型求值所需的磁盘使用量,而且不管走 fetchTree/flake 路径还是 fetchTarball 路径,在获取并解压 nixpkgs tarball 时确实带来(does)大约 2 倍的速度提升。这是项目方自测,硬件、方法论、对比的版本都没有写明。原文在措辞上的温度差我也照样搬过来了 — 磁盘那部分是「预计」,只有速度那部分是断言句。

上游没有 lazy-treeslazy-locks 这样的配置项。按 GitHub 代码搜索,这两个字符串在 NixOS/nix 仓库里的命中数是 0。没有什么可以打开或关闭,默认行为本身变了。这就是它和 Determinate 方式的实质区别 — 不是多加开关,而是在不破坏兼容性的前提下,能懒多少就懒多少。

什么依然没修好

这是本文最重要的一节。发布说明自己写出了这个改动的局限,而这类句子通常会在摘要里消失。

原文说,触碰整棵源码树、对其 NAR 序列化做哈希这件事是不可避免的(unavoidable),并给出了两个理由。

  1. 对于 flake 输入,必须 eager 地检查 narHash 的完整性。
  2. flake 的 outPath 属性必须事先已知,而且为了向后兼容,它必须是一个 content-addressed 的 store path 字符串。

所以,在一个 3 GB 的 monorepo 里改一个文件,nix build 现在不会再把 3 GB 复制到 store,但它依然要读取并哈希这 3 GB。在大树上,这仍然是一笔看得见的成本。消失的是对 store 的写入和磁盘占用,不是文件系统遍历。

发布说明也指出,前一个版本 2.32 就已经清理掉了一半 — 从 Nix 2.32 起,求值期间读取源码所涉及的所有 I/O(path:hg+: 风格的输入除外)都被改道到了原始的文件系统位置,基于 tarball 的输入则改道到 ~/.cache/nix/tarball-cache-v2 下的一个 bare git 仓库。留下来的是计算 store path 所需的那次 fetch,而 2.35 去掉的正是它。

而且假设也有出错的时候。如果 .outPath 真的是一个最终会出现在 derivation 属性里的表达式 — 用发布说明自己的话说,「如果这个假设错了」 — 就要付出额外工作的代价。说明把这称为「slight」,没有给出具体数字。这样的数字从未被公布过,所以我也不会在这里编一个出来。

最后一点:这不是一个能提升可复现性的改动,而是一个性能改动。最终的 store path 必须保持不变,从求值器的视角看,它依然表现得像是发生过复制一样 — toString ./. 依然会输出一个不带上下文的 content-addressed store path 字符串,从 flake 的 outPath 里读文件的代码也依然能用,不会被当作 IFD 处理。说明之所以把这份兼容性清单列得这么长是有原因的 — 向后兼容本身就是这个设计要满足的约束条件。顺带一提,如果想从发行版这一侧、换个角度看「可复现性的代价」这个主题,同一天上线的还有Debian 开始阻止不可复现的软件包迁移到 testing这篇文章。

那么,你该怎么做

升级值回票价的情况

  • 你在大仓库、monorepo 上用 flake,而 copying '...' to the store 正是你反复迭代时能感受到的成本。光是复制消失,就已经值了。
  • 你在 CI 里反复获取、解压 nixpkgs 的 tarball。发布说明说的那约 2 倍,说的正是这个场景(项目方自测)。
  • /nix/store 的磁盘压力是个实际问题。每次求值都在堆积的源码树副本会减少。

没什么差别,或者不必着急的情况

  • 你的树很小。遍历和哈希计算还在,如果本来复制就便宜,能省下的也不多。
  • 你不用 flake。这项改动说的是 flake 和 fetchTree/fetchTarball 的路径。
  • 你 monorepo 的瓶颈不是求值,而是构建本身。依然要读取并哈希 3 GB 这件事没有变。

如果你在纠结要不要打开 Determinate Nix 的 lazy-trees — 这和 2.35 是两码事。它更懒,但要彻底懒下去需要 lazy-locks,而文档明确说了这会破坏向后兼容。除非能保证团队所有人用的是同一个 Nix 实现,否则不要打开 lazy-locksflake.lock 是团队共享的文件,实现之间的 lock 不兼容,是 Lix wiki 已经作为一般性问题指出过的已知陷阱。

如果你用 Lix,flake 不会再有新功能了。这不是意外,而是已经公布的政策,长期计划是把 flake 挪到代码库之外,做成一个插件。

结语

归纳一下。2019 年,Nix 的创造者开了一个「让我们懒惰地复制源码」的 issue,2026 年关闭了。关闭它的不是他那个 402 个提交的 PR,而是别人在他的前期工作之上搭出来的一个 180 行的 PR。这期间,同样的功能先在下游的一个商业发行版里以百分比放量的方式发布了,而另一个分叉则把这整块领域冻结了起来。

技术上的教训很简单。这个问题的难点从来不在于实现懒惰本身 — Determinate 在 2025 年已经证明那是可行的。难点在于不破坏向后兼容地做到懒惰,所以最终进入上游的答案是「稍微没那么懒,但更确定性」。只要还决定把 narHash 一直放进 lock 文件,遍历整棵树做哈希这件事就会留下来,正如发布说明自己写的那样 — "unavoidable"。

而这似乎是这个故事里最能留下来的一句话:解决问题的不是大 diff,而是接受了约束条件的小 diff。PR 的标题早就知道了 — maybe 3rd time's the charm。

参考资料

현재 단락 (1/91)

如果你在一个用了 flake 的仓库里改了一个文件,然后跑过 `nix build`,你应该见过这个动作。

작성 글자: 0원문 글자: 9,297작성 단락: 0/91