跳过正文

NixOS(二):核心概念——声明式配置、flake与回滚

·3230 字·7 分钟
锦李本鲤
作者
锦李本鲤
光锥之内,皆为命运。
NixOS系列 - 这篇文章属于一个选集。
§ 2: 本文

缘起
#

上一篇说了"为什么现在重新选择NixOS",这一篇回答一个更基础的问题:NixOS到底是怎么工作的?如果你从没用过NixOS,但熟悉Fedora这类传统发行版,这篇会帮你建立正确的心智模型。理解之后,后面讲仓库架构、AI维护流程的文章就好读多了。

我会把概念分成五块来讲:声明式配置/nix/store的内容寻址nixpkgsflake与flake.lock代次与回滚。每块都会对照Fedora的做法说明区别。

前置知识
#

  • 熟悉基本的Linux操作:装软件(dnf)、改配置文件(/etc下的文件)、重启服务
  • 不需要任何Nix语言基础,本文会用最小示例

声明式配置:描述"要什么"而不是"怎么做"
#

传统发行版是**命令式(imperative)**的。想让一台机器跑Tailscale,在Fedora上你要:安装包、编辑/etc下的配置文件、确认防火墙放行端口、启用并启动systemd服务。每一步都是一条"动作",系统最终的形态是你执行过哪些动作的历史累积结果。几年后回头看,你很难说清这台机器"现在到底是什么状态"——它取决于所有历史操作的叠加,而且这些操作大多没有留痕。

NixOS是声明式(declarative)的:你用一份配置描述"系统最终应该处于什么状态",而不是"如何到达那个状态"。NixOS的配置是一个属性集(attribute set),而不是一串命令。例如:

1
services.tailscale.enable = true;

这一行的意思是"系统里应当包含并启用Tailscale服务"。至于需要哪些包、什么配置文件、哪个systemd单元、防火墙要不要放行——这些是NixOS在构建时自动推导出来的,你不需要写。再比如:

1
networking.firewall.allowedTCPPorts = [ 22 ];

意思是"防火墙应当放行22端口"。NixOS会据此生成对应的nftables规则。

这种方式的直接后果是:整个系统的状态可以由一份(或少数几份)文件完整描述。把这份文件放进Git,你就拥有了系统的"单一事实来源(single source of truth)"。新机器上克隆仓库、执行一次构建,就能得到和旧机器完全一致的系统——这正是"可复现"的含义。

/nix/store:内容寻址的不可变存储
#

NixOS装的所有东西都在/nix/store里。这里的每个路径长这样:

1
/nix/store/1rkkwlqg6ym07qmwyb6jw30z553xj96r-nixos-system-surf-26.05.20260804.04607e1

路径开头的哈希,是由这个软件包的构建输入(源码、依赖、构建脚本等)计算出来的。相同输入必然产生相同哈希,因此相同输入也必然产生相同产物——这就是内容寻址(content-addressing)。它带来三个性质:

  • 不可变/nix/store里的文件永远不会被原地修改。升级一个包不是"覆盖旧版本",而是"装一个新路径的包,再把系统指向新路径"。旧版本仍然在磁盘上,随时可以指回去。
  • 可共享:两台机器上由相同输入构建出的包,哈希相同,可以互相复用(甚至通过二进制缓存直接下载,不用自己编译)。
  • 无依赖地狱:每个包带着自己精确的依赖版本,多个软件依赖同一库的不同版本时互不干扰。

系统本身也是/nix/store里的一个"包"——一个叫nixos-system-<主机名>的目录。切换配置就是把这个目录的符号链接指到新的构建结果,原子地、瞬间完成

nixpkgs:巨大的软件包集合
#

nixpkgs是NixOS的软件包仓库,包含数以万计的包,每个包都是一段描述"如何从源码构建它"的Nix表达式(叫derivation)。它有几个特点值得注意:

  • 频道与分支:nixpkgs持续滚动更新。我们把它锁定到nixos-26.05这个稳定发布分支,而不是master,以获得可预期的稳定性。
  • 快照(snapshot):每次构建都会"钉住"某一时刻的nixpkgs状态。同一个配置在不同日期构建,可能因为nixpkgs变了而得到不同的包版本——除非你用flake.lock把它锁死(下一节)。

