nix是一个基于函数式语言的声明式包管理器,几年前我把macOS上面的包管理器从brew切换到nix,实现了 Configuration-as-Code 的方式来管理环境。有了nix这样一个可编程层,基本上所有的配置都可以提交到git仓库:任何时候都可以稳定复现日常使用的环境,这极大地降低了维护个人开发环境的心理负担,尝试不同的技术生态也变得更加容易。

NixOS是以nix为基础实现的Linux发现版。得益于nix的设计原则,NixOS也具有Declarative、Reproducible、 Reliable的优点。除此之外,NixOS可以高度定制化,基本上可以积木式地搭建自己独特的Linux系统,这一点是使用ArchLinux、Debian、RedHat系等发行版时无法想象的。尤其对于Server端环境,从引导程序、内核到系统服务,完全可按需定制成最小的Linux系统。同时,系统整体上的配置是声明式风格,将系统维护融合进自动化管理流程也容易得多。因此,我逐步将所有的Linux Server全部切换到NixOS。

要使用NixOS,当然先得把它安装到机器上。NixOS的文档跟不上它的发展速度,安装手册跟最佳实践相去甚远,官方文档也没有仔细说明如何使用flake配置和安装NixOS,多少有点对不起它 「声明式」、「可重现性」的slogan。在nixpkgs 仓库中,至今这个使用”驱逐”方式进行安装的issue都还没解决。因此,本文记录一下我在使用NixOS时摸索的安装方式和常见问题。

安装使用NixOS系统大概分为下面的几个步骤:

  • 系统定义

    既然NixOS是声明式管理的系统,显然首先需要”声明/定义”一个系统,即用代码描述我们的操作系统。

  • 系统安装

    选择一种安装模式,将我们定义的系统安装到机器(磁盘)上。

  • 系统维护

    安装好系统后,日常使用需要更新时,只需要维护好咱们的系统定义,再将其Apply到系统中,如此重复即可。

系统定义

安装一个操作系统和一个软件包没有太大的区别:无外乎是把一堆软件/文件持久放置到某个地方,等待将来点击/开机即可使用。不过,安装操作系统大概会复杂一点:需要对硬件和系统软件两部分进行配置。硬件配置包括设置BIOS、磁盘分区等。在准备好硬件之后,则需要进行比较复杂的软件配置过程,包括安装系统内核、配置硬件驱动、安装必要的软件包、设置网络、初始化用户等等。

nix flake可以看作是一个用nix语言描述的”声明式配置包”,有了flake规范之后,我们可以用一个锁定依赖版本、可复现的”程序包”来定义和维护我们的NixOS配置。使用flake时,通过执行命令 nixos-rebuild switch --falke <flake-uri>实现更新升级NixOS系统。每次执行switch命令时,NixOS会自动为系统生成一个新配置版本,如果使用过程中发现新配置有问题,我们可以选择回滚到上一个版本的系统配置,不再有系统升级的烦恼。

准备flake仓库

在本地安装nix,注意,我们提前配置nix打开flake和新风格命令行特性:

❯ mkdir -p ~/.config/nix
❯ echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
❯ sh <(curl -L https://nixos.org/nix/install) 

新建一个git repo,可使用flake template初始化一个配置:

❯ mkdir nixos-example && cd nixos-example 
❯ nix flake init -t github:penglei/nixos-example --refresh
❯ git init && git add .
❯ git comimit -m "mynixos configuration"
❯ cat flake.nix
{
  description = "nixos configuration example";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };
  outputs = { nixpkgs, flake-utils, ... }:
    let
      username = "penglei";
      hostname = "mynixos";
    in {
      packages = flake-utils.lib.eachDefaultSystemMap (system: {
        nixosConfigurations."${hostname} " = nixpkgs.lib.nixosSystem {
          inherit system;
          specialArgs = { inherit nixpkgs username hostname; };
          modules = [
            ./configuration.nix
            ./hardware.nix # should match target machine
          ];
        };
      });

    };
}
❯
❯ cat configuration.nix hardware.nix
{ }
{ }

