构建沙箱与可复现性:Nix 如何隔离构建过程

Nix 的构建沙箱不是可选装饰,而是「输入确定」得以成立的前提:它给构建脚本一个干净的挂载视图、清空的环境变量、独立的构建用户与默认断开的网络。本文拆开沙箱的三种模式与内部结构,讲清环境变量如何被清洗、网络隔离与固定输出例外、如何检测构建是否纯净,以及容器与 CI 里沙箱失效的原因与修法。

引言

「相同的输入产生相同的输出」这句话里,真正的难点是什么算输入。如果构建脚本能读宿主机的 /etc/hosts、能继承 $HOME、能随手 curl 一个文件,那么「输入」就变成了整台机器加整个互联网——可复现无从谈起。Nix 的构建沙箱(build sandbox)就是用来把「输入」这个概念收窄到可枚举范围的:只保留声明的依赖、清空环境变量、默认断开网络、用一个专用的非特权用户执行构建。

很多人把沙箱当成「Linux 上的一个安全选项」,出问题就 sandbox = false 关掉。本文要说明的是:关掉沙箱等于放弃可复现性,而绝大多数沙箱失败都有正确的修法。全文拆开沙箱的模式与内部结构、环境变量清洗规则、网络隔离与固定输出例外、纯度检测手段,以及在容器与 CI 环境中沙箱失效的原因。

前置:可复现与封闭构建 、derivation 与 store 内幕 。

目录

1. 沙箱到底防的是什么

1.1 三类隐式输入

① 文件系统隐式输入:脚本读了未声明的 /etc/passwd、/usr/lib、家目录下的缓存
② 环境隐式输入:继承了宿主机的 LANG、TZ、HOME、PATH,行为随之漂移
③ 网络隐式输入:构建时下载依赖、拉取最新版本、访问私有 registry
这三类输入都不会出现在 .drv 里,因此哈希算不出来,缓存也命中不了。

1.2 后果不是「不安全」而是「不可复现」

症状 A:A 机器构建成功、B 机器失败(B 没装某个系统库)
症状 B:同一 commit 两次构建产物哈希不同(下载到了不同版本)
症状 C:本地能构建、CI 失败(CI 没有 ~/.cache 里的东西)
症状 D:二进制缓存命中率异常低(每次构建的输入其实都不同)

1.3 沙箱要建立的边界

文件系统:只有 /nix/store 里声明过的路径(只读)+ 一个私有可写工作目录
环境变量:由 derivation 属性显式生成,宿主环境一律不带入
网络:默认不存在(除非是声明了输出哈希的固定输出派生)
身份:用专用的非特权构建用户,而不是当前用户

记忆:沙箱防的是三类隐式输入——文件系统、环境变量、网络;它们的后果不是「不安全」而是「哈希算不出来、缓存命中不了、换机器就崩」,因此关沙箱等于放弃可复现性。

2. 三种沙箱模式

2.1 配置项与三种模式

# /etc/nix/nix.conf;NixOS 上写 nix.settings.sandbox = true;
# sandbox = true | relaxed | false
模式隔离程度何时用
true完整沙箱:独立挂载/网络/PID 命名空间 + chroot + 专用构建用户Linux 默认,生产与 CI 的标准配置
relaxed仍启用沙箱,但降级掉部分隔离能力内核或容器不允许创建用户命名空间的环境
false完全关闭:可读宿主文件系统、可访问网络、继承当前用户身份只用于排错对比,绝不用于生产

2.2 平台差异

Linux:沙箱依赖命名空间(user/mount/pid/ipc/net),能力完整
macOS:没有等价的内核原语,只能做有限限制,无法提供完整 chroot 隔离
结论:同一份 derivation 在 Linux 上更容易做到封闭,这也是 Linux 作为官方构建平台的原因

2.3 如何确认当前设置

nix show-config | grep -E '^sandbox'
nix --version

记忆:三种模式——true 完整隔离(Linux 默认)、relaxed 降级但仍启用(受限内核/嵌套容器)、false 只用于排错;macOS 缺少等价内核原语,隔离能力天然弱于 Linux。

3. 沙箱内部做了什么

3.1 一次沙箱构建的建立过程

① 创建新的命名空间:mount / pid / ipc / uts / net(以及 user,视配置)
② 挂载私有根文件系统:
     /nix/store      → 以只读方式绑定(整个 store 可见)
     /tmp、/build    → tmpfs,构建的工作目录 NIX_BUILD_TOP
     /dev            → 最小设备集(null、zero、random、urandom、tty 等)
     /proc           → 供脚本读取进程信息
     /etc            → 仅放入必要的静态文件(如 passwd、group、hosts)
