Nix 入门介绍

Nix 是什么

在介绍 Nix 之前,先看一下 Nix 要解决的问题,也就是软件部署中的一些痛点。

软件部署的痛点

软件部署就是我们获取并使用一个软件的过程,比如在 Debian 系统中使用 apt 安装一个包,或是在开发的 Python 项目中用 pip 安装一个依赖包,都属于这一类。 软件部署听起来简单,但是在现实中会遇到各种各样的问题,大致可以分为环境问题和管理问题两类:

环境问题

环境问题指运行软件需要的环境,包括软件的外部依赖(现在很少见纯静态链接的软件了),以及软件自身的配置等等。 下面举一些典型的问题和现实中的例子。

  1. 依赖版本冲突

假设我们安装了两个包 A 和 B ,但是这两个包同时依赖了 C 的不同版本,这个时候会发生什么呢?

img

Debian/APT: 某些软件包会在文件后面添加版本作为后缀,但是没有这个机制的包就会出现冲突,用户只能在 A 和 B 之间二选一:

Python, GoLang, Java: 只能使用 C 的某一个版本,并祈祷依赖关系被破坏的 A 或者 B 正常工作。

NodeJS/NPM: 在 A / B 的目录下面安装另一个版本的 C ,比较好的解决了依赖冲突的问题。

  1. 接口兼容性

通常一个包的依赖是以版本区间声明的,比如 A 依赖 C 的 v1.2.0 ~ v1.7.0 ,这时又引入了一个问题,软件开发者通常很难测试这个区间的所有版本是否都可以正常工作,我们也很难假设所有的依赖都严格遵循 semver 的规范,不会改变相应的接口。

  1. 软件如何找到依赖

即使相关的依赖都存在,软件能否找到它们也是一个问题,这在操作系统级别的环境中尤其突出。 如果我们用非标准的方式安装了某些依赖,通常需要手动修改一些东西来让它可以被使用,比如 PATH, LD_LIBRARY_PATH , Windows 注册表。 相关的工作也是繁琐而且不容易管理的。

前面的例子可以总结为两个流程的问题:

  1. 如何确定软件依赖什么
  2. 如何在部署环境实现相关的依赖

管理问题

除了软件的依赖,我们通常还需要对软件进行管理,比如安装、升级、卸载、打包等等。

安装

如果一个软件已经被某种包管理工具提供了,那么安装起来还是比较简单的,比如 apt/yum/pip/npm install ... ,但是还有很多工具并没有这种方便的安装方式,下面是一些例子:

  • plantuml: java 软件,直接发布一个 jar,需要用户手动安装运行时,官网也没有提供兼容性说明和指引。
  • poetry: 需要用 Python 跑一个脚本来安装,所以需要先安装 Python ,而且这个 Python 同时也是运行时依赖,需要一直保存在系统里。
  • golang: 下载页面给你一个压缩包,解压出来是带有二进制的源码树,甚至没有指引如何操作,添加到 PATH 。(Linux)

升级/换版本

软件安装好之后,如何在不破坏其它组件依赖的情况下升级? 如果出了问题如何回滚? 大部分包管理工具都没能解决这个问题。 比如 apt upgrade 这个操作是无法回滚的,升级出了岔子只能手动排查。

针对版本控制,甚至会衍生出很多不同领域特定的版本管理工具,比如 Node-nvm, Golang-gvm, 以及相当混乱的 Python (压缩了详情):

img

来源

Nix to the Rescue

Nix 源自 2006 年 Eelco Dolstra 的 PhD 毕业论文 The Purely Functional Software Deployment Model 。 论文中提出了一种名叫 Nix 的 软件部署系统 ,解决了很多现有部署系统的痛点,十几年过去了,当时强调的很多痛点仍然存在, Nix 也逐渐发展出一套完善的生态系统和社区。 目前, Nix 生态系统主要包括下面几个部分:

img

  • Nix Lang: 一个函数式编程语言,对描述软件部署相关的事情有定制优化,是所有其它工具的配置描述方式
  • Nix: 一个包管理器,解析 Nix 表达式,构建对应的软件,并安装到系统
  • NixOS: 一个 Linux 发行版,所有的系统组件都由 Nix 来管理,支持原子化的系统更新和回滚
  • Nixpkgs: 一个社区维护的 Nix 和 NixOS 模块仓库
  • NixOps: 一个基于 Nix 的集群管理工具
  • Hydra: 一个基于 Nix 的持续构建系统

