Per-Project Development Environments with Nix

Michael Lynch

使用 Nix 为每个项目创建独立的开发环境

原文由 Michael Lynch 发布,订阅该博客

Nix 是一个功能广泛、学习曲线陡峭的工具。它几乎无所不能,从安装单个软件包到管理系统上的每一个文件和应用都不在话下。

即使是完全的新手,也能用 Nix 做一件非常实用的事——管理开发环境。

借助 Nix,我可以在同一台机器上为多个项目分别维护一套独立的依赖视图。比如,我可以让一个遗留项目运行在 Python 2.7 和 Node.js 4.x 上,同时让另一个现代项目运行 Python 3.11 和 Node.js 20,二者互不干扰。

即使你从未接触过 Nix,也只需大约 20 分钟就能用上由 Nix 管理的开发环境。

说明:我目前仍是 Nix 新手,不确定自己的做法是否最优。如果有经验更丰富的 Nix 用户有改进建议,欢迎告诉我,我会据此更新本文。

为什么不用 Docker 来管理开发环境?

我喜欢 Docker,也会在部署和一些运维任务中使用它,但用它来管理开发环境,我始终觉得不好用。

我平时通过 VS Code 远程连接(SSH)进行开发,而 Docker 会让这种方式变得很麻烦。我知道有一些变通方案,但我从未觉得它们好用。

为什么不用 Ansible 来管理开发环境?

过去六年里,我一直用 Ansible 来管理开发环境,效果还算过得去。

对于每个软件项目,我都会创建一台独立的虚拟机,并编写对应的 Ansible playbook 来为虚拟机配置好所有依赖。

问题在于,当我只是想临时尝试点东西、折腾几分钟时,却要为此启动一整台虚拟机、编写 playbook,再等上 10 到 20 分钟让 Ansible 完成配置,这实在让人提不起兴致。

我正在逐步将所有项目从 Ansible 迁移到 Nix,因为 Nix 要轻量得多。通过 Ansible 升级一个依赖通常要花我大约 20 分钟,而用 Nix 完成同样的事大约只需两分钟。

创建一个简单的 Nix 开发环境

为了演示 Nix 开发环境的工作方式,我将从一台什么都没装的 Debian 11 系统开始。

安装 Nix

首先安装 Nix。这里我使用的是第三方 Determinate Systems 提供的安装器,而非官方安装器,因为前者做了一些更符合本文演示需求的预设配置。

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install && . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh

创建一个简单的 Python 2.7 应用

为了直观展示 Nix 开发环境,我将创建一个运行在 Python 2.7 上的简单应用——也就是那个已于 2020 年正式停止维护的 Python 遗留版本。

首先,为项目新建一个目录。

mkdir example && cd example

接着,下载或复制下面这个 Nix flake,也就是定义 Nix 开发环境的文件:

{
  description = "Demo Nix dev environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 2.7.18.7 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9";
  };

  outputs = {
    self,
    flake-utils,
    python-nixpkgs,
  } @ inputs:
    flake-utils.lib.eachDefaultSystem (system: let
      python-nixpkgs = inputs.python-nixpkgs.legacyPackages.${system};
    in {
      devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python2
        ];

        shellHook = ''
          python --version
        '';
      };
    });
}

下载 flake.nix

curl --show-error --fail https://mtlynch.io/notes/nixos-dev-environment/flake.nix > flake.nix

如果你不熟悉 Nix,flake.nix 文件看起来可能是一堆令人困惑的语法,但其实大部分只是简单的样板代码。稍后我会做更详细的解释如下

最后,就可以启动 Nix 开发环境了。注意,第一次运行该命令时需要几分钟来完成初始化,但之后的启动只需几秒钟即可完成。

# We need NIXPKGS_ALLOW_INSECURE and --impure because Python 2.7 is past end of
# life.
$ NIXPKGS_ALLOW_INSECURE=1 nix develop --impure
Python 2.7.18.7

成功了!现在我已经拥有了一个可用的 Python 2.7 环境。

注意,Python 2.7 仅存在于这个特定的 Nix 环境中,并没有安装到系统其他地方。如果我打开一个新终端而不执行 nix develop,就会看到如下提示,表明系统并未安装 Python:

