缘起#
上一篇说了"为什么现在重新选择NixOS",这一篇回答一个更基础的问题:NixOS到底是怎么工作的?如果你从没用过NixOS,但熟悉Fedora这类传统发行版,这篇会帮你建立正确的心智模型。理解之后,后面讲仓库架构、AI维护流程的文章就好读多了。
我会把概念分成五块来讲:声明式配置、/nix/store的内容寻址、nixpkgs、flake与flake.lock、代次与回滚。每块都会对照Fedora的做法说明区别。
前置知识#
- 熟悉基本的Linux操作:装软件(
dnf)、改配置文件(/etc下的文件)、重启服务 - 不需要任何Nix语言基础,本文会用最小示例
声明式配置:描述"要什么"而不是"怎么做"#
传统发行版是**命令式(imperative)**的。想让一台机器跑Tailscale,在Fedora上你要:安装包、编辑/etc下的配置文件、确认防火墙放行端口、启用并启动systemd服务。每一步都是一条"动作",系统最终的形态是你执行过哪些动作的历史累积结果。几年后回头看,你很难说清这台机器"现在到底是什么状态"——它取决于所有历史操作的叠加,而且这些操作大多没有留痕。
NixOS是声明式(declarative)的:你用一份配置描述"系统最终应该处于什么状态",而不是"如何到达那个状态"。NixOS的配置是一个属性集(attribute set),而不是一串命令。例如:
| |
这一行的意思是"系统里应当包含并启用Tailscale服务"。至于需要哪些包、什么配置文件、哪个systemd单元、防火墙要不要放行——这些是NixOS在构建时自动推导出来的,你不需要写。再比如:
| |
意思是"防火墙应当放行22端口"。NixOS会据此生成对应的nftables规则。
这种方式的直接后果是:整个系统的状态可以由一份(或少数几份)文件完整描述。把这份文件放进Git,你就拥有了系统的"单一事实来源(single source of truth)"。新机器上克隆仓库、执行一次构建,就能得到和旧机器完全一致的系统——这正是"可复现"的含义。
/nix/store:内容寻址的不可变存储#
NixOS装的所有东西都在/nix/store里。这里的每个路径长这样:
| |
路径开头的哈希,是由这个软件包的构建输入(源码、依赖、构建脚本等)计算出来的。相同输入必然产生相同哈希,因此相同输入也必然产生相同产物——这就是内容寻址(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。它做两件事:
- 声明输入——本项目依赖哪些外部仓库、各自的哪个版本:
| |
- 声明输出——构建出什么。对我们来说就是三台机器各自的系统配置,放在
nixosConfigurations下:
| |
注意--flake /path#<名字>语法里#后面的部分,就是选哪个输出。例如:
| |
这里#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(一):为什么现在重新选择NixOS”:本系列的缘起,为什么AI让NixOS重新变得可用
- “容器(1):容器相关知识简介——容器化、docker、docker-compose、Kubernetes / K8s等”:内容寻址和不可变存储的思想,在Docker镜像里也能看到类似的设计