③ 切换到专用的构建用户(build-users-group 中的 uid)
④ 清空环境变量,注入 derivation 派生的变量($NIX_BUILD_TOP、$TMPDIR、$out)
⑤ 执行 builder(通常是 bash)

3.2 一个容易被忽略的细节

沙箱把整个 /nix/store 以只读方式暴露,而不是只暴露声明的依赖。
后果:
  - 构建脚本可以"恰好读到"某个未声明但已在 store 里的路径
  - 这次构建会成功,但该引用会被扫描进 .drv 的引用集,成为运行时依赖
  - 若依赖没有被正确声明,换一台没有该路径的机器就会失败
因此:沙箱防的是"宿主文件系统",不能替代"依赖声明正确性"。

3.3 构建用户与权限

# NixOS 上指定构建用户组,构建以组内随机 uid 执行
nix.settings.build-users-group = "nixbld";
id nixbld1 2>/dev/null || getent group nixbld

若构建用户不可用(例如容器里没有创建这些用户),沙箱会直接失败——这是第 8 节要处理的问题。

记忆:沙箱构建 = 新命名空间 + 私有挂载视图(store 只读绑定、tmpfs 工作目录、最小 /dev 与 /proc、精简 /etc)+ 专用构建用户 + 清洗后的环境变量;注意整个 store 都只读可见,因此沙箱不能替代依赖声明的正确性。

4. 环境变量是如何被清洗的

4.1 清洗规则

宿主环境变量:一律不带入(PATH、HOME、LANG、TZ、SSH_AUTH_SOCK 全部消失)
derivation 属性:每个字符串属性变成一个同名环境变量
                  例如 buildInputs、nativeBuildInputs 会被拼成 PATH
固定注入:NIX_BUILD_TOP、TMPDIR、TMP、TEMP、TEMPDIR、PWD、HOME=/homeless-shelter
          NIX_STORE、NIX_BUILD_CORES、NIX_LOG_FD、out / outputHash 等

4.2 一个直观的例子

pkgs.stdenv.mkDerivation {
  pname = "demo";
  version = "1.0";
  MY_FLAG = "enabled";          # → 构建脚本里 $MY_FLAG 可用
  buildPhase = ''
    echo "flag=$MY_FLAG"        # 输出 flag=enabled
    echo "home=$HOME"           # 输出 home=/homeless-shelter
    echo "lang=$LANG"           # 输出 lang=(空,宿主 LANG 被丢弃)
  '';
}

4.3 需要放行时的做法

# 明确列出允许穿透的环境变量(常用于固定输出下载的代理)
pkgs.fetchurl {
  url = "https://example.com/archive.tar.gz";
  hash = "sha256-...";
  impureEnvVars = [ "http_proxy" "https_proxy" "no_proxy" ];
}

# 更好的做法:不放行,而是把值作为属性显式传入构建

4.4 结构化属性

__structuredAttrs = true 会把属性以 JSON 形式传给构建脚本,
而不是展开成环境变量,避免超长环境变量与转义问题。

记忆:环境变量清洗规则 = 宿主变量全丢、derivation 属性逐条变变量、Nix 固定注入少数几个(HOME 被设为 /homeless-shelter);确实需要穿透时用 impureEnvVars 显式列白名单,而不是关沙箱。

5. 网络隔离与固定输出例外

5.1 默认无网络

普通 derivation:没有网络出口,任何 curl / wget / git fetch 都会失败
失败信息通常形如:
  curl: (6) Could not resolve host: example.com
  fatal: unable to access 'https://...': Could not resolve host
这是设计意图,不是缺陷:构建期联网意味着产物依赖外部世界的当前状态。

5.2 固定输出派生是唯一例外

# 声明了 outputHash 的派生(固定输出)允许联网,因为产物会被哈希校验
src = pkgs.fetchFromGitHub {
  owner = "NixOS";
  repo = "nixpkgs";
  rev = "24.11";
  hash = "sha256-AAAA...";      # 校验点:内容不符即失败
};

哈希是「锚」:即使中途内容变化,最终校验会拦住;但哈希只保证「内容就是这一份」,不保证来源可信——信任来自你写下这个哈希的那一刻。

5.3 把联网构建改造成离线构建

