若说到当代编程领域的神教,我会想起这3样:Rust、Arch Linux以及Nix。这3个神教都得有一些魔力,吸引信徒虔诚追随。这篇文章主要介绍Nix能做到的事情,用于像初学者传教。

安装

你可以按照Download | Nix & NixOS中描述的安装Nix,推荐使用multi-user installation。安装后,我们通常会编辑/etc/nix/nix.conf开启下面两个实验特性。

1experimental-features = nix-command flakes

这些特性虽然被标记为“实验”,Nix开发团队并不保证CLI的稳定性。但确实很好用,社区内也已经基于此构造了庞大的生态。后文都会假设这两个选项已经开启。

接下来我们会介绍一下Nix能做的事。

包管理器

Nix是一个包管理器。你可以像apt、yum、pacman那样命令式地安装软件。

1$ nix profile add nixpkgs#kubectl

这个命令无需用root身份运行,只为当前用户安装软件。当然要真正使用Nix提供的软件,你需要配置一下shell,把~/.nix-profile/bin添加到PATH路径即可。

1export PATH=~/.nix-profile/bin:$PATH

不同于其他包管理器,Nix支持原子回滚。我们会在后文介绍一下Nix是如何实现这个魔法的。

1$ nix profile rollback

它还支持你ad-hoc运行一个软件,类似于npxgo run那样。你可以配置上Nix的GC daemon。这样这些ad-hoc下载的软件过一阵子会被垃圾回收。

1$ nix run nixpkgs#bun

你也可以同时运行同一个软件的不同版本

1$ nix run nixpkgs#python313 -- --version
2Python 3.13.12
3$ nix run nixpkgs#python314 -- --version
4Python 3.14.3

甚至你可以直接运行GitHub上发布的一个软件。当然如果不添加上游二进制源时,Nix会按照仓库中的Nix代码构建整个应用。

1$ nix run github:nix-community/nixos-anywhere -- --help

自然而然地,你就可以把Nix作为shebang,进而在自己的脚本里声明所有依赖。只要用户装了Nix,就不用担心脚本中的依赖不存在了。这使得我们可以放心使用bleeding edge的软件。如果还担心软件本身不稳定,你可以修改nix-shell参数,固定nixpkgs版本。这样就相当于固定了所有软件版本,能拥有极致的可复现性。

showip.nu
1#!/usr/bin/env nix-shell
2#!nix-shell -i nu -p nushell
3
4let resp = http get "https://api.ipify.org/?format=json"
5print $"Your IP is ($resp.ip)"

这里提到的nixpkgs是刚才所有命令隐式依赖的一个GitHub仓库。它是个巨大的monorepo,使用Nix语言描述了刚才所有命令中软件的构建方式。下图是Repology整理的常见仓库包数量和新旧程度。可以从图中看到nixpkgs在包数量上一骑绝尘。这得益于Nix提供的可复现的构建、完善的CI/CD以及开放的社区协作。

仓库大小及包的新旧程度对比图

构建系统

nixpkgs并没有什么魔法,你也可以使用Nix打包软件。通常大家会使用nixpkgs提供的各种设施。下面的代码就能打包bbolt,一个用来查看etcd数据库的工具。

bbolt.nix
 1{
 2  pkgs ? import <nixpkgs> { },
 3}:
 4let
 5  inherit (pkgs)
 6    fetchFromGitHub
 7    buildGoModule
 8    ;
 9in
10buildGoModule (final: {
11  pname = "bbolt";
12  version = "1.4.3";
13  src = fetchFromGitHub {
14    owner = "etcd-io";
15    repo = "bbolt";
16    rev = "v${final.version}";
17    hash = "sha256-awBkr2ObRxPQkMlfVFZxEbQ9JQJsFrJvSBHtqP4Hb3I=";
18  };
19  vendorHash = "sha256-TzVmAMrNrNkFE9jQ+SILJXvbhBK1WenNPqA0FfuDU+M=";
20  subPackages = [ "cmd/bbolt" ];
21})
1$ nix build --file ./bbolt.nix
2$ ./result/bin/bbolt --version
3bbolt Version: 1.4.3
4Go Version: go1.25.9
5Go OS/Arch: linux/amd64

Nix是个惰性求值的语言,只有代码中用到的包才会参与求值构建。而构建时的依赖正是代码中用到的那些包。上面示例中的构建依赖可简化成下图。

