Nix 的 Substituter 列表不是路由表
摘要
一篇博客文章,解释了 Nix 的序列化二进制缓存查找的性能限制,并介绍了 ncro,一个用 Rust 编写的小型代理,它通过并行竞争上游缓存来减少延迟。
<p><a href="https://lobste.rs/s/2ibvbm/nix_s_substituter_list_is_not_routing">评论</a></p>
查看缓存全文
缓存时间: 2026/05/25 11:07
# Nix 的 Substituter 列表并非路由表
来源:https://notashelf.dev/posts/nix-cache-proxy
## 目录
- [问题的形态](#the-shape-of-the-problem)
- [ncro 简介](#ncro-briefly)
- [实际包含的内容](#whats-actually-in-it)
- [我没有构建的东西](#what-i-did-not-build)
- [它值得写吗?](#was-it-worth-writing)
- [脚注](#footnote-label)
Nix 的 substituter 模型是那种**几乎**正确、但**并不完全**到位的设计之一。它本身很简单:你在 `nix.conf` 里列出几个二进制缓存,守护进程按顺序遍历它们,如果你需要的路径在网上任何地方存在,就不必在你的笔记本上构建。二进制缓存常被列为 NixOS 的**优势**,但实际上它是 Nix 的优势,也是像 NixOS 这样的系统能够为用户正常工作的最低要求。也就是说,在你为配置的依赖项(或者更罕见的情况下,为项目的依赖项)设置了足够大的多缓存环境之前,它实际上都完美工作。这里“多缓存环境”意味着你拥有多个二进制缓存实例,因为你决定从互联网上获取大型的 C++ 和 Rust 项目。在这种情况下,列表中的第一个缓存几乎总是 `https://cache.nixos.org`。它速度快,是全球性的,但没有你 overlay 中的包。第二个通常是类似 `nix-community` 的缓存,因为你经常从 nix-community 组织拉取一些非常有用的东西[^1]。在不太常见的情况下,你还会添加第三方项目的 Cachix,偶尔,如果你的技术水平足够,还会添加你自己家庭实验室的私有缓存。在这样的设置中,每一次 narinfo 查找都会遍历这个列表。每一次 `nix-shell -p hello` 都会变成跨越四大洲三个主机的串行扫描,因为 Nix 没有“**哪个 substituter 最可能回答这个路径**”的概念。它只会按照你写下的顺序,一个接一个地去询问它们。
## 问题的形态
为了解释为什么代理是正确的答案,你必须诚实地看待 Nix 的 substituter 逻辑的**本质**:
- 一个按顺序遍历 `substituters` 的循环。
- 对每个 substituter:`HEAD /<hash>.narinfo`。如果返回 200,则获取并使用它。如果返回 404,则继续下一个。
- 没有并发。没有延迟跟踪。没有记忆上一次你询问这个哈希时哪个缓存成功了。
Substituter 列表是一个**偏好**,而不是一个**路由表**。存在一个 `priority` 字段,但它是一个在配置时选择的静态整数。它不知道 `cache.nixos.org` 在你家连接上延迟是 40 毫秒,而在公司 VPN 下是 800 毫秒。它不知道你的私有缓存是唯一拥有该路径的缓存。它什么都不知道,因为没什么可知道的。守护进程在每次请求时都是无状态的。对于一个以声明式纯净模型为傲的工具,其底层的网络层仍然是静态且请求本地的。
## ncro 简介
ncro(Nix Cache Route Optimizer,发音为 Necro)是一个轻量级的 HTTP 代理,位于 `nix-daemon` 和你的 substituter 之间。它大约有三千行干净、高性能的 Rust 代码。它做三件事:
1. 在 narinfo 查找时,它**竞速**所有配置的上游源,并行发送 `HEAD` 请求,并记住哪个赢了。
2. 在 NAR 获取时,它直接将正文流式传输给客户端。没有磁盘。没有缓冲区。没有 NAR 会驻留在代理上。
3. 它保留一个小的、有边界限制的 SQLite 路由决策表,这样重启不会强迫它重新学习整个世界。
这就是全部产品。它特意**不是**另一个 `ncps`——那种将缓存镜像到磁盘并给你带来随之而来的缓存失效烦恼的东西。ncro 一旦数据流式传输通过,就不会保留有效载荷数据。
## 实际包含的内容
有趣的部分是那些架构图(你可能注意到了,也可能没注意到)没有展示出来的:
1. **竞速。** ncro 的路由器按 `priority` 分组候选,然后对于每个优先级层,生成一个 `FuturesUnordered` 的 `HEAD` 请求,并在第一个成功时中断。这个层循环让你可以说“首选我的私有缓存,但仅当它响应时——否则回退到 cache.nixos.org”,而无需在 Nix 本身中编写任何逻辑。选择循环上有一个截止时间,这样单个挂起的上游源不会阻塞整个查找。失败被分类为**未找到**、**网络错误**和**超时**,因为“每个上游都返回 404”和“每个上游的 TCP 握手都挂了”应该给客户端不同的答案。
2. **缓存,分为两层。** 一个 moka `Cache` 位于 SQLite 前面,容量为 1024 个条目,TTL 绑定到路由 TTL。底层是 SQLite,`narinfo_bytes` 与路由一起存储,这样热路径甚至不需要第二次上游获取。通过滥用 `AtomicU64::fetch_add` 的前自增语义,每写入一百次就触发一次驱逐。这个细节在审查中困扰了我,因为 `count % 100 == 0` 在**第一次**写入时就会触发,而此时计数器为零。修复只需一个字符,但影响是真实的:在纠正这个边界情况之前,延迟指标是扭曲的。
3. **健康状态,带 EMA。** `ema_latency`。第一个样本绕过平滑处理。否则,第一次探测将永久锚定到启动时 `ema_latency` 中的任何垃圾。连续失败将上游源划分为 活跃 / 降级 / 宕机,并带有多重退避(x1,x4,x10),这样宕机的缓存就不会再浪费你的探测流量。只有当两个上游源的延迟相差在 10% 以内时,排序才回退到 `priority`,这是在处理抖动几毫秒的网络延迟时对“平局”的唯一诚实解释。
4. **飞行中重复数据删除。** 如果两个客户端同时请求相同的 narinfo,则只发生一次竞速;另一个等待互斥锁,并在退出时读取 LRU。清理路径使用带有 `Arc::ptr_eq` 的 `remove_if`,因为一个**幼稚的** `remove` 会愉快地删除**别人的**该死的 `Arc`——这个 `Arc` 可能在你读取 guard 和丢弃它之间的时间槽内被放入。这避免了重复数据删除映射中的 TOCTOU 类错误。
5. **签名。** 使用 `ed25519-dalek` 的 ed25519,公钥从 Nix 的 `name:base64(key)` 格式解析,指纹以 `nix store sign` 写入的精确 `1;StorePath;NarHash;NarSize;refs` 格式重建。如果你为某个上游源配置了密钥,未通过验证的 narinfo 会在进入缓存之前被拒绝。这是你不能跳过的部分:一个剥离签名检查的代理,会将一个被攻破的上游源变成**每一个**通过你拉取数据的机器都受其影响。
## 我没有构建的东西
没有 NAR 缓存。没有镜像。没有“预热”任务。ncro 会愉快地从上游流式传输同一份 200 MB 的闭包十次,每次都是如此,这对路由器来说是**正确**的行为。优化在于**你询问哪个上游源**,而不是**完全避免上游源**。一旦你开始存储 NAR,你就等于在注册免费的磁盘空间警报、内容寻址但又不完全是缓存投毒,以及一个维护表面——这一切与你真正想要的无关,你只是希望 `nix build` 不要再为那个永远没有路径的缓存花费 400 毫秒做 DNS 解析。
默认情况下也没有网格。对于可信对等方的情况,确实存在一个可选的、带有签名 UDP 数据包的 gossip 层,但它是关闭的,并且应该保持关闭,除非你对对等交换有清晰的信任和威胁模型。我计划将来对此进行扩展。
## 它值得写吗?
是的。这个代理足够小,我可以把整个东西记在脑子里,而这一点我不能对我**实际依赖的**大多数软件这么说。它解决了一个问题,它从一开始就打算解决的问题,并且它没有试图发展成一个内容缓存、CI 构件存储或点对点 Nix 网格——尽管这三个都很诱人,而且每一个都会大大复杂化设计。
Nix 的 substituter 逻辑本可以支持这种行为,但实际上多年来它一直是一个静态有序的循环。一个专门的代理是在不修补守护进程本身的情况下添加动态路由的实用方法。也许你也会对它感兴趣。试试吧!
[^1]:咳咳,`nh`。它很有用,它很大,而且如果你从 master 分支构建,构建它会很烦人,因为 Rust 就是这样。↩
相似文章
Nix 需要可重定位的二进制文件
文章指出了一个问题是 Nix 二进制文件不可重定位,当存储前缀改变时会导致哈希变化和重新编译,并提出使用带有 $ORIGIN 的相对路径在 RUNPATH 中实现可重定位,而不会使缓存失效。
Nix Evaluation Is a Scheduling Problem
The article argues that Nix evaluation is fundamentally a scheduling problem and introduces Evix, a library-first async Nix evaluation engine designed for persistent, structured evaluation results.
Nix Flakes 及其在 Guix 中的对应物
详细比较 Nix Flakes 与 Guix 包管理系统中的对应物,涵盖依赖声明、锁定、纯净性、输出、开发环境和系统配置。
使用Nix构建系统软件
一篇博客文章,讨论Nix如何帮助解决构建系统软件时的依赖和可重现性问题,特别是针对像BPF和io_uring这样快速演进的子系统。
自行过期的 Nix 覆盖
一篇博客文章,演示了一种 Nix 模式,使包覆盖在过时时发出警告,基于评估期间的条件版本或元数据检查。