Nix二进制打包心得

一、缘起:为什么想摆脱 Nix 打包

Nix 打包很强大,但很贵:

  • 每个项目要写 buildRustPackage / buildGoModule / buildNpmPackage,要声明依赖、锁 vendorHash / npmDepsHash;
  • 依赖一变,hash 就变,重新 nix build 往往要重新编译大半依赖树;
  • 为一个"本机自用的小工具"维护这套东西,性价比极低。

而现实是:很多工具本机早就编译好了——cargo build --release 出一个二进制,go build 出一个二进制,bun build --compile 也能出一个。既然产物已经在本地了,为什么还要让 Nix 重新编译一遍?

于是有了一个朴素的思路:“二进制 base"打包 —— Nix 不编译,只打包:用 stdenv.mkDerivation 跳过所有构建阶段,用路径字面量把本机构建产物直接拷进 $out,然后 nix profile install ./result,秒级进系统。

这套思路最初是为 Rust + cargo 打磨的(Oscivoid 一个 Rust TUI、Sleephat 一个 Tauri GUI),验证可行。问题来了:

这套"二进制 base"思想,能不能不局限于 Rust 和 cargo,推广到任意语言?

二、研究方法:不是拍脑袋,是交叉验证

为了回答这个问题,我用了三层手段交叉验证:

  1. 4 路并行联网研究 agent,各自负责一族:Go / Rust 的静态 vs 动态边界、Node 生态(bun compile / SEA / deno / pkg)、Python(PyInstaller / Nuitka / 解释型)、通用机制(patchShebangs / autoPatchelfHook / FHS / 社区共识);
  2. 主循环 8 次直接搜索,钉住关键事实(bun 在 NixOS 的 interpreter bug、PyInstaller onefile 的社区结论等);
  3. 本机 5 次实证构建,把"能不能跑"从理论变成数据——这是最重要的一层,后面会看到它甚至纠正了我自己的一个错误判断。

三、核心发现一:现有方案隐含了三个 Rust/cargo 假设

复盘现有方案,发现它其实已经"按产物类型"分模板(basic / gui / patchelf),但分类轴不对,且三个隐含假设没写出来:

隐含假设(Rust 思维) 现实(其他语言)
产物是单文件 ELF Go/Rust/C 成立;Node/Python 纯脚本是解释型,根本没有单文件
产物"自包含” = 静态链接 Oscivoid 其实是动态链 libc,靠 Nix 引用扫描器补闭包,不是静态
buildInputs 补库就够了 外来动态二进制 buildInputs 不会重写 rpath,必须 autoPatchelf

结论:不能按语言选模板,要按产物的 ELF 形态选。

四、核心发现二:正确的分治轴 —— “产物体检"四型

三个命令,语言无关,任何产物都先做这一步:

1
2
3
file <产物>                              # statically / dynamically linked
readelf -l <产物> | grep interp          # ELF 解释器(interpreter)指向哪
ldd <产物>                               # 动态库依赖

据此分四型:

类型 判据 处理 典型
A 静态 lddnot a dynamic executable 直接 cp,闭包零依赖 Go 纯静态、Rust musl、gcc -static
B 动态·store 内 interpreter/RUNPATH 指向 /nix/store/... 直接 cp,闭包自动补齐 在 NixOS / nix shell 里构建的任何产物
C 动态·外来 interpreter 是 /lib64/ld-linux-... autoPatchelfHook 补库 在 Ubuntu 等非 nix 环境构建的
D 解释型/多文件 不是单 ELF 单文件化 / wrapProgram / nix 原生 Node/Python 纯脚本

五、核心发现三:引用扫描器,让"零配置动态二进制"成立(实证)

这是整件事的机制基础,也是我中途差点搞错、最终靠实证钉死的点。

Q:一个动态链接的二进制(依赖 libc、libgcc),零 buildInputs 拷进 $out,闭包里会不会有 glibc?会不会被 nix-collect-garbage 删掉?

我最初基于一次构建失败的假数据判断"闭包不含 glibc,GC 会删库”,吓得要给 basic 模板打补丁。重测后真相是:

1
2
3
4
# 用真实 oscivoid 二进制,零 buildInputs 打包
$ nix-store -q --references <output>
/nix/store/8kvxvr3pmsypxiypq4g8zy13glnfr7nx-glibc-2.42-67
/nix/store/hngmi01i8wgi25a0byrxcn4ysz5j79mw-gcc-15.2.0-lib

Nix 的引用扫描器会扫描输出文件的字节——凡出现 /nix/store/<hash>-... 字符串就记为引用。二进制的 RUNPATH 和解释器里就嵌着 glibc 的 store 路径(链接时 cc-wrapper 写入的),所以扫描器自动把 glibc/libgcc 补进闭包:

  • GC 安全——闭包引用着它们,删不掉;
  • 零配置能跑——实测从 store 直接运行打包产物,ldd 全部解析,无 not found;
  • basic 模板本来就是对的。

