你是否经常发现自己处于这样一种情况:有些东西在你的机器上构建并工作,但在CI上却无法构建,或者在生产中出现灾难性的失败?
在许多情况下,这些问题是大多数开发人员面临的问题的症状:缺乏可重复性。你的代码在编译时依赖于一个环境变量,或者在依赖关系的不同版本下行为发生了明显的变化。
这样的问题通常很难诊断,甚至很难修复。然而,有一个工具可以帮助你解决这些问题--Nix。
Nix由一个软件包管理器和一种为上述管理器描述软件包的语言组成。它的特点是可复制的构建,跨小酒馆和平台的兼容性,二进制缓存,以及由成千上万的贡献者维护的大型软件包集合。
它是如何工作的
Nix由两部分组成:一个软件包管理器和一种语言。语言是一种相当简单的懒惰的(几乎)纯功能语言,具有动态类型,专门用于构建包。另一方面,包管理器很有趣,而且相当独特。这一切都始于一个想法。
FHS不适合于可重复的构建
Nix源于这样一个想法:FHS从根本上与可重复性不相容。让我解释一下。
每次你看到像/bin/python
或/lib/libudev.so
这样的路径时,你对位于那里的文件有很多不了解的地方:
- 它来自哪个版本的软件包?
- 它使用的库是什么?
- 在构建过程中启用了哪些配置标志?
这些问题的答案可以(而且很可能会)改变使用这些文件的应用程序的行为。在FHS中,有一些方法可以解决这个问题--例如,直接链接到/lib/libudev.so.1.6.3
,或者在你的shebang中使用/bin/python3.7
。然而,仍然有很多未知的因素。
这意味着,如果我们想获得任何重现性和一致性,FHS是行不通的,因为没有办法推断出一个给定文件的很多属性。
一种解决方案是像Docker、Snap和Flatpak这样的工具,它们创建了隔离的FHS环境,包含了特定应用程序的所有依赖的固定版本,并分发这些环境。然而,这种解决方案有一系列的问题。
如果我们想给我们的应用程序应用不同的配置标志,或者改变其中的一个依赖项,怎么办?不能保证你能从构建指令中得到构建工件,因为把所有的构建工件放在一个隔离的容器中,保证的是一致性,而不是可重复性,因为在构建时,经常会用到主机的FHS的工具,此外,来自其他隔离环境的依赖可能会发生变化。
例如,两个人使用相同的docker镜像总是会得到相同的结果,但两个人构建相同的Dockerfile却可能(而且经常)得到两个不同的镜像。
这让人不禁要问:为什么不把构建本身隔离起来,类似于构建工件?
Nix实际上(作为一个软件包管理器)做了什么?
对于Nix构建的每个包,它首先计算其派生(这通常是通过评估用Nix语言编写的表达式来完成的),这个文件包含了:
- 在构建过程中需要的所有文件和其他软件包的说明
- 用于实际构建软件包的构建说明。
- 一些关于软件包的元信息。
- 最关键的是,一个存储路径(前缀),该包将被安装在这个路径下,其形式为
/nix/store/<hash>-<name>-<version>
(因此被称为存储路径),其中hash
是衍生中所有其他数据的哈希值。
例如,这里有一个非常简单的GNUhello
的派生,为简洁起见,省略了一些(空)字段的JSON格式:
{
"/nix/store/sg3sw1zdddfkl3hk639asml56xsxw8pf-hello-2.10.drv": {
"outputs": {
"out": {
"path": "/nix/store/dvv4irwgdm8lpbhdkqghvmjmjknrikh4-hello-2.10"
}
},
"inputSrcs": [
"/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh"
],
"inputDrvs": {
"/nix/store/8pq31sp946581sbh2m18pb8iwp0bwxj6-stdenv-linux.drv": [
"out"
],
"/nix/store/cni8m2cjshnc8fbanwrxagan6f8lxjf6-hello-2.10.tar.gz.drv": [
"out"
],
"/nix/store/md39vwk6mmi64f6z6z9cnnjksvv6xkf3-bash-4.4-p23.drv": [
"out"
]
},
"platform": "x86_64-linux",
"builder": "/nix/store/kgp3vq8l9yb8mzghbw83kyr3f26yqvsz-bash-4.4-p23/bin/bash",
"args": [
"-e",
"/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh"
],
"env": {
"buildInputs": "",
"builder": "/nix/store/kgp3vq8l9yb8mzghbw83kyr3f26yqvsz-bash-4.4-p23/bin/bash",
"doCheck": "1",
"name": "hello-2.10",
"nativeBuildInputs": "",
"out": "/nix/store/dvv4irwgdm8lpbhdkqghvmjmjknrikh4-hello-2.10",
"outputs": "out",
"pname": "hello",
"src": "/nix/store/3x7dwzq014bblazs7kq20p9hyzz0qh8g-hello-2.10.tar.gz",
"stdenv": "/nix/store/hn7xq448b49d40zq0xs6lq538qvldls1-stdenv-linux",
"system": "x86_64-linux",
"version": "2.10"
}
}
}
然后,它通过在一个包含软件包的依赖性和唯一的依赖性的隔离环境中运行其中指定的构建指令来实现该派生。
通过这种方式,Nix可以保证一些真正重要的属性:
- 实现相同的派生将总是得到几乎相同的输出,因为构建指令只能访问明确指定的依赖关系。(如果构建指令使用了一些与硬件相关的信息,如当前时间、
/dev/urandom
、或CPU性能,则输出会有所不同) - 同样的存储路径(
/nix/store/<hash>-<name>-<version>
)将总是包含完全相同的指令的结果,因为<hash>
取决于在构建所述包时起作用的所有变量。
但这些保证能给我们带来什么呢?
Nix的好处
可重复性
这是最明显的好处,因为它是Nix背后的全部动机。如果你足够小心,把输入的版本固定下来,两个人构建相同的包将总是得到相同的输出*(见上面的注释,输出可能略有不同的情况*)。而且,即使某些输入不同,也会非常清楚,因为存储路径会改变。
二进制缓存
我们从Nix得到的另一个好处是二进制缓存。由于在构建之前就知道存储路径,而且我们确信相同的存储路径将包含相同的输出,所以我们可以从其他地方替代(换句话说,获取)该存储路径,只要该地方有该路径,而且我们信任构建该路径的人(构建后输出可以被签名,这样就可以把它们存储在一个不被信任的地方)。
任何软件包的多个版本可以同时安装
每个软件包都安装在自己的前缀下,所以没有碰撞(除非某个软件包依赖于另一个软件包的多个版本,这是很罕见的情况,通常很容易处理)。这在开发过程中真的很方便--想想Python的virtualenvs,但对任何语言来说都是如此。
分布式构建
由于我们确切地知道实现一个给定的派生需要什么,只要远程服务器已经有了所有的依赖项(或者比本地机器更快地构建它们),就可以很容易地远程完成。
非特权的构建
构建是隔离的,除了它的输出,它不能改变主机系统上的任何东西。这意味着,允许非特权用户实现派生是安全的。
更少的状态=更小的备份
由于应用程序和它的依赖关系现在可以很容易地从派生中重建,我们可以安全地从备份中省略它们。
生态系统
这些好处很酷,但是,Nix的概念还可以进一步扩展。到目前为止,我们只描述了如何进行可复制的构建,而没有说到另一个属性,这是所有DevOps团队健康睡眠的一个非常重要的前提条件--运行时一致性。我们怎样才能确保每次在服务器上部署应用程序时,它的配置文件和Linux内核版本都是完全一样的?
NixOS
NixOS是一个GNU/Linux发行版,它使用Nix作为软件包管理器和配置管理器。每一个配置文件都被描述为一个派生文件,而你的系统就是一个派生文件,它依赖于你所指定的所有应用程序和配置文件。因此,我们得到了Nix的所有好处,应用于我们的运行环境。如果两个人安装一个具有相同存储路径的NixOS系统,他们的计算机上将总是得到完全相同的系统
此外,因为没有任何东西是像其他发行版上通常的方式安装的,所有的更新和回滚都是原子性的。你为你的init系统指定了一个不正确的配置文件,你的电脑就不能启动了?不用担心,每一个安装在你的电脑上的派生系统*(并且没有被垃圾收集*)都被列在启动加载器的列表中,你可以直接启动到它。
NixOS的另一个好处是,它真的很容易启动一个新的服务器,因为你唯一需要的就是你用来建立你的旧服务器的原始Nix表达式。
Nixpkgs
nixpkgs
是一个巨大的Nix语言包描述的集合,可以很容易地被改变和组合在一起。它包含了很多特定语言的包集,包括Hackage的一个完整的、最新的镜像。它还具有强大的交叉编译工具。例如,将bash交叉编译到Windows就像 。nix build nixpkgs.pkgsCross.mingwW64.bash
nixpkgs
这使得Nix成为构建你的应用程序的一个非常强大的工具,特别是当你需要针对多个平台和使用多种语言时。
与许多现有工具和服务的深度集成
nixpkgs
和NixOS也包含很多与现有工具的集成。
需要用你的应用程序构建一个Docker镜像?很容易:
dockerTools.buildImage {
name = "helloworld";
contents = [ hello ];
config.Entrypoint = "hello";
}
需要快速启动nginx
,而不需要编写大量的配置?只需两行字:
services.nginx.enable = true;
services.nginx.virtualHosts."example.com".webRoot = "/var/lib/example.com";
想声明性地配置vim
?这就是精神:
vim_configurable.customize {
name = "vim-with-plugins";
# add custom .vimrc lines like this:
vimrcConfig.customRC = ''
set hidden
set colorcolumn=80
'';
vimrcConfig.vam.pluginDictionaries = [
{ name = "youcompleteme"; }
{ name = "phpCompletion"; ft_regex = "^php\$"; }
{ name = "phpCompletion"; filename_regex = "^.php\$"; }
{ name = "phpCompletion"; tag = "lazy"; }
];
}