Store 垃圾回收与存储优化:gc root、去重与瘦身

内容寻址意味着 store 从不覆盖旧路径,一次重建就多出一份闭包,磁盘很快被历代产物吃满。回收的唯一依据是 gc root,而不是「看起来没人用」。本文讲清 gc root 的六种来源、手动与自动回收参数、硬链接去重的原理与代价、占用分析方法、结构性瘦身手段,以及误删与存储损坏后的恢复路径。

引言

Nix 的 store 是内容寻址的:同一个 derivation 永远产出同一个路径,路径一旦存在就永不覆盖。这个性质带来了缓存复用与原子切换,也带来了一个必然结果——每次修改配置重新构建,旧路径并不会被替换,而是与新路径并存。一台跑了几年的 NixOS 机器,/nix/store 轻松涨到几百 GB。

回收机制的核心概念只有一个:gc root(垃圾回收根)。从根出发沿引用图可达的路径一律保留,其余称为「死路径」,可以被删除。绝大多数「删不掉」「删错了」的问题,都源于对根的理解偏差。本文从根出发,讲清回收参数、去重优化、占用分析与结构性瘦身。

前置:derivation 与 store 内幕 、求值与构建性能优化 。

目录

1. store 为什么会一直变大

1.1 三个放大器

① 内容寻址:路径由输入哈希决定,输入变一点就是一个全新路径,旧路径原地不动
② 引用闭包:保留一个路径往往连带保留它的整条运行时闭包(可能是几百个路径)
③ 多层根:系统 profile、用户 profile、项目目录里的 result 符号链接,各留一份
典型曲线:首次安装数 GB;每次 nixos-rebuild switch 再 +1~3 GB(新闭包 + 旧世代);
          每次开发构建数百 MB;长期不回收则累积到数百 GB。

1.2 为什么不能靠「删文件」解决

/nix/store 下的路径是共享的:同一个库可能被几十个包同时引用,
直接 rm -rf 会让其他包的运行环境瞬间损坏,
而且 store 的元数据库不知道你删了东西,后续操作会出现不一致。
正确做法只有一个:让路径变成"不可达",再交给 GC 统一删除。

记忆:store 变大是内容寻址的必然结果——三个放大器是「路径不覆盖、闭包连带保留、多层根各留一份」;绝不能直接 rm,只能让路径不可达后交给 GC。

2. gc root 是回收的唯一依据

2.1 可达性判定

GC 算法(概念版):
  ① 枚举所有 gc root
  ② 从每个根出发,沿 .drv 与产物的引用关系做图遍历
  ③ 可达集合 = 保留;不可达集合 = 死路径,可删除
因此:"没人用"是主观判断,"不可达"才是客观事实。

2.2 根的六种来源

