Nix needs relocatable binaries

Nix needs relocatable binaries

Nix 需要可重定位的二进制文件

This is my problem statement and proposal for a TacoSprint 2026 project 🏄. Nix, or store-based systems, are a class of package managers that use a well-defined prefix to store all packages. This can be /nix/store for Nix or /gnu/store for Guix. This is simple. It makes rewriting paths to binaries or libraries easy. Derivations only need to sed the strings with the full store-path; /bin/bash becomes /nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash for instance.

这是我为 TacoSprint 2026 项目准备的问题陈述和提案 🏄。Nix 或基于存储(store-based)的系统是一类使用定义明确的前缀来存储所有软件包的包管理器。对于 Nix 来说,这通常是 /nix/store,而对于 Guix 则是 /gnu/store。这很简单,使得重写二进制文件或库的路径变得容易。派生(Derivations)只需要通过 sed 将字符串替换为完整的存储路径;例如,/bin/bash 会变成 /nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash

What if you wanted a different path, one not prefixed at the root /? This could be desirable if you don’t have Nix installed already or are missing necessary permissions – “rootless Nix”. Well, Nix already lets you specify a different store-path today but there is a catch! Let’s take a look at a simple example. We can build hello two different ways.

如果你想要一个不同的路径,而不是以根目录 / 为前缀的路径呢?如果你尚未安装 Nix 或缺乏必要的权限(即“无根 Nix”),这可能很有用。实际上,Nix 目前已经允许你指定不同的存储路径,但有一个陷阱!让我们看一个简单的例子。我们可以用两种不同的方式构建 hello

> nix build nixpkgs#hello
> nix build --store /tmp/fzakaria/store nixpkgs#hello

The first command builds and installs hello at /nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/ and the second at /tmp/fzakaria/store/nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/ using chroot and mount namespaces. Notice both have the same hash zi2bj2hlavv8q743li2s9diqbcpmrf9b. This is important. By keeping the hash the same, we can leverage the precomputed derivations from binary substituters like https://cache.nixos.org.

