目录

Nix 实战:来几个 use case

距离我 上篇介绍 Nix 的文章 有一段时间了,积累了不少 use case 和常见问题,这里总结成文。

一些小建议

强烈建议没看过上文的 Nix 新用户先去看一眼,否则会跟不上。

另外强烈建议先学一下 nix 基础语法 ,下面不赘述语法的部分。

语法上它很像 OCaml。

再详解一些概念

Flake

Flake 已经几乎成为 Nix 世界的 de facto 了。其结构大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
{ # 只列举最常用的字段
  description = "这个项目的文字描述";
  inputs = rec {
    # 所有外部输入。除了这些外部输入外,接下来的所有构建都会被切断网络通信。
    # 由此可以保证最大限度的可复现性(aka 函数式)
    # 外部输入可以是 git repo 或网络文件。
    nixpkgs-master.url = "github:nixos/nixpkgs/master";
    nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    # rec 关键字让你可以在同一个结构体内 ref 自己的字段。
    # 我喜欢用这种方式快速切软件源版本
    nixpkgs = nixpkgs-unstable;
  };
  outputs = {nixpkgs, ...} @ args: {
    # 这里是这个 nix 项目的构建定义。照着这里的流程做就能 build 出成果。
    # 注意 outputs 是一个函数,输入是所有 input 组成的 struct,被解构
    # 赋值了,所以这里能拿到 nixpkgs 字段作为传入变量。没被匹配的字段
    # 就放在 args 变量里了。比如 args.nixpkgs-master.xxxxxx

    # nix fmt 命令会使用这个字段指定的 formatter 来 format Nix 代码。
    # 如下是一个常见的例子:
    formatter = { "x86_64-linux" = nixpkgs.legacyPackages.x86_64-linux.nixpkgs-fmt; };
    # 所有 overlay 。Overlay 是现场修改 input 内容物的方式,最常见的是
    # 修改或者新增某个软件包的定义(而不用 fork 一份 nixpkgs),接下来
    # 使用 input 的部分可以完全无感地继续该怎么用怎么用。
    overlays = { ... };

    # 对于软件项目,下面的字段比较常用:

    # 所有软件包的定义。 nix build 和 nix shell 命令会用这里的定义来build 软件包。
    # 关于如何编写你的软件的 derivation,请翻阅文档或随便在 nixpkgs 里
    # 找一个软件包定义。
    # 下面这个软件可以用 nix build .#software-name 构建。成果会放在
    # ./result 里。
    packages = { x86_64-linux.software-name = myDerivation; };
    # 如果 nix build 命令没有附加包名参数,则此 derivation 会被构建:
    defaultPackage = { x86_64-linux = myDerivation; };
    # nix flake check 命令会执行下面 derivation 定义里的测试流程。
    checks = {  x86_64-linux.software-name = myDerivation; };

    # 开发环境。比如下面一个很简单的例子。
    # 在此项目根目录使用 nix shell 命令可以把你带入这个预装好环境的 shell 中。
    # 你自己项目外的个性化设置(shell、配色、alias、缩写等)不会被覆盖。
    # 不难注意到,你可以定义很多套 shell
    devShells = {
      x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell {
        name = "my-software-dev";
        packages = with nixpkgs.legacyPackages.x86_64-linux; [ nodejs yarn nodePackages.prettier just ];
      };
    };

    # 对于 NixOS 系统构建,下面的字段比较常用:

    # nixos-rebuild 会看这个定义
    # home-manager 的设置会作为它的 module 而被注入
    # my-computer 是电脑的 hostname
    # nixos-rebuild switch 命令若不指定这个名字,则以本机的 hostname 作为 key 在这里查找
    nixosConfigurations = { my-computer = systemDerivation; };
    # nix-darwin 会看这个定义
    # home-manager 也可以注入它
    # 字段名同样是 hostname
    darwinConfigurations = { my-mac = darwinSystemDerivation; };

    # 还有其它的第三方工具会在这里找自己感兴趣的字段
    # 比如 deploy-rs 会要求这么定义:
    deploy = {
      nodes.my-computer = { ... };
    };
  }; # outputs
}

