上一篇文章中,我引入了 Nix 管理 Arch 上的软件包。不久之后,抱着好奇与探索的心态,我参加了国内爱好者举办的 NixOS Meetup #2,社区的分享让我获益匪浅。

Nix,你好!

最近在配置 PVE 上的多个 LXC 实例时,为了解决环境配置的重复劳动问题,让我又一次想到了 Nix。Nix 打包后的环境天然具有只读属性,配合宿主机的 ZFS 共享存储,理论上可以实现所有 LXC 实例使用同一套 Nix 环境。

我去研究了一下 Home Manager using Nix 的实现,它其实是通过 activate 脚本进行的环境初始化。这样的话,我们只需要在其他 LXC 中调用这个 activate 脚本就可以实现配置复用了。本文简单记录了研究和配置的过程。

$ ls -lah /nix/store/6mq4hx9nhp5w114bv9j15q3fnbgr60vx-home-manager-generation
Permissions Size User Date Modified Name
.r-xr-xr-x   13k root  1 Jan  1970  activate
dr-xr-xr-x     - root  1 Jan  1970  bin
.r--r--r--     0 root  1 Jan  1970  extra-dependencies
.r--r--r--     2 root  1 Jan  1970  gen-version
.r--r--r--     6 root  1 Jan  1970  hm-version
lrwxrwxrwx     - root  1 Jan  1970  home-files -> /nix/store/77016ziha4gndr065v97m0si62gi8a0x-home-manager-files
lrwxrwxrwx     - root  1 Jan  1970  home-path -> /nix/store/bn5xzb5nrav7w1fp2mc31gx64paws9sq-home-manager-path

打包后的 home-manager 环境

我的总体设计思路为:由一个 LXC 专门负责 Nix 环境的部署,其他 LXC 以只读的方式使用该 /nix 目录。那么第一步就是创建一个 nix 文件系统,在 PVE shell 中执行:

# 使用 zstd 压缩
# 关闭最后被读取/访问的时间,避免更新元数据和写入损耗
zfs create ssd-pool/nix_store \
  -o compression=zstd \
  -o atime=off

这里使用了 zstd 压缩,并关闭了最后被读取/访问的时间,避免更新元数据和写入损耗。我们可以通过 zfs list 查看当前的所有 ZFS 文件系统。

接下来创建名称为 Nix 的特权容器,这里我选择了 Arch Linux 模板。

Important

在配置中注意不要选择无特权的容器,在配置后,去选项 功能中开启嵌套,否则 systemd 无法正常加载,会获取不到 IP。

默认的 Arch 模板需要更新,除此之外,新版本的 pacman 中引入了 alpm 功能,使用 Landlock 机制安全下载文件,但是在 LXC 中无法使用。这里我们一并禁用。

pacman-key --init
pacman-key --populate archlinux
pacman -Sy archlinux-keyring
pacman -Syyu git

接下来在 PVE 中将 nix_store 挂载到 LXC 上:

pct set 107 -mp0 /ssd-pool/nix_store,mp=/nix

安装 Nix:

curl -fsSL https://install.determinate.systems/nix | sh -s -- install

我在 ~/.config/home-manager 下已经有一套 home-manager 配置了,这里给出一套可用的 flake.nix:

flake.nix
{
  description = "Home Manager configuration for multi devices";
 
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
 
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
 
  outputs =
    {
      nixpkgs,
      home-manager,
      ...
    }@inputs:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        # 允许非自由软件
        config.allowUnfree = true;
        overlays = [];
      };
    in
    {
      homeConfigurations = {
        "root@Nix" = home-manager.lib.homeManagerConfiguration {
          inherit pkgs;
          extraSpecialArgs = { inherit inputs; };
          modules = [ ./hosts/lxc-base/home.nix ];
        };
      };
    };
}
 

lxc-base 对应着 LXC 通用的配置。如果希望配置多台机器,将他们的配置分为不同的文件是个很不错的选择。./hosts/lxc-base/home.nix 的内容为:

./hosts/lxc-base/home.nix
{
  pkgs,
  ...
}:
{
  imports = [
    ../../modules/common.nix
  ];
 
  home.username = "root";
  home.homeDirectory = "/root";
 
  home.packages = with pkgs; [
    nmap
    socat
  ];
  
  programs.zsh = {
  initContent = ''
    export PATH="/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
  '';
};
 
 
  home.stateVersion = "25.11";
}
 

