How I like to install NixOS (declaratively)

Michael Stapelberg

我喜欢如何安装 NixOS(声明式)

在我其中一次网络存储 PC 搭建中,我在寻找 Flatcar Container Linux 的替代品,于是时隔近 10 年再次尝试了 NixOS。安装 NixOS 的方法有很多种,在本文中我将概述我喜欢如何在物理硬件或虚拟机上安装 NixOS:通过网络进行,并且完全声明式。

简介:什么是声明式?

声明式(declarative)这个术语的意思是你描述要达成什么,而不是如何达成。对 NixOS 而言,这意味着你声明希望系统包含哪些软件(添加到配置选项 environment.systemPackages 中,或启用某个模块),而不是运行 apt install 之类的命令。

声明式方式的一个很好的特性是:系统会遵循你的配置,因此通过回退一次配置更改,你也能干净地回退系统上的相应更改。

我喜欢在版本控制(通常是 Git)下管理声明式配置文件。

当我最初搭建当前的网络存储设备时,我选择了 CoreOS(后来的 Flatcar Container Linux),因为它是一个带有声明式 cloud-init 配置的自动更新基础系统。

安装 NixOS 的各种方式

图形化安装器:仅适用于桌面

NixOS 手册的“Installation”章节描述了一个图形化安装器(“面向桌面用户”,基于 Calamares 系统安装器,于 2022 年加入)和一个手动安装器。

使用图形化安装器,把 NixOS 安装到磁盘很容易:只需多次确认默认选项,你就会得到一个可用的系统。但它也有一些缺点:

  • 安装完成后你需要手动启用 SSH——只能在本地操作,不能通过网络。
  • 图形化安装器会为你生成一份初始 NixOS 配置,但没有办法注入你自己的初始 NixOS 配置。

图形化安装器显然不是为远程安装或自动化安装设计的。

手动安装

而手动安装器对我的口味来说又太手动了:展开 NixOS 手册的 Installation summary 章节中的“Example 2”和“Example 3”感受一下。需要说明的是,这些步骤完全可行,但我不想在匆忙中以这种方式安装系统。一方面,手动流程在压力下容易出错;另一方面,交互式地复制粘贴命令恰恰是编写声明式配置文件的反面。

网络安装:nixos-anywhere

理想情况下,我希望大部分安装工作都能在自己舒适的 PC 上完成,也就是说安装器必须可以通过网络使用。此外,我希望机器在安装完成后立即带着一份可用的初始 NixOS 配置启动(无需任何手动步骤!)。

幸运的是,有一个(社区提供的)解决方案:nixos-anywhere。你负责启动一个 NixOS 安装器,然后运行一条命令,nixos-anywhere 就会通过 SSH 连入该安装器,为你的磁盘分区并把 NixOS 安装到磁盘上。值得注意的是,nixos-anywhere 是声明式配置的,因此你可以随时重复这一步骤。

(我知道 nixos-anywhere 甚至可以通过 SSH 连入任意系统并用 kexec 将其重启进一个 NixOS 安装器,这无疑是一个很酷的派对技巧,但我更喜欢显式启动安装器的方式,因为在我看来它风险更小、更通用、也更容易重复。)

准备工作:安装 Nix

我想在我的某台机器上使用 NixOS,但(目前)不在我的主力桌面 PC 上使用。

因此,我只在 Arch Linux 上安装了 nix 工具(即使不运行 NixOS 也可以用于构建):

% sudo pacman -S nix
% sudo groupadd -r nixbld
% for n in $(seq 1 24); do sudo useradd -c "Nix build user $n" \
    -d /var/empty -g nixbld -G nixbld -M -N -r -s "$(which nologin)" \
    nixbld$n; done
% sudo systemctl enable --now nix-daemon.socket

现在,运行 nix-shell -p hello 应该会让你进入一个新 shell,其中安装了 GNU hello 包:

% export NIX_PATH=nixpkgs=channel:nixos-25.05
% nix-shell -p hello
hello

[nix-shell:/tmp]$ hello
Hello, world!

顺便一提,Arch Linux wiki 上的 Nix 页面介绍了如何使用 nix 安装软件包,但这不是我感兴趣的:我只想远程管理 NixOS 系统。

构建你自己的安装器

前面我说过“你负责启动一个 NixOS 安装器”,这很简单:把 ISO 镜像写入 U 盘并从它启动你的机器(或者选择该 ISO 并启动你的虚拟机)。

但在我们能够通过 SSH 远程登录之前,我们需要手动设置一个密码。我还需要带上 TERM=xterm 环境变量进行 SSH 连接,因为 rxvt-unicode(我偏好的终端)的 termcap 文件不包含在默认的 NixOS 安装器环境中。同样,我配置的 locale 无法工作,我偏好的 shell(Zsh)也不可用。

如果安装器预先配置好了便利的环境,岂不是好得多?

