1. 为什么要下探到 derivation 与 store
大多数 Nix 用户停留在「写表达式、跑 nix build」的层面。真正让人卡住的时刻,往往来自更底层的问题:
- 为什么改了源码,store 路径就完全变了?路径里的那串哈希到底算了什么?
- 为什么
nix-store --delete有时删不掉,提示「still alive」? - 为什么
nix build成功后./result只是一个符号链接? - 为什么两个不同的包可以共享同一个 store 路径(
nix store optimise的魔法)? - 为什么有些构建在断网机器上也能命中缓存,而另一些不能?
这些问题的答案都指向两个概念:**derivation(派生)**与 store path(存储路径)。derivation 是「如何构建」的纯数据描述,store 路径是「构建结果」的全局唯一地址。理解了这对关系,你就理解了 Nix 可复现性的物理基础。
本文假设你已经读过 Nix 包管理实战 与 Nix 语言基础,聚焦机制层面。
2. derivation 的本质:一个描述构建的 attrset
2.1 derivation 不是「包」,而是「构建配方」
在 Nix 里,pkgs.hello 并不是一个包,而是一个 derivation——一个描述「如何生产某个产物」的数据结构。它由 builtins.derivation(或 nixpkgs 的 stdenv.mkDerivation)构造:
let
drv = derivation {
name = "hello";
system = builtins.currentSystem;
builder = "/bin/sh";
args = [ "-c" "echo hello > $out" ];
};
in drv
derivation 的参数就是它的「输入」:构建器、参数、环境变量、依赖。Nix 把这些输入序列化成一个纯数据文件,存放在 store 里,扩展名 .drv。
2.2 mkDerivation 在 derivation 之上做了什么
stdenv.mkDerivation 是一层厚厚的封装:它把你的 src、buildInputs、patches 等字段翻译成 shell 脚本能读的环境变量,再调用底层 derivation。所以:
| 层次 | 产物 | 说明 |
|---|---|---|
| 表达式层 | pkgs.hello | 一个 attrset,含 .drvPath 与 .outPath |
| 派生层 | /nix/store/xxx-hello.drv | 序列化后的构建描述 |
| 输出层 | /nix/store/yyy-hello | 构建产物 |
2.3 .outPath 与 .drvPath 的惰性
nix eval --raw nixpkgs#hello.drvPath
# => /nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-hello-2.12.1.drv
nix eval --raw nixpkgs#hello.outPath
# => /nix/store/yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy-hello-2.12.1
关键点:.drvPath 与 .outPath 都是纯计算出来的,不需要真正构建。因为输出路径的哈希只依赖输入(这就是「输入寻址」)。这正是 Nix 能在构建前就写下所有依赖引用、并把产物路径烘焙进二进制的原理。
3. .drv 文件长什么样
.drv 是 Nix 自己的一种 ATerm 格式文本文件,可以直接 cat:
nix derivation show nixpkgs#hello | head -40
输出是 JSON(nix derivation show 做了转换),核心字段如下:
{
"/nix/store/xxx-hello.drv": {
"args": ["-e", "source $stdenv/setup; genericBuild"],
"builder": "/nix/store/yyy-bash-5.2/bin/bash",
"env": {
"name": "hello-2.12.1",
"out": "/nix/store/zzz-hello-2.12.1",
"src": "/nix/store/aaa-hello-2.12.1.tar.gz",
"buildInputs": "",
"nativeBuildInputs": "/nix/store/bbb-stdenv-linux"
},
"inputDrvs": {
"/nix/store/ccc-stdenv.drv": { "dynamicOutputs": {}, "outputs": ["out"] }
},
"inputSrcs": ["/nix/store/aaa-hello-2.12.1.tar.gz"],
"outputs": { "out": { "path": "/nix/store/zzz-hello-2.12.1" } },
"system": "x86_64-linux"
}
}
几个关键字段:
- inputDrvs:本构建依赖的其他 derivation(会触发递归构建)
- inputSrcs:本构建依赖的已有 store 路径(源码、补丁等,不触发构建)
- env:构建时的环境变量,
$out就是产物落点 - outputs:声明的输出(可以有
out、dev、lib、bin多个)
整个 .drv 是闭合的:它只引用 store 里已经存在或可推导的东西,没有任何「系统状态」或「当前目录」的隐式依赖。这就是 可复现与封闭构建 的基石。
3.1 用 nix-store 读原始 ATerm
nix-store --query --tree $(nix-instantiate default.nix) # 依赖树
nix-store --query --references /nix/store/zzz-hello # 直接引用
nix-store --query --requisites /nix/store/zzz-hello # 运行时闭包
4. store 路径的构成与哈希
4.1 路径解剖
一个 store 路径长这样:
/nix/store/b6gvzjyb2pg0kjfwrjmg1vfhh54ad73z-firefox-121.0
└──────────────┬──────────────┘└──────┬──────┘
32 位哈希 名称
- 哈希:32 个字符,是 160 位哈希的 Base-32 编码(去掉易混淆字符)
- 名称:人可读的名字,不参与哈希计算之外的身份判定——实际上名称是参与哈希的,但不影响查找
4.2 两种路径的哈希算法
| 路径类型 | 例子 | 哈希输入 |
|---|---|---|
| 派生输出路径 | /nix/store/zzz-hello | 哈希整个 .drv(含所有输入路径) |
| 固定输出路径 | /nix/store/aaa-hello.tar.gz | 内容哈希(fetchurl 声明的 sha256) |
派生输出路径的哈希算法(output:out:sha256:<drvHash>:<outputName>)大致是:把 .drv 的完整内容喂给哈希函数,再与输出名拼接。这意味着:
- 任何输入变化 →
.drv变化 → 输出路径变化 - 输出路径是「预言」出来的,构建之前就确定
# 观察:改动一个环境变量,输出路径立刻改变
nix eval --raw --expr '(derivation { name="t"; system=builtins.currentSystem; builder="/bin/sh"; args=["-c" "echo 1 > $out"]; }).outPath'
4.3 内容寻址(CA)派生
传统派生是输入寻址(input-addressed):路径由输入决定,所以「同样的输出、不同的输入」会有不同路径,无法去重。
Nix 2.4+ 的实验特性 内容寻址派生(content-addressed derivations) 让路径由输出内容决定:
# 需要开启 experimental-features = ca-derivations
derivation {
name = "ca-demo";
__contentAddressed = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
# ...
}
好处是「相同内容必然同一路径」,缓存命中率与去重能力都更强;代价是构建流程需要「重写」下游引用(reference rewriting),生态成熟度仍在演进中。日常使用的大多数包仍是输入寻址。
5. 引用图与运行时闭包
5.1 引用是构建时扫描出来的
Nix 在构建完成后,会扫描产物里出现的所有 /nix/store/... 字符串,把它们登记为该产物的引用(references)。这个扫描是可配置的(disallowedReferences、allowedReferences),但默认会捕获全部。
nix-store --query --references /nix/store/zzz-hello
# => /nix/store/...-glibc-2.38-4
# /nix/store/...-hello-2.12.1
5.2 闭包:递归展开的引用集合
闭包(closure) 是「从某路径出发,递归收集所有引用」得到的集合。它是 Nix 运行时最小可运行单元:
nix path-info --closure-size -h nixpkgs#hello
# => 46.5M /nix/store/zzz-hello-2.12.1
注意:hello 二进制本身只有几百 KB,但闭包 46 MB——因为包含 glibc、bash 等运行时依赖。这就是 Nix「每个包自带依赖、不依赖系统全局库」的代价与优势。
# 导出闭包到可传输的归档
nix-store --export $(nix-store --query --requisites ./result) > hello.closure
# 在另一台机器导入
nix-store --import < hello.closure
5.3 闭包与缓存的关系
二进制缓存(substituter)判断「能否跳过构建」,靠的就是闭包中每个路径是否都有对应 narinfo。这解释了为什么有时「只改了一行」却要重下几百 MB——路径全变了,缓存全失效。参见 Nix 构建与 CI。
6. gc root:让产物不被回收
6.1 GC 的判据是「可达性」
Nix 的垃圾回收不是引用计数,而是从一组根(roots)出发的可达性分析。凡是不可达的 store 路径,都可能被 nix-collect-garbage 删除。
根来自几处:
/nix/var/nix/gcroots/:系统级间接根(指向真正的根)/nix/var/nix/profiles/:profile 的世代链接/nix/var/nix/gcroots/auto/:由nix-build在./result存在时自动创建的根
nix-store --query --roots /nix/store/zzz-hello
# => /nix/var/nix/gcroots/auto/xxxx -> /home/user/proj/result
6.2 手动建立根
# 把一个 store 路径登记为根,防止被 GC
nix-store --add-root ./my-root --indirect --realise /nix/store/zzz-hello
# 查看所有根
nix-store --gc --print-roots
# 手动删除根后即可回收
rm ./my-root
nix-collect-garbage
6.3 为什么「删不掉」
如果 nix-store --delete 报错 cannot delete path ... it is still alive,说明存在一条从根到它的路径。用 --print-roots 与 nix-store --query --roots 反查即可定位是谁在「持有」它。
7. store 布局与去重优化
7.1 目录布局
/nix/store/ # 所有 store 路径的扁平命名空间
/nix/var/nix/db/ # SQLite 数据库,记录路径、引用、deriver 等元数据
/nix/var/nix/profiles/ # profile 世代链接
/nix/var/nix/gcroots/ # 根
/nix/var/nix/temproots/# 临时根
/nix/store 是扁平的——所有路径平铺在一层,没有嵌套目录。这让路径引用简单(一个字符串即全局地址),也让数据库索引高效。
7.2 硬链接去重
nix store optimise 会扫描 store 中内容相同的文件,把它们替换成指向同一 inode 的硬链接:
nix store optimise
# 或在 nix.conf 中开启自动优化
# auto-optimise-store = true
这能让多个相似闭包共享底层文件,显著节省磁盘。代价是 nix store optimise 本身是 I/O 密集操作,且与 nix store gc 并发时需谨慎。
7.3 数据库的作用
nix-store --query --deriver /nix/store/zzz-hello # 哪个 .drv 产出了它
nix-store --query --referrers /nix/store/...glibc # 谁引用了它
--referrers 是反向索引,依赖数据库。如果数据库损坏(nix-store --verify --check-contents),这些查询会失效,但 store 本身仍可用。
8. 手动操作 store 的实用命令
8.1 查询与检查
nix path-info -rSh nixpkgs#hello # 递归列出闭包、大小、哈希
nix store verify --all # 校验 store 完整性
nix-store --verify --check-contents # 逐字节校验(慢)
nix store diff-closures ./result-before ./result-after # 对比两个闭包差异
nix store diff-closures 是升级排查利器——它告诉你「这次升级究竟增删了哪些包、体积变化多少」。
8.2 导出、导入与复制
# 导出单个闭包(含所有依赖)为 NAR 归档流
nix-store --export $(nix-store --query --requisites ./result) > pkg.nar
# 复制到远程 store(走 SSH)
nix copy --to ssh://server ./result
# 从二进制缓存拉取
nix copy --from https://cache.nixos.org /nix/store/zzz-hello
8.3 从零观察一次构建
nix build --no-link --print-out-paths nixpkgs#hello # 只打印输出路径
nix log /nix/store/zzz-hello # 查看构建日志
nix derivation show $(nix-instantiate -E 'import <nixpkgs> {}') # 全量派生
9. 实战:为什么「改一行就全变」
考虑如下表达式:
{ pkgs ? import <nixpkgs> {} }:
pkgs.stdenv.mkDerivation {
pname = "demo";
version = "1.0";
src = ./.;
buildInputs = [ pkgs.openssl ];
buildPhase = "gcc -o demo main.c -lssl";
installPhase = "mkdir -p $out/bin; cp demo $out/bin";
}
若把 openssl 换成 openssl_3:
buildInputs里出现新的 store 路径 →.drv的env变化.drv内容变化 →.drv自身路径变化 → 输出路径的哈希输入变化- 输出路径变化 → 之前缓存的
demo完全失效 - 引用扫描后,新产物引用
openssl-3,闭包变化
整个链条是纯函数式的:输入决定输出路径,输出路径决定引用,引用决定闭包。没有隐藏状态,没有「先删缓存再重来」的玄学。
想亲眼验证,可以用 Nix 构建调试与错误排查 里的 --show-trace 与依赖二分法,逐步定位是哪个输入把路径「顶」变了。
10. 总结
本文把 derivation 与 store 的机制拆成了几层:
- derivation 是「如何构建」的纯数据,序列化为
.drv,可读可查 - store 路径由输入哈希推导,构建前即可预知,是全局唯一地址
- 引用图由构建后扫描得出,闭包是运行时可传输的最小单元
- gc root 决定了什么「存活」,
--print-roots是排查「删不掉」的钥匙 - 去重优化通过硬链接共享内容,
nix store optimise是常规运维动作
当你把这些机制内化后,Nix 的很多「怪现象」都会变成可解释、可预测的行为。下一步建议结合 Nix 构建与 CI 理解缓存层如何复用这些路径,以及用 NixOS 运维实战 中的世代与 GC 章节把机制落到生产环境。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。