$ python  --version
-bash: python: command not found

回到 Python 2.7 的 Nix 环境中,我来尝试运行一个使用了已被废弃、仅在 Python 2 中可用的 print 语法的简单脚本:

$ echo 'print "hello, world!"' > main.py && python main.py
hello, world!

太好了!在这个环境里,我可以正常运行遗留的 Python 2.7 代码。

查找版本字符串

那么,我的 flake.nix 文件是如何工作的呢?

文件中靠前的几行声明了我想要的 Python 包的确切版本:

{
  # 2.7.18.7 release
  python-nixpkgs.url = "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9";

# 2.7.18.7 release 这一行只是我自己留作备忘的注释,Nix 会忽略它。真正起作用的是 python-nixpkgs 这一行。

NixOS/nixpkgs 是一个 GitHub 仓库,而 517501bcf14ae6ec47efd6a17dda0ca8e6d866f9 则是该仓库中 python2 软件包对应 Python 2.7.18.7 的那个版本的提交哈希。

我是怎么找到这么长的版本字符串的?答案是 Nixhub

显示 NixHub 首页搜索对话框的截图

Nixhub 是由 Jetpack 提供的免费软件包搜索服务,这是一家基于 Nix 构建开发者工具的公司。

Nixhub 仅在三个月前发布,却已经让我的 Nix 使用体验轻松了许多。如果我想查找某个软件包特定版本的哈希,只需在 Nixhub 上搜索,就能找到对应的 commit ID。

因此,要查找 Python 2.7.18.7 的版本字符串,我在 Nixhub 上搜索了 python,然后在结果列表中向下滚动,找到了最新的 Python 2.7.x 版本:

显示 NixHub 搜索结果的截图,其中依次显示人类可读的版本字符串、nixpkgs 版本字符串和软件包名称

NixHub 可以帮我将人类可读的版本字符串转换为 nixpkgs 引用和软件包名称。

说实话,锁定精确的软件包版本是一件非常麻烦的事。我希望未来的 Nix 工具能进化到只需指定想要的 2.7.18.7 版本即可,而不必像现在这样绕来绕去地去查找对应版本的 git 提交哈希。但就目前而言,这是我所知的锁定版本的最佳方式。

理解 flake.nix 文件

好吧,前面说过要更详细地解释 flake.nix 文件。

我不会深入讲解 Nix flake 的方方面面,因为我自己也理解不深。我只会讲清楚创建开发环境所需的最少知识。如果想更深入地了解 Nix flake,可以参考 《Practical Nix Flakes》

inputs 部分用于声明你想在环境中使用的各种 Nix 源的版本。这里我使用了针对 GitHub 仓库的特殊语法,当然你也可以从其他代码仓库或 URL 导入。

{
  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 2.7.18.7 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9";
  };

devshells.default 定义了 Nix shell 的开发环境。packages 则列出了我希望在环境中可用的所有软件包。

{
    devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python2
        ];

对于大多数软件包,包名本身不带版本号。像 htopvim 这样的包,包名始终不变;但像 Python 这类在同一 Nixpkgs 版本中就提供多个版本的包,则必须明确指定 python2 而非 python,以避免与 Python 3 混淆。

最后一处相关的是 shellHook 部分。

{
  shellHook = ''
    python --version
  '';

Nix 会在将你带入 shell 之前执行 shellHook 中的命令。你可以在这里放入任意 shell 命令。

我喜欢在这里放入打印依赖版本的命令,这样就能直观地确认 Nix flake 是否正常工作。

升级到 Python 3

好,假设我已经准备好花功夫将这个只有一行的 Python 应用从 Python 2.7 移植到现代的 Python 3。我只需更新下面这两处:

{
    # 3.12.0 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10";
{
    packages = [
      python-nixpkgs.python312
    ];

更新后的 Python 3 flake 如下所示:

{
  description = "Demo Nix dev environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 3.12.0 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10";
  };

  outputs = { self, flake-utils, python-nixpkgs }@inputs :
    flake-utils.lib.eachDefaultSystem (system:
    let
      python-nixpkgs = inputs.python-nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python312
        ];

        shellHook = ''
          python --version
        '';
      };
    });
}

