跳转至

用 Nix 声明与管理脚本依赖:摆脱环境依赖地狱

Script dependencies with Nix

python-dependencies-with-nix.jpg

文章背景与核心概要

在日常开发与自动化运维中,很多开发者习惯编写轻量级脚本来处理日常任务,但这些脚本往往既依赖特定编程语言的第三方库(例如 Python 的 PyPI 包),又依赖系统层面的可执行工具(例如 Linux 下的音频播放或网络工具)。随着时间推移,跨机器迁移、全局系统升级或者包管理器的一次回车,极容易让脚本陷入“依赖缺失”或“环境冲突”的瘫痪境地。本文深入浅出地介绍了一种基于 Nix 的脚本依赖管理方案:即使不在 NixOS 上,仅在常规的 Linux 发行版中,也能通过轻量的 flake.nix 声明文件与 Nix 原生提供的“脚本编写器 (writers)”,将解释器、语言包和底层系统依赖严密打包为独立、自给自足的可执行文件。该方案不仅完美适用于 Python,还能轻松推广至 Emacs Lisp 等解释型语言以及 Ada 等编译型语言,彻底终结脚本环境的“依赖地狱”,实现跨系统、跨时间的无缝可复现执行。


内容总结

Summary

本文演示了如何在任意 Linux 发行版上运用 Nix 高度可靠地管理轻量脚本的各项依赖,确保它们能够长久保持可正常运行的状态,并彻底免受底层系统升级与环境变更引发的兼容性故障。本指南以一个 Python 文本转语音 (Text-to-Speech) 脚本为切入点,逐步带领读者构建一个可复用的 flake.nix 模板,利用 Nix 的“脚本编写器 (writers)”功能将解释器与系统级原生依赖(如 alsa-utils 与 Python 第三方库)严密打包封装;随后更进一步展示了如何将这套思路拓展至 Emacs Lisp 等解释型脚本以及 Ada 等编译型语言中。

This article demonstrates how to use Nix on any Linux distribution to reliably manage the dependencies of simple scripts, ensuring they remain runnable and isolated from system-wide breakage over time. Using a Python text-to-speech script as an example, the guide walks through creating a reusable flake.nix template, leveraging Nix "writers" to bundle interpreter and system dependencies (alsa-utils, Python packages), and adapting the approach to other languages such as Emacs Lisp and compiled languages like Ada.


用 Nix 管理脚本依赖

Managing Script Dependencies with Nix

延续我之前关于 NixOS 让人从容无畏 的分享,这里介绍一种我在其他常规 Linux 发行版上使用 Nix 的绝佳技巧,它能让我晚上睡得格外踏实:那就是用它来精确管理轻量脚本的各种依赖项。

In the theme of NixOS enabling fearlessness, here’s a way I use Nix on other Linux distributions to sleep better at night: managing dependencies of simple scripts.

这种使用 Nix 的方式面向所有人,而不仅仅局限于那些愿意折腾并尝鲜 NixOS 的极客玩家。在接下来的内容中,我们首先会以一个 Python 脚本为例展开拆解,然后再看看如何将这套模式通用化适配到其他任何编程语言中。

This way of using Nix is for everyone, not just those willing to try NixOS. We’ll look at it first for a Python script, and then see how it can be adapted to any other language.

我此前打算写一个自动化脚本,能够用合成语音将我写的博文逐字逐句朗读给我听 [^1]。实现这一功能最便利的途径之一是选用 Python,而且我早就听说 Trafilatura 是一款极具口碑的正文提取库,它能高效抓取网页正文,并精准过滤各类非核心杂质内容。

I wanted to create a script that would read the verbatim content of my articles out loud to me with a synthesised voice.[^1] One of the more convenient ways to do this is with Python, where I’ve heard good things about Trafilatura for extracting the contents out of a web page, filtering on types of content, etc.

[^1]: 这与最近的 AI 语音旁白实验完全无关。把写好的文章大声朗读出来是我进行自我内容审校的关键环节之一,主要用来敏锐捕捉行文的卡顿与语句的晦涩。我发现听别人 (哪怕是机器合成音) 朗读文章,效果要比自己亲自大声读出来稍微更好一些,尽管两者都很有帮助。当我自己朗读时,往往下意识地读出自己“原本想表达的内容”,而非屏幕上白纸黑字实际写出的字句。

