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,推广到任意语言?
二、研究方法:不是拍脑袋,是交叉验证
为了回答这个问题,我用了三层手段交叉验证:
- 4 路并行联网研究 agent,各自负责一族:Go / Rust 的静态 vs 动态边界、Node 生态(bun compile / SEA / deno / pkg)、Python(PyInstaller / Nuitka / 解释型)、通用机制(patchShebangs / autoPatchelfHook / FHS / 社区共识);
- 主循环 8 次直接搜索,钉住关键事实(bun 在 NixOS 的 interpreter bug、PyInstaller onefile 的社区结论等);
- 本机 5 次实证构建,把"能不能跑"从理论变成数据——这是最重要的一层,后面会看到它甚至纠正了我自己的一个错误判断。
三、核心发现一:现有方案隐含了三个 Rust/cargo 假设
复盘现有方案,发现它其实已经"按产物类型"分模板(basic / gui / patchelf),但分类轴不对,且三个隐含假设没写出来:
| 隐含假设(Rust 思维) | 现实(其他语言) |
|---|---|
| 产物是单文件 ELF | Go/Rust/C 成立;Node/Python 纯脚本是解释型,根本没有单文件 |
| 产物"自包含” = 静态链接 | Oscivoid 其实是动态链 libc,靠 Nix 引用扫描器补闭包,不是静态 |
buildInputs 补库就够了 |
外来动态二进制 buildInputs 不会重写 rpath,必须 autoPatchelf |
结论:不能按语言选模板,要按产物的 ELF 形态选。
四、核心发现二:正确的分治轴 —— “产物体检"四型
三个命令,语言无关,任何产物都先做这一步:
|
|
据此分四型:
| 类型 | 判据 | 处理 | 典型 |
|---|---|---|---|
| A 静态 | ldd 报 not 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 模板打补丁。重测后真相是:
|
|
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 后端能力丢失。这是"塞进去就能用"最容易忽略的隐性前提。
七、顺带纠正的两个常识误区
研究过程中发现两个流传甚广但不准确的说法,顺手修正了:
- “NixOS 上
/usr/bin/env不存在” —— 不准确。宿主机上它其实存在,是environment.usrbinenv创建的(整个/usr目录几乎只为这一个文件存在)。不存在的是解释器本身(/usr/bin/python等),以及构建沙箱里连 env 都没有。所以patchShebangs的本质不是"修一个不存在的路径",而是把 shebang 改写成 store 绝对路径,让脚本独立于用户 PATH 也能跑。 - “installPhase 用 runHook preInstall/postInstall 是实战提炼” —— 现有 skill 这么写,但两个参考项目的 default.nix 都没用。好实践没错,但不该标榜"从项目提炼",改成"建议"更诚实。
八、这算反模式吗?—— 是的,但有个诚实的定位
联网研究显示,Nix 社区普遍把"拿预编译产物塞 store"看作反模式:
- 它违背 nix 的核心价值(从源码、可复现构建);
- 反例引用 XZ 后门事件:预生成产物可能含源码里没有的恶意东西;
AdoptOpenJDK那条 nixpkgs issue 说得直接:预编译二进制"丢掉全部构建期依赖信息"。
但社区也承认一个实用豁免:自用、本机、不想维护 derivation 时,这是能接受的。而且"本机构建产物"比"下载第三方 blob"好——至少是你自己工具链产的,只是不可复现。
所以这套方法的诚实定位是:
“快而脏的本地工具”:本机个人使用、秒级进系统、复用打包经验。不是正统发布流程——要进 nixpkgs、要跨机器/跨架构、要审计供应链时,走源码构建(buildGoModule / buildNpmPackage / buildPythonApplication)。
九、结论与建议
- 二进制 base 完全可以语言无关,但判据是产物的 ELF 形态,不是语言。先体检,再选模板。
- “在 NixOS/nix shell 里构建"是 B 型成立的唯一前提;外来动态二进制直接上 autoPatchelf,别跟 buildInputs 较劲。
- 想彻底"塞进去就跑”:Go 用
CGO_ENABLED=0,Rust 用 musl,Node 用 bun compile(记得dontStrip),Python 用--onedir。 - 记住两个边界:解释型脚本没有单文件,得靠 wrapper 或单文件化;静态产物也会降级 NSS——DNS 和用户查询行为要实测。
- 别把它当发布流程:发布走 nix 原生构建,自用才走二进制 base。
所有机制结论都在 NixOS 本机用 nix-build + nix-store -qR 实证验证过;联网结论区分了"官方文档事实"和"社区经验"。过程中我自己的一次误判(GC 恐慌)也被实证修正——这正是"先实证再下结论"的价值。