第二大 Nix monorepo
二进制缓存:
- 缓存:https://cache.ysun.co
- 密钥:
cache.ysun.co-1:WxPYwT5g3kt9XhUhHPpNLZKI9HIOsVVAuqSHpok8Qt4=
也许标题没那么标题党?截至此提交,大约有 17k 行的 Nix……
我对 Nix 配置应如何构建和部署有强烈的看法。生态系统中的大多数“约定”(snowfall、flake-utils、dendritic 等)将偶然复杂性编码为社区规范。它们以框架开销换取结构清晰度,我个人认为这种权衡不值得。
这里的模式相当极简(我认为):基于文件系统的发现、库扩展,以及对模块系统的薄抽象,以保留/增强 组合推理。每个主机的配置由蓝图元数据、其组选择的模块集及其主机入口点决定。
如果你想复用其中任何内容,这些配置的外观相当直接,如果你 fork 并删除所有模块、主机和用户 配置,它们应该就能正常工作。
求值顺序
autopilot 是
flake-parts 的薄封装,为 lib、
pkgs 和 flake-parts 模块提供基于目录结构的自动加载。类似于
blueprint 或
import-tree,但我自己写了一个,因为
我想要围绕库扩展和包作用域的特定行为。
求值顺序:
lib: autopilot 加载 lib/ 下的每个 .nix 文件,将文件名
从 kebab-case 转换为 camelCase,并将它们合并为一个扩展了 nixpkgs.lib 的单一 fixpoint。
在名称冲突时,用户定义的函数会遮蔽 nixpkgs 内置函数。扩展库(colmena、nix-darwin、flake-parts 等)
在用户函数之前合并,因此用户定义具有最终优先级。
pkgs: 对于每个系统,autopilot 调用
import nixpkgs { system; config; overlays; },其中 overlays 包括本地
overlay(来自 pkgs/)和外部 overlays。生成的 pkgs 暴露给
所有 flake-parts perSystem 作用域。
flake-parts eval: modules/flake/ 下的模块会被自动发现并
加载。每个模块都在作用域中接收扩展后的 lib、inputs 等。
Library extension
lib/ 中的所有内容在任何地方都可用:在 NixOS/darwin 模块中通过
importApplyWithArgs,在 flake-parts 模块中通过 lib 参数,以及在包
定义中通过 overlay。lib/ 中的所有内容如果存在命名冲突,将自动覆盖
nixpkgs.lib 或其他扩展,因此很容易
修改标准库行为。
importApplyWithArgs(基本上是包装的 importApply)函数处理
向 NixOS/darwin/home-manager 模块的注入,而无需为每个自定义绑定要求 specialArgs。
它在导入时检查模块的函数签名:如果任何参数名称与提供的静态参数(例如
inputs、lib)相交,它会在模块系统看到该模块之前
对其进行部分应用。如果导入的文件是一个普通的 attrset 或参数不匹配的函数,
则原样传递。
这意味着这些模块的外部使用者永远不会被迫提供 它们未定义的参数。这种注入对模块系统在结构上是不可见的。这解决了与树突模式(将 所有内容声明为 flake-parts 模块)相同的问题,同时保留了模块系统提供的 结构化作用域。
包覆盖层
importPackagesTree 函数类似于库扩展。它执行
基于目录结构的自动发现,以定义、覆盖(针对派生项和作用域,甚至普通的 attrset),通过对
pkgs/ 的递归遍历来实现。该覆盖层由 autopilot 与外部覆盖层一起注入。
遍历根据目录结构处理三种情况:
独立包:包含 default.nix 的目录是包定义。
它针对当前包集以 callPackageWith 调用。本地
定义始终优先于 nixpkgs,因此放置
pkgs/alacritty/default.nix 会全局覆盖 pkgs.alacritty。该函数
签名接收完整的包作用域,因此标准的 nixpkgs 依赖项
(例如 fetchFromGitHub、stdenv)可以自然解析:
pkgs/
alacritty/
default.nix # overrides pkgs.alacritty
bird3/
default.nix # overrides pkgs.bird3
作用域覆盖:名称与现有
nixpkgs 作用域名称匹配(即具有 overrideScope 或 extend 的 attrset)且没有 default.nix 的目录
将触发递归作用域覆盖。子目录成为该作用域内的包覆盖,
在接收根级别的 pkgsFinal/pkgsPrev 的同时,还会接收作用域级别的不动点绑定(<scopeName>Final、
<scopeName>Prev)。例如,
pkgs/ocamlPackages/omd/default.nix 调用 ocamlPackages.overrideScope,并且
其中的 omd 包会从 OCaml 作用域接收 buildDunePackage:
pkgs/
ocamlPackages/ # no default.nix, matches pkgs.ocamlPackages
omd/
default.nix # receives buildDunePackage, ocamlPackagesFinal, etc.
yocaml/
default.nix
作用域创建:不包含 default.nix 且与任何现有 nixpkgs 作用域不匹配,但包含带有 default.nix 的子目录的目录,会通过 makeScope 创建一个新的作用域。
localPackagesFrom 函数镜像此遍历过程,仅提取 legacyPackages flake 输出中本地定义的包,将完整的 pkgs 集合过滤为名称对应于 pkgs/ 中目录的条目。作用域包作为嵌套 attrset 导出。packages flake 输出直接别名 legacyPackages,因为标准的 packages 输出不支持嵌套作用域(我们应该“修复”这一点吗?)。
蓝图
lib.blueprint 包含所有元数据。它以纯 attrset 形式定义主机、用户、服务、网络前缀以及 Tailscale/ranet 配置。每个主机声明(lib/blueprint/hosts/<name>/default.nix)指定操作系统、提供商、类型、标签等。自动生成的标签包括操作系统、提供商和类型,因此例如 lib.blueprint.hosts.walberla.tags 求值为
["nixos" "hetzner" "server" "routee" "glance" "kanidm" "ranet"]。
大多数 NixOS 服务模块使用 lib.hasTag 来有条件地启用自身:
{ lib, ... }:
{ config, ... }:
{ services.glance.enable = lib.hasTag config.networking.hostName "glance"; }
这使得服务分配在蓝图(blueprint)中保持声明式和集中化, 而不是分散在各个主机的入口点中。向主机添加一个服务 实际上只是添加一行标签。
蓝图数据还被以下组件消费:
- Colmena:
deployment.tags从蓝图填充,从而启用colmena apply --on @server、colmena apply --on framework,xps等。 - terranix:Cloudflare 资源(DNS 区域、反向 DNS、存储桶、SSO 设置)和 Tailscale DNS 条目是从蓝图主机元数据派生的。
- Prometheus:监控目标是从蓝图服务 声明生成的。
- 可能还有一些我实在记不清的其他东西……
部署
目前所有主机(16 台 NixOS 服务器、2 台 NixOS 笔记本电脑、1 台运行 nix-darwin 的 MacBook)
都通过 Colmena 进行管理。mkColmena 函数接受一个主机
组列表,每个组指定操作系统、平台、模块、用户和主机名:
mkColmena {
inherit inputs specialArgs getSystem;
nixpkgs = inputs.nixpkgs;
nix-darwin = inputs.darwin;
hosts = [
{ os = "nixos"; platform = "x86_64-linux"; modules = serverModules; users = serverUsers; names = [ "walberla" "butte" ... ]; }
{ os = "nixos"; platform = "aarch64-linux"; modules = serverModules; users = serverUsers; names = [ "isere" ]; }
{ os = "nixos"; platform = "x86_64-linux"; modules = laptopModules; users = laptopUsers; names = [ "framework" ]; }
{ os = "darwin"; platform = "aarch64-darwin"; modules = darwinModules; users = darwinUsers; names = [ "macbook" ]; }
];
}
内部地,这些组被扁平化为一个按主机划分的配置映射。每台主机从 getSystem platform 获取其 nodeNixpkgs(由 autopilot 实例化的带有 overlays 的包),并且 deployment.systemType 从该组的 os 字段中设置。
nixosConfigurations 和 darwinConfigurations flake 输出通过过滤每个节点的 class 属性从 colmenaHive.nodes 中提取。
Darwin 支持来自我的
经过修补的 Colmena 分支,该分支基于
colmena#319 为 hive 评估器添加了
evalDarwinNode、deployment.systemType 和 meta.nix-darwin。该分支还
包含针对 NixOS 节点的分离激活(通过
systemd-run 启动激活,以便在网络/防火墙重启期间
SSH 断开时仍能存活)。
网络
这里的这些服务器运行着我的个人自治系统。一些节点与上游提供商 维护 BGP 会话并起源前缀,其余的是路由节点,通过内部网格接收流量。
内部网格已从 Tailscale(wg 网格)迁移到 ranet(IPsec 网格)。Tailscale 消耗了 整个 CGNAT 范围,且无法在多个跳数间保留源 IP 地址, 不支持多播(排除了诸如 babel 之类的协议),并且上游 对解决这些限制没有表现出兴趣 (tailscale#18781,其中 原始问题已停滞多年, lobste.rs 上关于我的硬补丁写作的讨论)。
我在 NixCon 2025 上做了一个关于使用 NixOS 运行路由实验和覆盖网络的演讲: 使用 NixOS 进行互联网规模的路由 (YouTube, media.ccc.de, 仓库)。
仓库管理
本仓库中的其他所有内容都是声明式的,那么 git 仓库为何不也是如此?
Miroir 是一个 CLI 工具(兼索引守护进程),
通过单个 TOML 配置文件管理多个 git 代码托管平台上的仓库
(repos/config.toml)。每个仓库声明其描述、
可见性和归档状态。每个平台声明一个代码托管域名和
用户名。Miroir 将声明的状态收敛到所有已配置的代码托管平台:
创建不存在的仓库,更新已存在仓库的元数据,并
归档标记为 archived = true 的仓库。
实际的动机是多代码托管平台冗余。所有仓库都镜像到
GitHub、GitLab、Codeberg 和 SourceHut,以便任何单一代码托管平台宕机(或
出问题?我在看着你 GitHub?)都不会导致任何损失。miroir push -a
并发推送到所有已配置的远程仓库,miroir init -a 在一台新机器上克隆
所有内容,且所有远程仓库均已配置好。
同一配置文件还驱动一个服务端代码搜索引擎。NixOS 模块
(modules/nixos/neogrok.nix)导入
repos/config.toml,叠加服务端特定设置(监听地址、来自 sops 的 SSH 密钥),
并将 miroir index 作为 systemd 服务运行。miroir 定期
获取并索引所有已声明的仓库到
zoekt,通过
neogrok 在 Caddy 后面提供服务,并在
grep.ysun.co 处进行 SSO。
代码托管元数据同步目前支持 GitHub、GitLab(官方或自托管)、 Codeberg(以及衍生版 Forgejo/Gitea 实例)和 SourceHut。这也可以 作为迁移工具,如果你希望从一个代码托管平台切换到另一个。
详见 NixOS Discourse。
License
本仓库中的内容,除所有子模块外,均依据 MIT 许可证 授权。 第三方文件和/或代码受其原始条款和/或许可证约束。