反模式:buildPhase 里直接 npm install / cargo fetch / pip download
改造步骤:
  ① 用固定输出派生把依赖预取到 store(如 npmDepsHash、cargoHash 对应的 fetch 派生)
  ② 构建阶段使用离线模式(npm ci --offline、cargo build --offline)
  ③ 校验:把网络彻底断掉后构建仍能成功
检查是否偷偷联网:
  nix build .#mypkg -L 2>&1 | grep -iE 'could not resolve|network is unreachable'

记忆:普通派生默认无网络,联网是设计禁止;唯一例外是声明了 hash 的固定输出派生(哈希校验内容,但信任仍来自你写下哈希的那一刻);把 npm/cargo/pip 类联网构建改造成「固定输出预取 + 离线构建」。

6. 检测构建是否纯净

6.1 对照法:开沙箱 vs 关沙箱

nix build .#mypkg --print-out-paths                          # 沙箱下构建
nix build .#mypkg --option sandbox false --print-out-paths   # 仅用于对比
判读:两者输出路径相同 → 没有读到宿主环境(纯净)
      输出路径不同 → 脚本依赖了未声明的东西,需要定位

6.2 重构建校验

# 强制重新构建并比较结果(可复现性的直接检验)
nix build .#mypkg --check -L

6.3 查看引用集,找出意外依赖

nix-store --query --references ./result      # 该产物的直接引用(运行时依赖)
nix why-depends ./result /nix/store/<某路径> # 为什么产物依赖某个路径
nix log .#mypkg | grep -E '/etc/|/usr/|/home/|/var/'   # 构建日志里的宿主路径痕迹

多出来的引用往往就是「隐式输入」留下的痕迹。

6.4 求值层面的纯净

# 纯求值模式:禁止 builtins.currentSystem、getEnv 等不纯内建
nix eval --option pure-eval true .#packages.x86_64-linux.mypkg.drvPath

记忆:四种检测手段——--option sandbox false 对照构建(输出路径不同即有隐式输入)、nix build --check 重构建、nix-store --query --references 与 nix why-depends 查引用、grep 构建日志找宿主路径;求值层面用 --option pure-eval true。

7. 有意放宽沙箱的手段

7.1 向沙箱内精确添加路径

# 某些构建确实需要访问特定宿主路径(如虚拟机测试需要 /dev/kvm)
{
  requiredSystemFeatures = [ "kvm" ];
}
# 系统侧:nix.settings.extra-sandbox-paths = [ "/dev/kvm" ];

requiredSystemFeatures 让调度器只把该派生派给具备该能力的机器,extra-sandbox-paths 负责把路径放进沙箱——两者配合才能既保留隔离又满足需求。

7.2 旧式逃逸开关与不纯派生

{ __noChroot = true; }   # 让该派生不做 chroot 隔离(历史遗留,尽量避免)
{ __impure  = true; }    # 显式声明"这个构建不保证可复现"
__impure 的代价:
  - 产物路径不可复现,二进制缓存无法有效复用
  - 依赖它的派生也无法稳定缓存
适用:确实无法封闭的构建(如需要访问本地设备的测试),且要隔离在依赖图边缘

7.3 决策顺序

遇到沙箱失败时的正确顺序:
  ① 先问:失败是否因为缺少声明?→ 补 buildInputs / nativeBuildInputs
  ② 再问:是否真的需要宿主路径?→ extra-sandbox-paths 精确添加
  ③ 最后才考虑:sandbox = relaxed 或 false(并记录原因与影响范围)

记忆:放宽沙箱的手段按优先级——extra-sandbox-paths + requiredSystemFeatures 精确放行 > __noChroot(历史遗留)> __impure(明确放弃可复现);决策顺序永远是先补声明、再精确放行、最后才降级模式。

8. 沙箱失败的典型原因

8.1 无法创建用户命名空间

症状:error: while setting up the build environment: setting up a private
      mount namespace: Operation not permitted
原因:内核禁用了非特权用户命名空间,或所在容器限制了 clone/unshare
处理(按优先级):
  ① 开启内核支持:sysctl kernel.unprivileged_userns_clone=1
  ② 容器里放开:docker run --privileged,或 --security-opt seccomp=unconfined
  ③ 确实无法放开时,退到 sandbox = relaxed

8.2 构建用户缺失

症状:error: the build users group 'nixbld' has no members
原因:容器镜像里没有创建 nixbld 用户
处理:在镜像里创建组与用户;或直接使用官方 nixos/nix 镜像