下面主要介绍 Nix Lang, Nix, Nixpkgs 和 NixOS 。

The Nix Lang

首先是 Nix 语言。 Nix Lang 是一个纯函数式的编程语言,对它有初步的了解才能看懂后续的一些配置。 使用函数式范式是为了保证 Pure Evaluation ,因为无法使用外部变量(没有副作用),每个函数的执行结果是完全由参数确定的,这保证了每次软件构件的结果相同,同时使 “构建结果作为缓存分发” 成为了可能。 尽管现实的复杂性不是换一个函数式语言就能解决的,这种选择很大程度上减少了软件分析的麻烦。

The language is not a full-featured, general purpose language. Its main job is to describe packages, compositions of packages, and the variability within packages.

Nix Manual 提供了一个完整的语言说明 https://nixos.org/manual/nix/stable/expressions/writing-nix-expressions.html

安装 Nix 之后可以执行 nix repl 进入 Nix Lang 交互式环境。

数据类型

"string with ${templating}"
''
多行字符串
''

5    # 整数 int
1.2  # 浮点 float

./bin/nix  # 路径 path | 必须包含 '/'
http://example.org/foo.tar.bz2  # URI

true, false  # boolean
null

[1 ./xx.bin true]  # 列表 list

{key1="val1"; key2="val2"}  # 映射 Sets

arg: body  # 函数 function

基本语法

# 注释
/* 多行注释 */

# 每个文件是单个表达式,import会执行这个表达式并返回
import /tmp/foo.nix

# let: 表达式alias
(let x = "a"; in
  x + x + x)
#=> "aaa"

# 条件语句
(if 3 < 4 then "a" else "b")

# inherit: 继承属性, inherit xx 等价于 xx = xx
(let x = "a"; in
  { inherit x })
#=> { x = "a"; }

# 递归,没有变量,所以没有循环
let
 # fib' : int -> int -> int -> int
 fib' = i: n: m:  if i == 0 then n
                            else fib' (i — 1) m (n + m);
 # fib : int -> int
 fib = n: fib' n 1 1;
in
 fib 30

