flake-parts 与大型仓库组织:模块化 flake 的工程实践

当 flake 从个人小项目长成多包 monorepo,手写 forAllSystems 的模板代码会迅速失控。flake-parts 把 NixOS 模块系统的合并语义引入 flake 组织,让每个子项目只声明自己关心的输出。本文详解 perSystem、模块复用、inputs 分层与 monorepo 布局。

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-utilsflake-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 列表。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nix」更多文章

  1. NixOS 代际管理与回滚:从 generation 机制到引导项治理
  2. Nix 语言生态打包:Python、Node 与 Rust 的依赖治理
  3. Nix 派生与 Store 内幕:derivation、输入寻址与引用图