这是一个简单的flake骨架,首先我们通过 nixosConfigurations."${hostname}"申明了一个nixosSystem。然后,通过modules引入这个系统的配置模块。这个示例中引入了 configuration.nixhardware.nix 两个模块,模块(module) 是NixOS非常重要的特性,一方面它将不同软件的配置组织在内聚的模块中,另一方面通过 module 系统,可以在不同配置之间建立依赖关系,实现配置复用和统一。在这个例子中,当前它们的内容都是一个空模块({ }),我们接下来将其逐步完善。

基础配置

将如下内容填写到 configuration.nix 配置模块中:

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
{ pkgs, username, ... }:

{
  system.stateVersion = "23.05";

  time.timeZone = "Asia/Shanghai";

  users.users.${username} = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    ];
  };

  security.sudo.extraRules = [{
    users = [ username ];
    commands = [{
      command = "ALL";
      options = [ "NOPASSWD" "SETENV" ];
    }];
  }];

  services.openssh.enable = true;

  environment.systemPackages = with pkgs; [ git vim curl pstree htop tree ];
}

这个模块包含多个部份:

  • 第6行声明了系统的时区;
  • 第8至14行声明了这个系统中的一个用户。这个用户的用户名是通过函数参数username 传进来的。我们将其加到了 wheel 组中,并且给他配置了一个ssh的登录密钥(这里需要替换成自己的公钥)。第16至23给用户配置了sudo权限,方便我们安装好系统后切换到root用户进行管理。
  • 第24打开了openssh,这样系统安装好后我们才方便通过网络登录;
  • 第26行在系统中声明安装一些常用的软件包;

准备磁盘

前面的基础配置只是定义了一些最基本的软件,但要将NixOS安装到自己的机器上,还需要让NixOS的配置感知到磁盘信息,这样它才知道如何安装引导程序、挂载rootfs等。

为了让我们的配置尽量通用,我们可以给磁盘打上label,然后在配置中通过label来引用,这样即使我们以后更换了机器,只需要重新初始化硬件,配置好disk label即可,而不用去修改仓库中的NixOS配置。

下面是不同的文件系统打label的一些方式

  • 传统 ext2/3/4 类型的分区使用 e2label 修改label

    # e2label /dev/vda1 nixos
  • vfat(fat16) 类型的分区使用 dosfslabel ,一般使用efi启动的系统都会有这样的分区

    # apt install dosfstools
    # dosfslabel /dev/nvme0n1p1 boot
  • btrfs 使用 btrfs filesystem label <device/mountpoint> <label>

注意: 由于我们还没有安装好NixOS,准备磁盘这些操作可以临时启动一个Live系统完成,如果可行的话,也可以将磁盘挂载到其它机器上进行操作。

配置硬件(磁盘和网络)

准备好磁盘后,我们需要在NixOS的配置中定义文件系统和启动方式等。同时,我们还需要定义网络配置,才能比较方便用ssh登录系统,下面我们在hardware.nix中对硬件部份进行配置:

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
{ hostname, ... }: {
  boot = {
    initrd.availableKernelModules = [ "nvme" "xhci_pci" ];
    loader = {
      timeout = 1;
      systemd-boot.enable = true;
      efi.efiSysMountPoint = "/boot";
      efi.canTouchEfiVariables = true;
    };
  };

  fileSystems."/boot" = {
    device = "/dev/nvme0n1p1";
    fsType = "vfat";
    options = [ "fmask=0077" "dmask=0077" ];
  };
  fileSystems."/" = {
    device = "/dev/disk/by-label/nixos";
    fsType = "ext4";
  };

  networking = {
    #使用 dhcpcd, resolvconf 管理网络配置,因此打开这两个配置。
    dhcpcd.enable = true;
    resolvconf.enable = true;

    useDHCP = true;
    hostName = hostname;
  };
}