Nix Lang 属于动态类型语言且没有类型标注能力,这一点是为了简洁性但是没有经过深入考虑 (参见 https://www.cs.tufts.edu/~nr/cs257/archive/andres-loeh/nix-os-jfp.pdf Page 610 Section 6.4) 。 这导致 Nix 语言的 Tooling 比较难做,也滋生了很多元编程的奇技淫巧。 在实际使用中,通常只有文档中约定好的接口格式可以参考,实际运行时才会进行检查。

补充一些学习资源:

Nix, the Package Manager

Nix 包管理器通过 Nix 表达式来管理软件包。 管理包括了软件包的构建、分发/获取、升级、删除等操作,下面会简单介绍 Nix 的工作机制。

构建

以构建 GNU Hello 为例,我们需要通过 Nix 声明构建的元信息,编写构建的脚本,并执行构建。 下面是元信息的声明文件:

{ stdenv, fetchurl, perl }:

stdenv.mkDerivation {
  name = "hello-2.1.1";
  builder = ./builder.sh;
  src = fetchurl {
    url = "ftp://ftp.nluug.nl/pub/gnu/hello/hello-2.1.1.tar.gz";
    sha256 = "1md7jsfd8pa45z73bz1kszpp01yw6x5ljkjk2hx7wl800any6465";
  };
  inherit perl;
}

文件总体是一个函数类型的表达式,函数的输入是构建所需的外部输入,这里有 stdenv, fetchurl, perl , stdenv 包括 gcc 和一些通用 Unix 工具如 cptar, 其它输入的名字可以解释自己是什么。 构建的过程叫做 derivationstdenv 提供了函数来注入它提供的依赖。 这些输入通常来自一个官方维护的仓库 nixpkgs ,会在下文解释。

name 是构建出包的名字, builder 是构建时执行的脚本,这两个属性是 Nix 直接需要的。

其它属性(src perl)会在脚本的执行环境中以环境变量的形式提供,例如 $src 就是指向下载下来的源码压缩包的路径,将 $perl 添加到路径就可以使用相关工具来编译源码。

Nix 管理器会准备好所有的输入,然后执行构建脚本,在这里,脚本只需要解压下载下来的源文件然后编译即可。 编译结果需要放在环境变量 $out 下,作为构建的输出。

derivation 更详细的解释可以参考 https://nixos.org/guides/nix-pills/our-first-derivation.html

分发

前面描述的软件包要如何分发给别人呢? 理论上只要有了构建的 Nix 表达式,在任何地方都可以构建出同样的软件包,但是每次都重新构建的时间和资源成本都很高(例如浏览器)。 因此 Nix 不仅有着集中的构建信息仓库 nixpkgs ,提供了所有软件分发的构建信息;还提供了对大部分构建结果的二进制缓存 https://cache.nixos.org/ 。 在用户尝试安装一个包的时候,如果命中了缓存就只需要下载而无需重新构建。

顺便一提, Nix 不可能提供所有的软件缓存,例如 TexLive ,本身包含了很多不同的 Tex 插件, Nix 会分发几个常用的组合包,但是如果用户自己配置了需要的插件组合的话,构建结果就是另外一个不同的软件包了。 像这样对软件的不同配置都会生成不同的软件,因此缓存几乎无法穷尽所有的构建。 (可以自己 host 缓存服务器来缓解这一问题)

Nix 把所有的包放在 /nix/store 路径下(可以修改但是麻烦一点),例如 /nix/store/b6gvzjyb2pg0kjfwrjmg1vfhh54ad73z-firefox-33.1/ ,前面的字符串是对软件所有依赖的哈希值。

软件包在 /nix/store 下准备好之后,最后一步就是把它暴露在用户环境中。 Nix 同样把用户环境作为一个软件包来处理,构建的过程就是把所有指定的软件软链接到同一个目录,最后把这个目录暴露在用户环境中。 如图所示:

img

来源

Nix 机制的优势

我们回顾一下 前面提到的软件部署痛点,看看 Nix 是如何解决的。

首先是依赖版本冲突的问题,由于软件包名前面的哈希值,同一个软件的不同版本完全可以共同存在,下游的软件只要使用不同的目录去访问就可以了(在构建时就确定了依赖的位置 /nix/store/xxx ,运行时只要保证对应的目录存在,就不会有问题),这些冲突的依赖不会暴露在同一个环境中。

其次是接口兼容性,Nix 中构建时输入的是固定版本的依赖而非某个版本区间,只要这一个构建经过测试没问题,安装时会复现一个一模一样的环境。 这会不会导致用户机器上同个软件有很多个不同版本呢?也不会,因为我们可以指定 nixpkgs 的版本,大家都统一就不会出现冗余。

还有依赖查找问题,以前我们需要各种环境变量来找到依赖,例如在 PATH 中寻找二进制,在 INCLUDE 中找头文件。 在 Nix 体系下,所有的依赖都是通过固定位置查找的,这一点在构建时就得到了保证。

最后是软件的管理, Nix 提供了 nix-env 命令行工具来管理所有安装的软件包,类似 aptyum ,也可以通过其它方式来做更高级的管理,在下文 Home Manager 中有介绍。

最后总结一下 Nix 机制带来的优势:

多版本共存。 只要依赖不同、版本不同,同一个软件就在不同的路径下,互不干扰。

完整依赖。 Nix 的构建环境是完全隔离的,只有所有构建依赖都被声明时才能正确构建。 之后,运行时依赖可以被自动检测出。

多用户支持。 所有用户可以共享同一个全局的 /nix/store

原子的更新和回滚。 属于是多版本共存带来的优势。 更新这种原本具有 “破环性” 的行为现在可以安装新版本而不更改旧版本,也因此可以回滚。

Nixpkgs

前面提到了 Nix 有一个集中的软件分发仓库 nixpkgs ,里面实际上包含了很多官方或社区贡献的描述软件构建方式的 Nix 源代码,例如 hello 。 这是 Nix 官方的软件源,在执行相关的指令时会把这一仓库作为默认环境,例如执行 nix-env --install cmake cmake 就会解析到 nixpkgs 下面的 pkgs.cmake 这一表达式。

因为 Nix 有一个极为灵活的构建与分发模型,不与实现软件的语言绑定(如pip/npm),它能够对非常多的软件进行打包和分发。 外加打包的方式相对简单且易于维护,社区贡献软件包的比例也比较大。 目前 nixpkgs 仓库中已经有超过 8 万个软件包,凝聚了大量宝贵的相关知识,是全世界最大的软件源:

img

来源

如何在 nixpkgs 中找到想要的软件包呢? 可以在命令行通过 nix-env -qa <name> 根据名字查找,也可以在 NixOS Search 网站上进行搜索。

极为丰富的软件源是 nix 生态一个非常大的优势,许多平时安装起来需要折腾许久的包,在配置好 nix 环境之后只需要一个命令或者一行描述就可以安装上,还能统一管理,例如一开始提到的 plantuml 、 poetry 和 golang ,每一个软件都需要我们去官网的页面查看并理解它们的安装方式并手动修改环境,这样安装之后的卸载、升级也是问题,时间久了甚至会忘记自己还安装过某个软件,怎么安装的,要怎么卸载才能删干净。 在这一点上如果 nix 提供了对应的包就可以很大程度上解放我们的心智负担(当然没有提供也可以自己打包)。

NixOS

前面介绍了 Nix 作为包管理器的工作原理,在此基础上,有一个通过这种包管理机制来管理操作系统的项目,叫做 NixOS ,意图用 Nix Lang 作为接口,声明式地构建一个操作系统,由 Nix 来管理操作系统的所有内容,例如 sshd 服务、系统级别的软件环境和配置。 这样可以享受到 Nix 的机制带来的一系列优势,包括系统环境完全可复现(在部署多台机器的工作环境时很有用)以及原子的更新和回滚。 还有一个类似的操作系统 GUIX ,使用的是比较通用的函数式语言 scheme 。

由于 NixOS 的理念比较激进,大部分系统级别的部件都是存在 immutable 目录下的,遇到某些问题是没有办法像其它发行版一样 sudo 去改一些内容的,作为用户要去调试或者查找一些 NixOS 相关的配置也会比较困难(社区的文档建设并不是很好)。 同时, NixOS 也没有遵循 Linux World 常用的 FHS 结构,如果没有适配的话一些依赖 FHS 的软件无法正常工作。 因此只建议熟练用户使用 NixOS 。

Nix in Read World

以上便是对 Nix 领域一些重要概念的图景,下面介绍在实际开发中如何通过 Nix 来管理环境。 我们可以在 Linux 和 MacOS 环境下使用 Nix 包管理器,也可以直接使用专门的 Linux 发行版 NixOS 。 如果对 Nix 不熟悉直接使用 NixOS 难度会比较大,下面只介绍在 Linux / MacOS 下使用 Nix 包管理器的情况。 很多具体的操作官方提供的指引更加清晰,这里只做简单说明。

安装

Nix 的安装在官网有指引: https://nixos.org/download.html ,在 Nix: the package manager 一节。 如果遇到网络问题, TUNA 也提供了镜像: https://mirrors.tuna.tsinghua.edu.cn/help/nix/

Nix Flakes: Towards Real Pure Evaluation

Nix 包管理器早期的指令是 nix-env ,类似 apt ,可以命令式地向用户的环境安装软件包。 但是这种命令式的设计使得 nix 表达式的执行变得 impure : 有一些输入(例如 nixpkgs 的版本)需要从系统环境变量中读取,因此在不同的地方执行 nix-env -i 安装的东西可能是不一样的;同时安装了很多软件包之后很难在新的机器上复现之前的环境,这些都破坏了 Nix 的设计初衷。

为了弥补这一缺陷, Nix 社区提出了一种新的特性 Nix Flakes ,由新版 Nix 包管理器提供。 Flakes 可以将计算 Nix 表达式需要的所有输入(原本在外部如系统环境变量中的)全部在源文件中声明,使用 git 来管理源文件(可选),并提供了锁定计算时输入所有信息的机制(类似 npmpackage-lock.jsongolanggo.sum)。 这样通过同一个仓库我们可以在不同的机器上重现完全相同的环境,并且环境本身可以通过 git 来做版本管理。 Nix Wiki 中有在安装 nix 管理器之后开启 Flakes 功能的说明。

下面是关于使用 Flakes 的简单说明。

首先准备好一个空目录,执行 nix flake init 会根据模板初始化一个 flake 项目,现在这个项目中只有 flake.nix 一个文件:

{
  description = "A very basic flake";

  outputs = { self, nixpkgs }: {

    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;

    defaultPackage.x86_64-linux = self.packages.x86_64-linux.hello;

  };
}

flake.nix 本身也是一个表达式,它的类型是 set ,具有三个属性:

  • description: 字符串,用途如名字
  • inputs: 一个 set,指定执行计算时的输入,默认有 nixpkgs 作为输入。
  • outputs: 一个函数,输入为 inputs 中的每一项属性被 flake 解析后的值,输出为一个 set,set 的每一项属性可以交给不同的工具作为输入。

关于 inputs 和 outputs 属性的具体形式在 Wiki 中 Schema 部分有详细说明。

正如前面对 outputs 的描述, Flakes 只是一种保证 Nix 表达式计算可以复现的机制,需要配合其他工具才能发挥作用,方法就是将输出作为其他工具的输入。

在上面的样例中执行 nix build . , nix 就会对输入进行解析(由于没有指定 inputs ,输入只有默认的 nixpkgs ,而且没有 lock 文件,因此会创建一个新的,说明使用的 nixpkgs 是哪个 commit ),将结果作为参数交给 outputs 函数,然后计算出它的返回值,使用返回值的 defaultPackage 属性作为 nix build 的对象。 这里同样指定了对应的系统环境 x86_64-linux ,类似的还有 aarch64-linuxx86_64-darwin 等。 如果需要管理多个构建对象,可以放在 packages.<system>.<package_name> 属性上,并通过 nix build .#package_name 来指定要构建的目标。 这里的 .#package_nameFlake Reference 语法,表示当前目录下 flake 输出的 package_name 属性。

构建完成后,当前目录下会有一个指向构建结果的链接,执行 ./result/bin/hello 就可以看到程序的输出了。

~/tmp/nix-tutorial ❯ nix build
~/tmp/nix-tutorial ❯ ll
total 16
drwxr-xr-x 2 sine sine 4096 Apr 14 23:25 .
drwxr-xr-x 3 sine sine 4096 Apr 14 21:52 ..
-rw-r--r-- 1 sine sine  508 Apr 14 21:55 flake.lock
-rw-r--r-- 1 sine sine  229 Apr 14 21:53 flake.nix
lrwxrwxrwx 1 sine sine   54 Apr 14 23:25 result -> /nix/store/js9xgkjs8qhjv6r48g1dk9ian8iszg8w-hello-2.12
~/tmp/nix-tutorial ❯ ./result/bin/hello
Hello, world!

Git 集成

使用 Git 来管理 Flake 项目通常是一个明智的选择。 如果 Flake 项目文件夹被 git 管理,那么构建时只有 git 范围内的文件(被 git add 过)才是可见的。 如果没有 git 仓库,那么可见文件就是当前目录下所有的文件。 Nix 构建时会把所有可见的文件拷贝到另一个目录,按照 Nix 的规则执行计算并返回结果。

有一点需要注意,如果构建输入有大文件的话,拷贝过程会花费比较长的时间,目前还没有针对这个场景的优化,参见这个 issue

Home Manager: 管理全局环境

上面的样例展示了如何通过 Nix Flake 来构建一个项目,但通常我们对包管理工具的需求不止于此。 例如 NodeJS 的包管理工具 npm ,不仅支持项目级别的构建 npm run xxx ,还提供了全局环境管理能力 npm install -g xxxaptyum 也是全局环境管理的例子。 对于这类需求, nix-env 可以满足,但是受到之前所述缺陷的制约,因此下面介绍一个社区维护的基于 flake 的全局(用户)环境管理工具, Home Manager

Home Manager 的能力在 github 页面有清晰的描述, “managing a user environment using the Nix package manager together with the Nix libraries found in Nixpkgs” ,管理用户的环境。 最常用的能力有两个,一个是管理各类软件,另一个是管理各类配置文件(dotfiles)。 下面介绍 Home Manager 的接口和使用方式,更完整的信息可以参考 Home Manager 的 用户手册

nix build 类似, Home Manager 接受 flake.nix 输出的 homeConfigurations.<username> 属性作为输入(也可以通过 ~/.config/nixpkgs/home.nix 来配置,不过还是推荐在一个单独的目录下用 git 来管理)。 本文中尖括号包裹表示需要替换的内容。 首先我们准备好 HM 的配置文件,下面是一个精简的样例,在同一目录下:

home.nix

{ config, pkgs, ... }:

{
  home.stateVersion = "22.05";

  home.packages = [
    pkgs.htop
  ];

  programs.emacs = {
    enable = true;
    extraPackages = epkgs: [
      epkgs.nix-mode
      epkgs.magit
    ];
  };

  # Let Home Manager install and manage itself.
  programs.home-manager.enable = true;
}

flake.nix

{
  description = "An example Nix configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-21.11";
    home-manager = {
      url = "github:nix-community/home-manager/release-21.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, home-manager }: {
    homeConfigurations.<user> = home-manager.lib.homeManagerConfiguration {
      system = "x86_64-linux";
      homeDirectory = "/home/<user>";
      username = "<user>";
      configuration = import ./home.nix;
    };
  };
}

home.nix 的内容就是普通的 home-manager 配置, home.stateVersion 固定了 home-manager 的版本, home.packages 是需要在环境中安装的包的列表, programs.xxx 包含了许多 home-manager 提供的软件包,通常有一些方便的自定义能力,比如这里安装的 emacs 自带了一些插件。

flake.nix 中,我们固定了 nixpkgs 的输入,添加了 home-manager 作为输入项,并指明要 home-manager 的 nixpkgs 版本与我们使用的一致。 在输出部分,声明了用户的名字和家目录位置,然后把 home.nix 表达式导入作为 configuration 属性。

手册中有介绍安装方式,不过在非 NixOS 的发行版的单用户模式下面会遇到比较尴尬的依赖问题,我们需要 Nix 来构建并安装 Home Manager ,但是 Home Manager 初始化之后会接管 Nix 工具导致冲突,因此需要在激活 HM 之前手动设定一下 Nix 二进制的优先级来避免冲突:

# resolve nix conflict in single user installation
nix-env --set-flag priority 6 nix

正常的初次激活流程:

nix build --no-link .#homeConfigurations.<user>.activationPackage
"$(nix path-info .#homeConfigurations.<user>.activationPackage)"/activate

之后我们就有 home-manager 命令行工具可以用,后续的环境更新就只需要在目录下执行

home-manager switch --flake .#<user>

在 Github 搜索 homeConfigurations 能找到很多人的配置可以学习,我的配置文件在 这里 也可以参考。

Dotfile Management

除了管理软件环境, Home Manager 还能管理用户家目录下的各种配置文件,通过

home.file."<target location>".source = <source path>;

就可以把 source path 中的文件内容软链接到 target path ,配合 git 就能实现大部分软件配置的管理,例如 .zshrc ,非常的方便。

修改默认 Shell

如果你通过 HM 安装了新的 shell (如 zsh ),在切换默认 shell 时它不会出现在 chsh 的允许列表里,会报错 chsh: xxx is an invalid shell ,这时需要把 zsh 加入该列表,以及在对应 shell 的初始文件中执行激活的脚本:

echo '/home/<user>/.nix-profile/bin/zsh' | sudo tee -a /etc/shells >/dev/null
chsh -s /home/<user>/.nix-profile/bin/zsh
echo "if [ -e /home/<user>/.nix-profile/etc/profile.d/nix.sh ]; then . /home/<user>/.nix-profile/etc/profile.d/nix.sh; fi" >> ~/.zprofile

允许 unfree 软件

Nix 默认只会安装免费软件,要允许非自由软件(例如 vscode ),需要在配置中添加:

mkdir -p ~/.config/nixpkgs
echo "{ allowUnfree = true; }" > ~/.config/nixpkgs/config.nix

管理项目依赖

前面提到了 nix flake 进行项目构建和环境管理的能力,下面介绍在日常开发中对单个项目依赖环境的管理。 一个样例 flake 文件如下:

{
  description = "A basic flake with a shell";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-21.11";
  inputs.flake-utils = {
    url = "github:numtide/flake-utils";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShell = pkgs.mkShell {
          buildInputs = with pkgs; [
            (python39.withPackages (p: with p; [
              beancount
              dateparser
              tika
              pandas
            ]))
            fava
            jdk17
          ];
        };
      });
}