[^1]: This is entirely separate from the recent ai narration experiment. Reading the article out loud is one of my self-editing steps, to detect flow problems and awkward phrasings. I’ve discovered that hearing someone else read the article is slightly more effective than reading it out loud myself, though both are useful. When I read it out loud myself, I tend to read what I intended to say, not the words that are actually on the page.

然而,Trafilatura 属于第三方 Python 依赖库。这类依赖常常让人叫苦不迭——只要你稍不留神用 pip 更新或触碰了环境,脚本随时可能莫名瘫痪。不仅如此,脚本底层还依赖了系统原生工具 aplay(用于播放音频),这意味着同时存在语言级和操作系统级两类未来极易暴雷的潜在破坏源。要是放在几年前,我大概率会觉得维护成本太高而直接放弃写这个脚本。所幸的是,我现在掌握了 Nix。

But Trafilatura is a third-party Python dependency. Those suck because they break your scripts when you so much as glance sideways at pip. The code also ended up depending on aplay, which is a system dependency, making it two different sources of future breakage. A couple of years ago, I would probably have judged that to be too much maintenance, so I wouldn’t have created the script. Fortunately, now I know about Nix.

Nix Flake 的基本骨架

General outline of a Nix flake

现在我们手里有一个 Python 脚本,并希望它在任何机器上都能极其轻巧地开箱即跑,完全不必操心依赖配置的问题。要达成这种理想状态,我们首先在与该 Python 脚本相同的目录下创建一个名为 flake.nix 的文件,并填入基础骨架代码:

We have a Python script, and we want to make it trivially runnable anywhere, without having to worry about dependencies. To get to that position, we first create a file called flake.nix in the same directory as the Python script, and we start to fill it in:

{
  # Pick the package versions of the most recent NixOS.
  # Change these numbers to upgrade later if desired.
  # If the upgrade breaks, it can be rolled back by
  # decrementing these numbers again.
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { nixpkgs, ... }:
    let
      # Generate outputs for both Intel and ARM. In this
      # case, I do not include macOS among the supported
      # systems because aplay is linux-only.
      #
      # This references "legacyPackages" which might sound
      # odd, but in Nix flake contexts, the entire nixpkgs
      # package set is called "legacy" because it uses an
      # older structure for organisation.
      forAllSystems = f: nixpkgs.lib.genAttrs
        [ "x86_64-linux" "aarch64-linux" ]
        (system: f nixpkgs.legacyPackages.${system});
    in {
      # The default entry point of a Nix flake are the
      # derivations named "packages.<system>.default". Here
      # they are defined for the two Linux architectures.
      packages = forAllSystems (pkgs: {
        default =
          # Default package definition to be filled in!
      });
    };
}

无论我们使用哪种编程语言、挑选了何种依赖库,只要使用 Nix 来管理程序依赖,这个骨架模板都是完全通用的。唯一会因项目而异的具体细节,仅仅体现在 default 软件包的定义当中。

This outline is the same every time we use Nix to manage the dependencies of a program, regardless of language, choice of dependencies, etc. The specifics that differ are those in the definition of the default package.

从 Python 脚本构建可执行文件

Creating a binary from a Python script

既然我们面对的是一个 Python 脚本,就可以利用 writePython3Bin 这一辅助函数,将其打包为一个内置 Nix 依赖纳管的可执行程序。它接收两个关键参数:

Since we have a Python script, we’ll use the writePython3Bin helper function to turn that into an executable with Nix-managed dependencies. It takes two arguments we need:

  • makeWrapperArgs:用一个微型包装器脚本 (wrapper script) 替代直接调用主脚本。该包装脚本负责预先配齐正确的运行时环境变量,随后通过 execve 切换调用实际的主脚本。我们利用该参数配置 PATH 环境变量,使其精准指向由 Nix 托管的系统原生依赖项 ^2。这意味着,我们在编写 Python 脚本时压根不需要考虑 Nix 的存在。就脚本本身而言,它完全认为自己运行在一个标准的普通环境中,但实际上它所调用的所有系统命令都源自隔离的 Nix Store,并且版本与我们声明的完全一致。
  • libraries:允许我们精确声明当 Python 解释器执行该脚本时,哪些 Python 第三方依赖库应该处于可用状态。
  • makeWrapperArgs replaces the main script with a small wrapper script that sets up the right environment and then execves into the main script. We will use it to set up the PATH variable to point at Nix-managed versions of system dependencies.^2 This means the Python script doesn’t have to be written with Nix-managed dependencies in mind. As far as the script cares, it thinks it’s running in a normal environment. But the commands it runs come from the Nix store, and are of the specific versions we requested.
  • libraries lets us specify which Python dependencies should be available to the Python interpreter when it runs our script.

^2: This may be the most common way to use it, but it can be used to fix up the environment in any number of other ways.

为这些参数填入具体的对应值后,我们的 flake.nix 最终会呈现如下结构:

Filling in the right values for these arguments, our flake.nix will look something like the following.

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

  outputs = { nixpkgs, ... }:
    let
      forAllSystems = f: nixpkgs.lib.genAttrs
        [ "x86_64-linux" "aarch64-linux" ]
        (system: f nixpkgs.legacyPackages.${system});
    in {
      packages = forAllSystems (pkgs: {
        # Create executable from Python script.
        default = pkgs.writers.writePython3Bin "readarticle" {
          # Make system dependencies available.
          makeWrapperArgs = [
            "--prefix" "PATH" ":" (pkgs.lib.makeBinPath [
              pkgs.alsa-utils
            ])
          ];
          # Make libraries available.
          libraries = [
            pkgs.python3Packages.requests
            pkgs.python3Packages.trafilatura
          ];
        } (builtins.readFile ./main.py);
      });
    };
}

大功告成!配置完成后,我们只需在终端执行 nix run . 即可运行脚本。在初次执行时,Nix 会自动在后台拉取并配置好所有必需的依赖,随后立即唤起脚本;而在后续的执行中,Nix 会识别出所有依赖均已就绪,从而瞬间毫秒级启动运行 [^3]。

And that’s it. After this, we can run the script with nix run . and the first time we do it, Nix is going to set up all the dependencies and then run the script. On subsequent runs, Nix recognises that everything is in place and runs the script right away.[^3]

[^3]: 实际上,writePython3Bin 函数还附带了一个功能:它会在构建时自动对脚本执行代码静态检查 (linting)。这一行为可以手动禁用,但我通常喜欢保留默认设置。例如对应的 writeBashBin 函数会自动通过 ShellCheck 对脚本进行严格静态语法分析,如果无法通过 ShellCheck 检查,构建会直接中断报错而绝不盲目运行。这一点在工程实践中极其珍贵!

[^3]: Actually, the writePython3Bin function does one more thing: it runs the script through a linting pass. This can be disabled, but I generally like keeping the default behaviour. The corresponding writeBashBin it runs the script through ShellCheck and fails the build before running the script if it doesn’t ShellCheck cleanly. That is very valuable!

如果我们把这个脚本连同目录完整拷贝到另一台计算机上,它的运行表现依然会与第一台机器毫厘不差,因为 Nix 会在拉起脚本前以完全相同的方式严密构建所有依赖。哪怕我们把这个脚本彻底抛在脑后,一年之后再次运行它,它依然能够像第一天那样稳健执行——因为 Nix 生成的可执行文件硬编码引用着完全一致的确定性依赖集合,不论这期间主机的底层操作系统发生了怎样天翻地覆的升级变化。

If we copy this script to another computer, it will continue to work exactly the same as on the first computer, because Nix will set up all the same dependencies the same way before running the script. If we forget about it and run it a year later, it will continue to run just the same as before, because the executable created by Nix refers to the exact same set of dependencies regardless of what else has changed on the system.


推广与适配到其他编程语言

Adapting to Other Languages

