使用 Nix 打造按项目隔离的开发环境
Nix 是一个功能广泛但学习曲线陡峭的产品。它既能用来安装单个软件包,也能管理操作系统上的每一个文件和应用程序。
即使是完全的新手,也可以用 Nix 做一件很有用的事情:管理你的开发环境。
Nix 让我能在同一台系统上拥有多个项目,而每个项目对可用依赖都有各自独立的视图。我可以让一个运行 Python 2.7 和 Node.js 4.x 的老项目,与一个运行 Python 3.11 和 Node.js 20 的现代项目并存,而且它们互不干扰。
即使你完全没有使用过 Nix,也只需大约 20 分钟的工作就能用上由 Nix 管理的开发环境。
注:我自己也还是 Nix 新手,所以我不确定我的做法是否是最优方案。如果更有经验的 Nix 读者有改进建议,请告诉我,我会更新这篇文章。
为什么不使用 Docker 管理开发环境?
我喜欢 Docker,并且在部署和某些 DevOps 任务中使用它,但我从未发现它对管理开发环境有什么帮助。
我通过 SSH 在 VS Code 中进行开发,而 Docker 让这件事变得很麻烦。我知道有一些变通方法,但我从未觉得它们有吸引力。
为什么不使用 Ansible 管理开发环境?
过去六年里,我一直用 Ansible 管理我的开发环境,效果还算可以。
对于我的每个软件项目,我都会创建一个专用的虚拟机以及相应的 Ansible playbook,用来配置虚拟机上的所有依赖。
问题是,当我想花几分钟试验某个东西时,我并不想费劲启动一整个虚拟机、编写一个 playbook,然后等上 10 到 20 分钟让 Ansible 完成服务器配置。
我正在逐步把所有项目从 Ansible 迁移到 Nix,因为 Nix 轻量得多。通过 Ansible 升级依赖通常每个依赖要花我大约 20 分钟,而在 Nix 中同样的操作大约只需两分钟。
创建一个简单的 Nix 开发环境
为了演示 Nix 开发环境是如何工作的,我准备从一个什么都没装的 Debian 11 系统开始。
安装 Nix
首先,安装 Nix。我使用的是第三方的 Determinate Systems 安装器,而不是官方的 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 下的简单应用——Python 2.7 是官方已于 2020 年弃用的旧版本。
首先,我为项目创建一个新目录。
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
'';
};
});
}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 环境。
注意,我没有在这个特定的 Nix 环境之外的任何地方安装 Python 2.7。如果我打开一个新终端而不运行 nix develop,就会看到如下错误消息,提示 Python 未安装:
$ python --version
-bash: python: command not found回到我的 Python 2.7 Nix 环境,让我试着运行一个使用 Python 3 中已不存在的、邪恶且被弃用的 print 语法的简单 Python 脚本:
$ echo 'print "hello, world!"' > main.py && python main.py
hello, world!很酷!我可以在这个环境中运行旧版 Python 2.7 代码。
查找版本字符串
那么,我的 flake.nix 文件是如何工作的?
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 是由 Jetpack 创建的免费包搜索服务,这家公司在 Nix 之上销售开发者工具。
Nixhub 三个月前才刚刚发布,但它让我的 Nix 使用体验轻松了很多。如果我想找到某个软件包特定版本的版本哈希,我就在 Nixhub 中搜索它,找到对应的 commit ID。
所以,为了找到 Python 2.7.18.7 的版本字符串,我在 Nixhub 中搜索了 python,然后在结果列表中向下滚动,找到最新的可用 Python 2.7.x 版本:

NixHub 让我能把人类友好的版本字符串转换为 nixpkgs 引用和包名。
说实话,锁定确切的软件包版本是一件极其痛苦的事。我希望 Nix 的工具链能发展到只需指定你想要 2.7.18.7 版本即可的程度,而不必经历这种绕来绕去、查找对应 git commit 哈希的繁琐流程。但就目前而言,这是我所知的锁定版本的最佳方式。
理解 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
];对大多数包来说,包名中不包含版本号。对于像 htop 或 vim 这样的包,包名始终相同;但某些包(比如 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 2.7 所需的 NIXPKGS_ALLOW_INSECURE 选项,因为现代版本的 Python 不被视为不安全。
现在我应该处于一个 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 脚本 linter,我写 bash 代码的地方都会用它。我想把 shellcheck 引入我的开发环境,让它指出潜在的 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,正是我请求的版本。
现在,是时候对我的 run.sh 脚本运行 shellcheck 了。
$ 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 版本。如果我看到 shellcheck 有新规则想应用到我的某个项目,我的 pre-commit 钩子可能会开始在所有项目中失败。
Nix 允许我把每个项目与我想要运行的 linter 版本绑定在一起。这意味着我可以按项目升级到新的 linter,而不是在全局共享一个版本。
使用 direnv 自动加载 Nix 开发 shell
我的 Nix 开发 shell 已经可以工作了,但这意味着每次打开新的终端窗口时,我都必须输入 nix develop 来进入我的 shell。
能自动化吗?事实证明,可以,使用 direnv 就行。
每当你 cd 进入项目目录时,direnv 都会自动加载你的 Nix shell。当你 cd 离开该目录时,direnv 会自动卸载该 shell。
direnv 可以作为普通的 apt 包安装,但令人恼火的是,在 Debian Bullseye 及更早版本上,最新的可用包是 2.25.0。
由于我使用的是 Nix flake,我需要 direnv 2.29.0 或更高版本,所以我改用官方的 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
如果你像我一样喜欢 dev 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 需要预先做大量工作来创建它自己的环境。在我的本地开发系统上,Nix 首次初始化环境需要 60 到 180 秒,通常会从包服务器下载多个 GB 的数据。
缓慢的初始化虽然烦人,但在我的本地系统上尚可容忍,因为初始化只需进行一次。而在 CI 上,这是更大的问题,因为这意味着过去 10 秒就能跑完的简单 CI 步骤,现在会膨胀成 2 分钟的 Nix 初始化加上 10 秒的真正工作。
我尝试过使用 Cachix,一个 Nix 专用的云缓存。它或许有些帮助,但我始终无法把每个 CI 步骤的初始加载时间降到 90 秒以下。
有一些专门围绕 Nix 构建的 CI 方案(Garnix、Hercules 和 smithy),但我没有尝试过。我希望在我已经熟悉的 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 开发环境也有大量的陷阱。下面列出的是我到目前为止遇到的那些。
Nix 要求 flake.nix 处于 git 中
Nix flake 的一个奇怪特性是:如果它们所在的目录处于 git 版本控制之下,但你还没有把 flake.nix 文件 git add 到仓库中,你会看到这样一个令人困惑的错误:
error: getting status of '/nix/store/66snibk6a9y3dbam1ww7fj0bdrh0ylw6-source/flake.nix': No such file or directory如果发生这种情况,你可以用 git add flake.nix 来修复。你甚至不需要提交这个更改——只要添加了这个 flake 就够了。
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 用户的问题类似。
我尝试过把 libc 和 musl 添加到我的 Nix 环境的包列表中,但没有任何效果。
唯一能解决这个链接问题的方法是用 -tags=netgo,osusergo 编译我的 Go 应用。我不知道为什么这样能行。
关于在 Nix 环境中构建多平台 Go 二进制文件的完整示例,请参阅我的 PicoShare flake 和构建脚本。
Golang:版本 X 与 go 工具版本 Y 不匹配
在我的某些系统上,我开始在运行构建脚本时看到这些错误:
compile: version "go1.18.4" does not match go tool version "go1.19.6"原来是我的 GOROOT 环境变量指向了我的 Nix 环境之外的某个 Go 编译器版本。
快速修复方法是运行这个命令:
unset GOROOT永久修复方法是搜索我系统上所有设置环境变量的文件,找到设置 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我最好的猜测是,那么老的 nixpkg 版本早于对 Nix flake 的兼容支持,而 Nix flake 是 Nix 的一项新特性,至今仍未获得官方支持。
我的一些 Nix 开发 flake
以下是我到目前为止创建的几个 Nix 开发 flake:
- PicoShare - 一个 Go Web 应用
- mtlynch.io - 一个基于 hugo、带有 Node.js 依赖的博客
- python3_seed - 一个带
requirements.txt依赖的基础 Python 应用
参考资料
我费了好大劲才弄清楚如何让 Nix 开发环境跑起来,因为我找不到多少有文档记载的示例。
最终让我豁然开朗的是 Attila Gulyas(阿蒂拉·古利亚什)的详细指南。
随机一篇博客
