Nix Flakes 与 devShell:锁定输入、矩阵输出和 CI 环境
同一个 shell.nix,为什么三台机器得到三种答案
一个项目已经把 jq、Node.js 和代码生成器写进 shell.nix,开发者都通过 Nix 进入环境,CI 却仍然偶发漂移:有人本机的 <nixpkgs> 指向较新的 channel,有人的 ARM 笔记本没有对应输出,Runner 临时更新输入后生成了不同依赖图。声明存在,却没有一份受评审的输入锁,也没有把目标平台写进输出结构。
Flake 把项目根目录的 flake.nix 作为接口:inputs 描述依赖,outputs 按标准属性暴露包、检查和开发 Shell,flake.lock 固定输入图。它让输入更新变成可见 diff,但不会锁住 Nix 二进制、宿主内核、CPU、企业 CA、外部服务和运行时凭证。devShell 也不是容器;它准备 derivation 的构建环境并启动 Bash,仍然共享宿主内核、网络、文件系统和设备。
先把实验能力显式启用
Flakes 与新的 nix 命令行在当前参考手册中仍标为 experimental,接口可能变化,不能把广泛使用等同于稳定承诺。先运行 nix --version,再查该版本的 Flakes 状态说明 与 experimental features 说明。临时实验可逐条传参,长期团队环境则由管理员或用户配置明确启用:
nix --extra-experimental-features 'nix-command flakes' flake --help# multi-user 通常由管理员写入 /etc/nix/nix.conf
# single-user 可写入 ~/.config/nix/nix.conf
experimental-features = nix-command flakes重新打开终端后验证:
nix --version
nix config show | grep '^experimental-features ='
nix flake --help >/dev/null
echo "exit=$?"预期最后退出 0。如果仍报 experimental Nix feature 'nix-command' is disabled,先确认编辑的是当前客户端和 daemon 实际读取的配置;multi-user 中只改用户配置,可能无法改变 daemon 侧受限设置。团队还要固定受支持的 Nix 版本线,并把 nix --version 放进 CI 证据;Flake CLI 的参数在历史版本间有过变化,只固定 flake.lock 无法消除客户端接口漂移。
实验接口不等于项目必须把全部逻辑绑死在 flake.nix。对需要长期维护、又要兼容未启用 Flakes 的仓库,可把包与 Shell 的主体放在普通 Nix 模块中,让 flake.nix 只负责锁定 inputs 和映射标准 outputs;这样接口变化主要影响薄封装,而不是重写构建逻辑。选择这一做法的代价是多维护一个入口,因此必须用 CI 证明 Flake 与非 Flake 入口得到的关键工具版本一致。
inputs、outputs 与 lock graph 各自承担什么
Flake 是含根级 flake.nix 的文件树。inputs 给依赖命名,引用可以指向 Git 仓库、tarball、路径或另一个 Flake;outputs 是接收已解析 inputs 的函数,返回一组带约定名称的属性。devShells.<system>.<name> 是其中一种标准输出。
flake.lock 记录输入图节点、依赖边、revision 与 narHash 等锁定信息。它固定直接和间接 Flake inputs,应进入版本控制;它不固定 Nix 本身、OS、CPU、环境变量、DNS 或 shellHook 副作用。更新 lock 和修改环境声明是两种可独立审查的变更,不应在 CI 中悄悄合并。
正向实验:提交一份可锁定的开发 Shell
在 Linux、macOS 或 WSL2 的一次性目录执行。先记录 Nix 和平台,再初始化 Git;Git 仓库很重要,因为 Flake 的 source 边界会参与后面的反例。
mkdir flake-shell-lab && cd flake-shell-lab
git init -q
nix --version
nix eval --impure --raw --expr builtins.currentSystem创建 flake.nix:
{
description = "locked project development shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
devShells = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; };
in {
default = pkgs.mkShellNoCC {
packages = [ pkgs.jq pkgs.nodejs ];
LAB_MARKER = "locked";
shellHook = ''
echo "dev shell: ${system}"
'';
};
});
};
}先把声明纳入 Git source,再生成锁文件并审查元数据:
git add flake.nix
nix flake lock
nix flake metadata
git diff -- flake.lock
git add flake.lock
git commit -m 'add locked development shell'nix flake metadata 应显示锁定后的 Nixpkgs revision;flake.lock 应出现 root、nixpkgs 节点与依赖边。Git 提交只是让实验拥有稳定坐标,Flake 可见性本身要求文件被 Git 跟踪,不要求每次修改都先 commit。
在目标平台执行以下验证,并保存 Nix 版本、system、命令输出与退出码:
nix develop --command bash -c '
jq --version
node --version
test "$LAB_MARKER" = locked
'
echo "exit=$?"预期打印 jq 与 Node.js 版本,最后退出 0。nix develop 未指定属性时先寻找 devShells.<current-system>.default;--command 不打开交互终端,适合脚本与 CI。mkShellNoCC 不默认携带编译器,只有项目确实需要 C/C++ 工具链时才换用 mkShell 或显式加入包。
完成一次在线实现后,再验证 lock 与本地闭包的关系:
nix develop --offline --no-update-lock-file --command jq --version
echo "exit=$?"输入和闭包都已在本机时应退出 0。失败若提示无法下载,说明 lock 完整但本地 Store 尚无所需对象;失败若指向属性或平台,则是声明问题。--offline 证明的是“当前机器已有闭包时可以离线进入”,不是仓库单独携带了所有二进制。
纯求值与 Git source:最容易误判的输入边界
Flake 默认纯求值,不能任意读取环境变量、当前时间或未声明的绝对路径。位于 Git 工作树时,Flake source 只包含 Git 已跟踪的文件;已跟踪文件即使尚未提交,其修改仍可进入 dirty source,而新建但未 git add 的文件不可见。
创建一个尚未跟踪的标记文件,并让输出读取它:
printf 'tracked-value\n' > marker.txt在 flake.nix 的 let 中增加:
marker = builtins.readFile ./marker.txt;再在 mkShellNoCC 中增加:
LAB_FILE_MARKER = marker;此时只暂存 flake.nix,不要暂存 marker.txt:
git add flake.nix
nix develop --no-update-lock-file --command true
echo "exit=$?"预期非零退出,并出现 marker.txt 不存在一类求值错误。执行 git add marker.txt 后重跑应成功。这组正反证据说明输入必须进入 Git source,不说明必须 commit。CI 应额外要求工作树干净,因为 dirty source 虽可求值,却难以绑定到唯一提交。
--impure 可以放开部分可变输入,但它是例外开关,不是修复缺失声明的通用答案。确需读取本地 SDK 或硬件路径时,应把例外写入项目决策、在启动时打印摘要,并让 CI 明确拒绝或提供等价输入;否则开发机成功只是把依赖藏回宿主。
锁更新必须产生可审查 diff
普通开发与 CI 都应消费已提交的 flake.lock。修改 input 引用后,先阻止自动修锁观察失败:
nix develop --no-update-lock-file --command true
echo "exit=$?"当声明与旧 lock 不一致时,预期非零退出并提示 lock 需要更新。随后由专门更新动作移动输入:
nix flake update nixpkgs
git diff -- flake.lock
nix flake check
nix develop --no-update-lock-file --command ./scripts/check.sh只有 lock diff、平台检查和项目测试都通过,才合并更新。nix flake update nixpkgs 是当前 CLI 更新单一 input 的形式;旧版本曾使用其他参数形式,这正是 Runner 必须固定 Nix 版本的原因。该命令负责移动输入,不负责证明新闭包与项目兼容。
回退不能只看 Git diff。更新前先保存旧提交和旧环境闭包,更新后用相同项目检查做正向验证,再恢复旧 flake.nix 与 flake.lock 做反向验证:
old_rev="$(git rev-parse HEAD)"
nix develop --no-update-lock-file --profile .nix-dev-profile-before \
--command ./scripts/check.sh
# 完成并验证 input 更新后,用独立 worktree 验证旧提交,避免改写当前工作区
git worktree add ../flake-rollback-lab "$old_rev"
(
cd ../flake-rollback-lab
nix develop --offline --no-update-lock-file --command ./scripts/check.sh
echo "rollback_exit=$?"
)
git worktree remove ../flake-rollback-lab旧输入和闭包仍在本地时,离线反证应退出 0;如果报缺少 Store 对象,说明 Git 能恢复声明,但本机回退窗口已经被 GC 截断。此时可以从仍保留旧闭包且签名可信的 cache 恢复,或付出冷重建成本。.nix-dev-profile-before 会保护旧闭包并增加磁盘占用,确认回退窗口结束后再删除,不能让每次升级都永久制造 GC root。
System matrix 不是自动跨平台
system 通常由 CPU 架构与内核族组成,例如 x86_64-linux、aarch64-linux、x86_64-darwin、aarch64-darwin。示例显式生成四个输出,只表示这些属性可求值;每个包是否支持目标 system、项目是否依赖 Linux-only 库、运行行为是否一致,仍要逐个平台验证。
先查看输出树,再故意请求不存在的平台或名称:
nix flake show
nix develop .#missing --command true
echo "exit=$?"预期命名 Shell 不存在时明确非零退出。若要稳定证明缺少某个 system,可在临时分支从 systems 删除当前 system,再运行默认 nix develop;应出现 devShells.<current-system>.default 不存在或找不到默认输出的错误,而不是偷偷选择另一架构。
团队不应仅在一台 x86_64 Linux Runner 上宣布矩阵完成。至少为实际支持的平台建立独立 CI job,记录 builtins.currentSystem、工具版本和项目测试结果;不支持的平台从矩阵移除并明确失败。跨平台 lock 相同,只能说明共享输入图,不能证明不同 OS 的 Store path 或二进制字节相同。
shellHook 要保持轻量、幂等和可退出
shellHook 每次进入环境都会执行任意 Shell 代码。适合设置提示、创建可安全重复的本地目录、检查工具或打印环境摘要;不适合下载未锁定脚本、修改 ~/.gitconfig、启动无法回收的后台服务、读取长期凭证或执行数据库迁移。
一个更稳妥的 hook 只做本地、可重复检查。若缓存位于仓库内,应先把目录加入 .gitignore,否则后面的 CI 干净工作树检查会被 hook 自己创建的未跟踪目录触发:
.cache/dev-shell/shellHook = ''
mkdir -p .cache/dev-shell
test -w .cache/dev-shell || {
echo "dev-shell cache is not writable" >&2
return 1
}
echo "dev shell: ${system}"
'';连续进入两次,.cache/dev-shell 状态应稳定,仓库外文件不应变化:
nix develop --command true
nix develop --command true
git status --short如果 hook 需要启动本地服务,把生命周期交给项目任务脚本,保存 PID、设置就绪探测并提供 stop;非交互 CI 则直接调用任务命令。这样 shell 初始化失败与业务服务失败可以分开诊断。
接入项目、IDE 与 CI
仓库应把 flake.nix、flake.lock 和无秘密的检查脚本一起提交。开发者入口可以保持简单:
nix developIDE 不需要复制一套 PATH。让 IDE 的项目任务通过 nix develop --command <tool> 启动,或从已经进入 devShell 的终端打开工作区。若 IDE 运行在宿主而语言服务器在 devShell,必须确认扩展进程实际解析到 Nix Store 中的二进制,避免编辑器与终端版本分叉。
CI 使用非交互命令并禁止修改 lock:
set -euo pipefail
test -z "$(git status --porcelain)"
nix flake metadata --no-update-lock-file >/dev/null
nix flake check --no-update-lock-file
nix develop --no-update-lock-file --command bash -c '
node --version
jq --version
./scripts/check.sh
'
test -z "$(git status --porcelain)"首尾两次工作树检查会同时发现受跟踪文件改动和未忽略的新文件,因此项目必须显式忽略可接受的本地缓存,并让其他新增产物使作业失败。Runner 还应固定 Nix 版本线并输出 nix --version;否则相同 lock 遇到不同实验 CLI 或求值行为,故障难以归因。需要复用环境时可用 nix develop --profile .nix-dev-profile,但该 profile 会成为 GC root,CI 缓存策略必须包含失效与删除。
缓存、Flake 配置与权限信任
项目可以在 nixConfig 中建议 substituter 与公钥,但仓库声明不是自动授权。accept-flake-config 默认关闭,首次接受未知仓库配置前,应审查缓存 URL、公钥和其他 Nix 设置;受管终端不应全局无提示接受任意 Flake 配置。尤其不能把“开发者在提示框选择接受”混同于“multi-user daemon 已允许该 substituter”:前者接受仓库建议,后者才控制非特权用户能否使用额外下载源。
nixConfig = {
extra-substituters = [ "https://cache.example.com/nix" ];
extra-trusted-public-keys = [ "cache.example.com-1:<public-key>" ];
};multi-user daemon 还会检查 trusted-substituters 与调用用户权限。遇到“项目写了 cache 但仍本地构建”,依次核对 Flake 配置是否被接受、URL 是否由管理员允许、对象签名是否被信任、目标 system 是否有缓存对象。可以在未全局接受 Flake 配置的隔离账号上运行一次 nix flake show,预期出现配置确认或拒绝证据;再由管理员批准 URL 与公钥后重试。若直接静默成功,应检查该终端是否已全局启用 accept-flake-config,或调用者是否被错误加入 trusted-users。不要把开发者加入 trusted-users 作为快捷修复,也不要关闭签名校验;content-addressed 对象可能凭内容身份被接受,判断“某把公钥生效”时必须选用签名测试用的 input-addressed canary。
缓存的容量预算应分开计算四项:每个平台闭包大小、cache 保留空间、下载流量和 miss 后的本地算力。ARM 与 x86、Linux 与 Darwin 通常需要不同对象;矩阵越宽,缓存覆盖成本越高。命中率上升也可能长期保留旧漏洞闭包,因此 lock 更新、漏洞响应和缓存保留策略必须联动。
私有 input 与敏感数据
私有 Git input 可通过 Nix 的 access-tokens,HTTP(S) 认证可通过权限受控的绝对路径 netrc-file。令牌和 netrc 属于机器或 Runner 的运行时配置,不能写进 flake.nix、flake.lock、shellHook 或 derivation 环境。
Flake source 会被复制进 Store,Store 默认对本机其他用户可读,也可能进入 binary cache。不要把 .env、私钥或生产配置加入 Git 后再指望 .gitignore 保护它;一旦成为 Flake source,它就可能进入闭包。开发 Shell 只声明“应用从哪个运行时接口取秘密”,真正的值在命令启动时由权限受控文件、短期进程环境或秘密服务注入,并在日志中脱敏。
Fork 和外部 PR 也属于信任边界。flake.nix 与 shellHook 都是仓库代码,CI 不应在持有生产 cache 写凭证或私有 input token 的高权限 Runner 上直接执行未审查变更。先用无秘密、只读 Runner 完成求值与静态检查,合并后再进入需要私有资源的受保护作业。
故障证据按阶段收集
求值失败常见于未跟踪文件、纯求值访问宿主状态、属性缺失和错误 system;第一证据是 nix flake show、git status --short 与错误属性路径。锁失败表现为 --no-update-lock-file 拒绝继续;比较 flake.nix 与 flake.lock,不要删除 lock 重来。
实现失败常见于 cache 不可达、签名拒绝、sandbox 或本地构建资源不足;用 -L 查看完整日志,并区分 copying path 与 building。进入 Shell 后才失败,则检查 shellHook 退出码、实际 PATH、工具版本和项目脚本。相同命令只在某个平台失败时,先确认输出属性与包可用性,再查宿主内核、CA、文件系统和外部服务。
git status --short
nix flake metadata --no-update-lock-file
nix flake show
nix develop -L --no-update-lock-file --command envenv 可能包含敏感值,只能在隔离实验中查看,不能原样上传 CI 日志。团队故障模板应保存 Nix 版本、system、提交、lock 摘要、失败阶段、退出码和脱敏日志;这比一句“Flake 坏了”更快定位责任层。
清理、回退与长期治理
实验结束先退出 Shell,确认当前目录确实是一次性实验仓库,再列出并删除仓库内显式 profile、临时缓存和实验目录:
ls -ld .nix-dev-profile .nix-dev-profile-*-link 2>/dev/null || true
rm -f -- .nix-dev-profile .nix-dev-profile-*-link
rm -rf .cache/dev-shell
cd ..
test -d flake-shell-lab/.git || { echo "refusing to remove an unexpected path" >&2; exit 1; }
rm -rf -- flake-shell-lab
nix-store --gc --print-dead删除工作区不会立即删除 Store 闭包;只要 profile、其他项目或 generation 仍引用它们,就仍是 live。先查看 dead,再按机器 owner 的保留策略执行 GC。共享开发机上不得用删除所有旧 generation 的命令替代项目清理。
环境回退以 Git 提交为主:恢复配套的 flake.nix 与 flake.lock,用固定 Nix 版本执行 nix develop --no-update-lock-file --command ./scripts/check.sh。如果回退还依赖某个私有 input、cache 或外部服务,必须验证这些资源仍可访问;lock 只能指认输入,不能保证远端永远存在。关键发布应同时保存 lock、Nix 版本、目标 system、闭包路径和可用 cache 的保留期限,才能把“理论可重建”提升为有时间边界的恢复能力。
长期维护需要明确 Flake owner、支持的 system matrix、Nix 版本线、input 更新节奏、cache 与公钥 owner、冷构建演练和退出路径。轻量项目若只需要一两个运行时版本,普通版本管理器加语言 lockfile 成本更低;需要完整 Linux 用户空间隔离时,容器更清晰;需要可审查的多语言工具闭包、并存依赖和跨开发机/CI 入口时,Flake devShell 才能体现价值。选型的核心不是“有没有 lockfile”,而是团队能否持续证明锁住了哪些输入、哪些状态仍在宿主,以及失败时从哪一层回退。