nixpkgs module

nixpkgs module 定义了一种函数的输入和输出结构。用在所有和 nixpkgs 有关的系统定义里。它也是会被下文 imports 自动 call 的结构。具体参见 中文教程

经常看到 pkgs ,它是啥

我们写 NixOS 配置时,习惯上会这么做:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{ nixpkgs, ... }: {
  someConfig =
    let
      # 提前把自己系统的 packages 列表做个别名
      pkgs = nixpkgs.legacyPackages."x86_64-linux";
    in
    {
      environment.systemPackages = with pkgs; [ btop ]; # 这样就不用写一大堆了
    }
}

之后你看到别人配置里的 pkgs ,你就知道它是 nixpkgs.legacyPackages.${system} 的别名了。

我也推荐你自己遵照这个约定俗成。

相对路径、 import 关键字和 imports 字段的区别

  • 一个文件或目录的相对路径会在 nix 被求值时被拷贝进求值环境,并在此处留下一个绝对路径的字符串(形如 /nix/store/xxxxxx )。这是 Nix 语法那一层的功能。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    # 我们来 build 一个别人写的 C++ 软件包:
    # 这是一个很经典的 legacyPackage 的定义
    { lib, fetchgit, clangStdenv, openssl, ... }:
    clangStdenv.mkDerivation {
      pname = "my-cpp-program";
      version = "0.0.1";
      src = fetchgit {
        url = "https://github.com/nykma/test.git";
        rev = "abcdef123456...";
        hash = "sha256-xxxxxx";
      };
      buildInputs = [ openssl ];
      # 本地测试时我们发现需要给几个源文件打 patch ,否则在 nix 环境下编译不通过
      # 如果我们不想提交这个 patch 至上游,可以这么做:
      patches = [
        ./nixify.patch
        # 这就是一个相对路径的文件,它会在被求值时拷贝进 nix 的环境并被 hash
        # 同时,这个文件必须被 git add 进 staged files 里才能工作,否则会报错文件不存在。
        # 为什么 Nix 不支持引入绝对路径的文件呢?
        # 因为绝对路径定义的文件没法保证在别人的电脑里也存在,也就会失去可复现的优势。
      ];
    }
    
  • import 是 Nix 语法那一层的概念。其对象只能是 .nix 文件或一个文件夹,效果是让内容物好似就写在 import 所处位置一样。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    
    # ./a.nix
    "AAA"
    
    # ./b.nix
    "BBB"
    
    # ./c/default.nix
    # 注意这里定义的是一个函数
    input: "CCC ${input}"
    
    # ./main.nix
    let
      a = import ./a.nix;
      # 上面等效于
      # a = "AAA";
      # 就好似 a.nix 文件的内容写在 import 所处的那个位置一样
      # 记住这个「好似内容写在这里一样」,能帮你节省很多绕弯子的时间
      b = import ./b.nix;
      # import 对象为一个文件夹时,指的是 import 下面的 default.nix 文件
      c = import ./c;
      # 上面等效于:
      # c = input: "CCC ${input}";
    in {
      result = a ++ b ++ (c "wow"); # c 是个函数,所以要 call
    }
    # 对上面求值的结果:
    {
      result = "AAABBBCCC wow";
    }
    
  • imports 字段为一个 list ,该字段是 nixpkgs module 层级的概念。对其所有 item 求值(若该 item 为函数,则使用当前的 nixpkgs module 环境 call 它),并将最后结果 合并 于同结构的对应字段内。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    
    # ./modules/a.nix
    {
      config.users.users.nyk.isNormalUser = true;
    }
    # ./modules/b.nix
    {
      config.users.users.nyk.home = "/home/NyK";
    }
    # ./modules/c.nix
    # 注意:该文件的顶层不是 struct ,而是一个函数了
    { pkgs, ... }:
    {
      config.users.users.nyk.shell = pkgs.fish;
    }
    # ./main.nix
    { pkgs, ... }: {
      # 为什么 pkgs 需要从外部传入?
      # 因为 imports 列表里的 c 的定义要求这个入参存在
      # imports 将其当前环境内的 pkgs 再传入 c 作为 c 的环境
      imports = [
        # 想象上面说的「好似内容写在这里一样」
        import ./a.nix
        # imports 是能处理相对路径的,所以一般来说这么写就可以了:
        ./b.nix
        # 注意下面文件里定义的函数会被 imports 自动 call 了,并取它的返回值
        ./c.nix
        # 当然你也可以现场定义。显然下面和 import 是等价的
        { config.users.users.nyk.homeMode = "755"; }
      ];
    }
    # 求值的结果:
    {
      config.users.users.nyk = {
        isNormalUser = true;
        home = "/home/NyK";
        homeMode = "755";
        shell = «derivation /nix/store/c369bf8iifx7ibflwhab98i528mb5gin-fish-3.7.1.drv»;
      };
    }
    
