Nix 语言生态打包:Python、Node 与 Rust 的依赖治理

把 Python、Node、Rust 项目打进 Nix 的难点不在编译,而在依赖锁定与原生库链接。本文详解 poetry2nix 与 uv2nix、node2nix 与 pnpm、naersk 与 crane 的打包机制、固定输出派生的哈希坑、devShell 与构建分离,以及选型建议。

1. 语言生态打包的共性问题

用 Nix 打包「手写的 C 程序」很直接:mkDerivation 加 buildInputs 就够了。但现代语言生态完全不同,它们的共同特征是:

  • 依赖不在源码里:Python 有 PyPI,Node 有 npm registry,Rust 有 crates.io
  • 依赖需要联网获取:Nix 的沙箱禁止网络,所以依赖必须预先固定(fetch)
  • 依赖有传递闭包:一个 requests 会拉出十几层依赖
  • 依赖有原生组件:Node 的 node-gyp、Python 的 C 扩展、Rust 的 -sys crate 都要链接系统库
  • 锁定文件格式各异: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 二者的取舍

维度poetry2nixuv2nix
锁定文件poetry.lockuv.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 三者对比

维度buildRustPackagenaerskcrane
增量粒度整个 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)poetry2nixdream2nixpoetry.lock
Python(uv)uv2nixpoetry2nixuv.lock
Node(npm)npmlock2nix / 手写 FODnode2nixpackage-lock.json
Node(pnpm)pnpm.fetchDepsnode2nixpnpm-lock.yaml
RustcranenaerskCargo.lock
GobuildGoModuledream2nixgo.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 构建调试与错误排查。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「nix」更多文章

  1. NixOS 代际管理与回滚:从 generation 机制到引导项治理
  2. Nix 派生与 Store 内幕:derivation、输入寻址与引用图
  3. Nix 求值与构建性能优化:从 eval 剖析到远程构建