本来是想简单介绍一下 NixOS 以及在 Macbook Pro 2016 上安装 NixOS 的一些建议, 结果拖到了现在电脑都换成戴尔了… 想起什么写什么吧.
前言
Nix 是 Linux 上的又一个包管理器. 在官网的介绍中宣称 Nix 是一个“纯函数式包管理器 (purely functional package manager)”. 这里的“纯函数式”指什么呢? 我个人对它的理解是包含了两层意思:
- Nix 使用了一种纯函数式的语言定义和配置 package.
- Nix 利用了各种方法使 package 的编译构建以及运行满足“纯函数式”的要求.
这两条在接下去的几个小节中解释. 由于本文本来是想写成安利向的, 所以先罗列一下我个人心目中 Nix 区别于其他包管理器的特色:
- 独立的依赖管理: 不同的 package 可以各自依赖同一个 package 的不同版本而不发生冲突, 即避免了“Dependency Hell”的问题. 同时, 安装或升级一个 package 也不会出现覆盖已有 package 的依赖的情况.
- 也就是不会出现类似 “我升级了 libreoffice 然后 gimp 挂掉了” 这种事情.
- 可精确重现的运行环境和构建过程: Nix 可以根据一份
.nix文件执行一个 package 的构建, 或者创建一个运行环境, 而这两件事情可以在任意相同架构的系统上精确地重现, 不论系统原本的环境是怎么样的. (没有使用容器, 但也可以和容器结合实现更好的复现)- 换句话说, “但在我的机器上是没问题的啊!” 这种对白就会少很多.
- “一次性”安装: 或者叫“用完即走”, 当你临时需要使用一个程序, 或者只是想试一试某个命令的时候, 可以使用
nix run <package>或者nix-shell -p <package>创建一个可以使用<package>的 shell, 执行结束后exit退出即可, 而这个 package 并没有实际被安装到系统中.- 如果你和我一样不希望系统在一次次
apt install中变得乱七八糟的, 手动清理又担心删不干净, 你应该会喜欢这个功能的 :). - 事实上 Nix 允许声明式的定义系统的状态, 然后将系统设置为所定义的状态 – 该有的就有, 不该有的就没有, 很干净.
- 如果你和我一样不希望系统在一次次
- 原子性、可回滚的包管理操作: 安装或升级一个 package 的操作是原子性的, 即要么 package 安装成功然后系统被更新, 要么系统环境保持不变, 不存在装到一半然后失败但是很多依赖已经安装上来的尴尬局面. 同时所有的包管理操作都是可以回滚的 (只要旧版本没有被删除. Nix 中任何安装升级操作都不会覆盖或删除已有的 package, 但提供了垃圾回收指令手动删除). 而在 NixOS 中, 对系统的更新也是可以回滚的. 比如折腾 N 卡驱动把 X Server 搞炸了, 重启进入上一个可以正常运行的版本就好.
- 总之用上 NixOS 之后折腾系统的时候越来越浪了 XD.
纯函数式包管理
在我个人 片面 的认知中, 纯函数式编程包含了以下特征:
- 变量的值是不可变的.
- 函数的执行是没有副作用的.
- 使用相同的参数调用同一个函数总是返回相同的结果, 即函数的运算结果仅和输入有关.
纯函数式编程的优缺点在此就不再赘述, 而 Nix 显然认为纯函数式的思想在包管理这个领域依然可以带来诸多好处. 于是, 在 Nix 的模型中, package 被抽象为一种像int, string, list, set等一样可赋值给变量、可参与计算的数据类型, Nix 将这个类型命名为derivation. 一个derivation是调用一个构造器构造得到的, 而一个 package 的构建被抽象为对一个derivation求值的过程.
当一个 package 成为纯函数式编程中的一个值, 就 应当 有以下性质:
- 一个 package 在被构建出来后应当不再可变, 即文件只读, 不可被覆盖 / 删除 / 修改.
- 一个 package 的构建过程不会影响系统的状态.
- 使用相同参数调用得到的
derivation所构建的 package 总是相同的, 且 package 的构建结果仅和这里的参数有关, 不受系统状态和其他无关的derivation的影响.
一旦上述性质成立, 那么自然地, 我们就会得到:
- 独立的依赖管理: 因为
derivation的求值不受其他无关derivation影响. (当然, 上述性质只能推导出 构建时 的独立依赖管理, 不过 Nix 项目也做了很多工作来实现独立的 运行时 依赖) - 原子性、可回滚的包管理操作: 由于对
derivation的求值没有副作用, package 的构建自然也不应当对系统状态产生影响. 而又因为已求得的值是不可变的, 只要对之前求得的值的引用还在, 随时可以用旧值替换新值作为输入构造新的系统状态 (实际上就是一个 package set), 而根据纯函数的性质, 这个“新”的系统状态和之前第一次使用旧值构造的应该是一致的.
而可精确重现的运行环境和构建过程, 则是用于确保对derivation的求值满足纯函数式的定义. 在下一小节会讲解.
在具体的实现上, 每个 package 被构建出来后都会存放在/nix/store下的一个只读路径, 路径的格式为/nix/store/<hash>-<name>. 其中<name>对应derivation的名称, 而<hash>则是用于构造derivation所使用的所有参数和依赖的 Hash 值 (除了“fixed-output derivation”, 这里暂时不讨论). 也就是说, 一旦构造derivation的参数 (其中包含了 package 的依赖树和构建脚本) 发生任何变化, 其路径就会不同. 看起来有一点像 Gentoo 中的 Slot, 但是这里的“版本号”是根据 package 的依赖树和构建脚本唯一确定的.
而“安装”这一过程, 在 Nix 中只是建立了一些指向/nix/store中的某些目标路径的符号链接, 这也就是 Nix 包管理操作具有原子性的原因 – package 的构建过程只会在/nix/store下产生新的路径, 对系统其他部分不会有任何影响, 也不会修改/nix/store下已有的路径. 在构建成功后, 更新过程仅是切换符号链接的目标而已. 若是构建失败, 则不会在/nix/store之外产生任何变化. 而回滚操作就是简单地将符号链接指向之前的路径.
而“一次性”的安装就更简单了 – package 的路径只是被临时加入了PATH中, 一旦 shell 进程退出, 对系统来说这个 package 就不存在了.
在我的理解中, /nix/store中的文件就类似 nix-lang 程序运算中产生的derivation值的持久化缓存, 而这些缓存“恰好”可以作为包管理的“材料”, 而 Nix 则为此提供了一套工具链.
可重现构建
先简单介绍一下 Nix 的核心部分: Nix 语言. 这是 Nix 使用的配置语言, 由于名字也叫 Nix, 为了避免混淆下面都用 nix-lang 指代这个语言.
nix-lang 是一种“伪纯函数式”编程语言 – 为了实用性 nix-lang 默认允许在运行中读取环境变量以及任意位置的文件, 或是从任意 url 下载文件, 但也提供了“pure eval mode”关闭这些能力, 同时 nix-lang 在对derivation的求值过程中将不可避免地涉及网络 IO, 但在 nix-lang 中可以通过 Fixed Output 的约束将其影响降到最低. 除此之外的部分均符合纯函数式语言的定义 (当然, 纯函数式编程的定义是有争议的, 这里仅讨论上一小节提到的三个特征).
虽然是设计为用于包管理器的 DSL, 但 nix-lang 本身是接近图灵完全的 (nix 会尝试检测无限递归并中断计算), 提供了常见的基本数据类型、Lamba 表达式、惰性求值等特性.
nixpkgs是官方维护的一份巨大的 nix-lang library, 可以类比为 Arch Linux 的Package Repository.
如上一小节所说, nix-lang 中提供了名为derivation的数据结构来表示一个 package (但非常神奇的是, derivation是使用一个set来存储, 而非一个 primitive 的数据类型), 并提供了若干内置的构造函数. 在这里我将derivation分成两类:
- Fixed Output, 这类
derivation的值已经确定, 换句话说其所构建出来的 package 文件的内容是已知的. 这类derivation的求值 (构建) 过程也可以使用网络 IO, 但在 nix-lang 中将其视为是纯函数的. 在第一次对其求值之后, nix-lang 验证结果的 Hash 值, 如果和参数中指定的 Hash 值相同, 则将结果缓存, 否则将报错.- 也就是说 Nix 不确保这是一个纯函数, 但当它不符合纯函数的性质 (相同输入得到不同的输出) 的时候, Nix 会发现并打断计算过程.
- 普通的
derivation, 虽然其输出值无法确定, 但其求值过程无法使用任何网络 IO 以及 (当开启沙盒功能) 除参数以外的其他值和文件, Nix 假定这一过程是可重现的、求值仅和参数有关.- 嗯没错这是一个假设, Nix 的整个模型实际上是建立在这个假设之上的, 但由于 Nix 对构建环境的严格限制, 大部分情况下可以认为这个假设是成立的.
这两种derivation都是使用builtins.derivation这个函数构造的, 调用该函数的时候至少需要提供name, builder, system三个参数:
- name: 指定
derivation的名字, 也即 package 的名字. - builder: 用于构建 package 的程序, 可以是一个绝对路径, 也可以是一个“builtin:”开头的内建 builder.
- system: 指定可以构建该 package 的系统架构, 例如“x86_64-linux”或者“i686-linux”等.
还有一些常用的参数:
- args: 如果提供了这个参数, 那么会使用这个参数调用 builder.
- outputHash, outputHashAlgo和outputHashMode: 如果这三个参数被提供了, 这个
derivation被视为是一个“fixed-output derivation”, 它们分别指定了最后构建得到的 package 的 Hash 值、Hash 算法以及 Hash 值的计算方式.
Fixed-Output Derivation without Dependency
我们先来看一个最简单的derivation定义:
derivation {
name = "busybox";
builder = "builtin:fetchurl";
system = "x86_64-linux";
outputHash = "ef4c1be6c7ae57e4f654efd90ae2d2e204d6769364c46469fa9ff3761195cba1";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
url = "http://tarballs.nixos.org/stdenv-linux/i686/4907fc9e8d0d82b28b3c56e3a478a2882f1d700f/busybox";
executable = true;
unpack = false;
}
上述代码定义了一个名叫“busybox”的derivation, 其对应 package 构建过程所使用的 builder 是 nix-lang 内置的fetchurl函数, 且只能在 x86_64 架构的 linux 系统上执行. 因为我们给出了ouputHash等参数, 因此最后得到的 package 文件的 Hash 值已经被确定.
如果你正在使用 NixOS (那应该就不会来读这篇文档) 或者已经在系统上安装了 Nix (如果没有请立即在 shell 中输入 , 试着将上述代码输入到文件curl -L https://nixos.org/nix/install | sh并回车)busybox.nix中然后执行:
$ nix-build busybox.nix
那么 Nix 应该会提示它要开始 build 一些东西了, 视你的网络情况而定, 等待一段时间后应该会得到如下输出:
/nix/store/lan2w3ab1mvpxj3ppiw2sizh8i7rpz7s-busybox
同时当前目录下会出现一个名为result的符号链接指向上面的路径.
试着执行一下:
$ /nix/store/lan2w3ab1mvpxj3ppiw2sizh8i7rpz7s-busybox
BusyBox v1.23.2 () multi-call binary.
BusyBox is copyrighted by many authors between 1998-2012.
Licensed under GPLv2. See source distribution for detailed
copyright notices.
Usage: busybox [function [arguments]...]
or: busybox --list
or: function [arguments]...
BusyBox is a multi-call binary that combines many common Unix
utilities into a single executable. Most people will create a
link to busybox for each function they wish to use and BusyBox
will act like whatever it was invoked as.
Currently defined functions:
ash, mkdir, tar, unxz, xzcat
成了, 我们的第一个 package 构建成功.
我来解释一下当我们执行nix-build时发生了什么:
首先, busybox.nix的内容被读取并执行, 并返回了一个derivation. 之后nix-build命令会对这个derivation进行求值, 即构建相应的 package. 而构建 package 的进程会执行以下操作 (按 Linux 上的默认设置描述):
清空环境变量, 并设置一些 Nix 自己使用的环境变量.
- 这一步是为了消除环境变量可能对构建过程造成的影响.
将环境变量
out设为该 package 最后将存储的路径. 如之前所说这个路径以一个 Hash 值作为前缀, 在 fixed-output derivation 中, 这个 Hash 值通过outputHash参数得到.将构造
derivation所用的所有参数设置为环境变量. 例如, 在上述例子中, 除去第 1 步中涉及的变量, 环境变量将设置为:builder="builtin:fetchurl" executable="1" name="busybox" out="/nix/store/lan2w3ab1mvpxj3ppiw2sizh8i7rpz7s-busybox" outputHash="ef4c1be6c7ae57e4f654efd90ae2d2e204d6769364c46469fa9ff3761195cba1" outputHashAlgo="sha256" outputHashMode="recursive" system="x86_64-linux" unpack="" url="http://tarballs.nixos.org/stdenv-linux/i686/4907fc9e8d0d82b28b3c56e3a478a2882f1d700f/busybox"值得关注的是变量
out, 这个变量的值对应的是一个路径, 即该derivation所构建出来的 package 存放的位置, 其 Hash 值部分是根据derivation中给定的 Hash 值计算得到的.为构建进程设置 private namespace, 分别为:
- PID namespace, 使得构建进程只能看到自己和自己的子进程.
- Mount namespace, 保证只有在
derivation中指明的依赖路径 (即所依赖的其他derivation在/nix/store中对应的路径), 以及/proc、/dev、/etc等必要的目录是可见的. - IPC namespace, 防止构建进程与外部进程进行通信.
- UTS namespace, 防止构建进程获取真实的 hostname.
执行 builder 程序, 在这个例子中执行的是 Nix 内置的 fetchurl, 这个程序会读取环境变量中
url的值, 下载相应的文件到$out, 并根据executable和unpack的值决定是否进行运行权限设置和解压操作.如果 builder 程序在
$out路径成功创建了文件或目录, Nix 会计算该路径下文件内容的 Hash 值并和outputHash参数对比. 如果$out没有被创建或者 Hash 值不相符则报错, 否则构建成功.当同一个
derivation被再次构建时, 由于它已经被成功构建过一次, Nix 会发现其输出路径 (即$out) 已经存在, 求值过程直接返回, 不会执行构建操作.
这里并没有描述所有的细节, 仅仅摘录了其中比较重要的步骤. 可以看到, Nix 使用了多种方法, 尽量使得derivation的构建进程的运行环境不受系统环境的影响, 因而在不同的机器上执行都能得到相同的结果, 也就是实现所谓的Reproducible Build, 从而使得derivation的求值过程符合纯函数式的定义.
Normal Derivation with Some Dependency
上一节的例子中不存在对其他derivation的依赖, 接下来我们看一个稍微复杂一点的例子:
let
busybox = derivation {
name = "busybox";
builder = "builtin:fetchurl";
system = "x86_64-linux";
outputHash = "ef4c1be6c7ae57e4f654efd90ae2d2e204d6769364c46469fa9ff3761195cba1";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
url = "http://tarballs.nixos.org/stdenv-linux/i686/4907fc9e8d0d82b28b3c56e3a478a2882f1d700f/busybox";
executable = true;
unpack = false;
};
in
derivation {
name = "hello-world";
builder = "${busybox}";
args = [ "ash" "-c" "echo Hello World! > $out" ];
system = "x86_64-linux";
}
将以上内容写入hello.nix, 执行一下nix-build看看会发生什么:
$ nix-build hello.nix
/nix/store/w62gjxd3xj487mv7dgzypgcl32na5daw-hello-world
$ cat ./result
Hello World!
经典的 Hello World! 简单讲解一下这个例子中的代码:
let ... in这段将变量busybox引入到作用域内, 它的值就是我们上一个小节定义的derivation.
接下来我们定义了一个名叫“hello-world”的derivation, 它的 builder 是"${busybox}".
在 nix-lang 中, string内部出现的${}是antiquotation操作符, 类似于 python 的 F-String 中的{}, 也就是说"prefix${<expr>}suffix"等价于"prefix" + (toString <expr>) + "suffix"(实际上并不完全等价, nix-lang 中的 antiquotation 有点坑, 但这里不讨论这个细节). 而对一个derivation执行toString操作返回的是它的outPath, 即上一小节中环境变量out指向的路径 (这里是不会触发构建的, 因为这里相当于只是读取了busybox的元信息, 并没有进行读取outPath中的内容的操作 – 概念上可以这么理解, 具体的实现可能以后会再写一篇文档来讲 咕咕咕).
因此这个derivation的 builder 就是"/nix/store/lan2w3ab1mvpxj3ppiw2sizh8i7rpz7s-busybox". 如你所见这里也没有提供outputHash, 因此这不是一个 fixed-output derivation, 其构建过程会有如下不同:
- 在构建之前会首先递归地构建
derivation的所有依赖. - package 最后将存储的路径, 即环境变量
out的值, 其中的 Hash 值部分是将构造derivation的所有参数以及其所依赖的所有其他derivation作为输入计算得到的. 如果在计算derivation A时使用了derivation B的值, 则B会成为A的依赖. 例如, 在这个例子中, 计算builder参数时使用了busybox, 因此busybox是这个derivation的依赖之一. 可以看到, Nix 中的依赖在计算时就可以确定, 构建时不需要任何额外的依赖推导, 也避免了依赖推导的结果不同导致的构建结果变化. busybox的outPath对进程是可见的 (在上一个例子中, 由于没有任何依赖, 进程在/nix/store下是看不到任何路径的).- 进程会额外设置 Network namespace, 防止进程访问外部网络.
- 由于这里提供了
args参数, 执行 builder 程序时最终执行的命令是/nix/store/...-busybox ash -c 'echo Hello World! > $out'. - Nix 不会对最后的结果进行验证, 在这里 Nix 假设了更严格的环境限制 (禁止网络访问) 带来了纯函数式的求值过程.
到了这里, Nix 中 package 的构建部分就大致讲完了, 更多的细节可以参考 Nix 的手册. 基本上, Nix 就是通过严格的构建环境限制尽可能地保障了构建的可重现性, 并通过 Hash 值而不是仅凭名称和版本号区分不同的 package.
总结
Nix 项目本是作者 Eelco Dolstra 博士论文中的成果, 而其衍生的发行版 NixOS 属于其论文中 “Future Work” 这章的一部分内容. 很多人写下 Future Work 的时候可能并没有想着将其实现, 而 Eelco 不仅将其实现了, 还发展出了一个上千人的社区, 并且每年都会举办专门的讨论会议 – 虽然 NixOS 仍是一个小众的发行版, 但这份成果已然是我等学渣的楷模.
本文本来其实是想写成一篇安利向的文章的, 写完一看通篇没有什么实用的内容, 反而充斥着一些用户不需要了解的细节和作者自己的理解, 好像变成了一篇类似学习笔记的东西, 希望不要反而劝退了一些人吧 ORZ.
后面可能考虑写一篇真正的安利、写一些使用 NixOS 过程中的经验分享、翻译一点Nix Pills和Nix.dev中的内容等等 – 再说吧.