1. 语言生态打包的共性问题
用 Nix 打包「手写的 C 程序」很直接:mkDerivation 加 buildInputs 就够了。但现代语言生态完全不同,它们的共同特征是:
- 依赖不在源码里:Python 有 PyPI,Node 有 npm registry,Rust 有 crates.io
- 依赖需要联网获取:Nix 的沙箱禁止网络,所以依赖必须预先固定(fetch)
- 依赖有传递闭包:一个
requests会拉出十几层依赖 - 依赖有原生组件:Node 的
node-gyp、Python 的 C 扩展、Rust 的-syscrate 都要链接系统库 - 锁定文件格式各异:
poetry.lock、package-lock.json、Cargo.lock
因此各生态的 Nix 工具链,本质都在解决同一件事:把「联网解析依赖」这一步提前到求值阶段,变成一组固定哈希的 fetch,从而让构建阶段完全离线、可复现。
理解了这个统一模型,再看 poetry2nix、node2nix、naersk 就不再是「一堆互不相干的工具」,而是同一思路在不同生态的实现。开发环境的搭建参见 Nix Shell 开发环境 与 可复现开发环境。
2. Python:poetry2nix 与 uv2nix
2.1 传统 buildPythonPackage 的困境
python3Packages.buildPythonPackage {
pname = "mypkg";
version = "1.0";
src = ./.;
propagatedBuildInputs = [ python3Packages.requests ];
}
问题在于 propagatedBuildInputs 要手工列出全部传递依赖,而且必须与 nixpkgs 里已有的版本对齐。项目一复杂就维护不动。
2.2 poetry2nix:从 poetry.lock 生成表达式
poetry2nix 读取 poetry.lock,为每个依赖生成一个 derivation:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
poetry2nix.url = "github:nix-community/poetry2nix";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, poetry2nix, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) mkPoetryApplication;
in {
packages.default = mkPoetryApplication {
projectDir = ./.;
# 覆盖无法自动打包的依赖
overrides = poetry2nix.overrides.withDefaults (final: prev: {
some-crate = prev.some-crate.overridePythonAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.pkg-config ];
});
});
};
devShells.default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.default ];
packages = [ pkgs.poetry ];
};
});
}
关键机制:
projectDir里的poetry.lock是唯一真相,poetry2nix 按 lock 精确取版本- overrides 用于修补那些「自动生成的表达式构建失败」的包(缺系统库、需要打补丁)
- 生成过程本身需要联网(第一次),结果被
flake.lock与固定哈希缓存
2.3 uv2nix:面向 uv 的现代方案
uv 是 Astral 出品的极快 Python 包管理器。uv2nix 把 uv.lock 转成 Nix 表达式:
{
packages.default = pkgs.callPackage ./nix/uv.nix { };
}
# nix/uv.nix
{ pkgs, ... }:
let
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../.; };
overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
python = pkgs.python312;
pythonSet = (pkgs.callPackage pyproject-nix.build.packages { inherit python; })
.overrideScope (pkgs.lib.composeManyExtensions [ overlay ]);
in
pythonSet.mkVirtualEnv "myapp-env" workspace.deps.default
uv2nix 的优势是用 wheel 优先、速度快、对 PEP 621 支持好;代价是生态成熟度不如 poetry2nix,遇到需要源码构建的包仍需手写 overlay。
2.4 二者的取舍
| 维度 | poetry2nix | uv2nix |
|---|---|---|
| 锁定文件 | poetry.lock | uv.lock |
| 速度 | 中 | 快 |
| 生态成熟度 | 高(大量现成 overrides) | 中 |
| wheel 优先 | 部分 | 是 |
| 适合场景 | 已有 poetry 项目 | 新项目、CI 敏感 |
3. Node:node2nix 与 pnpm
3.1 为什么 Node 特别麻烦
npm 的依赖树是嵌套的(node_modules/a/node_modules/b),同一个包可能同时存在多个版本。而 Nix 的 store 是扁平的,无法直接表达这种结构。node2nix 的解法是把整棵 node_modules 树打成固定输出派生(FOD)。
3.2 node2nix 三步法
# 1. 生成表达式
node2nix -l package-lock.json -o node-packages.nix -c default.nix
# 2. 构建
nix-build default.nix
# 3. 开发环境
nix-shell -A shell
生成的 node-packages.nix 里,依赖被描述为:
{
"node_modules/express" = {
version = "4.19.2";
resolved = "https://registry.npmjs.org/express/-/express-4.19.2.tgz";
integrity = "sha512-...";
};
}
3.3 固定输出派生的哈希坑
node2nix 会把全部依赖 tarball 的哈希汇总成一个 node_modules 目录的哈希。一旦某个依赖的 tarball 在 registry 上被重新发布(罕见但会发生),或 package-lock.json 的解析顺序变化,哈希就会不匹配:
error: hash mismatch in fixed-output derivation
specified: sha256-AAAA...=
got: sha256-BBBB...=
解决:把 got 的值填回去。这在 Nix 源码获取与 fetchers 里有更系统的说明。
3.4 pnpm 与 npmlock2nix
pnpm 用内容寻址的全局 store + 符号链接,与 Nix 理念高度一致,因此 pnpm 项目在 Nix 里往往更顺:
{
packages.default = pkgs.stdenv.mkDerivation {
pname = "myapp";
version = "1.0";
src = ./.;
pnpmDeps = pkgs.pnpm.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-...";
};
nativeBuildInputs = [ pkgs.nodejs_20 pkgs.pnpm ];
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
};
}
nixpkgs 已内置 pnpm.fetchDeps,只需一次 hash mismatch 校正即可锁定整个依赖树。
3.5 前端产物的常见做法
对于「只需构建产物」的前端项目,另一种轻量做法是用 FOD 固定 node_modules,再用 stdenv 构建:
nodeModules = pkgs.stdenv.mkDerivation {
name = "node_modules";
src = ./package-lock.json;
buildInputs = [ pkgs.nodejs_20 pkgs.npmHooks.npmConfigHook ];
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = "sha256-...";
buildCommand = "npm ci --offline; cp -r node_modules $out";
};
4. Rust:naersk 与 crane
4.1 buildRustPackage:基线方案
rustPlatform.buildRustPackage {
pname = "mytool";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
}
它一次性构建整个 workspace,简单可靠,但增量能力弱:改一个 crate 要重编全部依赖。对大型 Rust 项目这是致命的。
4.2 naersk:按 crate 拆分
naersk 把每个依赖 crate 变成一个独立的 derivation,从而获得 Nix 级别的增量缓存:
{
inputs.naersk.url = "github:nix-community/naersk";
outputs = { self, nixpkgs, naersk }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
naersk-lib = naersk.lib.${pkgs.system};
in {
packages.default = naersk-lib.buildPackage {
src = ./.;
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ];
};
};
}
4.3 crane:更细粒度、更可控
crane 提供了更底层的原语,允许显式区分「依赖构建」与「本包构建」:
{
packages.default = craneLib.buildPackage {
src = craneLib.cleanCargoSource ./.;
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ];
};
# 分阶段:先编依赖(可缓存),再编本包
# craneLib.buildDepsOnly { src = ...; }
}
crane 的关键设计是 buildDepsOnly 与 buildPackage 分离:依赖编译结果是一个独立 derivation,只要 Cargo.lock 不变,改业务代码只需重编本包。
4.4 三者对比
| 维度 | buildRustPackage | naersk | crane |
|---|---|---|---|
| 增量粒度 | 整个 workspace | 每 crate | 依赖/本包分离 |
| 上手难度 | 低 | 低 | 中 |
| 可定制性 | 中 | 中 | 高 |
| vendor 依赖 | 支持 | 支持 | 支持 |
| 适合 | 小工具 | 中等项目 | 大型 workspace |
5. 其它生态简述
5.1 Go
Go 的 go.mod + go.sum 天然适合 Nix:
buildGoModule {
pname = "mytool";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-..."; # 由 hash mismatch 校正得到
}
Go 的依赖是模块缓存,Nix 把它作为 FOD 固定,机制清爽。
5.2 Haskell 与其它
Haskell 生态以 haskellPackages 与 callCabal2nix 为主,机制与 poetry2nix 类似(读 .cabal 生成表达式)。Java/Maven 可用 mvn2nix,Ruby 用 bundix。它们共享同一套「锁定文件 → 固定依赖 → 离线构建」的模型。
6. dream2nix:统一框架
dream2nix 试图用一套框架覆盖多语言打包,核心抽象是「模块 + 翻译器(translator)」:
- translator 把生态的锁定文件(
poetry.lock、package-lock.json、Cargo.lock)翻译成统一的依赖描述 - builder 把依赖描述变成 derivation
{
packages.default = dream2nix.lib.evalModules {
packageSets.nixpkgs = pkgs;
modules = [ ./dream2nix.nix ];
};
}
dream2nix 的愿景很好,但生态成熟度参差:Rust 与 Node 支持较好,Python 与其它仍在演进。生产建议:优先用各生态的专用工具(poetry2nix/naersk/crane),dream2nix 作为统一实验方向关注。
7. 常见坑
7.1 哈希不匹配的循环
FOD 哈希校正时,必须先让 Nix 报错拿到 got 值,再回填。不要手算。反复 --rebuild 时若两次 got 不同,说明上游不纯(如依赖了当前时间),需要 SOURCE_DATE_EPOCH。
7.2 原生依赖缺失
error: failed to run custom build command for `openssl-sys`
Rust 的 -sys crate、Node 的 node-gyp、Python 的 C 扩展都需要:
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl pkgs.zlib ];
pkg-config 几乎总是需要——它让构建脚本能在 Nix 的隔离环境里找到系统库。
7.3 devShell 与构建环境不一致
若 devShell 里能跑、nix build 却失败,多半是 devShell 偷偷用了系统工具链。用 inputsFrom = [ self.packages.${system}.default ] 让 devShell 继承构建依赖,保证一致。
7.4 依赖了 pip/npm 的「安装时脚本」
有些包在安装时下载额外二进制(如 puppeteer 下 Chromium、node-gyp 下载 headers)。这些在沙箱里必然失败,需要:
- 用 nixpkgs 提供的版本(
pkgs.chromium) - 或把下载步骤替换为
fetchurl提供的路径
7.5 交叉编译的语言特例
Rust 交叉编译需要 rustPlatform 的 cross 支持,Python 的 C 扩展需要目标平台的 sysroot。参见 交叉编译与 overlay,但要有心理准备:语言生态的交叉编译难度远高于 C。
7.6 缓存体积失控
Python 生态的 wheel 与 Node 的 node_modules 都很大,闭包动辄上 G。用 nix path-info --closure-size -h 检查,必要时把 devShell 依赖(测试框架、linter)排除出运行时闭包。
8. 选型速查
| 生态 | 首选 | 备选 | 关键文件 |
|---|---|---|---|
| Python(poetry) | poetry2nix | dream2nix | poetry.lock |
| Python(uv) | uv2nix | poetry2nix | uv.lock |
| Node(npm) | npmlock2nix / 手写 FOD | node2nix | package-lock.json |
| Node(pnpm) | pnpm.fetchDeps | node2nix | pnpm-lock.yaml |
| Rust | crane | naersk | Cargo.lock |
| Go | buildGoModule | dream2nix | go.sum |
选择原则:优先用 nixpkgs 或成熟社区工具已经覆盖的路径,只在必要时自己写 overlay。越少自定义,越少维护。
9. 实战:多语言 monorepo 打包
一个典型仓库:api/(Python + uv)、web/(pnpm)、cli/(Rust)。
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
uv2nix.url = "github:pyproject-nix/uv2nix";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-darwin" ];
perSystem = { pkgs, ... }: {
packages = {
api = pkgs.callPackage ./api/nix/package.nix { };
web = pkgs.callPackage ./web/nix/package.nix { };
cli = pkgs.callPackage ./cli/nix/package.nix { };
default = pkgs.symlinkJoin {
name = "all";
paths = [ ./api ./web ./cli ];
};
};
};
};
}
每个子项目各自维护锁定文件与哈希,flake.lock 统一 nixpkgs 版本。这样:
- 改
web/不会让cli的缓存失效(路径由各自输入决定) - CI 可以按目录过滤,只构建受影响的包
- 开发环境用
nix develop .#web精准进入
组织方式与 Flakes 最佳实践 的模块化建议一致。
10. 总结
语言生态打包的复杂度不在 Nix 本身,而在各生态「联网解析依赖」与「扁平 store」的天然冲突。统一思路是:
- 把依赖获取提前为固定输出派生,让构建阶段完全离线
- 用锁定文件作为唯一真相,由工具生成表达式
- 用 overlay 修补少数无法自动打包的包
- 保持 devShell 与构建环境一致,避免「本地能跑 CI 挂」
具体到工具:Python 看 poetry2nix 或 uv2nix,Node 看 npmlock2nix 或 pnpm.fetchDeps,Rust 优先 crane。与其追求「一个框架打通全部」,不如按生态选成熟方案,把自定义面压到最小——这才是 Nix 打包真正省心的姿势。起步可参考 Flake 模板与项目脚手架 里的多语言模板,排障时回到 Nix 构建调试与错误排查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。