上面我们 nixpkgs 的 nixos-21.11 版本,这是跟随 NixOS 发布的稳定 channel ,想要最版可以使用 unstable channel 。 同时额外引入了 flake-utils 来做多系统的兼容(下面的 flake-utils.lib.eachDefaultSystem )。

这个项目的场景是个人的记账,使用 beancount 做记账信息的管理,还用到 tika 来做银行每月的 PDF 账单的识别,由于 tika 会调用 jar 包来实际干活,还需要 jdk17 作为依赖,同时还有 python 命令行工具 fava 来做记账信息的可视化面板。

在这个场景下,我们不需要实际构建什么东西,只需要一个有这些依赖的 shell 环境,能够执行对应的 python 或 java 指令就够了。 这件事情对应的 nix 功能是 nix develop ,它会读取 flake 输出的 devShell 属性,构建一个新的 shell 并把这个表达式的所有输入暴露在这个新的 shell 中。

Direnv: 摆脱 Nix Develop

但是 nix develop 有一些比较烦人的地方,一个是新的 shell 是 bash 而非我们一般会配置好的自定义 shell ; 另一个是每次开发前都要手动执行一次 nix develop 体验不太好; 最后还有新的环境只能在 shell 中使用,如果用 vscode 开发就会失去对 python 的智能提示等能力。