我猜看到这里,读者中肯定会有人小声嘀咕起 uv。据我所知,uv 确实极大地缓解了 Python 依赖管理的痛点 [^4]。但它仅仅局限于 Python 生态。而 Nix 能够管理任何编程语言的任何软硬件依赖。 比如我们想为一个 R 语言脚本纳管依赖,做法如出一辙,只需将对应的函数替换为 writeRscriptBin 即可。

I imagine that of those reading this article, a small crowd starts murmuring about uv. From what I understand, uv does take a lot of the headache out of managing Python dependencies.[^4] But it is only for Python. Nix works for any dependency, with any language. If we have an R script we want to manage the dependencies for, we’d do the exact same thing and use writeRscriptBin instead.

[^4]: 说实话我也不是特别了解它。它兴起的时候我已经不再全职从事 Python 开发了,因此要学习理解它得占用我本就不多的业余闲暇时间;而现在既然我已经熟练掌握了 Nix,我觉得我有更值得探索和深挖的技术天地。

[^4]: I don’t know, honestly. It came around after I stopped working professionally with Python, so learning and understanding it would happen in my limited spare time, and now that I have Nix, I think I have better things to learn and understand.

寻找适用的辅助函数的诀窍在于:在 Nix 生态中它们被统称为“脚本编写器 (writers)”,你可以直接在 Noogle 搜索它们。如果在检索词中拿掉具体的语言名称,你就会惊喜地发现 Nix 还为 C、JavaScript、Shell、Haskell、Perl 等诸多语言提供了现成的专属 writers。

The way to find the right helper function is by knowing that they are called writers, and then searching Noogle for them. By excluding the language from that search, we can see there are writers for other languages too, like C, JavaScript, Shell, Haskell, Perl, and more.

不过,官方生态中并没有专门为 Emacs Lisp 量身定制现成的 writer 函数。如果我们恰好想用 Nix 来管理一个 Emacs Lisp 脚本的依赖,该怎么办呢?此时我们依然可以调用底层更通用的 makeScriptWriter 函数。下面是一个通用示例:

But there’s no writer helper function specifically for Emacs Lisp. What if we have an Emacs Lisp script we want Nix to manage the dependencies of? Then we can still use the lower level makeScriptWriter function. Here’s a generic example of that:

default = pkgs.writers.makeScriptWriter
  {
    # System dependencies work the same as before.
    makeWrapperArgs = [
      "--prefix" "PATH" ":" (pkgs.lib.makeBinPath [
        pkgs.some-package-with-executable
      ])
    ];

    # Make sure the interpreter is produced with the
    # necessary run-time dependencies. Most packaged
    # scripting languages support the "withPackages"
    # convention for this.
    interpreter = "${(pkgs.interpreter.withPackages (ps: [
      ps.dependency-1
      ps.dependency-2
    ]))}/bin/interpreter-binary";
  }
  "/bin/our-executable-name"
  (builtins.readFile ./source-file-path);

对于 Emacs Lisp 而言,我们使用的解释器软件包就是 Emacs 本身。这里指定的目标输出路径(/bin/our-executable-name)是相对于该派生包内部的局部路径,因此完全不必担心它会污染或覆盖宿主机的根文件系统。

For Emacs Lisp, the interpreter package we’d use would be Emacs. The output path (/bin/our-executable-name) is package relative, so we don’t have to worry about it clobbering the file system.

当然,我们日常还会接触编译型语言,比如 Ada。Ada 同样没有现成的快捷 writer 函数,因此我们可以使用通用的 makeBinWriter 辅助工具。它接受一个 compileScript 参数,允许我们在其中自由指定完整的编译命令:

Of course, we also have compiled languages, like Ada. Ada also doesn’t have a ready-made writer function, so we’d use the generic makeBinWriter helper, which takes a compileScript argument where we specify how to compile.

default = pkgs.writers.makeBinWriter
  {
    # System dependencies work the same again.
    makeWrapperArgs = [
      "--prefix" "PATH" ":" (pkgs.lib.makeBinPath [
        pkgs.some-package-with-executable
      ])
    ];

    # The compilation script can reference any compiler and
    # any libraries in nixpkgs, and they will be managed by
    # Nix during the build. Any runtime dependencies will be
    # managed by Nix automatically too.
    #
    # The file we read with readFile is called $contentPath
    # in this script, and the output must be written to the
    # path in the $out variable.
    compileScript = ''
      ${pkgs.some-compiler}/bin/compiler-binary \
        -o $out $contentPath
    '';
  }
  "/bin/our-binary-name"
  (builtins.readFile ./source-file-path);

