1. 大仓库为什么需要 flake-parts
Flakes 最佳实践 里给出的单文件 flake,在 3 个包、2 个系统时还很清爽。但当仓库变成这样:
- 20 个包,分布在
packages/下,各自有独立的package.nix - 6 个 devShell,按语言与角色拆分
- 3 台 NixOS 主机 + 2 台 darwin 机器 + 若干 home-manager 配置
- 需要为
x86_64-linux、aarch64-linux、aarch64-darwin三个系统出产物
单文件写法会退化成几百行「为每个系统重复一遍」的模板代码:
# 反面教材:每个输出都要手写 forAllSystems
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system}; in
{
packages = {
a = pkgs.callPackage ./packages/a {};
b = pkgs.callPackage ./packages/b {};
# ... 再写 18 个
};
devShells = {
default = pkgs.mkShell { buildInputs = [ pkgs.git ]; };
# ... 再写 5 个
};
# 而且 nixosConfigurations 不该按 system 重复
});
问题有三个:重复、不可组合、难以复用。flake-parts 用一个非常巧妙的办法解决:把 flake 输出本身变成一个模块系统。
2. flake-parts 的核心模型
2.1 模块系统搬到 flake 层
flake-parts 的核心洞察是:flake 的 outputs 函数接收一组 inputs,返回一个 attrset——这和一个模块接收 config/pkgs/lib 返回配置结构同构。于是它用 lib.evalModules 来求值:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
perSystem = { system, pkgs, ... }: {
packages.hello = pkgs.hello;
devShells.default = pkgs.mkShell { buildInputs = [ pkgs.git ]; };
};
flake = {
# 与 perSystem 平级:非 system 相关的输出
nixosConfigurations.my-host = inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./hosts/my-host ];
};
};
};
}
关键点:
systems一次声明,perSystem自动为每个系统调用flake.<attr>里放「与系统无关」的输出(nixosConfigurations、overlays、nixosModules)perSystem里放「每个系统一份」的输出(packages、devShells、checks、apps)
2.2 多个模块的合并
模块可以拆到独立文件,并在 imports 里组合:
# flake.nix
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-darwin" ];
imports = [
./nix/packages.nix
./nix/devshells.nix
./nix/hosts.nix
];
};
# nix/packages.nix
{ perSystem = { pkgs, ... }: {
packages.web = pkgs.callPackage ../packages/web {};
packages.api = pkgs.callPackage ../packages/api {};
};
}
模块之间可以像 NixOS 模块一样读 config,因此能表达「依赖另一个包的 devShell」这类跨模块引用。这与 NixOS 模块系统 的合并语义完全一致。
3. perSystem 输出与 withSystem
3.1 perSystem 的函数签名
perSystem = { config, self', inputs', pkgs, system, lib, ... }: {
# config: 当前系统下已合并的全部输出
# self': 当前系统的 flake 输出子集(self'.packages 等)
# inputs': 各 input 在当前系统下的 legacyPackages
# pkgs: 等价于 inputs'.nixpkgs.legacyPackages.${system}
};
self' 与 inputs' 是 flake-parts 提供的便利:它们让你在 perSystem 内部直接引用「当前系统」的输出,而不必写 self.packages.${system}。
3.2 用 config 表达包间依赖
{ perSystem = { config, pkgs, ... }: {
packages.lib = pkgs.callPackage ../packages/lib {};
packages.app = pkgs.callPackage ../packages/app {
inherit (config.packages) lib; # 直接引用同系统的兄弟包
};
};
}
3.3 withSystem:在非 perSystem 上下文里取某系统的输出
有时 flake.nix 顶层需要引用某个系统的包(比如给部署脚本用):
flake = { config, withSystem, ... }: {
# 取 x86_64-linux 系统下的 packages.deploy
deployScript = withSystem "x86_64-linux" ({ config, ... }: config.packages.deploy);
};
注意:withSystem 会导致该系统的输出被急切求值,用多了会拖慢 nix flake show 等操作。
4. 跨仓库复用:模块作为 flake 输出
4.1 导出 flake-parts 模块
这是 flake-parts 最强大的地方:一个仓库可以把「我的构建逻辑」导出为模块,供其他仓库 import:
# 在 infra 仓库的 flake.nix 里
flake.flakeModules.rust-service = { lib, ... }: {
options.rustService = lib.mkOption { type = lib.types.str; };
config.perSystem = { pkgs, ... }: {
packages.default = pkgs.callPackage ./rust.nix { name = config.rustService; };
};
};
4.2 消费方导入
{
inputs.infra.url = "github:my-org/infra";
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ inputs.infra.flakeModules.rust-service ];
systems = [ "x86_64-linux" ];
rustService = "payment-api";
};
}
这相当于在 flake 层面复用了「团队的标准构建方式」,与 Flake 模板与项目脚手架 里的 templates 形成互补:templates 复制文件,flakeModules 共享逻辑。
4.3 私有模块仓库的组织
建议把可复用模块单独建仓(如 nix-modules),只导出 flakeModules,不导出 packages,避免消费者被迫拉取大量依赖。使用 follows 让它的 nixpkgs 与消费者一致。
5. inputs 分层与 follows 治理
5.1 分层原则
大型仓库的 inputs 应该分层:
| 层 | 内容 | 变更频率 |
|---|---|---|
| 基础 | nixpkgs | 低(跟随 stable 或 unstable) |
| 工具 | flake-parts、treefmt-nix、pre-commit-hooks | 低 |
| 领域 | home-manager、nix-darwin、sops-nix | 中 |
| 内部 | 团队私有 flakeModules 仓库 | 中 |
5.2 follows 消灭重复 nixpkgs
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
infra = {
url = "github:my-org/infra";
inputs.nixpkgs.follows = "nixpkgs"; # 关键:私有模块也用同一个 nixpkgs
};
};
如果漏掉 follows,flake.lock 里会出现多个 nixpkgs 节点,求值内存与下载量都会翻倍。
5.3 检查 lock 是否健康
# 列出 lock 中所有 nixpkgs 节点
nix flake metadata --json | jq '.locks.nodes | to_entries[] | select(.value.locked.repo == "nixpkgs") | .key'
# 期望只看到少数几个(理想是 1 个)
6. monorepo 布局实战
6.1 推荐目录结构
.
├── flake.nix # 只做编排,不含具体逻辑
├── flake.lock
├── nix/
│ ├── packages.nix # flake-parts 模块:包
│ ├── devshells.nix # flake-parts 模块:开发环境
│ ├── checks.nix # flake-parts 模块:测试
│ └── hosts.nix # flake-parts 模块:NixOS/darwin 主机
├── packages/
│ ├── web/
│ │ ├── package.nix
│ │ └── src/
│ └── api/
│ ├── package.nix
│ └── src/
└── hosts/
├── server-1/
└── macbook/
6.2 用目录自动发现包
手写 20 行 packages.x = ... 仍然啰嗦,可以用 lib.mapAttrs 扫描目录:
{ perSystem = { pkgs, lib, ... }:
let
dirs = builtins.attrNames (lib.filterAttrs (_: t: t == "directory") (builtins.readDir ../packages));
in {
packages = lib.genAttrs dirs (name: pkgs.callPackage ../packages/${name}/package.nix {});
};
}
注意:builtins.readDir 在 flake 里是受控的——它只能读 flake 已纳入 git 的文件,所以新增目录必须先 git add。
6.3 devShell 按语言拆分
{ perSystem = { pkgs, ... }: {
devShells = {
default = pkgs.mkShell { buildInputs = [ pkgs.git pkgs.jq ]; };
frontend = pkgs.mkShell { buildInputs = [ pkgs.nodejs_20 pkgs.pnpm ]; };
backend = pkgs.mkShell { buildInputs = [ pkgs.python311 pkgs.uv ]; };
};
};
}
配合 可复现开发环境 里的 direnv 配置,可以让不同子目录自动加载对应 shell。
7. CI 集成
7.1 用 checks 表达 CI 要跑的东西
flake-parts 的 checks 是天然的 CI 入口:
{ perSystem = { config, pkgs, ... }: {
checks = {
web-build = config.packages.web;
api-tests = pkgs.runCommand "api-tests" { } ''
${config.packages.api}/bin/api --self-test
touch $out
'';
};
};
}
7.2 GitHub Actions 里的用法
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix flake check --all-systems
- run: nix build .#packages.x86_64-linux.web
nix flake check 会遍历所有 checks 并构建,把「CI 定义」与「构建定义」统一在一处。缓存推送策略见 Nix 构建与 CI。
7.3 只跑受影响的包
大型仓库里全量 check 太慢,可用 nix-diff 或自建「路径过滤」逻辑,只构建改动目录对应的输出:
changed=$(git diff --name-only origin/main... | cut -d/ -f1-2 | sort -u)
for p in $changed; do nix build ".#packages.x86_64-linux.$(basename $p)"; done
8. 与 flake-utils 的对比与选型
| 维度 | flake-utils | flake-parts |
|---|---|---|
| 抽象层次 | 辅助函数(eachDefaultSystem) | 模块系统 |
| 多系统输出 | 必须包在 eachDefaultSystem 里 | systems 声明一次即可 |
| 跨文件拆分 | 无原生支持 | imports 模块 |
| 跨仓库复用 | 只能复用函数 | 可导出 flakeModules |
| 非 system 输出 | 需手动提到外层 | flake 属性天然分离 |
| 学习曲线 | 低 | 中 |
| 适合规模 | 单包、脚本 | 多包 monorepo、团队 |
结论:单包项目继续用 flake-utils 完全没问题;一旦出现「多个包 + 多台主机 + 多文件」的信号,就应迁移到 flake-parts。两者也可以共存——flake-parts 允许在 perSystem 里使用 flake-utils 的辅助函数。
9. 常见坑
9.1 readDir 看不到未 git add 的文件
error: path 'packages/newpkg' does not exist
flake 的求值源是 git 快照(或在纯求值模式下的工作区)。新增文件后必须 git add,否则 builtins.readDir 与 path 类型都会失败。调试期可用 nix flake check --impure 临时放宽。
9.2 withSystem 导致的无限递归
在 flake 属性里用 withSystem 取 config.packages,而该包又依赖 flake.something,就会形成求值环。避免在 flake 顶层引用 self。
9.3 self 引用陈旧
self.packages.${system}.x 在 perSystem 内部指的是当前求值轮次的输出,但若你在 nixosConfigurations 里引用 self.packages,可能拿到未完成合并的版本。优先用 self' 与 inputs'。
9.4 每个系统的 pkgs 未统一
若某模块自己写 import nixpkgs {} 而不走 inputs'.nixpkgs,会引入第二份 nixpkgs,破坏一致性与缓存命中。始终从 perSystem 的参数取 pkgs。
9.5 nix flake show 变慢
flake show 会求值所有输出。若某输出依赖 withSystem 或重量级 import,可考虑拆分为独立 flake 或延迟到 packages 内部。
10. 总结
flake-parts 把「flake 组织」从手写模板升级为模块化工程:
- systems + perSystem 消灭了重复的 forAllSystems 样板
- imports 与 config 让多文件、跨模块引用变得自然
- flakeModules 让团队标准构建方式可以跨仓库共享
- inputs 分层 + follows 控制依赖图规模与求值开销
- checks 把 CI 定义与构建定义合二为一
迁移建议:先用 Flakes 最佳实践 里的单文件版本跑通,再按「先拆模块、后加 follows、最后抽 flakeModules」的顺序渐进重构,避免一次性大改导致 lock 与缓存全失效。若涉及多架构产物,可结合 交叉编译与 overlay 一起设计 systems 列表。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。