1. 从「能用」到「可维护」
https://plumephp.com/nix-flakes/ 讲解了 flake.nix 的基本结构与命令;本文解决的是更高一层的问题:当 flake 从一个人练习变成多人、多项目、跨团队的共享基础设施时,怎么组织才不会烂掉?
我们在多个生产仓库中总结出的共性痛点:
flake.nix膨胀到上千行,没人敢动inputs的follows关系混乱,flake.lock频繁冲突- 包、devShell、NixOS 模块、CI 检查全部堆在一个文件里
- 团队间复用配置靠复制粘贴,版本永远对不上
nix flake update一次升级全部依赖,回归爆炸
本文给出的一套可落地的最佳实践,配合 https://plumephp.com/nix-language-deep-dive/ 的语言功底与 https://plumephp.com/nix-ci-cachix/ 的 CI 流水线,构成完整的工程化闭环。
📌 相关专题:多仓库依赖治理可参考 https://plumephp.com/posts/devops/;CI 集成见 https://plumephp.com/posts/github-actions/。
2. inputs 组织:依赖治理的第一道闸门
2.1 依赖分类与命名约定
inputs 建议按功能前缀分组命名,一目了然:
inputs = {
# 基础:nixpkgs 是唯一真正的「运行时」
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
# 工具链:devShell 与 CI
devenv.url = "github:cachix/devenv";
flake-utils.url = "github:numtide/flake-utils";
# 平台配置:NixOS / macOS
home-manager.url = "github:nix-community/home-manager";
nix-darwin.url = "github:LnL7/nix-darwin";
# 基础设施:本组织的私有 flake(Git 私有仓库)
my-common = {
url = "git+ssh://git@github.com/my-org/my-common?ref=main";
inputs.nixpkgs.follows = "nixpkgs";
};
};
2.2 follows 治理:一个 nixpkgs 原则
「一个项目只应有一个 nixpkgs 主版本」是避免锁文件膨胀的第一原则。所有相互配合的 flake 都应该 follows 到同一个 nixpkgs:
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
# 常见错误:不写 follows,home-manager 会拉自己的 nixpkgs
# 正确做法:显式共享
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
# 工具链也 follow
devenv = {
url = "github:cachix/devenv";
inputs.nixpkgs.follows = "nixpkgs";
};
};
用 nix flake metadata 检查是否有「漏网」的独立 nixpkgs:
# 列出依赖树,检查是否出现多个不同 rev 的 nixpkgs
nix flake metadata --json | jq '.locks.nodes | to_entries[] | select(.key|contains("nixpkgs")) | .value.locked.rev'
2.3 需要两个 nixpkgs 时:显式命名
某些场景(如生产系统用稳定版、devShell 用 unstable 的新工具)需要两个 nixpkgs,此时必须显式命名并在使用点明确选择:
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; # 生产
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; # 工具
};
outputs = { self, nixpkgs, nixpkgs-unstable }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
in {
devShells.${system}.default = pkgs.mkShell {
# 混用:多数用稳定,个别用 unstable
packages = with pkgs; [ nodejs_20 yarn ] ++ [ pkgs-unstable.ripgrep ];
};
packages.${system}.default = pkgs.hello; # 构建产物用稳定
};
3. outputs 组织:少而明确的出口
3.1 最小化「顶层杂货铺」
outputs 是 flake 对外的公共 API,建议只暴露这五类,其余内部逻辑全部拆到子文件(见第 4 节):
| 输出 | 用途 | 团队约定 |
|---|---|---|
packages.<sys>.default | 构建产物(唯一默认包) | 一个 flake 只产出一个默认包 |
devShells.<sys>.default | 开发环境 | 与 devenv/direnv 配合 |
nixosConfigurations.* | NixOS 机器 | 机器按主机名命名 |
checks.<sys>.* | CI 检查项 | 与 GitHub Actions 一一对应 |
overlays.default | 对外暴露的 nixpkgs 覆盖 | 复用方通过 overlay 接入 |
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
packages.default = pkgs.callPackage ./pkgs/default.nix {};
devShells.default = import ./shells/default.nix { inherit pkgs; };
checks.default = import ./checks/default.nix { inherit pkgs; };
}
) // {
# 跨系统的输出放在 eachDefaultSystem 之外
overlays.default = import ./overlays/default.nix;
nixosModules.default = import ./modules/nixos.nix;
};
3.2 eachDefaultSystem 的正确姿势
flake-utils 的 eachDefaultSystem 是省模板的利器,但它只遍历四个默认系统;不要在它里面放与系统无关的东西:
{
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: {
# ✅ 系统相关:packages / devShells / checks / apps / formatter
packages.default = ...;
devShells.default = ...;
})
// {
# ✅ 系统无关:overlays / nixosModules / nixosConfigurations / templates
overlays.default = ...;
nixosModules.default = ...;
templates.default = ...;
};
}
4. 模块化拆分:把 1000 行拆成 100 行
4.1 推荐目录骨架
一个中型仓库的建议结构(避免子目录过深,保持扁平):
repo/
├── flake.nix # 总入口:只做接线(wiring)
├── flake.lock # 锁定文件,提交进 Git
├── inputs.nix # 1. inputs 集中定义
├── outputs/
│ ├── packages.nix # 2. 包定义出口
│ ├── devshells.nix # 3. devShell 出口
│ ├── checks.nix # 4. CI 检查出口
│ ├── overlays.nix # 5. overlay 出口
│ └── nixos.nix # 6. NixOS 配置出口
├── pkgs/
│ ├── default.nix # callPackage 统一入口
│ ├── myapp.nix # 单个包的 derivation
│ └── mylib.nix
├── shells/
│ ├── default.nix
│ └── ci.nix # CI 专用精简 shell
├── checks/
│ ├── nixfmt.nix
│ └── test.nix
├── modules/
│ └── nixos.nix
└── templates/
└── default/ # 团队模板
├── flake.nix
└── ...
4.2 flake.nix 只做「接线」
# flake.nix —— 真正的总入口只有二十几行
{
description = "Acme 核心服务 monorepo";
inputs = import ./inputs.nix;
outputs = inputs@{ self, nixpkgs, flake-utils, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
flake-utils.lib.eachDefaultSystem
(system:
let pkgs = nixpkgs.legacyPackages.${system}; in {
packages = import ./outputs/packages.nix { inherit pkgs; self; };
devShells = import ./outputs/devshells.nix { inherit pkgs; };
checks = import ./outputs/checks.nix { inherit pkgs; };
})
// {
overlays = import ./outputs/overlays.nix;
nixosModules = import ./outputs/nixos.nix;
nixosConfigurations = import ./outputs/nixos.nix { inherit nixpkgs; };
};
}
4.3 子文件保持「纯函数」风格
每个子文件只接收显式参数、返回明确的 attrset,不隐藏全局依赖:
# outputs/packages.nix
{ pkgs, self }:
{
default = pkgs.callPackage ../pkgs/default.nix { };
mylib = pkgs.callPackage ../pkgs/mylib.nix { };
}
# outputs/devshells.nix
{ pkgs }:
{
default = import ../shells/default.nix { inherit pkgs; };
ci = import ../shells/ci.nix { inherit pkgs; };
}
最佳实践:子文件不 import
<nixpkgs>、不用with、不读环境变量。所有输入都从 flake.nix 传入,让整个配置「纯」到可以测试——这也是后续做单元检查的基础。
5. flake.lock:锁定文件治理
5.1 锁文件是「可复现」的保险单
flake.lock 必须提交进 Git。CI 里应该用 nix flake build .#... 直接消费它,保证构建与本地一致:
# 查看当前锁定的每个 input
nix flake lock --show-lock-file
# 只更新某个 input(避免一次全量升级)
nix flake update nixpkgs
# 锁定到当前解析(不升级,只固化)
nix flake lock
# 检查 lock 是否与 flake.nix 一致
nix flake check
5.2 升级策略:分层滚动
| 场景 | 更新方式 | 回归控制 |
|---|---|---|
| 日常开发 | nix flake update <单个 input> | 小步、可回退 |
| 稳定版 nixpkgs | 跟踪 nixos-24.05 这类稳定分支 | lock 自动锁住,天然安全 |
| 安全补丁 | 手工 nix flake update nixpkgs | 构建所有 checks |
| 团队大版本 | 改 URL → nix flake lock → 全量 checks | PR 级评审 |
5.3 合并冲突的止血方案
多人在同一分支改 flake.nix 时,lock 冲突常见。策略:
- 代码评审时不让人手改 lock:flake.nix 的修改与 lock 的刷新绑定(
nix flake lock在同一 PR 完成) - 冲突时以 flake.nix 为准:
git checkout --theirs flake.lock && nix flake lock重新解析 - 利用
nix flake update --recreate-lock-file:彻底重建,适合 lock 已严重损坏的场景
# 重建 lock(谨慎:会丢失精确锁定,尽量只在冲突无法解决时用)
nix flake update --recreate-lock-file
6. 模板复用:让团队从同一张白纸开始
6.1 内建 templates 输出
flake 可以内置 templates,让 nix flake init -t 直接创建标准骨架:
# flake.nix 追加
templates = {
default = {
path = ./templates/default;
description = "Acme 标准服务骨架";
};
library = {
path = ./templates/library;
description = "Acme 纯库骨架";
};
};
# 使用
nix flake init -t github:my-org/my-common#default
nix flake init -t github:my-org/my-common#library
6.2 模板内容要点
templates/default/flake.nix:
{
description = "Acme 标准服务骨架";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
packages.default = pkgs.callPackage ./pkgs/default.nix { };
devShells.default = import ./shells/default.nix { inherit pkgs; };
});
}
注意:模板目录里的
flake.nix也会被 nix 解析。若templates指向的目录包含顶层flake.nix,nix 会要求它自身可求值——为避免「模板模板」复杂度,模板目录内不要再嵌套独立 flake,只用扁平.nix文件。
6.3 模板更新:单一来源
模板一旦被多个项目使用,更新模板后旧项目不会自动同步。两个治理手段:
- 把模板的「核心」抽成公共 flake(如
my-common),模板只做薄壳 - 定期跑
nix flake init -t ...到临时目录比对差异,用diff手工合入
7. 企业级布局:多服务的 monorepo
7.1 场景与取舍
一个仓库承载:多个可独立部署的服务、共享的 NixOS 模块、统一的 devShell 与 CI。
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 单一 flake | 依赖锁定统一,开发体验一致 | 构建量大、升级联动 | 中小 monorepo(本文方案) |
| 嵌套子 flake | 服务间依赖隔离 | lock 各自独立,跨服务升级复杂 | 服务边界极强的超大仓库 |
| 独立仓库 + 公共 flake | 权限/发布独立 | 依赖发布流程 | 跨团队、跨组织 |
7.2 单一 flake monorepo 骨架
acme-monorepo/
├── flake.nix
├── inputs.nix
├── outputs/
│ ├── packages.nix
│ ├── devshells.nix
│ ├── checks.nix
│ └── nixos.nix
├── pkgs/
│ ├── default.nix
│ ├── service-a.nix
│ └── service-b.nix
├── services/
│ └── nixos-modules/
│ ├── service-a.nix
│ └── service-b.nix
├── hosts/
│ ├── prod-a.nix
│ └── prod-b.nix
├── shells/
│ ├── default.nix
│ └── ci.nix
└── checks/
├── nixfmt.nix
├── build.nix
└── test.nix
7.3 跨服务共享的「内部库」
共享配置通过 outputs/nixos.nix 暴露 nixosModules,服务主机 import 它:
# outputs/nixos.nix
{ pkgs, lib }:
{
nixosModules.service-base = import ../services/nixos-modules/base.nix;
nixosModules.service-a = import ../services/nixos-modules/service-a.nix;
nixosConfigurations = {
prod-a = lib.nixosSystem {
system = "x86_64-linux";
modules = [
../hosts/prod-a.nix
(import ../services/nixos-modules/service-a.nix)
];
};
prod-b = lib.nixosSystem {
system = "x86_64-linux";
modules = [
../hosts/prod-b.nix
(import ../services/nixos-modules/service-b.nix)
];
};
};
}
8. 常见反模式与修正
| 反模式 | 问题 | 修正 |
|---|---|---|
顶层 outputs 写 800 行 | 难以 review、复用 | 拆 inputs.nix + outputs/*.nix |
每个 input 都 follows 或都不 follows | lock 膨胀 / 版本不一致 | 遵循「一个 nixpkgs」原则,例外显式命名 |
手改 flake.lock | 版本漂移、冲突 | 一律 nix flake lock/update 生成 |
子文件 import <nixpkgs> | 通道依赖、不可复现 | 全部从 flake 传入 |
| 模板目录嵌套 flake | 解析混乱 | 模板内只放扁平 .nix |
nix flake update 无差别全升 | 回归爆炸 | 单 input 升级 + checks 把关 |
9. 总结
Flakes 工程化的核心不是某个语法技巧,而是把「接线」与「实现」分离的纪律:
- inputs:显式命名、单 nixpkgs 优先、
follows显式声明 - outputs:少而明确的公共 API,系统无关输出放
eachDefaultSystem之外 - 结构:
flake.nix只接线,inputs.nix与outputs/*.nix承载实现 - 锁定:lock 提交 Git、CI 消费 lock、单 input 滚动升级
- 复用:
templates统一起点,overlays/nixosModules作为公共资产输出
掌握这套布局后,与 https://plumephp.com/nix-ci-cachix/ 的缓存流水线、https://plumephp.com/nixos-module-system/ 的模块化开发配合,就能把 Nix 从「个人玩具」升级为「组织级基础设施」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。