Development shells with Nix: four quick examples

Michael Stapelberg

使用 Nix 的开发环境:四个快速示例

我想在其中一个项目中使用 GoCV(用于从较大的扫描件中查找并提取纸质文档),而又不想在系统上永久安装 OpenCV。

这似乎是一个很好的示例场景,可以用来演示我常用的几个 Nix 命令,涵盖从快速、交互式、一次性的 dev shells(开发环境)到完全声明式、hermetic(隔离)、可复现、可共享的 dev shells。

值得注意的是,你不需要使用 NixOS 也能运行这些命令!你可以在任何 Linux 系统(如 Debian、Arch 等)上安装和使用 Nix,只要设置好 Nix 路径或使用 Flakes 即可(见准备工作)。

对比:Debian 的做法

在开始介绍 Nix 之前,我先展示如何在 Debian 上让 GoCV 运行起来。

让我们创建一个使用了 GoCV 函数(如 gocv.NewMat())的最小 Go 程序,只是为了验证能否编译该程序:

package main

import "gocv.io/x/gocv"

func main() {
  gocv.NewMat()
}

如果我们在 Debian 系统上尝试构建,会得到:

debian % mkdir -p /tmp/minimal
debian % cd /tmp/minimal

debian % cat > minimal.go <<'EOT'
package main
import "gocv.io/x/gocv"
func main() { gocv.NewMat(); }
EOT

debian % go mod init minimal
go: creating new go.mod: module minimal
go: to add module requirements and sums:
	go mod tidy

debian % go mod tidy
go: finding module for package gocv.io/x/gocv
go: downloading gocv.io/x/gocv v0.41.0
go: found gocv.io/x/gocv in gocv.io/x/gocv v0.41.0