Direnv 可以很好的解决这些问题。 这个工具本身与 Nix 关系不大。 它的功能是在用户进入到一个新目录时,自动检测目录下是否包含 .envrc 文件,如果存在,就根据其中的声明,把相关的内容加载到当前环境中,并在用户离开当前目录后卸载这些环境。 这个工具支持直接加载 Nix Flake 文件中的 devShell 环境,只需要在 .envrc 中添加 use flake 命令即可。 这解决了两个问题,一个是我们进入目录时就可以自动加载环境,不需要手动执行 nix develop ,另一个是 direnv 可以在当前的 shell 中叠加环境,这样我们还可以使用原本 shell 的一些配置。

最后是 IDE 的集成, VSCode Marketplace 中有人制作了相关的插件,可以把 direnv 声明的环境加载到 VSCode 中。

垃圾清理

前面是使用 Nix 来做开发环境管理的部分。 除此之外还有一点需要注意,通过 Flake 管理环境的时候是没有 删除 的概念的,不像 apt remove 会实际删除某个软件,我们不需要一个软件时只是把它从 flake.nix 中删除,即使是 nix-env --uninstall 也只是移除了软件的软链接。 久而久之会有很多用不到的软件留在系统中,如果需要可以作为缓存再次出现,但是会比较占地方。