通过按 Ctrl+D 或输入 exit 退出原有的 Nix shell,然后运行以下命令来初始化新的 Python 3 环境:

$ nix develop
warning: updating lock file '/home/mike/example/flake.lock':
• Updated input 'python-nixpkgs':
    'github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9' (2023-09-27)
  → 'github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10' (2023-10-03)
Python 3.12.0

方便的是,由于现代 Python 版本不再被视为不安全,我可以省去之前为 Python 2.7 所需的 NIXPKGS_ALLOW_INSECURE 相关选项。

现在我应该已经处于 Python 3 环境中了。为了验证,我来尝试运行之前那个 Python 2 风格的 main.py,看看 Python 3 是否会如预期那样报错:

$ python main.py
  File "/home/mike/example/main.py", line 1
    print "hello, world!"
    ^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)?

看起来 Python 3 正在按预期工作。我来把语法改成 Python 3 的形式再试一次:

$ echo 'print("hello, world!")' > main.py && python main.py
hello, world!

一切又恢复正常了。我仅仅修改了 Nix flake 中的几行,就把环境从 Python 2.7 升级到了 Python 3.12!

添加新的依赖

前面演示了如何更新软件包,那么如何添加新的依赖呢?

我将添加一个新的 bash 脚本,让它自动运行我的 Python 文件。

(cat <<EOF
#!/usr/bin/env bash

set -eux

readonly MAIN_SCRIPT="main.py"
python $MAIN_SCRIPT
EOF
) > run.sh && chmod +x run.sh && ./run.sh

你应该会看到如下输出:

+ readonly MAIN_SCRIPT=main.py
+ MAIN_SCRIPT=main.py
+ python main.py
hello, world!

我的 bash 水平一般,所以静态分析工具应该能帮我改进这个 run.sh 脚本。

shellcheck 是一款非常出色的 bash 脚本检查工具,只要写 bash 代码我都会用到它。我想把它引入到开发环境中,让它帮我检查潜在的 bash 陷阱,因此更新了 Nix flake:

{
  description = "Demo Nix dev environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 3.12.0 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10";

    # 0.9.0 release
    shellcheck-nixpkgs.url = "github:NixOS/nixpkgs/8b5ab8341e33322e5b66fb46ce23d724050f6606";
  };

  outputs = { self, flake-utils, python-nixpkgs, shellcheck-nixpkgs }@inputs :
    flake-utils.lib.eachDefaultSystem (system:
    let
      python-nixpkgs = inputs.python-nixpkgs.legacyPackages.${system};
      shellcheck-nixpkgs = inputs.shellcheck-nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python312
          shellcheck-nixpkgs.shellcheck
        ];

        shellHook = ''
          python --version
          echo "shellcheck" "$(shellcheck --version | grep '^version:')"
        '';
      };
    });
}

同样,通过按 Ctrl+D 或输入 exit 退出原有的 Nix shell,然后用以下命令开启一个新的 shell:

$ nix develop
warning: updating lock file '/home/mike/example/flake.lock':
• Added input 'shellcheck-nixpkgs':
    'github:NixOS/nixpkgs/8b5ab8341e33322e5b66fb46ce23d724050f6606' (2023-09-19)
Python 3.12.0
shellcheck version: 0.9.0

一切正常。shellcheck 显示的版本是 0.9.0,正是我指定的版本。

现在,来用 shellcheck 检查我的 run.sh 脚本。

$ shellcheck -o all run.sh
In run.sh line 6:
python $MAIN_SCRIPT
       ^----------^ SC2248 (style): Prefer double quoting even when variables don't contain special characters.
       ^----------^ SC2250 (style): Prefer putting braces around variable references even when not strictly required.

Did you mean:
python "${MAIN_SCRIPT}"

For more information:
  https://www.shellcheck.net/wiki/SC2248 -- Prefer double quoting even when v...
  https://www.shellcheck.net/wiki/SC2250 -- Prefer putting braces around vari...

成功了!

这看起来只是件小事,却解决了我过去长期面临的一个大问题。我喜欢在所有项目中把 shellcheck 作为 git pre-commit 钩子来运行,但过去只能依赖系统全局安装的单一版本。如果我发现 shellcheck 有了想在某个项目中应用的新规则,我的 pre-commit 钩子就可能会导致所有项目的检查都失败。