硬件部份配置主要是三部份:

  • bootloader

    在这里我使用的是efi引导而不是传统的bios,因此在第6行打开systemd-boot。在这里我们可能需要特别注意内核模块的配置,如果我们的机器用的是磁盘阵列(raid),NixOS系统也安装到这样的存储中,则可能需要加载想要的kernel module才能正确识别磁盘,进而找到文件系统,运行stage2启动1号进程。比如我的R630机器就需要 megaraid_sas 内核模块,需要将其添加到 availableKernelModules 配置中。

  • 文件系统

    12~20行,注意18行的device路径是label的形式

  • 网络

    22行至结束,主要是用DHCP配置网络,以及启用resolvconf来维护DNS配置

很多时候我们都不知道机器有什么硬件配置,如果我们能在机器上运行nix(无论是iso live还是已经存在的其它发行版),则可以使用工具 nixos-install-tools 自动生成nixos系统配置和硬件配置。下面的命令在目录 /mnt/etc/nixos 中生成系统配置,可以将其中的特殊配置拷贝到我们flake repo的configuration中。

❯ nix shell nixpkgs#nixos-install-tools
❯ sudo `which nixos-generate-config` --root /mnt
❯ ls /mnt/etc/nixos
configuration.nix  hardware-configuration.nix

配置密码登录

安装好NixOS之后如果网络没有配置好,我们就无法通过ssh登录。这时候通过控制台密码登录是很有必要的。

密码登录的验证信息配置在 /etc/shadow 文件中,它的格式类似下面:

user:$y$.n.:17736:0:99999:7:::
[--] [----] [---] - [---] ----[-]
 │      │     │   │   │   │││  └───────────► 9. Unused
 │      │     │   │   │   ││└──────────────► 8. Account Expiration date
 │      │     │   │   │   │└───────────────► 7. Inactivity period
 │      │     │   │   │   └────────────────► 6. Warning period
 │      │     │   │   └────────────────────► 5. Maximum password age
 │      │     │   └────────────────────────► 4. Minimum password age
 │      │     └────────────────────────────► 3. Last password change
 │      └──────────────────────────────────► 2. Encrypted Password
 └─────────────────────────────────────────► 1. Username


Fields description:

1. Username, same as in /etc/passwd.
2. MCF(Modular Crypt Format), see [Wiki](https://en.wikipedia.org/wiki/Crypt_(C)#Key_derivation_functions_supported_by_crypt).
3. When changed the password? (the number of days since 1970-01-01)
4. Password can't be modified within the min age days. (Relative to field 3)
5. Password must be modified after the max age days. (Relative to field 3)
6. Days prior to password expiration for notification.
7. The number of grace days after the password expires, and you can still log in during the grace period.
8. When will the account expire, and even if the password has not expired, you will be unable to log in after expiration.
9. Unused reserved extension field

每行记录一个用户的登录配置信息。这些信息总共包含9个字段,每个字段由冒号:进行分割。其中最重要的是前面两个字段,即用户名以及密码。下面的例子是配置root用户的密码,其它字段留空。

root:$y$j9T$JIC4Lr.iMSUQwWfRqQT8I/$BUBtIM0x9xGEk7r65AoWUKr19vMqRd77Cvg3AhYo28B:20013::::::

第二个字段是MCF格式的密码哈希值。它用美元符号$分割出多个字段,其中第一个字段(id)表示hash算法,后面的字段表示具体的配置和密码hash值,不同的hash算法会定义自己的字段格式。通常其格式如下所示:

$<id>[$<param>=<value>(,<param>=<value>)*][$<salt>[$<hash>]]

id: an identifier representing the hashing algorithm (such as 1 for MD5, 5 for SHA-256 etc.)
param name and its value: hash complexity parameters, like rounds/iterations count
salt: salt following the radix-64 alphabet (DES uses the decoded value)
hash: radix-64 encoded result of hashing the password and salt

常见的hash id :

$1$  : MD5
$2a$ : Blowfish
$2y$ : Eksblowfish
$5$  : SHA-256
$6$  : SHA-512
$y$  : yescrypt, see https://en.wikipedia.org/wiki/Yescrypt https://en.wikipedia.org/wiki/Scrypt

如要使密码失效,通常习惯在第MCF前面增加叹号 ! 。如果想让某个用户不能使用密码登录,则常常用!代替整个MCF,如nobody:!:1::::::

如果我们要手动生成一个密码,比较简单的方式是用openssl工具,例如下面生成一个hash算法为SHA-512,密码为 123456 的MCF。

❯ openssl passwd -6 --salt tlas 123456
$6$tlas$bwAx2Uxe2FPdQnrOaXZv.jsxia5mD14Db3Ymdbk2MAZomqIaT1YJgeWeQZQGnDxSf/YjjRGJ8SfwzvdakPMpr1

补齐用户名(root)和其它字段,写入/etc/shadow文件,root用户便可使用密码123456登录认证。

root:$6$tlas$bwAx2Uxe2FPdQnrOaXZv.jsxia5mD14Db3Ymdbk2MAZomqIaT1YJgeWeQZQGnDxSf/YjjRGJ8SfwzvdakPMpr1:1::::::

执行安装

有了NixOS的完整定义,我们便可以将其安装到系统中。最方便的安装方式是 将一个已经运行的Linux发行版替换为NixOS

准备:

  • 目标机器上运行着一个Linux系统,比如常见的ubuntu、debian、arch等。

    已有系统需要有root权限,并且Linux内核版本不能太低。我曾使用3.10内核版本的CentOS,发现user namespace无法工作,导致nix安装不上。

  • 现有机器位于一个网络质量比较好环境中

    安装NixOS的过程需要下载大量的包,网络环境直接影响安装体验。

首先在已有的Linux系统上准备好支持flake的nix的环境。我采用在root用户下安装单用户使用的nix环境:

# mkdir /etc/nix
# cat <<EOF > /etc/nix/nix.conf
experimental-features = nix-command flakes
build-users-group =
EOF
# sh <(curl -L https://nixos.org/nix/install) --no-daemon

lustrate

NixOS boot支持一种延迟安装模式,特别适用于不需要重新调整磁盘分区的情况下直接替换当前的系统。

  1. 构建系统

    假设我们把flake nixos配置存放在github上,通过如下命令先构建系统:

     # git clone github:penglei/nixos-example
     cd nixos-example
     nix profile install --profile /nix/var/nix/profiles/system .#nixosConfigurations."mynixos".config.system.build.toplevel

    Tips

    • 多次执行命令会提醒profile中已经存在该package。使用执行如下命令进行删除:

      # nix profile remove toplevel --profile /nix/var/nix/profiles/system
    • 如果一直调整配置,构建出系统的多个版本,占用当前系统过多的存储空间,可使用如下命令进行清理清理:

      # nix profile wipe-history --profile /nix/var/nix/profiles/system
  2. 标记重启安装

    前面准备好了系统文件,但是NixOS还无法启动,比如当前的 /etc 文件夹还是已有系统的配置,不是NixOS通过配置文件生成。因此,我们需要告诉NixOS的boot阶段执行 lustrate 完成最后的安装动作:通过创建 /etc/NIXOS 文件给boot阶段一个提示。

    cat <<EOF | sh
      chown -R 0:0 /nix
      touch /etc/NIXOS
      echo etc/nixos >> /etc/NIXOS_LUSTRATE
    EOF
    

    执行完上面的动作,我们便可以重启机器,系统将会进入新安装的NixOS系统。

  3. 执行switch boot

    # mkdir -p /run/current-system/sw/bin
    # NIXOS_INSTALL_BOOTLOADER=1 /nix/var/nix/profiles/system/bin/switch-to-configuration boot

    环境变量 NIXOS_INSTALL_BOOTLOADER 表示我们要在指定的磁盘上安装boot loader。boot loader有两种选择,grub 或者 systemd-boot

kexec

如果我们要格式化磁盘,则需要进入一个临时系统,才能卸载掉磁盘,然后对其进行格式化操作。传统发行版通常在iso镜像中提供一个isolinux系统和installer程序(NixOS也有这种方式),安装系统时先通过光驱或者网络或者USB设备启动这个临时系统,执行安装程序,实现配置硬件如磁盘分区等动作。

这中方式总是比较麻烦,它通常需要我们直接接触硬件(否则我们不方面进入installer)。进入这些installer具有非常明显的Imperative风格,配置BIOS或网络等等,显得和NixOS格格不如。

从运行时看来,操作系统(内核)不过是内存中的一段运行着的程序,如果我们已经有Linux系统了,那其实可以把自己替换掉:在内存中开一个临时文件系统,将操作系统保存到这个临时文件系统中,然后加载运行一个新的内核,重新从头执行运行1号进程的程序,跳过硬件完全运行一个临时的新的系统。这就是kexec的作用。

nixos-kexec 封装了运行临时系统的initrd,可以非常方便地拉起一个临时的纯内存NixOS系统。在拉起临时系统前,我们还可以为其申明网络配置、账户、密码、SSH公钥等,这让我们在这个临时系统启动后,可过当前网络进入这个临时系统,进而执行前面的安装流程!

另外,NixOS社区提供了一个申明式工具(disko)来实现划分磁盘,这里我提供了一个的示例

一些安装探索

NixOS iso-image 系统启动流程

现代Linux系统的启动比较灵活,它可以粗略地分为两个阶段:

  1. 阶段1: bootloader 加载 Image(内核映像)和initrd,将initrd加载到ramfs得到一个内存文件系统,然后执行 /init。从内核角度看,一旦开始执行 /init 程序,就意味着内核已经正常初始化完毕,操作系统的初始化将交给用户态程序 /init。内核以pid 1 执行 /init/init 程序的关键工作是为阶段2准备好rootfs文件系统,为阶段2启动最终的系统做好准备。

  2. 阶段2: 阶段1的 /init 准备好基本信息后,将执行阶段2的初始化流程:切换到真实使用的rootfs并启动系统服务。大多数Linux发行版使用Systemd作为服务管理进程,一些发行版会使用Systemd作为1号进程,也就是阶段2最终会执行新的rootfs下的Systemd程序:exec systemd $@ 。至此,我们通常就得到了一个可登录的系统。

    debian使用 klibcrun_init作为阶段2的包装程序以简化阶段2的开发,这个程序将切换到到新的rootfs,执行一些清理工作,回调传入的阶段2初始化程序:

    exec run-init ${drop_caps} "${rootmnt}" "${init}" "$@" <"${rootmnt}/dev/console" >"${rootmnt}/dev/console" 2>&1

    一些社区认为Systemd太复杂,难以审计,进而导致系统安全性降低。debian使用 /sbin/init (SysV Init)作为1号进程。

NixOS使用Systemd作为1号进程和服务管理工具。它的阶段1和阶段2都是使用bash脚本实现,阶段2最终会启动执行Systemd。NixOS如何为准备stage2使用的rootfs? 它首先创建 /mnt-root 作为stage2的根目录(下表中使用@R表示),然后在这个目录中进行初始化,挂载设备。NixOS通过nix 生成 stage-1 启动脚本,对于iso-image(安装盘)的启动过程,它会逐步挂载如下的文件/设备。

order mountPoint device fsType options
1 @R/ tmpfs tmpfs x-initrd.mount,mode=0755
2 @R/iso /dev/root auto x-initrd.mount
3 @R/nix/.ro-store @R/iso/nix-store.squashfs squashfs x-initrd.mount,loop
4 @R/nix/.rw-store tmpfs tmpfs -initrd.mount,mode=0755
5 @R/nix/store overlay overlay x-initrd.mount,lowerdir=/nix/.ro-store,upperdir=/nix/.rw-store/store,workdir=/nix/.rw-store/work

每一步的挂在可能会依赖前一步的挂载,例如 /nix/.ro-store 依赖 /iso,它要求这个目录中存在后续使用的 nix-store.squashfs 文件。device的路径是相对于 /mnt-root 的,也就是说,device /iso/nix-store.squashfs 的实际路径是 /mnt-root/iso/nix-store.squashfs

我一台日常使用NixOS的只有一个磁盘分区,这个过程就简单得多,它只有rootfs的挂载:

order mountPoint device fsType options
1 @R/ /dev/disk/by-label/nixos ext4 x-initrd.mount

/mnt-root 准备好之后,NixOS使用busybox的switch_root切换到新的rootfs,执行stage2的初始化,最终启动Systemd。

已有的系统中进入copytoram模式

有时候我们想重新格式化磁盘,驱逐安装的模式很难这样做,因为某个分区已经被挂载到rootfs,我们无法将其卸载再调整分区。这时候我们只能使用传统方式进行安装:先进入live installer系统对磁盘执行分区,再安装系统。

NixOS的iso installer提供了一种copytoram模式,可以完全在内存中启动iso上的NixOS系统。这是通过将iso镜像中的内容拷贝到tmpfs中实现的:如果配置的mountPoint为 $R/iso 以及 device 为 /dev/root是,stage1不会挂载iso,而是将整个iso进行copy:首先将/dev/root 挂载到 /tmp-iso 目录,然后拷贝到 /mnt-root/iso 目录中。拷贝完成后就会释放(umount) /dev/root

menuentry nixos-installer-iso {
  set isopath="/nixos.iso"
  set stage2init="/nix/store/bxqbp6l1vd3dmima6slfdx8176lindf8-nixos-system-nixos-24.05.5667.a3f9ad65a0bf/init"

  #search --label nixos --set=rootpart
  search --fs-uuid --set=rootpart b54d0269-01ce-4956-a4e9-6cccd2ea5e35
  loopback isoloop ($rootpart)$isopath

  set image=Image #shoule be "bzImage" if arch is x86_64
  linux (isoloop)/boot/$image findiso=$isopath copytoram init=$stage2init boot.shell_on_fail loglevel=4
  initrd (isoloop)/boot/initrd
}

nixos-rebuild switch –install-bootloader –flake .

编辑initrd

 mount /nixos.iso /mnt
 mkdir ~/edit-initrd && cd ~/edit-initrd
 cp /mnt/boot/initrd initrd.zst
 umount /mnt
 unzstd initrd.zst
 mkdir initrd-root && cd initrd-root
 cpio -imdv < ../initrd
 find | cpio -o -H newc > ../initrd-new
 zstd ../initrd-new

错误记录

  • 安装nix时提示build-users-group不存在

    error: the group ‘nixbld’ specified in ‘build-users-group’ does not exist

    解决方案:禁用 build-users-group。在 /etc/nix/nix.conf 中配置 build-users-group =

拷贝构建

如果安装NixOS系统的机器性能比较差,可以在一台高性能机器上构建系统,再拷贝到目标机器上:

# nix copy --to ssh://192.168.101.1 $(realpath ./result)

或在目标机器从构建机器进行拷贝:

# ssh 192.168.101.1
# nix copy --from ssh://192.168.101.100 /nix/store/22130zprm2nqhz6jwlpk3v226saj5yzg-nixos-system-ganger-25.05.20250319.a84ebe2 --no-check-sigs

拷贝完成之后,切换系统:

# ssh root@192.168.101.1 $(realpath ./result)/bin/switch-to-configuration switch