Nix/NixOS 特性评述

原文与参考

核心论点

NixOS 的卖点不是「可复现」——该诉求 Docker 与 K8s 已能覆盖,以此作为宣传点易使人不得要领。真正的差异是完整的状态解算:

  • 安装或更新系统 = 对「完整最终系统状态」求解一次方程,属函数式思维;而非「安装 / 删除什么包」的过程式思维。
  • 其它包管理器只计算版本号;Nix 将配置文件(系统级与用户级)、systemd 服务、防火墙、shell 注入、GUI 菜单注册等全部纳入求解。目标:只要系统状态有解,系统即可正常使用。
  • 每次「系统更新」都是从零完整构建依赖树与配置,再将整个系统状态树原子替换为当前结果。

关键特性

1. 操作系统 = 一个大号软件包

操作系统本质是一个依赖大量上游软件包的大型 project,定义它的过程即「引入一堆依赖并写胶水将其粘合」,思路与 Emacs 一致。为项目添加过 nix flake init 开发环境后即可看出:项目中的 flake.nix 与定义系统的 configuration.nix 是同一种东西。

2. 原子替换带来的容错

  • 凡由 Nix 管理的文件,运行时被删除均无影响;即使删除 /boot,执行一次 nixos-rebuild switch 即可恢复。
  • NixOS 可将整个 root 挂载到 tmpfs 上。
  • 系统完全无法进入时,用 U 盘启动 NixOS,再以同一份系统配置执行一次 nixos-install 即可。
  • 更新执行到一半取消无影响,因为尚未进行最后「替换系统根节点」那一步——只有最后一步是原子的,性质类似日志型文件系统。
  • 对比:Ubuntu do-release-upgrade 中途报错退出后,系统处于不稳定的中间态,重启与否均难处理,重跑亦不可。在 NixOS 中,软件包、内核 module 乃至内核本身都可随时切换:成功即可用,失败不影响现有系统。

3. flake = 完整的时间切片

  • 只要项目 flake.lock 不更新,两年后进入该项目的人会回到同一时间点,使用当时可用的软件包。
  • 示例:NodeJS 使用 opencv 时对系统 OpenCV 组件版本限制严格。锁定 flake.lock 中 Nixpkgs channel 的版本号后,将永远无法安装比当时更新的 OpenCV 及其周边生态(含显卡驱动)。
  • Nixpkgs 软件包的本体是源码、上游依赖与编译流程的定义,编译产物二进制只是副产物。即使预编译二进制被镜像站清除,只要源码仍可通过 git checkout / 下载获得并通过哈希校验,就一定能编译(使用 stable channel 时几乎必定编译通过)。
  • 对比:Arch 只能依赖 Archive,而 Archive 的存续无保证,且每个二进制体积不小。Nixpkgs 当前整个 unstable channel 仅约 35 MiB,保存历史几无成本;且 channel 本身即一个 git repo。
  • 对比:无法保证一两年后 Ubuntu focal 软件源中的 imagemagick / ffmpeg 不会升一两个小版本号,从而导致应用失效或效率下降。
  • 推论:即使最终要将应用打包为 Docker 镜像,也建议以 FROM nixos/nix 为基础,用 flake 思路安装依赖。
  • 附注:有观点认为 NixOS 是 Gentoo 的精神续作。

4. 依赖隔离,无冲突

每个项目只 link 自己的依赖,因此 asdf、rtx、virtualenv、rustup 这类工具显得多余。开发环境除打镜像外几乎不需要 Docker;用 nix flake init --template=xxx 创建的开发环境优于 Docker:

  • 「时间切片」是 Docker 无法保证的能力,属不可替代项。
  • 项目内定义的依赖得到确保的同时,个人配置(如 shell 配置)仍可保留;不像 Docker 镜像内开发始终与宿主机隔一层(通常是 ssh)。

5. 一次性环境(nix-shell)

示例:iPhone 白苹果时执行 nix shell nixpkgs#idevicerestore,进入含救砖工具的 shell 环境,处理完毕后 C-d 退出即可不再关心;两周后系统 GC 会自动将其删除。

局限

1. Nix 语言

精确地说是类型系统不足:输入 services. 后 LSP 无法补全;模块系统隐式行为过多。若有 TypeScript 到 Nix 的生成器会颇受欢迎。相比 k8s YAML 与 Dockerfile 仍好不少。

作者补充观点:Unison 的思路与 Nix 高度契合——为每个函数做哈希,正如 Nix 为每个软件包做哈希;另认为 Nix 应有条件把软件包上传到 IPFS,或至少能输出对应的 IPFS CID。

2. 只负责到服务的单点 deploy,无复杂运行时编排

  • NixOS 部署服务端无法替代 K8s,容器自动编排目前仍是 K8s 独一档。好在用 Nix 打包 Docker 容器既容易又精简:可以做到镜像里连 busybox 都没有,从而变相减小攻击面。参考 dockerTools.buildLayeredImage。
  • NixOS 仍不能完整管理 systemd,偶尔仍需手动 systemctl enable。示例见 blueman-applet.nix。