8.3 需要读取宿主 /etc 里的文件

症状:脚本报 /etc/resolv.conf 或 /etc/passwd 缺失
正解:不要添加宿主 /etc,而是把需要的内容作为构建输入传进去
反例:extra-sandbox-paths = [ "/etc" ]  ← 直接把宿主环境泄漏进构建

8.4 与代理相关的失败

症状:固定输出派生在企业代理后无法下载
处理:用 impureEnvVars 放行 http_proxy/https_proxy/no_proxy,
      而不是关沙箱(固定输出本身允许联网,缺的只是代理环境变量)

8.5 在 macOS 上的期望偏差

症状:macOS 上"关沙箱"与"开沙箱"构建结果一致,误以为沙箱无效
说明:macOS 的隔离能力有限,沙箱主要限制部分操作而非提供完整 chroot
处理:把可复现性验证放到 Linux 构建机上做,macOS 只做开发

记忆:五类沙箱失败——命名空间被禁(放开或 relaxed)、nixbld 用户缺失(建用户或用官方镜像)、脚本想读宿主 /etc(应改为传入输入)、代理变量未放行(用 impureEnvVars)、macOS 隔离能力有限。

9. 在容器与 CI 里跑沙箱

9.1 容器里的三种姿态

姿态配置隔离程度
特权容器docker run --privileged沙箱可正常工作,隔离最强
放开 seccomp--security-opt seccomp=unconfined多数场景可用
降级沙箱sandbox = relaxed 或 false可运行,但放弃封闭性
docker run --rm -it --security-opt seccomp=unconfined \
  -v "$PWD:/src" -w /src nixos/nix:latest nix build .#mypkg

9.2 CI 上的推荐配置

env:
  NIX_CONFIG: |
    sandbox = true
    experimental-features = nix-command flakes
    trusted-users = root runner
要点:
  - 保持 sandbox = true;若必须降级,在流水线里显式写注释说明原因
  - trusted-users 决定谁能使用额外的沙箱路径与 impure 特性,不要给普通用户
  - 与构建缓存配合:只有封闭构建才可能被安全共享,参见二进制缓存篇

9.3 沙箱与供应链可验证性

沙箱保证"构建只依赖声明的东西",这是可追溯的前提:
  - 产物 → derivation → 输入哈希 这条链才有意义
  - 若构建能读宿主文件或联网,链条断裂,产物来源无法证明
因此沙箱不是"安全加固的加分项",而是供应链可验证性的基础设施。
需要设备(如 nixosTest 的 /dev/kvm)时:
  - 系统侧 extra-sandbox-paths = [ "/dev/kvm" ]
  - 派生侧 requiredSystemFeatures = [ "kvm" ]
  这样既保留沙箱,又精确放行了唯一需要的设备。

记忆:容器里三种姿态(privileged / seccomp=unconfined / 降级沙箱),CI 上尽量保持 sandbox = true 并把降级原因写进注释;沙箱是供应链可验证性的前提而不是加分项,需要设备时用 extra-sandbox-paths + requiredSystemFeatures 精确放行。

10. 速查表与一句话记忆

主题要点
默认值Linux 上 sandbox = true
环境变量宿主变量全丢,HOME=/homeless-shelter
网络默认无;仅固定输出派生可联网
纯净检测--option sandbox false 对照、nix build --check
精确放行extra-sandbox-paths + requiredSystemFeatures
最后手段sandbox = relaxed(记录原因)

一句话记忆:沙箱把构建的「输入」收窄成可枚举集合——私有挂载视图(整个 store 只读可见、tmpfs 工作目录、最小 /dev 与 /proc)、由 derivation 属性生成的环境变量(宿主变量全丢、HOME 设为 /homeless-shelter)、默认断开的网络(唯一例外是声明了 hash 的固定输出派生);检测是否纯净用「--option sandbox false 对照构建看输出路径是否一致」加 nix build --check 与 nix why-depends;遇到失败按「先补依赖声明 → 再用 extra-sandbox-paths 精确放行 → 最后才降级模式」的顺序处理,因为沙箱不是安全加分项,而是可复现与供应链可验证性的前提。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「nix」更多文章

  1. Nix 语言服务器与编辑器工具链:补全、格式化与静态检查
  2. Store 垃圾回收与存储优化:gc root、去重与瘦身
  3. nixos-anywhere 远程部署:把裸机变成 NixOS