对于其他 Linux 发行版,比如 Debian、Fedora 或 Arch Linux,我不会尝试重新构建官方的安装器 ISO 镜像。我相信它们的流程和工具运行良好,但我也确信那是我需要学习、调试和维护的又一件额外事务。

但构建一个 NixOS 安装器与配置一个普通的 NixOS 系统非常相似:相同的配置,相同的构建工具。具体流程记录在官方 NixOS wiki 中。

我把我通常会放进 configuration.nix 的定制内容复制过来,从 nixpkgs 导入了 installation-cd-minimal.nix 模块,并把结果放入 iso.nix 文件:

{ config, pkgs, ... }:

{
  imports = [
    <nixpkgs/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix>
    <nixpkgs/nixos/modules/installer/cd-dvd/channel.nix>
  ];

  i18n.supportedLocales = [
    "en_DK.UTF-8/UTF-8"
    "de_DE.UTF-8/UTF-8"
    "de_CH.UTF-8/UTF-8"
    "en_US.UTF-8/UTF-8"
  ];
  i18n.defaultLocale = "en_US.UTF-8";

  security.sudo.wheelNeedsPassword = false;
  users.users.michael = {
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
    ];

    isNormalUser = true;
    description = "Michael Stapelberg";
    extraGroups = [ "wheel" ];
    initialPassword = "SGZ3odMZIesxTuh2Y2pUaJA";  # random for this post
    shell = pkgs.zsh;
    packages = with pkgs; [];
  };

  environment.systemPackages = with pkgs; [
    git  # for checking out github.com/stapelberg/configfiles
    rsync
    zsh
    vim
    emacs
    wget
    curl
    rxvt-unicode  # for terminfo
    lshw
  ];

  programs.zsh.enable = true;
  services.openssh.enable = true;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "25.05"; # Did you read the comment?
}

要构建 ISO 镜像,我设置 NIX_PATH 环境变量,让 nix-build(1) 指向 iso.nix 文件,并选择 NixOS 25.05 的上游频道:

% export NIX_PATH=nixos-config=$PWD/iso.nix:nixpkgs=channel:nixos-25.05
% nix-build '<nixpkgs/nixos>' -A config.system.build.isoImage

在我的 2025 高端 Linux PC 上大约 1.5 分钟后,安装器 ISO 就可以在 result/iso/nixos-minimal-25.05.802216.55d1f923c480-x86_64-linux.iso 找到(在我的情况下大小为 1.46 GB)。

启用 Nix Flakes

遗憾的是,nix 项目尽管已经推出 5 年多,仍未设法默认启用“实验性”新命令行界面(CLI),因此我们需要创建一个配置文件并启用现代的 nix-command 接口:

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

如何区分新旧命令?旧命令用连字符(nix-build),新命令用空格分隔(nix build)。

你会注意到我还启用了 Nix flakes,我用它来确保我的 nix 构建是密封的(hermetic),并固定到 nixpkgs 以及我想包含在构建中的任何其他 nix 模块的某个特定版本。我喜欢把 flakes 类比为其他编程环境中的版本锁定文件:其理念是,5 个月后构建系统会得到与今天相同的结果。

要验证 flakes 是否正常工作,运行 nix shell(不是 nix-shell):

% nix shell nixpkgs#hello
/tmp 2 % hello
Hello, world!

(重新)安装步骤

供参考,以下是我用来在 Proxmox 中创建新 NixOS 虚拟机的配置。最重要的设置是 bios=ovmf(即 UEFI 启动,这不是默认值),这样我在物理机和虚拟机上可以使用相同的引导加载器配置:

proxmox 虚拟机创建对话框截图

在我们启动(未签名的)安装器之前,需要进入 UEFI 设置并禁用 Secure Boot。例如,Proxmox 默认就启用了 Secure Boot。

然后,在目标系统上启动自定义安装器 ISO,并确保 ssh [email protected] 无需输入密码即可工作。

声明一个包含以下内容的 flake.nix

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";

    disko.url = "github:nix-community/disko";
    # Use the same version as nixpkgs
    disko.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs =
    {
      nixpkgs,
      disko,
      ...
    }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = false;
      };
    in
    {
      nixosConfigurations.zammadn = nixpkgs.lib.nixosSystem {
        inherit system;
        inherit pkgs;
        modules = [
          disko.nixosModules.disko
          ./configuration.nix
        ];
      };
      formatter.${system} = pkgs.nixfmt-tree;
    };
}

disk-config.nix 中声明你的磁盘配置:

disk-config.nix
{ lib, ... }:

{
  disko.devices = {
    disk = {
      main = {
        device = lib.mkDefault "/dev/sda";
        type = "disk";
        content = {
          type = "gpt";
          partitions = {
            ESP = {
              type = "EF00";
              size = "500M";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                mountOptions = [ "umask=0077" ];
              };
            };
            root = {
              size = "100%";
              content = {
                type = "filesystem";
                format = "ext4";
                mountpoint = "/";
              };
            };
          };
        };
      };
    };
  };
}