来源位置说明
直接根/nix/var/nix/gcroots/手工或工具创建的符号链接
间接根/nix/var/nix/gcroots/auto/指向别处的符号链接的自动登记
profile 根/nix/var/nix/profiles/、~/.local/state/nix/profiles/每个世代是一个根
项目 result项目目录里的 result 符号链接通过 auto 目录成为间接根
运行中进程/proc/*/root、打开的文件正在被使用的路径不会被删
引导项NixOS 的 system-*-link启动菜单里的每个世代

2.3 查看根与死路径

nix-store --gc --print-roots        # 列出所有根(含类型与目标)
nix-store --gc --print-dead | head  # 只看将被删除的死路径
nix-store --gc --print-live | wc -l # 统计活路径数量

2.4 两个容易被忽略的点

① 运行中进程也是根:GC 会扫描 /proc,正在运行的服务所引用的路径不会被删;
   但存在竞态——进程启动前路径被删就会启动失败,生产机回收应安排在维护窗口。
② 间接根的残留:删掉项目里的 result 之后仍可能"删不掉",
   因为 gcroots/auto 里还有一条指向它的登记,需要先清掉登记。

记忆:GC 只认「从根可达」——六种根是直接根、间接根(auto 登记)、profile 世代、项目 result 链接、运行中进程、引导项;删除 result 后可能仍删不掉,是因为 auto 里还留着登记。

3. 手动回收与常用参数

3.1 基本命令与参数

nix-collect-garbage          # 老命令
nix store gc                 # 新命令,等价
nix store gc --dry-run       # 先看会删什么
nix-store --gc --print-dead | wc -l   # 死路径条数
参数作用
-d / --delete-old先删除所有 profile 的旧世代,再回收
--delete-older-than 30d只删除早于 30 天的世代
--max-freed 10G释放够 10G 就停止(避免长时间 IO 占用)
--dry-run只报告不删除
nix-collect-garbage --delete-older-than 30d   # 按时间回收
nix-collect-garbage --max-freed 20G           # 限量回收

3.2 回收单个路径

nix store delete /nix/store/<hash>-<name>              # 仍被引用时会拒绝删除
nix-store --query --referrers /nix/store/<hash>-<name> # 查谁在引用它

3.3 为什么「删了没效果」

常见原因:
  ① 只删了 result 链接,没删 profile 里的世代 → 世代仍是根
  ② 世代删了但 auto 登记还在 → 仍是间接根
  ③ 服务正在运行 → /proc 让它仍是根
  ④ 删除的是硬链接去重后的路径,磁盘并未真正释放(见第 5 节)

记忆:手动回收用 nix-collect-garbage(= nix store gc),-d 先删旧世代、--delete-older-than 按时间、--max-freed 限量、--dry-run 演练;「删了没效果」四个原因:只删 result、auto 登记残留、进程仍在用、硬链接导致未真正释放。

4. 自动回收策略

4.1 定时回收

# NixOS:每周回收一次,删除 30 天前的世代
nix.gc = {
  automatic = true;
  dates = "weekly";
  options = "--delete-older-than 30d";
};

dates 使用 systemd 时间表达式(daily、weekly、*-*-* 03:00:00 等),背后是 nix-gc.service 与 nix-gc.timer。

4.2 按剩余空间触发

# 空闲空间低于 min-free 时自动触发 GC,直到达到 max-free
nix.settings.min-free = 5 * 1024 * 1024 * 1024;      # 5 GiB
nix.settings.max-free = 20 * 1024 * 1024 * 1024;     # 20 GiB
这两个值的作用是"防爆盘":低于 min-free 时守护进程主动回收,
回收直到空闲达到 max-free 为止。与定时 GC 互补:定时管日常,min-free 管突发。
注意:min-free 触发的是回收,不保证能腾出足够空间(死路径本来就少时会失败)。

4.3 场景化建议

场景建议配置
开发机nix.gc 每周 + --delete-older-than 14d
生产服务器nix.gc 每周 + --delete-older-than 30d,并设 min-free
CI 构建机每次任务结束回收,或把 min-free 设大一些
磁盘紧张的容器只留 1~2 个世代,依赖 min-free 兜底

4.4 观察回收是否真的跑了

systemctl list-timers | grep nix-gc
systemctl status nix-gc.service --no-pager
journalctl -u nix-gc.service -n 30 --no-pager
df -h /nix

记忆:自动回收两条腿——定时 nix.gc(dates + --delete-older-than)管日常,min-free/max-free 管突发爆盘;用 systemctl status nix-gc 与 journalctl -u nix-gc 确认它真的跑过。

5. 硬链接去重与存储优化

5.1 为什么会有重复内容

同一个文件出现在多个 store 路径里的情况非常普遍:
每个新世代都包含一份几乎相同的闭包,多个包也各自带一份相同的许可证文本与图标。
这些文件内容相同但路径不同,逻辑上占两份磁盘。

5.2 优化开关与效果

nix.settings.auto-optimise-store = true;   # NixOS:每次构建后自动去重
nix store optimise                          # 手动对已有 store 做一次全量去重
du -sh --apparent-size /nix/store           # 逻辑大小
du -sh /nix/store                           # 实际占用(差值即去重节省)

原理是:对内容相同的文件建立硬链接,让多个路径共享同一个 inode,从而只占一份磁盘。

5.3 代价与注意事项

代价:
  - 每次构建后多一次全 store 扫描,构建密集的机器上 IO 明显增加
  - 文件系统必须支持硬链接(部分网络文件系统不支持,会导致失败)
风险:
  - 去重后的路径相互耦合,删除其中一个只减少链接计数,磁盘不一定释放
  - 若怀疑 store 被外部修改,用 nix store verify --all 校验、nix store repair 修复
建议:
  - 长期运行、世代保留较多的机器开启(收益通常 20%~40%)
  - 一次性构建容器可以不开启;先量化差值再决定

记忆:auto-optimise-store/nix store optimise 用硬链接让相同内容的文件共享 inode,收益常达 20%~40%;代价是每次构建后多一次全 store 扫描,且要求文件系统支持硬链接;先比较 du -sh 与 du -sh --apparent-size 再决定。

6. 分析 store 占用

6.1 从闭包角度看

nix path-info -Sh /run/current-system                      # 当前系统闭包大小
nix path-info -Sh --recursive /run/current-system | sort -k2 -h | tail -20
nix path-info -S --json /run/current-system \
  | jq -r 'to_entries[] | "\(.value.narSize)\t\(.key)"' | sort -n | tail

6.2 从引用图角度看

nix-tree /run/current-system   # 交互式浏览引用图:谁依赖谁、谁占多大
nix-du -s=500MB | dot -Tpng > store.png   # 可选:生成占用树图

nix-tree 的价值在于把「某个包为什么被保留」直接展示出来——顺着父节点一路往上,就能找到那个 gc root。

6.3 一个分析流程

① df -h /nix                          → 总量与增长趋势
② nix path-info -Sh /run/current-system → 当前系统闭包有多大
③ nix-tree /run/current-system        → 谁是大头、为什么被保留
④ nix store gc --dry-run              → 回收能释放多少
⑤ du -sh vs du -sh --apparent-size    → 去重还能省多少

记忆:占用分析四把尺子——df -h /nix 看总量、nix path-info -Sh 看闭包、nix-tree 看引用图找大头与根、nix store gc --dry-run 看可回收量;再用 du -sh 与 --apparent-size 的差值看去重潜力。

7. 结构性瘦身手段

7.1 控制保留策略

# 默认 keep-derivations = true、keep-outputs = false
nix.settings.keep-derivations = false;
nix.settings.keep-outputs = false;
含义:keep-derivations 决定是否保留 .drv 作为根的传播;
      keep-outputs 决定是否保留构建输入的输出版本。
这两个开关影响"为了能重新构建而保留多少":关掉后 store 更小,
但重建时需要重新下载或重建部分依赖。

7.2 不让每次构建都留下根

nix build .#mypkg --no-out-link      # 不创建 result 符号链接(用完即弃)
nix shell nixpkgs#jq -c jq --version # nix shell / nix run 不注册长期根
注意:--no-out-link 得到的路径没有根,下次 GC 就会被回收,
      因此只适合"用完即弃"的验证,不适合需要保留的产物。

7.3 把大文件请出 store

不该放进 store 的内容:数据集、模型权重、虚拟机镜像、日志归档。
代价:每次变更都新增一份完整副本,且无法增量存储。
替代:放在普通目录(如 /srv/data)在配置里只引用路径;
      需要版本化时用独立的制品库,而不是 store。

记忆:结构性瘦身三招——关掉 keep-derivations/keep-outputs 减少保留、临时验证用 --no-out-link、数据集与镜像不要放进 store。

8. 代际与 profile 的清理

8.1 系统与用户世代

nix-env --list-generations --profile /nix/var/nix/profiles/system
nix-env --delete-generations +5 --profile /nix/var/nix/profiles/system   # 留最近 5 个
nix-env --delete-generations 30d --profile /nix/var/nix/profiles/system  # 删 30 天前
nix-env --list-generations && nix-env --delete-generations +3           # 用户 profile
home-manager expire-generations "-30 days"                               # Home Manager
删除世代只是去掉 gc root,磁盘释放要等后续 GC;
因此"删世代 + 回收"要成对执行(`-d` 参数就是替你做了这两步)。

8.2 引导项与世代的关系

boot.loader.systemd-boot.configurationLimit = 10;
boot.loader.grub.configurationLimit = 10;

configurationLimit 会清理超出数量的旧世代,因此它既是「启动菜单整洁」的手段,也是「控制保留量」的手段。

8.3 推荐的保留策略

机器类型保留世代数理由
开发机5~10需要频繁回滚到近期配置
生产服务器10~20保留足够回滚纵深
CI 构建机1~2只关心当前配置,磁盘优先
边缘设备3~5磁盘小,回滚需求有限

记忆:世代是最大的根——nix-env --delete-generations +5 保留最近 5 个、configurationLimit 同时管启动菜单与根的数量;删世代只去掉根,必须再跑一次 GC 才真正释放磁盘。

9. 安全边界与恢复

9.1 不要手工删除 store 路径

禁止:rm -rf /nix/store/xxx
原因:破坏共享 inode 与元数据库一致性,可能导致大量包损坏
正确:nix store delete(会检查引用)或交给 GC

9.2 校验与修复

nix store verify --all                        # 校验所有路径的内容哈希(耗时)
nix store repair /nix/store/<hash>-<name>     # 从缓存重新拉取或重建
nix-store --verify --check-contents --repair  # 全量校验并修复

9.3 并发与共享 store

并发风险:GC 删除某路径时,脚本恰好要启动它 → 报 No such file or directory。
  缓解:生产机在维护窗口回收或先停服务;用 --max-freed 限制单次时长。
共享 store(网络文件系统或 SSH store):
  GC 必须只在"拥有该 store 的机器"上运行,客户端只做构建与使用;
  否则会删掉别人正在用的路径。定时 GC 只配在 store 主机上。

9.4 爆盘后的急救顺序

nix-env --delete-generations +2 --profile /nix/var/nix/profiles/system
nix-collect-garbage --max-freed 20G
nix-tree /run/current-system                        # 找出大闭包
du -sh /nix/store/* 2>/dev/null | sort -h | tail -20

记忆:安全边界四条——不手工 rm store、怀疑损坏用 nix store verify 与 repair、回收要避开启动竞态、共享 store 只在 store 主机上回收;爆盘急救顺序是删旧世代 → 限量回收 → nix-tree 找大头。

10. 速查表与一句话记忆

需求命令
演练回收nix store gc --dry-run
删旧世代并回收nix-collect-garbage -d
按时间回收nix-collect-garbage --delete-older-than 30d
去重nix store optimise
看闭包与引用图nix path-info -Sh / nix-tree
校验修复nix store verify --all / nix store repair

一句话记忆:store 只增不减是内容寻址的必然结果,回收的唯一依据是「从 gc root 可达」——六种根分别是直接根、auto 间接根、profile 世代、项目 result 链接、运行中进程与引导项;因此「删了没释放」几乎总是根没去掉或硬链接没断,正确顺序是「删世代(-d/--delete-older-than)→ 跑 GC → 需要时 nix store optimise 去重」;日常用 nix.gc 定时 + min-free 兜底,分析用 df / nix path-info -Sh / nix-tree / du --apparent-size 四把尺子,结构性手段则是关掉 keep-derivations/keep-outputs、临时验证用 --no-out-link、大数据不要放进 store。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「nix」更多文章

  1. Nix 语言服务器与编辑器工具链:补全、格式化与静态检查
  2. 构建沙箱与可复现性:Nix 如何隔离构建过程
  3. nixos-anywhere 远程部署:把裸机变成 NixOS