若说到当代编程领域的神教,我会想起这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运行一个软件,类似于npx,go 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版本。这样就相当于固定了所有软件版本,能拥有极致的可复现性。
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数据库的工具。
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
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。
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可能会花费一小会编译。
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我们可以向项目的目录
学习建议
-
实际上还有Content-Addressed,但那是实验特性。 ↩︎