开篇:一个 458MB 的空白系统
把大把调试时间花在等待 nixos-rebuild 上,是每个 NixOS 用户的必修课。但当你拿到一个全新的 ISO——里面除了一个 root 登录的 getty 之外几乎什么都没装——却发现它占了 458MB 时,难免会想问一句:这些东西都是从哪来的?
NixOS 社区最近在 Lobsters 上展开了一场关于这个问题的讨论。起因是 Natalie Klestrup Röijezon(natkr)的一篇深度实验文章I can haz smoller NixOS ISOs?,而她原本只是想拿 NixOS 做一个可以随手扔到物理机器上跑的小镜像。没想到这一拆,意外戳到了 NixOS 架构里一个更深层的结构性问题。
458MB → 183MB:natkr 的减法实验
natkr 的实验思路并不复杂:从一个啥也没配的 ISO 配置出发,一点点去掉不需要的东西,每去掉一样就看尺寸变化。
基线配置长这样:
pkgs.nixos ({ lib, ... }: {
system.stateVersion = "26.05";
services.getty.autologinUser = "root";
imports = [ "${pkgs.path}/nixos/modules/installer/cd-dvd/iso-image.nix" ];
})
构建结果:458MB。顺便说一句,这连 vim 都没有——bash: vim: command not found。
拆开一看:内核 13MB,initrd 26MB,用户空间 squashfs 占了 416MB。一个压根没装任何应用程序的镜像,近 90% 的重量来自用户空间。
natkr 通过一系列渐进的配置改动,逐步拔掉了这些”无主”的重量级依赖:
| 改动 | 镜像大小 |
|---|---|
| 基线(纯 ISO 配置) | 458MB |
nix.enable = false + documentation.enable = false | 384MB |
砍掉 register-nix-paths 服务 | 360MB |
停用 SSH 模块(disabledModules = ["programs/ssh.nix"]) | 344MB |
| 砍 GRUB 冗余(双版本 EFI+BIOS) | 282MB |
砍内核模块目录(rm $out/kernel-modules) | 197MB |
启用 system.etc.overlay + services.userborn | 183MB |
从 458MB 到 183MB,压缩到原来的 40%,看起来是不错的成绩。但 natkr 自己在文章末尾说得很实在:“比 Alpine 的 66MB 还差得远,事情不该这么难。“
60MB UKI:phaer 的另一种路径
natkr 的文章在 Lobsters 上引发了广泛讨论。其中 phaer(也是 nixpkgs 的维护者之一)的评论把这场对话推向了新高度。phaer 提到自己的项目 nix-dabei(约 400 行 Nix),能构建一个 ~60MB(zstd 压缩)/ ~150MB(未压缩)的 UKI(Unified Kernel Image),可直接通过 kexec 热加载到正在运行的内核中,或写入 ESP 分区作为独立启动镜像。
怎么做到的?关键思路和 natkr 不同:phaer 不把 NixOS 当成一个完整的操作系统来构建,而是把它嵌入到 systemd 的 initrd 环境中。
传统 NixOS 在启动时会经历 stage-1(initrd)→ stage-2(切换根到完整系统)的两阶段流程。phaer 的方案干脆跳过 stage-2,直接让 initrd 成为整个运行环境。这意味着:
- 文件系统就是一个
tmpfs,不需要 GRUB、不需要生成 initrd 切换脚本、不需要处理一堆 systemd 单元的跨阶段依赖。 - 所有需要的工具(
nix、zfs、rsync、helix等)直接配进boot.initrd.systemd.initrdBin。 - SSH 密钥注入通过
systemd的凭据机制(Credentials),无需复杂的authorized_keys管理。
结果是 60MB 压缩/150MB 未压缩的完整、可启动、可网络访问的 NixOS 镜像,里面甚至还能跑 nix 来做现场救援。Alpine 的 ISO 大概在这个量级,但 NixOS 能做到这一点,本身就值得注意。
模块评估:真正的大象在房间里
phaer 评论中有一段话很值得细读:
“Another dimension of ‘small nixos’ that bugs me from time to time, is the fact that NixOS still goes through ~ALL nixos modules in nixpkgs to evaluate even the smallest closures which does have negative performance implications.”
翻译过来就是:哪怕你只想构建一个最小到只剩内核和 busybox 的系统,NixOS 的模块系统仍然会把 nixpkgs 里全部 1500+ 个模块加载一遍进行求值。
想想这有多反直觉。传统 Linux 发行版(Alpine、Arch、Debian)的”最小化”是包管理层面的问题——你装得越少,系统越轻。包管理器本身不会因为你只装了 10 个包就变慢 10 倍。但在 NixOS 这里,情况完全不同:模块系统的求值开销是固定的,和你的配置复杂度无关。
natkr 的文章里也无意中展示了一个侧面。他想禁用 SSH 客户端,却发现:
programs/ssh.nix没有独立的enable开关——它直接把openssh注入到environment.corePackages里。- 用
disabledModules移除它之后,Plasma 桌面、PAM 模块、一堆服务配置相继报错,因为它们都假定programs.ssh这个选项必须存在。 - 最终解决方案是手动创建一个空壳选项定义
options.programs.ssh = lib.mkOption {};来安抚依赖方。
这是 NixOS 模块系统的一个结构性特征。所有模块在求值时被揉进同一个命名空间,然后通过选项合并(config. 和 options.)进行通信。如果你拿掉一个模块,其他依赖它的模块就会「断联」——哪怕这些依赖在实际运行时根本不会被触发。
这个设计在 NixOS 的早期阶段是合理的。2010 年代,当社区只有几十个模块时,全部加载求值不是什么大问题。但今天,nixos/modules/module-list.nix 里列着超过 1500 个模块,每个 nixos-rebuild switch 都要一头扎进这个稠密的依赖网络里做一次全量遍历。
求值性能代价有多大?
phaer 在 PR #507740 中给出了具体数据。他的”最小可启动”profile 把模块数从全量 1500+ 降到了 53 个,对比结果如下:
| 指标 | 全量模块(1500+) | 最小 profile(53 个) | 比值 |
|---|---|---|---|
| CPU 时间 | 2.99s | 1.76s | 1.7× |
| 表达式数 | 1,493,514 | 302,636 | 4.9× |
| GC 总量 | 610 MB | 419 MB | 1.5× |
| 墙钟时间(5 次) | 3.006s | 1.976s | 1.52× |
表达式数量削减了几乎 5 倍,但墙钟时间只缩减了 1.5 倍。这说明即使我们不加载那些模块,Nix 解释器在花掉的时间中仍有相当一部分是不可避免的——语言本身的开销、属性集的合并、类型检查,这些都是固定成本。
不过 1.5 倍的差异在每次 nixos-rebuild 时都能感受到。如果你的 CI 跑几十个 NixOS 测试实例,这个差异会被放大到分钟级别。
通向”模块可按需加载”的多条道路
让 NixOS 支持”只加载必需模块”不是一个新鲜的想法。过去几年有多条路线在并行探索:
PR #148456:NixOS à la carte(2021,roberth)。这是最早的系统尝试。提出了一种”轻量 NixOS 核心”概念,把模块拆成可按需组合的独立单元。作者估算可以把求值性能提升 3×。但设计上需要较大范围的 API 重构,最终没有被合并。不过它奠定了”模块独立求值”的讨论基础。
PR #401751:nixosMinimal(2025,DavHau)。更务实的尝试:从 1500+ 模块中手工筛选出 185 个核心模块,形成一个”最小 toplevel 配置”。优点是入侵性极小(只是加了一个文件),缺点是维护负担——NixOS 每个 release 都在添加新模块、改变模块间依赖,这个列表需要持续维护。
PR #507740 + #507676:lib.nixos.evalModules + featureFlags.minimalModules(2026,phaer)。目前最激进的方案。引入了一个新的入口点 lib.nixos.evalModules,配合 featureFlags.minimalModules 标识,允许用户显式指定需要加载的模块集合。同时 PR #507676 对核心模块做了大量”安全跨模块读取”改造——使用 or false 回退或 options ? X 检查,确保模块在不加载全部依赖的情况下不至于报错。
phaer 在 PR 中坦诚地将 #507740 描述为”currently stalled”。原因在于跨模块依赖的清理工作非常繁琐:很多模块的 config 分支里隐含了对其他模块选项的读取,这些隐式依赖难以通过静态分析发现。
这不是一个”修复”的问题
看到这里,读者可能会想:这个问题有”修复”的一天吗?笔者的看法是:与其说它是一个 bug,不如说它是 NixOS 模块系统设计哲学的一个自然结果。
NixOS 模块系统的核心优势——所有选项在一个统一命名空间中可相互引用、类型安全、声明式合并——恰恰也是导致”全量模块加载”的原因。你不能只选一半选项参与合并,因为 A 模块的 config 可能依赖 B 模块定义的选项。Nix 语言虽然是惰性求值的,但模块系统的合并操作在构建配置 AST 时就需要知道所有参与方。
这和编程语言中的模块系统有本质区别。Rust 的 crate 或 Go 的 package 可以只编译你实际 import 的代码,因为编译单元之间有明确的接口边界。NixOS 模块的”接口”是全量共享的选项命名空间——一个模块可以从 config 根路径上的任意位置读取数据。这种灵活性的代价就是:为了求值任何一个模块,你基本上需要所有模块。
从另一个角度看,phaer 的 60MB UKI 方案绕开了这个问题——如果你不需要完整的 NixOS(包括 stage-2,systemd 启动整套流程),而是在 initrd 环境里做事情,那么模块系统加载全量模块的开销就不再是瓶颈。natkr 的 183MB 镜像也暗示了类似的结论:很多”重量”来自于 NixOS 的基础设施假设(GRUB、Nix 守护进程、文档系统、SSH),而不仅仅是包本身。
写在最后
NixOS 的最小化之旅揭示了操作系统设计中一个极有意思的权衡:声明式统一配置模型的灵活度和模块级按需求值之间,存在结构性矛盾。传统发行版的最小化是”删文件”,NixOS 的最小化是”拆依赖图”。前者是加法(装什么得什么),后者是减法(声明什么得什么,但声明本身有固定成本)。
natkr 在文章的结尾说:“I don’t know. At some point I just kept going because I got curious.” 这或许是面对这类系统级问题时最诚实的姿态。60MB 的 UKI 已经是一个可操作的里程碑了,但通往更小、更快的 NixOS 的路,可能需要我们对模块系统本身的设计假设做一些更有勇气的重新审视。
笔者的分析基于 natkr 的原文实验、phaer 的 nix-dabei 项目及相关 nixpkgs PR。文中涉及的数据均来自公开来源,如有偏差欢迎指正。