flake与flake.lock:锁定一切输入
#

flake是Nix的一组标准化机制,用来声明一个项目的输入(inputs)输出(outputs)。我们的入口文件是flake.nix。它做两件事:

  1. 声明输入——本项目依赖哪些外部仓库、各自的哪个版本:
1
2
3
4
5
6
7
inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  nixos-hardware.url = "github:NixOS/nixos-hardware/master";
  home-manager.url = "github:nix-community/home-manager/release-26.05";
  sops-nix.url = "github:Mic92/sops-nix";
  # ... 还有 oh-my-rime、lazyvim-starter、opencode 等
};
  1. 声明输出——构建出什么。对我们来说就是三台机器各自的系统配置,放在nixosConfigurations下:
1
2
3
4
5
6
nixosConfigurations = {
  surf = surfacePro6;
  "surface-pro-6" = surfacePro6;
  nixos = surfacePro6;   # 兼容旧名字
  inherit halo t460s;
};

注意--flake /path#<名字>语法里#后面的部分,就是选哪个输出。例如:

1
sudo nixos-rebuild switch --flake /home/lijin/nixos-config#surface-pro-6

这里#surface-pro-6表示用这个仓库构建Surface Pro 6那台机器的系统。

flake.lock是配套的锁定文件,记录每个输入确切的commit和哈希。它必须提交进Git。有了它,任何人在任何时间构建这个仓库,用到的nixpkgs、home-manager等依赖都是完全相同的版本——这就是跨机器、跨时间的可复现性来源。要刻意升级依赖时,运行nix flake update,它会更新flake.lock,你再审查diff、重新构建。

代次与回滚:改坏了随时退回
#

每次nixos-rebuild switch都会生成一个新的代次(generation),旧的代次都保留在磁盘上作为回滚目标。这给了NixOS一个传统发行版没有的能力:任何一次配置变更都是可逆的

  • sudo nixos-rebuild test --flake .#<主机>:临时激活新配置,仅对本次开机有效(重启后回到旧系统)。适合先试一把。
  • sudo nixos-rebuild switch --flake .#<主机>:正式激活,生成新代次并设为默认启动项。
  • sudo nixos-rebuild switch --rollback:退回上一个代次。
  • sudo nixos-rebuild list-generations:列出还存在的回滚目标。
  • 启动时也能在systemd-boot菜单里直接选旧代次启动。

配合/nix/store的不可变性,“切换系统"和"回滚系统"都是改一个符号链接级别的操作,秒级完成、不丢数据。这就是为什么我们敢在一个月里迭代出174个代次——试错成本被系统本身兜住了

注意:nix-collect-garbage --delete-older-than 7d会删除旧代次的store路径,使其不再是可用回滚目标。但我们在Git里记录的"逐代次发布说明"是历史档案,即使对应代次已被垃圾回收也继续保留(详见后文仓库架构一篇)。

这些概念如何支撑我们的三台机器
#

把上面的概念串起来,就是我们的整体做法:

机制在我们这里的作用
声明式配置三台机器的系统状态由modules/+hosts/<主机>/下的Nix文件完整描述
/nix/store每次switch原子切换,旧代次保留可回滚
nixpkgs(锁定到26.05)提供稳定、可复现的软件包来源
flake + flake.lock一个仓库管理三台机器,依赖版本锁死,跨机器一致
代次与回滚每次变更可逆,支撑高频迭代和AI大胆改配置

下一篇我们看具体怎么组织这个仓库:目录结构、共享模块和主机模块如何划分、日常变更流程长什么样,以及"逐代次发布说明"这个我们用来当历史档案的机制。

相关文章
#

NixOS系列 - 这篇文章属于一个选集。
§ 2: 本文