有了 Nix,我就可以为每个项目绑定想要使用的检查工具版本。这意味着我可以按项目为单位升级检查工具,而不必在全局共享同一个版本。

使用 direnv 自动加载 Nix 开发 shell

我的 Nix 开发 shell 已经可以正常工作了,但这意味着每次打开新的终端窗口时,都必须输入 nix develop 才能进入环境。

能自动化这个过程吗?答案是可以,借助 direnv 就能实现。

每当你 cd 进入项目目录时,direnv 都会自动加载你的 Nix shell;当你 cd 离开时,它又会自动卸载。

direnv 可以作为普通的 apt 软件包安装,但在 Debian Bullseye 及更早版本中,可用的最新版本只有 2.25.0,着实有些恼人。

由于我使用的是 Nix flake,需要 2.29.0 或更高版本的 direnv,因此我改用官方的 direnv 安装器:

curl -sfL https://direnv.net/install.sh | sudo bin_path=/usr/local/bin bash && echo 'eval "$(direnv hook bash)"' >> ~/.bashrc && . ~/.bashrc

通过 --version 参数运行 direnv,可以看到我已安装的是最新版本:

$ direnv --version
2.32.3

要为我的项目启用 direnv,只需进入存放 Nix flake 的目录,并运行以下命令:

echo 'use flake .' > .envrc && direnv allow

这样一来,每当我 cd 进入项目目录时,direnv 都会自动加载 Nix 环境,离开目录时则自动卸载。

为非自有项目创建开发 shell

如果你和我一样喜欢开发 shell,就会想在参与的每个项目中都用上它。

但当你在别人的仓库中工作,而对方完全不想引入 Nix 时,该怎么办呢?

最简单的处理方式是为 Nix flake 单独创建一个目录,然后将第三方仓库作为子文件夹放在其中,结构如下:

.
├── examplerepo/ << The actual git repo
├── flake.lock
└── flake.nix

对此我在《Use a Nix Flake without Adding it to Git》一文中做了更详细的说明。

每新增一个依赖,初始化就会变慢

我发现 Nix 开发环境最大的缺点是环境加载速度慢。通常 cd 进入一个目录只需几毫秒,但如果需要加载 Nix 环境,则可能要花 5 到 10 秒。

更糟的是,依赖越多,加载就越慢。Nix 需要为每个依赖维护一份独立的 nixpkgs 实例,因此每新增一个开发工具,都要付出目录加载时间变长的代价。

遗憾的是,我尚未找到解决这个问题的办法。

在 CI 中使用 Nix,我还没有找到好的方案

既然我已经费了这么大功夫为项目创建了一个独立、可复现的开发环境,自然也希望能在持续集成(CI)中复用这个环境。但遗憾的是,我还没找到将 Nix 有效集成到 CI 流程中的实用方法。

Nix 在 CI 中的问题在于,它在创建自身环境时需要预先完成大量工作。在我的本地开发机上,Nix 首次初始化环境通常需要 60 到 180 秒,往往还要从软件包服务器下载数 GB 的数据。

在本地机器上,缓慢的初始化虽然烦人但尚可忍受,因为只需执行一次。而在 CI 上,问题就严重得多——这意味着原本只需 10 秒就能完成的简单 CI 步骤,现在要先花 2 分钟初始化 Nix,再花 10 秒执行真正关心的任务。

我曾尝试使用专为 Nix 设计的云缓存 Cachix。它或许有点帮助,但我始终没能将每个 CI 步骤的初始加载时间降到 90 秒以下。

市面上也有几个专门围绕 Nix 构建的 CI 方案(GarnixHerculessmithy),但我还没尝试过。我更希望能在自己已经熟悉的 CircleCI 环境中使用 Nix,而不是去学习一套全新的 CI 系统。

我期待的功能:让 Nix 管理语言特定的依赖

有一件事 Nix 看似能够做到,但我始终没弄明白该怎么实现,那就是管理语言特定的依赖。

例如,如果我用 Nix 创建了一个 Python 3 项目,并在 requirements.txt 中列出了 pip 依赖,我会非常希望 Nix 能自动察觉:“嘿,你的 requirements.txt 变了!我来帮你把环境更新到最新。”对于 Node.js 和 package.json 也是如此。但到目前为止,我还没找到让 Nix 监控这类文件的方法。

