Nix:从 Store、Derivation 到 Flakes 与 devShell
Nix 的核心对象和项目入口属于同一条运行链
Nix 表达式先被求值得到 derivation,构建或 substituter 再实现不可变 Store 对象;profile、generation 和 GC root 决定哪些闭包仍然可达。Flake 在这套机制上增加输入图、锁文件和按 system 组织的 outputs,devShell 则是其中一种项目输出。把基础对象与 Flake 拆成两篇,会重复安装、信任、缓存和清理边界,也容易让读者误以为 Flake 是另一套包管理器。
本篇按一条可验证的产品链组织:先理解 Store、derivation、profile 与 GC,再建立 Flake 输入锁和 devShell,最后把开发机、CI、二进制缓存、权限和退出动作放进同一份证据。锁文件只能约束声明过的输入,不能锁住宿主内核、外部服务、凭据和 shellHook 副作用。
一台共享开发机为什么越装越乱
新成员接手一个旧服务时,安装文档只写了“准备 jq、编译器和某个系统库”。他用系统包管理器补齐工具,服务终于启动;几天后另一项目要求不同版本,他升级了全局包,原服务却在链接阶段失败。回退系统包会影响整台机器,不回退又无法复现原来的构建环境,最后只能把“在老同事电脑上能跑”当成隐含依赖。
Nix 改变的不是包管理器命令,而是安装模型:构建结果进入按 Store path 区分的不可变 Nix Store,环境通过符号链接与依赖闭包引用这些对象,升级通常创建新的引用关系,而不是覆盖旧目录。这样做让并存、回退和垃圾回收成为同一套可观察机制,也带来新的责任:磁盘不会因为退出 Shell 自动释放,缓存地址不等于可信来源,写进构建输入的秘密可能永久进入所有用户可读的 Store。
先选对安装所有权
Nix 官方的 安装入口 会按平台给出命令。Linux 与 macOS 优先采用 multi-user:Store 和数据库由特权身份持有,普通用户把构建请求交给 Nix daemon,构建在专用 build users 下运行。它适合多人共用机器和长期开发机,因为下载与 Store 可以共享,构建身份也与登录用户隔离。
官方安装器的 Linux multi-user 路线要求 systemd,当前文档还要求关闭 SELinux;不满足这些前提时才考虑 single-user,并应在安装前复核目标版本的支持说明。single-user 让调用用户持有 /nix,依赖少一些,但共享、隔离和安全边界都更弱。macOS 不支持 single-user。原生 Windows 不是 Nix 运行平台;Windows 开发者应在 WSL2 的 Linux 文件系统内使用,WSL2 未启用 systemd 时用 single-user,启用 systemd 后用 multi-user。项目源码也宜放在 WSL 文件系统内,避免跨 /mnt/c 的权限、大小写和 I/O 语义干扰实验。
# Linux + systemd,或启用了 systemd 的 WSL2
curl -L https://nixos.org/nix/install | sh -s -- --daemon
# Linux 无 systemd,或未启用 systemd 的 WSL2
curl -L https://nixos.org/nix/install | sh -s -- --no-daemon
# macOS:安装器选择 multi-user
curl -L https://nixos.org/nix/install | sh在受管机器上,不应盲目执行浮动的 curl | sh。管理员先下载脚本,检查来源与内容;需要固定安装基线时,从 releases.nixos.org 选择版本化脚本并校验同目录摘要。安装模式还决定故障责任:multi-user 的客户端配置、daemon 配置和 build user 是三层对象,普通开发者能运行 nix,不代表他有权改变 daemon 的 substituter 或信任根;single-user 则把 Store 所有权和故障影响集中到当前账号。安装结束后新开终端,记录二进制、当前 system、Store 所有者和 daemon 连接方式:
nix --version
nix-instantiate --eval --expr builtins.currentSystem
nix-store --version
ls -ld /nix /nix/store
nix --extra-experimental-features nix-command config show \
| grep -E '^(store|system|sandbox|substituters|require-sigs) ='nix --version 只证明客户端存在。multi-user 下还要确认 daemon 正常,并确认 /nix/store 不是登录用户可任意写入;若出现 cannot connect to socket,先看 systemctl status nix-daemon 和 daemon 日志,而不是重装包。WSL2 中同一错误常见于 systemd 没有真正启动,或安装模式与当前 init 模式不一致。若 Store 由普通开发账号直接持有,却宣称已经完成 multi-user 安装,应暂停团队接入并复核安装记录,这不是可以靠增加目录写权限掩盖的小问题。
表达式、derivation 与 Store 是怎样接起来的
Nix 表达式是一种惰性、函数式配置语言。求值阶段把声明解析成值;当声明产生 derivation 时,Nix 会得到一份构建配方,里面包含 builder、参数、环境、目标 system 和输入路径。.drv 是这份配方在 Store 中的表示。传统 input-addressed derivation 的输出路径由配方及其输入决定;fixed-output 与实验性的 content-addressed derivation 另有寻址规则。相同名称不代表相同对象,只要影响路径身份的输入或构建声明变化,就可能得到另一条 /nix/store/<hash>-name。
先用一个小包观察完整链路。下列命令适用于已经配置 Nixpkgs 查找路径的 Linux、macOS 或 WSL2;--dry-run 先显示将下载或构建什么,避免在不知情时触发昂贵源码构建。
mkdir nix-foundation-lab && cd nix-foundation-lab
nix-build '<nixpkgs>' -A hello --dry-run
nix-build '<nixpkgs>' -A hello
readlink -f result
result/bin/hello
nix-store -q --deriver "$(readlink -f result)"
nix-store -q --references "$(readlink -f result)"
nix-store -qR "$(readlink -f result)" | wc -l预期 result 是指向 /nix/store/...-hello-... 的符号链接,程序打印问候文本,--deriver 返回对应 .drv,--references 给出直接引用,-qR 展开整个运行时闭包。闭包是“从当前对象沿引用可达的 Store 对象集合”,它比单个输出路径更接近部署与缓存的真实成本。
Store 不可变并不等于构建必然得到字节级相同结果。时间、随机数、未声明网络输入、非确定性归档顺序和编译器行为仍可能使同一配方产生不同字节;Nix 让已声明输入和依赖图参与对象身份,具体输入是否被锁定、构建是否可复现还需要项目声明与上游构建过程配合。团队应把“版本已选定”“闭包已实现”“重复构建字节一致”“运行行为等价”当成四个不同结论。
正向实验:让项目只在临时 Shell 中看到工具
在实验目录创建 shell.nix。这里用非 Flake 入口观察基础对象,<nixpkgs> 指向机器当前配置的 Nixpkgs,因此不会自动锁定 revision;跨机器固定输入由 Flake 篇继续解决。
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShellNoCC {
packages = [ pkgs.jq pkgs.curl ];
LAB_MARKER = "nix-foundation";
shellHook = ''
echo "entered $LAB_MARKER"
'';
}先确认宿主当前是否已有 jq,再进入临时环境执行一次真实操作:
command -v jq || true
nix-shell --run 'printf "{\"status\":\"ok\"}\n" | jq -r .status; test "$LAB_MARKER" = nix-foundation'
echo "exit=$?"
command -v jq || true预期 Shell 内输出 ok 且退出码为 0。如果宿主原先没有 jq,退出 nix-shell 后它仍不在普通 PATH;工具没有被永久安装进用户 profile。若最后一条仍找到 jq,先用 type -a jq 判断它来自系统包、其他版本管理器还是已有 profile,不能据此认定 Nix Shell 泄漏。
项目可把 shell.nix 与 scripts/check.sh 一起提交,并让开发者、IDE 任务和 CI 调用同一入口:
nix-shell --run './scripts/check.sh'这能统一工具闭包,却不会声明宿主内核、Docker daemon、企业 CA、GPU、文件系统大小写和外部数据库。项目检查脚本应主动验证这些逃逸输入,例如打印 uname -s -m、检查必要 socket 和 CA 文件是否存在,并在不满足时明确失败。
反向实验:从失败证据反推是哪一层
第一类错误发生在求值阶段。把 pkgs.jq 故意改成不存在的 pkgs.jqq 后执行:
nix-instantiate shell.nix
echo "exit=$?"预期非零退出,并出现 attribute 'jqq' missing 一类信息。此时 builder 尚未运行,网络和缓存不是首要方向;修正属性后重新 nix-instantiate,再进入 Shell。
第二类错误发生在实现阶段。保留正确表达式,临时禁止 substitute:
nix-shell --option substitute false --run 'jq --version'这可能触发长时间源码构建,因此只应用于小型隔离实验。若默认命令下载成功而禁用 substitute 后构建失败,证据指向本地 builder、sandbox、编译资源或缺失系统能力;若两者都在求值阶段失败,问题仍在表达式或输入。
第三类错误是试图原地修改 Store:
store_path="$(readlink -f result)"
printf 'changed\n' > "$store_path/SHOULD_FAIL"
echo "exit=$?"普通用户应得到只读文件系统或权限拒绝。不要用 sudo 强改 Store;那会绕过数据库与哈希信任,污染所有引用该对象的用户。需要修改程序时,应改变表达式或源码并生成新的 Store path。
Profile、generation 与 GC root 为什么会一起增长
临时 Shell 解决项目工具并存,profile 解决用户希望长期暴露在 PATH 中的程序。一次安装或升级通常创建新的 profile generation;旧 generation 保留对旧 Store 闭包的引用,所以可以回退,也会继续占磁盘。
nix-env -iA nixpkgs.jq
nix-env --list-generations
nix-env -q
nix-env --rollbackprofile generation、result 链接和显式注册的链接都可能成为 GC root。GC 从所有 root 出发标记可达 Store 对象,未被标记的对象才是 dead。退出进程、删除源码目录或从 PATH 移除命令,都不等于闭包已经可回收。
用隔离链接观察这个转变,避免操作全局 generation:
out="$(nix-build '<nixpkgs>' -A hello --no-out-link)"
nix-store --realise "$out" --add-root "$PWD/lab-root"
nix-store --gc --print-live | grep -F "$out"
rm "$PWD/lab-root"
nix-store --gc --print-dead | grep -F "$out" || true有 lab-root 时目标闭包应出现在 live 集合;删除链接后,它只有在没有其他 root 引用时才可能出现在 dead 集合。--print-dead 只是预览,不会删除。共享机器上不要把 nix-collect-garbage -d 当成日常清理按钮:它会删除旧 profile generations,连同团队期待的回退窗口一起缩短。
真正回收前,先记录 profile generations、--print-live、--print-dead 和目标闭包大小:
nix-env --list-generations
nix --extra-experimental-features nix-command path-info -Sh "$(readlink -f result)"
nix-store --gc --print-dead实验结束可删除 result、shell.nix 和实验目录。卸载 Nix 则必须使用当前安装模式对应的官方步骤;multi-user 还涉及 daemon、build users 和 /nix,不能只删客户端二进制。
Substituter 是加速路径,也是供应链边界
当 Nix 需要实现某个 Store path 时,会先查询 substituter;常见 substituter 是 binary cache。命中可信对象就下载 NAR,未命中才本地或远程构建。substituters 决定“去哪里找”,trusted-public-keys 与默认开启的 require-sigs 决定“得到的对象能否信任”,两层不能合成一项配置。对传统 input-addressed Store 对象,可信签名是跨 Store 复制的关键证据;content-addressed 对象可以由内容身份满足信任条件,因此“下载成功”不必然说明某把公钥参与了验签。
# /etc/nix/nix.conf,由管理员维护的示意配置
substituters = https://cache.nixos.org/ https://cache.example.com/nix
trusted-public-keys = cache.nixos.org-1:... cache.example.com-1:<public-key>
require-sigs = true先用 nix config show 检查生效值,再用 --dry-run 观察计划。Nix 2.20 起使用这一名称;更旧客户端的同类命令是 nix show-config,团队基线不应混用两套接口。日志出现 copying path ... from ... 表示缓存命中;没有可用 substitute 时才会按构建计划本地或远程实现。若 Nix 已发现 substitute、但下载该对象失败,默认不会因此改做昂贵的源码构建;只有显式允许 --fallback 才会走这条降级路径。无论命令最终成功还是失败,都要保存实际来源、回退情况和耗时,不能只凭退出码判断私有缓存健康。
缓存上线不能拿开发者已经拥有的闭包做验收,因为本地命中会绕过下载与验签。缓存管理员应发布一个新的、无业务数据的 canary 闭包,并在尚未拥有该闭包的隔离节点上完成两次拉取:第一次配置正确 URL 与公钥,预期日志出现 copying path 且退出 0;第二次只替换为测试公钥,预期 input-addressed canary 因签名不受信而非零退出。若第二次仍成功,先查本地 Store 是否已有对象、对象是否为 content-addressed,以及 daemon 是否仍从系统配置读到了原公钥,不能直接宣告“错误公钥也能用”。验收命令应显式打印实际配置和目标路径,但要过滤 token:
nix --extra-experimental-features nix-command config show \
| grep -E '^(substituters|trusted-public-keys|require-sigs|trusted-substituters) ='
nix-store --query --hash /nix/store/<canary-path>multi-user 模式下,普通用户只能使用管理员列入 trusted-substituters 的附加地址,除非该用户属于 trusted-users。不要为省一次配置审批就把全体开发者加入 trusted-users:官方配置说明明确指出,这项能力本质上接近 root 权限。公钥错误应让对象校验失败,不能把 require-sigs 关闭来制造成功结果。
凭证和秘密不能经过 Store
Nix Store 默认对本机所有用户可读,Store 对象还可能上传到共享 cache。只要秘密进入 Nix 字符串、源码输入、derivation 环境变量或生成文件,就可能出现在 .drv、构建日志或输出闭包中。下面这种写法即使来自环境变量也不安全:
# 错误示例:求值后秘密可能进入 derivation
pkgs.runCommand "config" { API_TOKEN = builtins.getEnv "API_TOKEN"; } ''
echo "$API_TOKEN" > $out
''私有 input 的访问令牌、HTTP netrc-file 和 cache 凭证应保留在 Nix 的运行时配置或专用秘密系统中,配置文件本身限制权限;构建输出只保存公开依赖和无秘密配置。应用启动后再从受控文件、进程凭证或秘密服务读取值,并确保 CI 不打印 nix config show 中可能包含的认证字段。
容量、成本与团队维护
Nix 把“全局覆盖冲突”换成“并存对象与引用治理”。容量应以闭包、generation 数量、缓存命中率和冷构建时间衡量,而不是只看包数量。nix path-info -Sh 可看闭包大小,nix-store --gc --print-dead 可看候选;团队还应分别记录开发机 Store、高速缓存存储、网络下载和 cache miss 算力成本。
治理策略需要同时指定 Nix 版本线、安装模式 owner、允许的 substituter、公钥轮换、profile generation 保留窗口、故障复现闭包保留期和 GC 变更入口。公钥轮换应有“新旧公钥并存、缓存双签、客户端完成迁移、停止旧签名、移除旧公钥”的窗口;直接替换公钥会让尚未更新的开发机把正常对象判断为不可信。keep-derivations 与 keep-outputs 会在可追溯性、重建速度和磁盘之间移动成本,修改前应在代表性工作负载上比较 live 集合与冷重建时间。
只需要几个语言运行时且系统库稳定时,mise、asdf 等版本管理器更轻;需要完整用户配置时,Home Manager 更适合管理 dotfiles 与用户服务;需要内核级隔离或完整 OS 合同时,应选择容器、虚拟机或远程工作区。Nix 的优势在依赖闭包、并存与声明式实现,不是替代所有隔离层。团队退出 Nix 时,也要先导出项目工具清单、替代缓存来源和 CI 入口,再按 generation、root、Store、daemon 的顺序清理,避免删除 /nix 后才发现仍有构建链依赖它。
同一个 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”,而是团队能否持续证明锁住了哪些输入、哪些状态仍在宿主,以及失败时从哪一层回退。