接下来就可以为本地进行配置了。第一次运行时没有 home-manager,因此需要执行:

nix run home-manager/master \
  -- switch \
    --flake .#root@Nix -b backup-$(date +%Y%m%d%H%M%S)

如果机器名 root@Nix 和配置一致,可以省略上面的 #root@Nix。之后的更新只需要执行:

home-manager switch \
    --flake . \
    -b backup-$(date +%Y%m%d%H%M%S)

我们可以先 build 出 result:

home-manager build --flake .#root@Nix

接下来就可以将 home manager 生成的 activate 配置到另一台 LXC 上了。先记录一下它的路径:

$ readlink -f ./result
/nix/store/y1azld4dq1j5rzl7aj8k36i6q8aabl73-home-manager-generation

我使用的另一台 LXC 已经有了两个挂载点(mp0 和 mp1),因此这里我们以只读的方式挂载到 mp2:

pct set 110 -mp2 /ssd-pool/nix_store,mp=/nix,ro=1

直接执行上文记录的 activate 的话会报错:

$ /nix/store/xxxx-home-manager-generation/activate
readlink: missing operand
Try '/nix/store/yyy-coreutils-9.8/bin/readlink --help' for more information.
dirname: missing operand
Try '/nix/store/yyy-coreutils-9.8/bin/dirname --help' for more information.
Starting Home Manager activation
/nix/store/zzz-home-manager-generation/activate: line 192: nix-build: command not found

主要是因为缺失了 nix-build,可以通过和上面类似的方法解决。首先获取 nix-build 的软链接:

$ dirname $(readlink -f $(which nix-store))
/nix/store/a6y4cspbx1b5x3s2gw6fq4aabmsdsa4g-determinate-nix-3.15.1/bin

之后在新的 LXC 中更新 PATH:

export PATH=$PATH:/nix/store/a6y4cspbx1b5x3s2gw6fq4aabmsdsa4g-determinate-nix-3.15.1/bin

再执行一次 activate 即可:

$ /nix/store/y1azld4dq1j5rzl7aj8k36i6q8aabl73-home-manager-generation/activate
Starting Home Manager activation
Activating checkFilesChanged
Activating checkLinkTargets
Activating writeBoundary
Creating new profile generation
Activating installPackages
installing 'home-manager-path'
Activating linkGeneration
Creating home file links in /root
Activating onFilesChange
Activating reloadSystemd

为了方便起见,我们可以使用脚本进行更新。我让 Gemini 为我生成了一份:

deploy.sh
#!/bin/bash
 
if [ -z "$1" ] || [ -z "$2" ]; then
    echo "Usage: $0 <User@ConfigName> <TargetIP>"
    echo "Example: $0 root@Nix 192.168.31.255"
    exit 1
fi
 
FULL_CONFIG_NAME="$1"  # 例如: root@Nix
TARGET_IP="$2"         # 例如: 192.168.31.255
 
REMOTE_USER="${FULL_CONFIG_NAME%%@*}"
 
# 获取 Builder 中的 Nix 路径
NIX_BIN_PATH=$(dirname $(readlink -f $(which nix-store)))
 
echo "🔨 Building configuration: $FULL_CONFIG_NAME..."
 
CONFIG_PATH=$(nix build .#homeConfigurations."$FULL_CONFIG_NAME".activationPackage --print-out-paths)
 
echo "🚀 Deploying to $REMOTE_USER@$TARGET_IP..."
 
ssh "$REMOTE_USER@$TARGET_IP" "
  export PATH=\$PATH:$NIX_BIN_PATH
  $CONFIG_PATH/activate
"
 
echo "✅ Done!"
 

使用方式为 bash deplay.sh <Username>@<Nix-profile> <IP>

它会构建 <Username>@<Nix-profile> 的配置并部署到指定 IP 的 <Username> 用户下。通过这种方式,我们就可以一键式在不同的 LXC 中使用同一套 Nix 环境,不但能避免重复部署的问题,还能以文件的形式定义配置,最大化空间的利用。

总的来说,Nix 确实给我带来了越来越多的惊喜。(当然我还是没有切换到 NixOS 的计划)。在 AI 时代,基础设施建设变得愈发重要,Meetup 上也有众多关于打包方式的讨论,我认为 Nix 作为声明式的文件配置,有着更独特的应用空间和应用价值,而且它与常见的打包方式并不冲突,有着巨大的潜力。

PS:感谢 @RazYang 老哥的分享给了我极大的启发。