configuration.nix 中声明你想要的 NixOS 配置:

{ modulesPath, lib, pkgs, ... }:

{
  imports =
    [
      (modulesPath + "/installer/scan/not-detected.nix")
      ./hardware-configuration.nix
      ./disk-config.nix
    ];

  # Adding michael as trusted user means
  # we can upgrade the system via SSH (see Makefile).
  nix.settings.trusted-users = [ "michael" "root" ];
  # Clean the Nix store every week.
  nix.gc = {
    automatic = true;
    dates = "weekly";
    options = "--delete-older-than 7d";
  };

  boot.loader.systemd-boot = {
    enable = true;
    configurationLimit = 10;
  };
  boot.loader.efi.canTouchEfiVariables = true;

  networking.hostName = "zammadn";
  time.timeZone = "Europe/Zurich";

  # Use systemd for networking
  services.resolved.enable = true;
  networking.useDHCP = false;
  systemd.network.enable = true;

  systemd.network.networks."10-e" = {
    matchConfig.Name = "e*";  # enp9s0 (10G) or enp8s0 (1G)
    networkConfig = {
      IPv6AcceptRA = true;
      DHCP = "yes";
    };
  };

  i18n.supportedLocales = [
    "en_DK.UTF-8/UTF-8"
    "de_DE.UTF-8/UTF-8"
    "de_CH.UTF-8/UTF-8"
    "en_US.UTF-8/UTF-8"
  ];
  i18n.defaultLocale = "en_US.UTF-8";

  users.mutableUsers = false;
  security.sudo.wheelNeedsPassword = false;
  users.users.michael = {
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
    ];

    isNormalUser = true;
    description = "Michael Stapelberg";
    extraGroups = [ "networkmanager" "wheel" ];
    initialPassword = "install";  # TODO: change!
    shell = pkgs.zsh;
    packages = with pkgs; [];
  };

  environment.systemPackages = with pkgs; [
    git  # for checking out github.com/stapelberg/configfiles
    rsync
    zsh
    vim
    emacs
    wget
    curl
  ];

  programs.zsh.enable = true;

  services.openssh.enable = true;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "25.05"; # Did you read the comment?
}

……然后锁定它:

% nix flake lock
  1. 使用 nixos-anywhere,从安装器获取 hardware-configuration.nix 并把 NixOS 安装到磁盘:
% nix run github:nix-community/nixos-anywhere -- \
  --flake .#zammadn \
  --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \
  --target-host [email protected]

大约一分钟后,我的虚拟机就安装完成并重启了!

完整的 nixos-anywhere 安装过程记录,如果你好奇的话
% nix run github:nix-community/nixos-anywhere -- \\      
  --flake .#wiki \\                                                                 
  --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \\
  --target-host [email protected]                                               
[... transcript truncated for brevity but contains full nixos-anywhere log ...]
### Installing NixOS ###
installing the boot loader...
setting up /etc...
Created "/boot/EFI".
installation finished!
### Rebooting ###
### Done! ###

安装后步骤

现在系统的声明式部分已经就绪,我们还需要处理有状态的部分。

在我的情况下,唯一需要设置的有状态部分就是 Tailscale mesh VPN。

要设置 Tailscale,我通过 SSH 登录并运行 sudo tailscale up。然后,我按照链接把新节点添加到我的网络。之后,在 Tailscale Machines 控制台中,我禁用密钥过期并添加 ACL 标签。

进行更改

现在,当我在配置文件中做了更改后,我会使用 nixos-rebuild 远程把更改部署到我的 NixOS 系统:

% nix run nixpkgs#nixos-rebuild -- \
  --target-host michael@zammadn \
  --use-remote-sudo \
  switch \
  --flake .#zammadn

请注意,并非所有更改都会在 nixos-rebuild switch 中完全生效:虽然 systemd 服务通常会被重启,但新需要的内核模块不会被自动加载(例如在 Frigate 中启用 edgetpu Coral 硬件加速器之后)。

因此,为了确保一切生效,请在部署更改后 reboot 你的系统。

NixOS 的一个优点是,在启动菜单中,你可以选择要运行系统的哪个代(generation)。如果最新的更改破坏了什么,你可以快速重启进入上一个代来撤销该更改。当然,你也可以撤销配置更改并部署一个新代——哪种方式在当时更方便就用哪种。

结语

通过本文,我希望能够传达出我希望在我开始使用 Nix 和 NixOS 时就有人告诉我的内容:

  1. 启用 flakes 和新 CLI。
  2. 使用 nixos-anywhere 进行远程安装。
    • 如果你愿意,可以构建一个自定义安装器,这很容易!
  3. 使用 nixos-rebuild 内置的 --target-host 参数进行远程部署。

接下来该去哪里?

原文由 Michael Stapelberg 发布

本文章由 stealth/ox-alpha 进行翻译