第一个命令将 hello 构建并安装在 /nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/,第二个命令则通过 chroot 和挂载命名空间将其安装在 /tmp/fzakaria/store/nix/store/zi2bj2hlavv8q743li2s9diqbcpmrf9b-hello-2.12.3/。请注意,两者的哈希值都是 zi2bj2hlavv8q743li2s9diqbcpmrf9b。这一点很重要。通过保持哈希值不变,我们可以利用来自二进制替代器(如 https://cache.nixos.org)的预计算派生。

Ok, so what’s missing? If you are using tools like Bazel or Buck2 they likely already employ their own sandboxing via namespacing for builds. Integrating Nix into these ecosystems becomes incredibly impractical because we run into nested user namespace and mount restrictions. We can ask Nix to use an alternate store prefix, without chroot and mount namespaces but it has a big gap.

那么,缺失了什么呢?如果你正在使用 Bazel 或 Buck2 等工具,它们很可能已经在构建过程中通过命名空间采用了自己的沙盒机制。将 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

The hash is now qv3fhi1j9gh27fyds5n5b16yia8i6zn5 😭 It’s even more disastrous. Changing this simple string cascade-invalidates the entire dependency graph. You are now waiting 4 hours for GCC to compile just so you can print “Hello World” from a different folder. 🫠 This means we cannot leverage the public cache. This gap is called out by the Nix documentation today.

现在的哈希值变成了 qv3fhi1j9gh27fyds5n5b16yia8i6zn5 😭 这简直是灾难性的。改变这个简单的字符串会导致整个依赖图失效。你现在得等上 4 个小时让 GCC 编译,仅仅是为了从另一个文件夹打印出“Hello World”。🫠 这意味着我们无法利用公共缓存。Nix 的文档目前也指出了这个缺口。

Does it have to be that way? What if we could install Nix binaries anywhere, without using namespacing or chroot. Can we have our cake and eat it too? 🍰 Nix needs relocatable binaries. The problem is that the store-prefix is part of the derivation itself so it affects the hash calculation. We don’t have to specify the full store-prefix everywhere. What if we used relative paths? 🤔

一定要这样吗?如果我们能在不使用命名空间或 chroot 的情况下将 Nix 二进制文件安装到任何地方呢?我们能鱼与熊掌兼得吗?🍰 Nix 需要可重定位的二进制文件。问题在于存储前缀本身就是派生的一部分,因此它会影响哈希计算。我们不必到处指定完整的存储前缀。如果我们使用相对路径呢?🤔

Let’s look at one place the full paths are written today in the binary via RUNPATH.

让我们看看目前二进制文件中通过 RUNPATH 写入完整路径的一个地方。

> patchelf $(nix build --no-link --print-out-paths nixpkgs#hello)/bin/hello \
  --print-rpath
/nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib

When this program runs, the dynamic linker looks at RUNPATH to find its shared dependencies. The loader in Linux however natively supports the variable $ORIGIN which translates to “the directory containing the executable.” [ref] We could instead write the RUNPATH to be $ORIGIN/../../57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib. If we did that then changing the store would cause no hashes to change. No recompilation. 🥳

当程序运行时,动态链接器会查看 RUNPATH 以查找其共享依赖项。然而,Linux 中的加载器原生支持 $ORIGIN 变量,它被解释为“包含可执行文件的目录”。[参考] 我们可以将 RUNPATH 改写为 $ORIGIN/../../57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib。如果我们这样做,更改存储路径就不会导致哈希值改变。无需重新编译。🥳

Okay, so are we done? Well, like most things the devil is in the details. 😈 Before the dynamic linker can read the RUNPATH to find the necessary libraries, the Linux kernel has to load the dynamic linker itself. This path is stored in a different ELF header called PT_INTERP (Program Interpreter).

好了,这样就大功告成了吗?嗯,像大多数事情一样,魔鬼藏在细节中。😈 在动态链接器读取 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

Unfortunately, the Linux Kernel does not support $ORIGIN in this field as of today. We run into the exact same kernel limitation with the shebang line in scripts as well.

不幸的是,截至目前,Linux 内核在此字段中不支持 $ORIGIN。我们在脚本的 shebang 行中也遇到了完全相同的内核限制。

#!/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash
echo "Hello!"

When we execute a script, the kernel parses the #! (shebang) and expects an absolute path. Support for $ORIGIN is also lacking as of today. We cannot use relative paths reliably here unless they are relative to the current working directory, which breaks the moment you run the script from anywhere else.

当我们执行脚本时,内核会解析 #! (shebang) 并期望得到一个绝对路径。截至目前,对 $ORIGIN 的支持也同样缺失。我们无法在这里可靠地使用相对路径,除非它们是相对于当前工作目录的,而一旦你从其他任何地方运行该脚本,这种方式就会失效。

How Do We Get There? 🗺️

我们该如何实现?🗺️

To achieve true relocatable binaries, we need to bypass these kernel limitations. $ORIGIN historically would never make sense for PT_INTERP in the Linux kernel because “Why would you want your dynamic linker to be found relative to the file!?”. Nix has changed that assessment. There are a few ways we could attack this:

为了实现真正的可重定位二进制文件,我们需要绕过这些内核限制。从历史上看,$ORIGIN 对于 Linux 内核中的 PT_INTERP 来说毫无意义,因为“你为什么要让动态链接器相对于文件本身来定位呢!?”。Nix 改变了这一评估。我们可以通过几种方式来解决这个问题:

  • We could patch the Linux kernel so that $ORIGIN is supported in PT_INTERP and the shebang.

  • We wrap every binary with a small static binary that computes its own location and then invokes the dynamic linker.

  • We need to replace file locations to also leverage language-specific features for relative paths. For instance, in Python we can leverage __file__ to access files relative to itself similar to $ORIGIN.

  • 我们可以修补 Linux 内核,使 $ORIGINPT_INTERP 和 shebang 中得到支持。

  • 我们用一个小的静态二进制文件包装每个二进制文件,该文件计算自己的位置,然后调用动态链接器。

  • 我们需要替换文件位置,以利用特定语言的相对路径特性。例如,在 Python 中,我们可以利用 __file__ 来访问相对于自身的文件,类似于 $ORIGIN

I believe augmenting support in the Linux kernel is the right approach. The beauty of Nix is we can even patch the kernel today in any NixOS machine for this support. As a final cherry on top, we can include additional metadata relocatable = true; on every derivation whether it’s relocatable. 🍒

我相信增强 Linux 内核的支持是正确的方法。Nix 的美妙之处在于,我们今天甚至可以在任何 NixOS 机器上修补内核以获得这种支持。最后,作为锦上添花,我们可以在每个派生中包含额外的元数据 relocatable = true;,以标识它是否可重定位。🍒