Nix 基础:从 Store、Derivation 到可回收的开发环境
一台共享开发机为什么越装越乱
新成员接手一个旧服务时,安装文档只写了“准备 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 后才发现仍有构建链依赖它。
