Nix 需要可重定位的二进制文件
摘要
文章指出了一个问题是 Nix 二进制文件不可重定位,当存储前缀改变时会导致哈希变化和重新编译,并提出使用带有 $ORIGIN 的相对路径在 RUNPATH 中实现可重定位,而不会使缓存失效。
<p><a href="https://lobste.rs/s/pa1atu/nix_needs_relocatable_binaries">评论</a></p>
查看缓存全文
缓存时间: 2026/06/22 13:34
# Nix 需要可重定位的二进制文件
来源:https://fzakaria.com/2026/06/21/nix-needs-relocatable-binaries
> 这是我在 aTacoSprint 2026 (https://tacosprint.org/) 项目中提出的问题陈述和提案 🏄。
Nix,或者更广义的**基于存储的系统**,是一类使用明确定义的前缀来存储所有包的包管理器。对于 Nix 来说是 `/nix/store`,对于 Guix 来说是 `/gnu/store`。
这很简单。它让**重写**指向二进制文件或库的路径变得容易。派生只需要使用 `sed` 替换带有完整存储路径的字符串;例如 `/bin/bash` 变成了 `/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash`。
如果你想要一个不同的路径,一个不以根目录 `/` 为前缀的路径呢?
如果你还没有安装 Nix,或者缺少必要的权限——比如“无根 Nix”——这可能会很理想。
嗯,Nix 目前确实允许你指定不同的存储路径,**但有一个问题!**
让我们看一个简单的例子。我们可以用两种方式构建 `hello`。
```
> nix build nixpkgs#hello
> nix build --store /tmp/fzakaria/store nixpkgs#hello
```
第一个命令构建并安装 `hello` 到 `/nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/`,第二个命令则使用 `chroot` 和挂载命名空间安装到 `/tmp/fzakaria/store/nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/`。
注意两者具有相同的哈希值 `zi2bj2hlavv8q743li2s9diqbcpmrf9b`。
**这一点很重要。** 通过保持哈希值相同,我们可以利用来自二进制替换服务(如 https://cache.nixos.org/)的预计算派生。
那么,缺少什么呢?
如果你正在使用像 Bazel (https://bazel.build/) 或 Buck2 (https://buck2.build/) 这样的工具,它们很可能已经通过命名空间进行自己的沙盒构建。将 Nix 集成到这些生态系统中变得极其不切实际,因为我们遇到了嵌套用户命名空间和挂载限制。
我们可以要求 Nix 使用替代的存储前缀,**无需 chroot 和挂载命名空间**,但存在一个巨大的差距。
```
> XDG_CACHE_HOME=/tmp/fzakaria/cache \
nix eval --store 'local?store=/tmp/fzakaria/store&state=/tmp/fzakaria/state&log=/tmp/fzakaria/log' \
--raw nixpkgs#hello.outPath
/tmp/fzakaria/store/qv3fhi1j9gh27fyds5n5b16yia8i6zn5-hello-2.12.3
```
现在哈希值是 `qv3fhi1j9gh27fyds5n5b16yia8i6zn5` 😭
情况更糟。仅仅修改这个简单字符串就会级联失效整个依赖图。现在你需要等待 4 小时编译 GCC,只为了从不同的文件夹打印“Hello World”。🫠
这意味着我们无法利用公共缓存。这个差距在目前的 Nix 文档 (https://nix.dev/manual/nix/2.24/store/types/local-store) 中已被指出。
它非得这样吗?
如果我们可以将 Nix 二进制文件安装到 **任何地方**,而不需要使用命名空间或 `chroot` 呢?我们能鱼与熊掌兼得吗?🍰
Nix 需要 **可重定位的二进制文件**。
问题在于存储前缀本身是派生的一部分,因此它影响了哈希计算。
我们不必在每个地方都指定完整的存储前缀。如果我们使用相对路径呢?🤔
让我们看看当前二进制文件中完整路径写在一个地方的情况:通过 `RUNPATH`。
```
> patchelf $(nix build --no-link --print-out-paths nixpkgs#hello)/bin/hello \
--print-rpath
/nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib
```
当这个程序运行时,动态链接器会查看 `RUNPATH` 来找到它的共享依赖。
然而,Linux 的加载器原生支持变量 `$ORIGIN`,它表示“包含可执行文件的目录”。[参考 (https://man7.org/linux/man-pages/man8/ld.so.8.html)]
我们可以将 `RUNPATH` 写为 `$ORIGIN/../57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib`。
如果这样做,那么更改存储路径就不会导致任何哈希值发生变化。无需重新编译。🥳
好了,那么我们完成了吗?
嗯,像大多数事情一样,魔鬼在于细节。😈
在动态链接器能够读取 `RUNPATH` 来找到必要的库之前,Linux 内核必须加载动态链接器本身。这个路径存储在另一个名为 `PT_INTERP`(程序解释器)的 ELF 头部中。
```
> patchelf $(nix build --no-link --print-out-paths nixpkgs#hello)/bin/hello \
--print-interpreter
/nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib/ld-linux-x86-64.so.2
```
不幸的是,Linux 内核目前 **尚不支持** 在这个字段中使用 `$ORIGIN`。
我们也在脚本的 shebang 行中遇到了完全相同的内核限制。
```
#!/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash
echo "Hello!"
```
当我们执行一个脚本时,内核会解析 `#!`(shebang)并期望一个绝对路径。对 `$ORIGIN` 的支持 **目前也缺失**。
我们不能可靠地在这里使用相对路径,除非它们相对于当前工作目录,而一旦你从其他任何地方运行脚本,这就会失效。
### 我们如何实现?🗺️
为了实现真正的可重定位二进制文件,我们需要绕过这些内核限制。`$ORIGIN` 在历史上对于 Linux 内核中的 `PT_INTERP` 来说是没有意义的,因为“为什么你会希望你的动态链接器相对于文件来查找?”。
Nix 改变了这种评估。有几种方法可以解决这个问题:
1. 我们可以修补 Linux 内核,使其在 `PT_INTERP` 和 shebang 中支持 `$ORIGIN`。
2. 我们用一个小型的静态二进制文件包装每一个二进制文件,该文件计算自己的位置,然后调用动态链接器。
3. 我们需要替换文件位置,以便也利用特定于语言的特性来实现相对路径。例如,在 Python 中,我们可以利用 `__file__` 来访问相对于自身的文件,类似于 `$ORIGIN`。
我相信向 Linux 内核增加支持是正确的做法。Nix 的美妙之处在于,我们甚至可以在任何 NixOS 机器上修补当前的内核以获得这种支持。
作为最后的锦上添花,我们可以在每个派生中添加额外的元数据 `relocatable = true;`,指示它是否 **可重定位**。🍒
相似文章
Linux 内核将支持 $ORIGIN,某种程度上
Linux 内核计划通过 eBPF 和 binfmt_misc 来支持可重定位二进制文件的 $ORIGIN,从而让 Nix 等工具能够以编程方式选择解释器。
Nix 的 Substituter 列表不是路由表
一篇博客文章,解释了 Nix 的序列化二进制缓存查找的性能限制,并介绍了 ncro,一个用 Rust 编写的小型代理,它通过并行竞争上游缓存来减少延迟。
使用Nix构建系统软件
一篇博客文章,讨论Nix如何帮助解决构建系统软件时的依赖和可重现性问题,特别是针对像BPF和io_uring这样快速演进的子系统。
Nix for Haskell: 静态构建
本教程介绍如何使用 Nix 为 Haskell 项目创建静态链接的可执行文件,涵盖 GHC 的静态构建配置以及与 Docker 的集成。
不到100行代码实现nix-build
本文通过用不到100行Go代码重新实现nix-build,揭示了Nix构建过程,表明将派生转换为存储路径本质上就是一次执行。