debian % go build
# gocv.io/x/gocv
# [pkg-config --cflags  -- opencv4]
Package opencv4 was not found in the pkg-config search path.
Perhaps you should add the directory containing `opencv4.pc'
to the PKG_CONFIG_PATH environment variable
Package 'opencv4', required by 'virtual:world', not found

在 Debian 上,我们可以如下安装 OpenCV:

debian % sudo apt install libopencv-dev

[…]

Summary:
  Upgrading: 7, Installing: 512, Removing: 0, Not Upgrading: 27
  Download size: 367 MB
  Space needed: 1590 MB / 281 GB available

Continue? [Y/n]

在此提示符下输入“yes”会下载并安装超过 500 个软件包(需要几分钟)。

现在可以构建了:

debian % go build
debian % file minimal
minimal: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), […]

……但我们的系统上多了 500 多个额外的软件包,以后将永远需要更新它们,因此我想把这次一次性的实验与常用系统隔离开来。

我们可以使用 Docker 启动一个 Debian 容器并在其中工作,但根据具体任务,这可能恰恰因为它是一个独立的环境而显得很笨重。就这个例子而言,我需要指定卷挂载,让输入文件能在 Docker 容器中访问,还需要在 Docker 容器内的程序能在宿主机上打开图形窗口之前设置好环境变量……

让我们看看如何用 Nix 来解决这个问题!

准备工作:在 Debian(或 Arch 等系统)上使用 Nix

NixOS 用户可以跳过本节,因为 NixOS 系统已自带可直接使用的 Nix。

在你自己的电脑上尝试这些示例之前,需要完成以下三个步骤:

  1. 安装 Nix
  2. 启用 Flakes
  3. 设置 Nix 路径

第 1 步:安装 Nix

Debian、Arch、Fedora 或其他 Linux 系统的用户首先需要安装 Nix。幸运的是,Nix 已适用于许多主流 Linux 发行版:

第 2 步:启用 Flakes

Nix flakes 是“一种打包 Nix 产物的通用方式”

示例 3 和示例 4 使用 Nix flakes 来锁定依赖,因此我们需要启用 Nix flakes

第 3 步:设置 Nix 路径

对于示例 1 和示例 2,我们想使用 Nix 表达式 import <nixpkgs>

在 NixOS 上,该表达式会跟随系统版本,意味着如果你在 NixOS 25.05 安装上使用 import <nixpkgs>,它将引用nixpkgs 的 nixos-25.05 版本

在其他 Linux 系统上,你会看到如下错误信息:

debian-server % nix-shell -p pkg-config opencv
error: file 'nixpkgs' was not found in the Nix search path (add it using $NIX_PATH or -I)

       at «string»:1:25:

            1| {...}@args: with import <nixpkgs> args; (pkgs.runCommandCC or pkgs.runCommand) "shell" { buildInputs = [ (pkg-config) (opencv) ]; } ""
             |                         ^
(use '--show-trace' to show detailed location information)

我们需要通过设置Nix 搜索路径来告诉 Nix 使用哪个版本的 nixpkgs

debian-server % export NIX_PATH=nixpkgs=channel:nixos-25.05
debian-server % nix-shell -p pkg-config opencv
[nix-shell:/tmp/opencv]#

好了!现在已经准备就绪。让我们进入第一个示例!

示例 1:交互式一次性环境:nix-shell

Nix 在在系统上安装 OpenCV(像上面例子中的 apt install)和在单独的 Docker 容器中安装 OpenCV 之间提供了一种折中方案:Nix 可以让你使用 OpenCV 而无需永久安装它。

我们可以运行nix-shell(1)来启动一个 bash shell,在其中指定的软件包可用。要成功构建使用 GoCV 的 Go 代码,我们需要让 OpenCV 可用:

% nix-shell -p pkg-config opencv
these 194 paths will be fetched (175.80 MiB download, 764.10 MiB unpacked):
  /nix/store/ig2nk0hsha9xaailhaj69yv677nv95q4-abseil-cpp-20210324.2
  /nix/store/yw5xqn8lqinrifm9ij80nrmf0i6fdcbx-alsa-lib-1.2.13
[…]

[nix-shell:/tmp/opencv]$ pkg-config --cflags opencv4
-I/nix/store/mh5b1dx2ifv4jkp9a8lgssxwhzxssb96-opencv-4.11.0/include/opencv4

如果你好奇的话:是的,我们确实需要在这个 nix-shell 命令中显式指定 pkg-config,否则运行 pkg-config 时会执行宿主机版本(在 dev shell 之外),它找不到 opencv4.pc

示例 2:nix-shell 配置文件:shell.nix

一旦我们有了一组适用于项目的软件包(在我们的例子中,就是 pkg-configopencv),我们就可以创建一个 shell.nix(在任意目录中,但通常在项目根目录),nix-shell(不带 -p 标志)会读取它:

{
  pkgs ? import <nixpkgs> { },
}:
pkgs.mkShell {
  packages = with pkgs; [
    # Explicitly list pkg-config so that mkShell will arrange
    # for the PKG_CONFIG_PATH to find the .pc files.
    pkg-config
    opencv
  ];
}

……然后,我们只需运行 nix-shell

% nix-shell
[nix-shell:/tmp/opencv]$ pkg-config --cflags opencv4
-I/nix/store/mh5b1dx2ifv4jkp9a8lgssxwhzxssb96-opencv-4.11.0/include/opencv4

如果你好奇的话,这里有几个关于软件包列表周围样板代码的文档指引:

  • 第 1 至 3 行声明了一个带参数集的函数——这是 nix-shell 能够调用你的 shell.nix 文件所需的结构。
  • pkgs.mkShell 是供 nix-shell 使用的便捷辅助函数。
  • with pkgs; 部分让我们可以写 opencv 而不是 pkgs.opencv

顺便一提:借助nixd 语言服务器,支持LSP 的编辑器可以显示软件包解析到的版本、指出拼写错误,或提供“跳转到定义”等功能。

例如,在这张截图中,我在 Emacs 中编辑 shell.nix,好奇 opencv 软件包的 Nix 源码是什么样子。通过将光标置于 opencv 上并按下 M-.xref-find-definitions),并借助“point”,我跳转到了本地 Nix store 中的 opencv/4.x.nix

Emacs 在跳转到 opencv 定义后显示 opencv/4.x.nix

示例 3:隔离的、已锁定的 devShells:Nix Flakes

前面的示例使用的是来自系统(或 Nix 路径)的 nixpkgs,这意味着升级系统时无需更改 .nix 文件——取决于使用场景,我认为这种行为要么很方便,要么很可怕。

对于那些要求无论周围操作系统版本如何,.nix 文件都能以完全相同方式构建的使用场景,我们可以使用Nix Flakes以隔离的方式构建,并在 flake.lock 文件中锁定依赖版本。

flake.nix 包含与上面相同的 mkShell 表达式,但在其周围声明了结构:mkShell 表达式位于 outputs.devShells.x86_64-linux.default 属性中,而 inputs 属性则包含该构建可用的Flake 引用

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";

  outputs =
    { self, nixpkgs }:
    {
      devShells.x86_64-linux.default =
        let
          pkgs = nixpkgs.legacyPackages.x86_64-linux;
        in
        pkgs.mkShell {
          packages = with pkgs; [
            # Explicitly list pkg-config so that mkShell will arrange
            # for the PKG_CONFIG_PATH to find the .pc files.
            pkg-config
            opencv
          ];
        };
    };
}

顺便一提:尽管名字如此,最佳实践是使用 nixpkgs.legacyPackages,它在概念上提供单一的 import nixpkgs 结果(为了效率)。

现在,我可以使用 nix develop 来获得带有 OpenCV 的 shell:

% nix develop
michael@midna$ pkg-config --cflags opencv4
-I/nix/store/mh5b1dx2ifv4jkp9a8lgssxwhzxssb96-opencv-4.11.0/include/opencv4

首次运行 nix develop 会创建一个 flake.lock 文件,因此之后再运行 nix develop 将获得完全相同的环境。要更新到更新的版本,请使用 nix flake update

提示:除了 shell,nix develop --command=emacs 也是一个有用的变体。

示例 4:让 Flake 不依赖特定系统

遗憾的是,上述 flake.nix 硬编码了 x86_64-linux,因此它将无法在例如 aarch64-linux(ARM)计算机或 x86_64-darwin(Mac)上使用。

默认情况下必须显式指定 system 是对 Nix Flakes 长期以来的一个批评。

有多种变通方法。例如,我们可以使用numtide/flake-utils并重构我们的 flake.nix 以使用其eachDefaultSystem便捷函数:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs =
    {
      self,
      nixpkgs,
      flake-utils,
    }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        formatter = pkgs.nixfmt-tree;
        devShells.default = pkgs.mkShell {
          packages = with pkgs; [
            # Explicitly list pkg-config so that mkShell will arrange
            # for the PKG_CONFIG_PATH to find the .pc files.
            pkg-config
            opencv
          ];
        };
      }
    );
}

或者我们可以使用numtide/blueprint,它是前者的精神续作。

LucPerkins 的 dev-templates 实际上内联了该技术的一个版本。

对于一个不属于 Nix 本身但与 Nix 相关的解决方案:devenv 是一个基于 Nix 构建的独立工具(不再使用 CppNix 实现,而是实际上使用 tvix),但使用自己的 .nix 文件。

提示:保留软件包

如果你发现 nix develop 或类似命令在 flake.lock 未发生变化的情况下仍会获取软件包,你可以将 Flake 安装到你的 profile 中,以将其声明为 Nix 的 gcroot(垃圾回收根)

% nix profile install .#devShells.x86_64-linux.default

等等,这难道不是让我们回到了Debian 做法的那种状态吗?不是的!虽然如果你将 flake 安装到 profile 中,OpenCV 将无限期地保留可用,但仍然有一层隔离:在你的系统中,OpenCV 并不可用,只有当你使用 nix-shellnix develop 启动开发环境时才可用。

结论

上面的四个示例相比如何?概览如下:

示例样板代码是否锁定?是否依赖系统?
示例 1nix-shell -p …😊
示例 2shell.nix🙂
示例 3flake.nix😲
示例 4:不依赖特定系统的 flake.nix🤨

对于个人的一次性实验,我使用 nix-shell

一旦实验可行,我通常想锁定依赖,因此会使用 flake.nix

如果这些软件不仅仅是进行版本控制,还要发布(或由多人/多系统协作),我就会花功夫将其做成不依赖特定系统的 flake.nix

我希望将来编写不依赖特定系统的 flake 会变得更容易。

尽管还有些粗糙之处,我依然欣赏 Nix 带来的可复现性和可控性!

原文由 Michael Stapelberg 发布

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