我了解过 poetry2nix,但因为我在 Python 项目中并不使用 Poetry,所以还没尝试过。不过如果有读者知道如何实现我设想的这种功能,欢迎在评论区告诉我。

更新(2023-10-28):我发现 pyproject.nix 已支持普通的 requirements.txt 文件,所以我现在改用了它

更新(2025-01-17):我已不再使用 pyproject.nix,因为我觉得它过于复杂。它似乎只能处理 Nix 维护者已专门移植到 Nix 的 PyPI 软件包,遇到其他包时就会以令人困惑的方式失败。

踩坑记录

和每一次 Nix 探索之旅一样,开发环境也有大量坑点。下面列出的是我目前遇到的一些。

Nix 要求 flake.nix 已被 git 跟踪

Nix flake 有一个奇怪的特性:如果它位于受 git 管理的目录中,但你尚未通过 git addflake.nix 添加到仓库,就会看到这样一条令人困惑的错误信息:

error: getting status of '/nix/store/66snibk6a9y3dbam1ww7fj0bdrh0ylw6-source/flake.nix': No such file or directory

如果遇到这种情况,只需执行 git add flake.nix 即可修复。甚至无需提交,仅仅添加到暂存区就足够了。

Go:链接 libc 失败

在依赖 CGO 的 Go 项目中,我曾在尝试于 Nix 开发环境中编译代码时遇到如下错误:

runtime.gcdata: missing Go type information for global symbol .dynsym: size 72
runtime/cgo(.text): relocation target stderr not defined
runtime/cgo(.text): relocation target fwrite not defined
runtime/cgo(.text): relocation target vfprintf not defined

看起来 Go 未能将二进制文件链接到 libc。这与影响 Zig 用户的这个问题类似。

我曾尝试将 libcmusl 添加到 Nix 环境的软件包列表中,但毫无效果。

唯一能解决该链接问题的方法是用 -tags=netgo,osusergo 来编译 Go 应用。至于为何有效,我也不清楚。

关于在 Nix 环境中构建多平台 Go 二进制文件的完整示例,可参考我的 PicoShare flake 和构建脚本

Golang: version X does not match go tool version Y

在我的某些机器上,运行构建脚本时开始出现如下错误:

compile: version "go1.18.4" does not match go tool version "go1.19.6"

后来发现,是我的 GOROOT 环境变量指向了 Nix 环境之外的 Go 编译器版本。

快速修复方法是运行以下命令:

unset GOROOT

要彻底修复,则需要在系统中搜索所有设置环境变量的文件,找到设置 GOROOT 的那一行并将其删除。删除后还需要重启系统——仅仅开启一个新的 shell 是不够的。

旧版本软件包无法使用

我曾尝试使用某些较旧版本的软件包,结果完全无法使用。

例如,如果我为 python39 选择 nixpkgs 版本 b4e193a23a1c5d8794794e65cabf1f1135d07fd9,不仅 Python 会出问题,连 shellcheck 也会一并失效:

• Updated input 'python-nixpkgs':
    'github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10' (2023-10-03)
  → 'github:NixOS/nixpkgs/b4e193a23a1c5d8794794e65cabf1f1135d07fd9' (2021-02-19)
environment:2863: python: command not found
environment:2864: shellcheck: command not found

我的推测是,如此陈旧的 nixpkgs 版本早于对 Nix flake 的兼容支持,而 Nix flake 至今仍是一项新的、尚未正式支持的功能。

我的一些 Nix 开发 flake 示例

以下是我目前创建的几个 Nix 开发 flake:

  • PicoShare - 一个 Go Web 应用
  • mtlynch.io - 一个基于 Hugo、带有 Node.js 依赖的博客
  • python3_seed - 一个带有 requirements.txt 依赖的基础 Python 应用

参考资料

我在摸索如何让 Nix 开发环境正常工作时遇到了不少困难,因为能找到的文档示例并不多。

最终让我豁然开朗的是 Attila Gulyas 的详细指南

本文章由 muse-spark-1.2-contributor 进行翻译

评论