如果想做一次清理,很简单,执行

nix-collect-garbage

就可以了,这条指令会找到所有目前没有被使用的软件并删除。

当然这个过程背后的机制很有意思,使用了类似编程语言中垃圾回收的方式,有一个根目录 /nix/var/nix/gcroots ,作为引用的根,指向了所有目前正在被使用的软件 (包括各种用户的 profile ,或是其它工具添加到 gcroot 的链接)。 垃圾回收的过程就是遍历 gcroot 为根的树,并把 /nix/store 中所有其它软件删除的过程。

总结

本文介绍了软件部署的痛点、 Nix 的工作方式以及实际使用,希望能够帮助有需要的人入门 Nix ,在开发中更好的管理相关的环境,提高效率。 一般来说,需要管理的环境越多, Nix 带来的优势越明显。


相关资料

最后附上一些流程的命令速查:

安装 Nix 单用户模式 ( WSL2 下面只能以单用户模式安装)

sudo apt update
sudo apt install curl xz-utils git
sh <(curl -L --insecure https://nixos.org/nix/install) --no-daemon

# log in again or activate:
. /home/<user>/.nix-profile/etc/profile.d/nix.sh

开启 Flakes 功能

mkdir -p ~/.config/nix/
echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf

nix-env 使用

# 包管理
nix-env -qa <name>  # 查询包
nix-env -i <name>   # 安装包
nix-env -e <name>   # 移除包
nix-env -u <name>   # 升级包
nix-env -u          # 升级所有包

# 用户profile版本管理
nix-env --list-generations     # 列出所有版本
nix-env --switch-generation x  # 选择某个版本
nix-env --rollback             # 回退倒上一个版本

垃圾回收

# 垃圾回收
nix-env --delete-generations [+5|14d|old]  # 删除旧profile版本
nix-store --gc          # 垃圾回收,引用计数源头在 /nix/var/nix/gcroots
nix-collect-garbage -d  # 删除所有旧版本并执行一次垃圾回收