3. 每个用户都有打包义务

小到为自己的项目写 flake.nix,中到 override 现有软件包以满足自身需要,大到向社区贡献新 pkgs 定义——使用 Nix 过程中几乎无法避免广义上的「打包」。但按入门顺序练级,难度可控;接受 Nix 的哲学后理解会越来越顺畅。

4. 文档混乱

NixOS 非常需要文档(在操作系统层面引入了过多新概念),但旧世代文档与新时代(flake)文档混杂。flake 用户常搜到旧世代配置,直接贴入 nix 文件即报错,且难以排查。

5. 报错信息不精确

函数式编程过于动态,很多类元编程场景导致难以给出精确的错误原因。

与同类物的比较

dotfile 管理器

若只把 Nix 当软件包管理器用,它是 dotfile manager 的上位替代。dotfile manager 的问题:

  • 若只负责 ln 配置文件:跨机器共用同一套配置会特别死板。同一配置文件在两台机器间需改动 5% 时难以处理;能像 fish 那样把配置拆成 conf.d/*.fish 碎片的软件尚可,不能拆配置的则无从下手。新配一台机器时还需判断该 ln 哪些碎片。
  • 若具备字符串注入 / template 功能:弹性边界不明——内置哪些控制流函数、能否满足需要、能否执行 shell 命令取返回值再注入、该 shell 命令能否在两台机器都跑通。

Nix 不存在上述问题:

  • 信息源灵活:系统环境(Linux 或 darwin、x64 或 arm64)、对该软件的直接配置(如 programs.atuin.settings.sync_frequency = "5m")、其它软件的配置、其它软件的注入(大量 CLI 工具会注入 shell completion)。
  • 所有字段均可动态求值,可引用其它配置的值做注入或判断,例如:
services.gitea.repositoryRoot = "${config.services.gitea.stateDir}/repositories";

更高阶的控制流同样可行:

# home-manager/modules/manual.nix
config = {
  home.packages = mkMerge [ # 即 flatMap
    (mkIf cfg.html.enable [ docs.manual.html docs.manual.htmlOpenTool ])
    (mkIf cfg.manpages.enable [ docs.manPages ])
    (mkIf cfg.json.enable [ docs.options.json ])
  ];
};

最后,Nix 把求值后的配置「DOM」渲染为文本配置文件,打包为一个 Derivation,再用类似 OverlayFS 的方式放到应有位置。

Docker / podman 容器

由于缺少「时间切片」能力,在不同时间点对同一 Dockerfile 执行两次 docker build 可能产出两个不同镜像。除非像大公司那样严格控制上游 Base 镜像版本,否则不算特别可控;一个能用的镜像 .tar 有可能成为传家宝。

Ansible / Chef

思路接近,但未对「可复现」特别上心(例如未严格限制被部署机器中软件包的版本),仅比纯 shell 脚本在可读性与可组合性上强一些。

带 .lock 的依赖管理器(bundler / cargo / pnpm 等)

可理解为这些工具的语言无关版包管理器。事实上 Nix 已把其它程序语言的「包」纳入 Nixpkgs 管辖范围,该功能称为 Package Set。

入门路径

  1. 先学新世代标准 nix flake,而非旧式 Nix 定义法。
    • 差异不在语法(语法相同),而在对外部依赖与软件包定义的风格,可理解为换了一套约定俗成。
    • 判别方法:文章中出现 nix-xxx 命令 = 旧世代;nix xxx = 新世代。
    • 几乎所有教程都会建议开启 nix-command 与 flake 两个 experimental feature,开启即进入新世代。建议直接自新世代入手。
  2. 先在虚拟机中安装 NixOS。
    • 推荐安装 GUI 版(如 KDE 版)。
    • 读懂官方提供的配置文件(在 /etc/nixos 中),尝试做简单修改并执行 nixos-rebuild switch,观察结果是否符合预期。
    • 暂不引入 home-manager、agenix 这类进阶组件。
  3. 然后在工作机器上安装 Nix 包管理器(暂不格盘)。
    • 慢慢打磨配置;之后想格盘装 NixOS,只需给配置加二三十行即可。
    • 尝试用 Nix 与 direnv 管理手头项目的依赖,并解决出现的问题。
    • 此时已有两个环境,可研究如何重构配置、复用配置、按功能拆 module——重构配置是最大乐趣,与 Emacs 类似。
    • 顺带了解 home-manager 在系统中的地位、解决了什么问题、如何使用。
    • 顺带了解如何在配置文件中安全存储敏感数据(如 agenix)。
  4. 万事俱备后再格盘安装 NixOS,此时对配置该做何修改已胸有成竹。
  5. 若想把手上的机器统一到一套配置,可进一步了解 nixos-anywhere、deploy-rs、disko 这类安装器与部署器。

参考链接

教程

文档