如果字段重复了怎么办?

假设 a.nixb.nix 都定义了 isNormalUser = ??? ,但一个是 true 一个是 false 。此时 nix 求值时会报错。

通过设定这些值的优先级可以解决。具体参见 lib.mkOverride 。TLDR:优先级数字越小,优先级越高。

我想在 imports 一个模块时临时外部传入几个参数怎么办?

假设有个 home manager 的 module :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ./modules/home/x11.nix
#             vvvvvvv 指定参数默认值
{ pkgs, hidpi ? false, ... }:
let
  actualDPI = if hidpi then 144 else 96;
in
{
  home.packages = with pkgs; [ xclip ];
  xresources.properties = { # 修改 ~/.Xresources
    "*.dpi" = actualDPI;
  }
}

使用时,我们希望把 hidpi 指定为 true

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ./home/my-computer.nix
{ pkgs, ... }: # 注意这个外部环境没有 hidpi 这个变量的传入
{
  imports = [
    # 这显然是不行的,值会是默认值 false
    import ./modules/home/x11.nix;
    # 我们不如别让 imports 来 call 这个函数,我们自己来 call
    (import ./modules/home/x11.nix { pkgs = pkgs; hidpi = true; })
    # 再复习一遍「好似内容写在这里一样」。好似在这里定义了一个函数。
    # 再多使用点 nix 的语法糖的话:
    (import ./modules/home/x11.nix { inherit pkgs; hidpi = true; })
    # 有点蠢,但保证能用。如果你想要更工程化的解法,看下面一章,定义你自己的 options
  ];
}

你理解了这个例子后,应该有能力自行组合 importsimport 来模块化你的 nixos 配置了。

configoptions 的区别

这是 nixpkgs module 层级的概念。

写 NixOS 配置的时候我们经常会使用诸如 config.networking.useDHCP=true; 之类的字段来定义自己的系统状态。

你应该遇到过,写错 config 下面的字段会报错「找不到字段定义」之类的。

定义哪些字段有哪些字段没有这件事儿就是在 options 字段里做的。

我们也来定义一个自己的模块,通过 options 来让模块的调用者动态地决定字段的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# /etc/nixos/modules/my_user.nix
{ lib, pkgs, config, ... }:
let
  # 省得下面打字了
  # 和上面 pkgs 一样,这个 cfg 也是一种约定俗成
  cfg = config.nyk.user;
in
{
  options.nyk.user = {
    # 对于非 bool 的字段,详见 lib.mkOption 的函数定义
    enable = lib.mkEnableOption "Whether to enable user:nyk definition";
  };
  config = lib.mkIf cfg.enable { # 只有当 config.nyk.user.enable = true 时整段定义才会出现
    users.users.nyk = { # 常规的 nixos 配置
      isNormalUser = true;
      shell = pkgs.fish;
    };
  };
}

使用它的场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# /etc/nixos/configuration.nix
{
  imports = [
    # 先把模块定义文件给注入进我们的环境
    ./modules/my_user.nix
  ];

  config = {
    nyk = {
      user.enable = true; # 这样上面的 config 注入就能生效了
    };
  };
}