尽管这段配置看起来颇有一番晦涩深奥的黑魔法味道,但它骨子里绝非玄学。Nix 最为优雅和精妙的魅力就在于:它完全依赖最标准的 Unix 文件路径、标准的 Unix 环境变量以及内容寻址哈希 (Content Hashing) 机制,来清晰判定组件之间的依赖关系,并自动推导输入源是否发生变化、是否需要重新触发构建。

Although this looks like deep, black magic, it’s not. What’s so neat about Nix is that it uses regular Unix paths, regular Unix environment variables, and content hashing to figure out what depends on what and whether an input has changed, necessitating rebuilding.


深入进阶与学习路径

Learning More

还有一个极其关键的问题我尚未回答:“你是怎么掌握这套完整知识的?”

The important question I haven’t yet answered is “How do you know all this?”

我也很希望能回答“我把官方文档通读了一遍”,但现实并非如此。掌握 Nix 的知识体系其实包含两大部分,并且它们的学习路径截然不同:

I wish I could answer “I have read the documentation” but that’s not it. There are two parts to the knowledge, and the learning paths are different for them:

  • 其核心本质是:“系统层面的 Nix”是一台逻辑精巧简洁的机器,花上几个小时的阅读即可融会贯通;而“语言层面的 Nix”本身也足够轻量,一两天之内便可熟练上手。这两部分都可以直接从 Eelco Dolstra 的博士毕业论文中领略其精髓。那是一篇行文极其优美且阅读体验非常愉悦的论文,即便你最终并不打算在生产中使用 Nix,我也由衷推荐一读 [^5]。
  • 另一部分则是被称为 nixpkgs 的庞大代码库生态。我们在本文中所用到的各类脚本编写器 (writers) 就深藏于此,而它们在任何公开文档中都鲜有详尽记录。想要彻底摸清例如 writers 这类工具的工作原理,唯一的途径就是直接研读其源码实现。nixpkgs 中的大量实用功能都只能靠阅读源码来掌握。虽然 Nix 是一门小巧易懂的语言,读源码听起来不算难事,但不巧的是,nixpkgs 内部充斥着环环相扣的依赖关系,且非常习惯通过属性集 (attribute sets, 即键值字典) 隐式透传具名参数,因此有时很难直观一眼看出究竟哪些参数被有意透传、又是如何被消费的。这往往会让你不得不顺藤摸瓜阅读浩瀚的代码。
  • At its core, Nix-the-system is a simple machine that can be grasped in few hours of reading. Similarly, Nix-the-language is small enough to be learned in a day or two. Both of these can be learned from Eelco Dolstra’s PhD thesis, which is a pleasant read and highly recommended, even if you end up not using Nix.[^5]
  • Then there’s the huge library of code called nixpkgs. That’s where we find the writers we’ve used in this article, and it’s not well documented anywhere. The way to learn how e.g. writers work is to read their source code. A lot of what’s in nixpkgs is only learnable by reading the code. That might not sound like a big deal, since Nix is a small and easy language. Unfortunately, plenty of what’s in nixpkgs depends on other things in nixpkgs, and it is common to pass named arguments implicitly through attribute sets (key–value dictionaries), so it’s not always obvious which things are actually passed down intentionally and which are not, nor how they are used. It’s easy to end up reading a lot of code.

[^5]: 不过我实在很难想象,有谁读完那篇博士论文后会忍得住不去动手试用 Nix。

[^5]: Though I have a hard time imagining how anyone could read the thesis and not end up with a desire to use Nix.

不过好在,只要你编写脚本所用的编程语言已经在 nixpkgs 中拥有现成的 writer 函数,你几乎可以直接复用同一套模板,唯一要做的仅仅是按需替换具体调用的 writer 函数即可。

But as long as the scripts you write are in languages that already have writers in nixpkgs, you can mostly use the same template for all of them except replace the specific writer you use.