Nix 派生与 Store 内幕:derivation、输入寻址与引用图

derivation 是 Nix 构建的最小描述单元,store 路径则是它在文件系统中的唯一身份。本文深入 .drv 文件结构、store 路径哈希算法、输入寻址与内容寻址的差异、引用图与运行时闭包、gc root 机制,以及 store 的手动操作技巧。

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:

  1. buildInputs 里出现新的 store 路径 → .drv 的 env 变化
  2. .drv 内容变化 → .drv 自身路径变化 → 输出路径的哈希输入变化
  3. 输出路径变化 → 之前缓存的 demo 完全失效
  4. 引用扫描后,新产物引用 openssl-3,闭包变化

整个链条是纯函数式的:输入决定输出路径,输出路径决定引用,引用决定闭包。没有隐藏状态,没有「先删缓存再重来」的玄学。

想亲眼验证,可以用 Nix 构建调试与错误排查 里的 --show-trace 与依赖二分法,逐步定位是哪个输入把路径「顶」变了。

10. 总结

本文把 derivation 与 store 的机制拆成了几层:

  • derivation 是「如何构建」的纯数据,序列化为 .drv,可读可查
  • store 路径由输入哈希推导,构建前即可预知,是全局唯一地址
  • 引用图由构建后扫描得出,闭包是运行时可传输的最小单元
  • gc root 决定了什么「存活」,--print-roots 是排查「删不掉」的钥匙
  • 去重优化通过硬链接共享内容,nix store optimise 是常规运维动作

当你把这些机制内化后,Nix 的很多「怪现象」都会变成可解释、可预测的行为。下一步建议结合 Nix 构建与 CI 理解缓存层如何复用这些路径,以及用 NixOS 运维实战 中的世代与 GC 章节把机制落到生产环境。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nix」更多文章

  1. NixOS 代际管理与回滚:从 generation 机制到引导项治理
  2. Nix 语言生态打包:Python、Node 与 Rust 的依赖治理
  3. Nix 求值与构建性能优化:从 eval 剖析到远程构建