所以"在 NixOS / nix shell 里构建"才是 B 型的唯一前提,也是这套打法成立的技术根基。而 A 型(静态)更进一步,闭包只有它自己。

六、语言可行性速查(联网 + 实证交叉验证)

语言 本机构建命令 类型 结论
Go 纯静态 CGO_ENABLED=0 go build -trimpath A ✅ 塞进去就跑,最顺的路径
Go 带 cgo NixOS 原生 go build B ✅ 闭包自动补 glibc;外来构建则 C
Rust 无 C 依赖 cargo build --target x86_64-unknown-linux-musl A ✅ 全静态;静态只能走 musl,glibc 静态链不可行
Rust 带 C 依赖/Tauri NixOS 原生 cargo build --release B ✅ 现有 gui 模板就是这路
C/C++ NixOS 原生编译 B ✅;外来构建则 C
Node 单文件 bun build --compile / Node SEA / deno compile A/B ⚠️ 可行但有坑:动态链 glibc、不能 strip
Python pyinstaller --onedir / nuitka --standalone C ⚠️ 目录式 + autoPatchelfHook 可行;--onefile 明确不可行
解释型脚本 D ❌ 非单文件;wrapProgram + 解释器进闭包,或走 nix 原生
Electron / AppImage ❌ 排除,走 appimageTools.wrapType2 / nix 原生

几个值得单列的坑:

  • PyInstaller --onefile 在 NixOS 上救不了:它是自解压 zip,运行时才把内部 ELF 解压到 /tmp,构建期磁盘上不存在 → autoPatchelf 补不到。社区结论是"别 patch 了,换 onedir 或直接 poetry2nix"。
  • bun compile 产物不能 strip:JS 运行时内嵌在二进制里,strip 会删掉它;而且 interpreter 会硬编码 nix store 路径,GC 后可能失效。
  • 静态 ≠ 无环境依赖:Go CGO_ENABLED=0 / musl 产物不依赖库文件,但运行时仍读 /etc/resolv.conf/etc/passwd——DNS 走纯 Go 解析器、用户查询只读 passwd,绕过 /etc/nsswitch.conf,LDAP、mDNS 等 NSS 后端能力丢失。这是"塞进去就能用"最容易忽略的隐性前提。

七、顺带纠正的两个常识误区

研究过程中发现两个流传甚广但不准确的说法,顺手修正了:

  1. “NixOS 上 /usr/bin/env 不存在” —— 不准确。宿主机上它其实存在,是 environment.usrbinenv 创建的(整个 /usr 目录几乎只为这一个文件存在)。不存在的是解释器本身(/usr/bin/python 等),以及构建沙箱里连 env 都没有。所以 patchShebangs 的本质不是"修一个不存在的路径",而是把 shebang 改写成 store 绝对路径,让脚本独立于用户 PATH 也能跑
  2. “installPhase 用 runHook preInstall/postInstall 是实战提炼” —— 现有 skill 这么写,但两个参考项目的 default.nix 都没用。好实践没错,但不该标榜"从项目提炼",改成"建议"更诚实。

八、这算反模式吗?—— 是的,但有个诚实的定位

联网研究显示,Nix 社区普遍把"拿预编译产物塞 store"看作反模式:

  • 它违背 nix 的核心价值(从源码、可复现构建);
  • 反例引用 XZ 后门事件:预生成产物可能含源码里没有的恶意东西;
  • AdoptOpenJDK 那条 nixpkgs issue 说得直接:预编译二进制"丢掉全部构建期依赖信息"。

但社区也承认一个实用豁免:自用、本机、不想维护 derivation 时,这是能接受的。而且"本机构建产物"比"下载第三方 blob"好——至少是你自己工具链产的,只是不可复现。

所以这套方法的诚实定位是:

“快而脏的本地工具”:本机个人使用、秒级进系统、复用打包经验。不是正统发布流程——要进 nixpkgs、要跨机器/跨架构、要审计供应链时,走源码构建(buildGoModule / buildNpmPackage / buildPythonApplication)。

九、结论与建议

  1. 二进制 base 完全可以语言无关,但判据是产物的 ELF 形态,不是语言。先体检,再选模板。
  2. “在 NixOS/nix shell 里构建"是 B 型成立的唯一前提;外来动态二进制直接上 autoPatchelf,别跟 buildInputs 较劲。
  3. 想彻底"塞进去就跑”:Go 用 CGO_ENABLED=0,Rust 用 musl,Node 用 bun compile(记得 dontStrip),Python 用 --onedir
  4. 记住两个边界:解释型脚本没有单文件,得靠 wrapper 或单文件化;静态产物也会降级 NSS——DNS 和用户查询行为要实测。
  5. 别把它当发布流程:发布走 nix 原生构建,自用才走二进制 base。

所有机制结论都在 NixOS 本机用 nix-build + nix-store -qR 实证验证过;联网结论区分了"官方文档事实"和"社区经验"。过程中我自己的一次误判(GC 恐慌)也被实证修正——这正是"先实证再下结论"的价值。