flowchart LR
  src["`source
  *fixed-output (hash)*`"]
  go-modules["`go-modules
  *fixed-output (vendorHash)*`"]
  src --> join1(( ))
  go --> join1
  join1 -- go mod download --> go-modules
  src --> join2(( ))
  go-modules --> join2
  go --> join2
  join2 -- go build --> bbolt
bbolt包的构建时依赖图

Nix在求值时会为每个包计算一个hash。构建后的包就位于/nix/store/<hash>-<name>nix build命令还会在当前目录下创建一个符号链接并加入GC root,以避免被GC。

1$ ls -l result
2lrwxrwxrwx 1 sun sun 55 Jun  3 01:04 result -> /nix/store/b1vbalgjm2andc0624rv036kc1m2lb9d-bbolt-1.4.3
3$ ls -l /nix/var/nix/gcroots/auto | grep "$PWD/result"
4lrwxrwxrwx 1 root root 16 Jun  3 23:46 2lax87vim8cgib3f7ksblr98srj2mljj -> /home/sun/result

根据hash的计算方式,Nix的包可分为两类:fixed-output和input-addressed。1

类型 Hash计算方式 构建环境
Fixed-Output 代码中指定,并在构建后校验 impure,可以访问网络、环境变量
Input-Addressed 根据输入计算 pure,不可访问网络、环境变量

这个设计极为巧妙。首先,它确保了可复现性。对于有外部依赖的包,其可复现性由代码中写死的hash保证; 而对于没外部依赖的包,其构建结果由其输入决定。其次,这个设计让缓存二进制分发更加简单。根据hash即可判断是否命中缓存。之前的例子中,go这个包的构建代码位于nixpkgs里。在求值阶段,Nix会计算go包的hash。在构建阶段,当Nix发现Binary Cache存在这个包时,就可以避免编译,直接下载。而再次运行nix build时,Nix会发现本地已经有bbolt包,就直接跳过了构建。

对比之下,Docker没有区分Fixed-Output和Input-Addressed,因此在可复现性和缓存命中上存在先天不足。例如在Dockerfile中,如果curl某个链接的内容发生了变化,那么同一个Dockerfile在有无本地缓存的情况下可能构建出不同的结果。而当所有内容不变,无本地缓存且远端已有构建结果时,即使理论上构建结果等价,Docker也常常因为mtime等细枝末节的差异而产生全新的layer和image。

Nix在构建完后,还会自动计算包的运行时依赖。它的做法很简单:在构建产物中搜索所有形似/nix/store/<hash>-<name>的字符串。这个技巧行之有效。因为构建发生在一个沙箱里,PATH环境变量指向了不存在的路径,也没有/lib这类隐式依赖。因此构建产物必须显式记录它的依赖才能正常运行。

 1$ nix-store --query --tree result
 2/nix/store/b1vbalgjm2andc0624rv036kc1m2lb9d-bbolt-1.4.3
 3├───/nix/store/75pp3hj82iirdfl7c153akl56kpffn1z-iana-etc-20250505
 4│   └───/nix/store/75pp3hj82iirdfl7c153akl56kpffn1z-iana-etc-20250505 [...]
 5├───/nix/store/7nbi22pcc92y2fqbkyp7h3srvvklmckb-glibc-2.40-224
 6│   ├───/nix/store/fv5lgysa3hmf3l3dkkpwvndcg6xwhy8m-xgcc-14.3.0-libgcc
 7│   ├───/nix/store/qywg7bxskvihq62ms2g51fkzkrdnyfkh-libidn2-2.3.8
 8│   │   ├───/nix/store/hjwppd89fk8781xl4r35xqlddwqi5f66-libunistring-1.4.1
 9│   │   │   └───/nix/store/hjwppd89fk8781xl4r35xqlddwqi5f66-libunistring-1.4.1 [...]
10│   │   └───/nix/store/qywg7bxskvihq62ms2g51fkzkrdnyfkh-libidn2-2.3.8 [...]
11│   └───/nix/store/7nbi22pcc92y2fqbkyp7h3srvvklmckb-glibc-2.40-224 [...]
12└───/nix/store/hvcpzbmw8hq5fzb424g9z6kkzhyx2iaw-tzdata-2026a
13$ strings result/bin/bbolt | grep -o '/nix/store/[^/]\+'
14/nix/store/7nbi22pcc92y2fqbkyp7h3srvvklmckb-glibc-2.40-224
15/nix/store/hvcpzbmw8hq5fzb424g9z6kkzhyx2iaw-tzdata-2026a
16/nix/store/75pp3hj82iirdfl7c153akl56kpffn1z-iana-etc-20250505

而传统的打包流程大多需要手动声明包的构建依赖和运行依赖。手动声明依赖很容易出错。在AUR中,遗漏依赖是很常见的错误。

当然也有命令查看构建依赖闭包。但构建依赖闭包通常很大。 bbolt的构建至少依赖它的代码、bash、git以及go。而bash、git、go的构建又依赖它们各自的代码以及gcc等等。直到一切的源头:某个由Nix语言内置的fetcher下载的初始版gcc和busybox。因此我这里只提供查看的命令,略去输出。

1$ nix-store --query --tree "$(nix-store --query --deriver result)"

编程语言

Nix提供了override机制,这使得你可以修改nixpkgs提供的包。你可以修改软件版本,添加patch,或是修改变异参数。例如,我们可以把Python中int的默认值改成42。

python-int42.patch
 1--- a/Objects/longobject.c
 2+++ b/Objects/longobject.c
 3@@ -6535,7 +6535,7 @@ long_vectorcall(PyObject *type, PyObject * const*args,
 4     }
 5     switch (nargs) {
 6         case 0:
 7-            return _PyLong_GetZero();
 8+            return PyLong_FromLong(42L);
 9         case 1:
10             return PyNumber_Long(args[0]);
11         case 2:

当然使用这个定制版本的Python可能会花费一小会编译。

answer.py
1#!/usr/bin/env nix-shell
2#!nix-shell -i python3 -p "python3.overrideAttrs (oldAttrs: { patches = (oldAttrs.patches or [ ]) ++ [./python-int42.patch]; })"
3print(f"The answer to life, the universe, and everything: {int()}")

Nix还提供了overlay机制,这使得你可以修改所有软件依赖的一个包。当然这可能会造成大规模的重新编译。

我们可以搭配direnv和VSCode direnv插件,为每个项目加上一个devshell。借助direnv我们可以向项目的目录

学习建议


  1. 实际上还有Content-Addressed,但那是实验特性。 ↩︎