所以你在配置 nixos 时写的那些 config 都是已经在 nixpkgs repo 里被 options 定义过了的。具体可以在 mynixos 里搜索字段,就能在详情里看到定义的位置。

config 有时是可以省略的
1
2
3
4
5
6
7
{ pkgs, ... }: {
  imports = [
    import ./modules/a.nix
  ];

  users.users.nyk.shell = pkgs.fish;
}

上面和下面是等价的:

1
2
3
4
5
6
7
{ pkgs, ... }: {
  imports = [
    import ./modules/a.nix
  ];

  config.users.users.nyk.shell = pkgs.fish
}

你可以粗糙地认为,若该文件里没有 options 顶层字段出现,则所有不是 imports 字段的字段都会被归于 config 字段下。

但请注意,一旦本文件中 config 顶层字段出现了一次,则所有该归于 config 下的配置就都不能省略了。

Nix 的优势场景

我不要的软件,别出现在我的 $PATH

试想,你想用 glacnces ,然后你开开心心地 pacman -S glances ,装好了。

过了三个月,你发现 $PATH 里有 python3 ,而你却完全想不起何时为啥装的了……

Nix 就不会有这样的问题:你要什么软件,就给你 expose 什么程序,其它的依赖会构成有完整层级的树(而非平级或一级)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 用 environment.systemPackages 装,
# 或者在 devShell 里使用也是一样的效果。
# 新建一个有 glances 的临时环境
$ nix shell nixpkgs#glances
$ which glances
/nix/store/xxxxxxx-glances-4.2.0/bin/glances
$ which python3
which: no python3 in (........)
$ exit # 退出那个临时环境
$ which glances
which: no glances in (........)
$ which python3
which: no python3 in (........)

# 以下是在 nix 里试用一个软件最快的方法:
$ nix run nixpkgs#glances
# 退出软件后
$
# 到这里就完全没有这个软件存在过的痕迹了
# 它可能还会在 /nix/store 里留存一段时间,但最终会被 GC 掉
$ which glances
which: no glances in (........)

当然,以上用 environment.systemPackages 装,或者在 devShell 里使用也是一样的效果。

好处?自己依赖自己的依赖,确保对环境(和其它软件包)的影响压缩在最小范围内。

  • 我的电脑里同时有 20、22 和 23 的 nodejs ,就是不同的软件包依赖不同大版本的结果。每个软件都能开心地用自己版本的依赖,同时我完全无 which node 没结果。
  • 每个工作的项目都有自己的依赖,我有几个 C++ 工程依赖 openssl 1.1,完全不影响系统中的其它应用使用 openssl 3,他们互相不知道对方的存在。
这样不会占硬盘?
  • 使用同一个 python312 作为依赖的两个不同程序在整个系统被求值(build)的时候,自然会引用到同一个 python312 的 derivation 实例上去,实际上不会有多余的硬盘占用。
  • 如果上述 nodejs 的例子里系统只允许装一个版本的话,就可能会有软件运行不正常了。在不正常和占硬盘之间选哪个不用说了吧。
  • 和三四十年前相比,硬盘已经完全不值钱了。现代语言(尤其是 go)已经标配静态链接了,我觉得很对。除非出于商业角度卖私有库的考虑,否则你也最好拥抱静态链接。

FAQ

如何 debug ?

对 Flake 而言,有 nix repl 命令可以让你进入某个名字空间内:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 进入 flake.nix 所在的目录
$ pwd
/etc/nixos
# 让你能进入当前系统 config 求值完之后的树内
$ nix --extra-experimental-features repl-flake repl ".#nixosConfigurations.$(hostname)"
nix-repl> config.networking.enableIPv6 # options 当然也能看到
true
nix-repl> config.nix.settings.substituters
[
  "https://mirrors.ustc.edu.cn/nix-channels/store"
  "https://cache.nixos.org/"
]
nix-repl> pkgs.cloudflared.version
"2024.11.0"