后现代构建系统

Lobsters Hottest 工具

摘要

一篇博客文章,探讨理想中的'后现代'构建系统的设计,该系统优先考虑可信的增量构建、最大化计算复用和分布式构建,并以Nix作为参考。

<p><a href="https://lobste.rs/s/da3ffr/postmodern_build_system">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/01 18:34

# 后现代构建系统(2025年更新) 来源:https://jade.fyi/blog/the-postmodern-build-system ## 文章后续 这是一篇关于某个想法的文章。我不认为这个想法已经存在,也不确定自己是否会去实现它。但类似的东西应该被创造出来。 ## 我们的需求 - **可信的增量构建:** 将增量机制迁移到一个不可信赖层,使得增量构建错误的存在只能由哈希碰撞引起。这一目标意味着需要对构建任务进行沙盒化,并将其转化为纯函数,当输入发生任何变化时重新运行。这在计算上天生是浪费的,因为它忽略结果语义等价性,只关注二进制等价性,而我们希望减少这种浪费的计算。然而,对于生产用例来说,计算成本比出现增量错误的可能性更便宜。这可以等价地用“构建身份”缺失来描述:系统是否有任何方式知道“同一构建”的“先前版本”是什么?后现代构建系统没有构建身份,因为它会在多租户等场景下引发问题:谁来决定上一个构建是什么? - **最大化跨构建的计算复用:** 修改一个源文件应尽可能少地重新构建。 - **分布式构建:** 我们生活在一个可以通过使用多台机器显著加快编译速度的世界。幸运的是,将构建转化为纯计算几乎天生就可以实现分布式。 ## 回顾 ## 构建系统点菜式 本文使用了“Build systems à la carte”(https://www.microsoft.com/en-us/research/uploads/prod/2018/03/build-systems.pdf)中的术语: - **Monadic(单子式):** 一种需要运行构建才能知道完整目标的构建。其名称来源于单子的核心操作定义:`bind :: Monad m => m a -> (a -> m b) -> m b`。这意味着,给定一个尚未执行的动作`m a`和一个接受该动作解析结果的函数,你会得到一个新动作,其形态在极大程度上依赖于`m a`的结果。这是一个动态构建计划,因为要完全了解构建计划,需要执行`m a`。 - **Applicative(应用式):** 一种计划静态已知的构建。通常这意味着严格的两阶段构建:先评估目标,制定构建计划,然后执行构建。其名称来源于应用式类型的核心操作:`apply :: Applicative f => f (a -> b) -> f a -> f b`。这意味着,给定一个构建内部预定义的纯函数,可以执行该函数来完成构建。但构建计划的形态是事先已知的,因为该函数不能执行其他构建。 ## Nix 尽管我是 Nix 的忠实拥趸,但 Nix 并非后现代构建系统。它存在一些很难纠正的设计缺陷。我们不妨记录一下它做得好的地方,这些概念值得在其他地方借鉴。 Nix 是一个基于“派生”概念的构建系统。派生仅仅是`execve`执行规格的说明。它的输出随后根据输入或输出的哈希所确定的名称存储在 Nix 存储中(`/nix/store/*`)。通过跳过输出路径已存在的构建来实现记忆化。 这种机制缺乏构建身份,并且是多租户的:你可以将大量不同版本的各种 Nix 项目放在同一台构建机器上,它们不会互相干扰,因为没有构建身份,唯一依赖的是哈希。 存储路径可以是: - **基于派生内容的哈希命名:输入寻址**。这通常是构建软件的情况。 - **基于输出的哈希命名:固定输出**。这通常是下载文件的情况,实践中具有宽松的沙盒,允许网络访问。然而,输出随后会被哈希并与硬编码的值进行验证。 - **基于派生输出的哈希命名(输出不固定):内容寻址**。注意,`ca-derivations`的部署时间线曾一波三折,并且已从 Lix 中移除。参见`ca-derivations`(https://www.tweag.io/blog/2021-12-02-nix-cas-4/)。 GNU hello 的派生示例: `` { "/nix/store/nvl9ic0pj1fpyln3zaqrf4cclbqdfn1j-hello-2.12.1.drv": { "args": [ "-e", "/nix/store/v6x3cs394jgqfbi0a42pam708flxaphh-default-builder.sh" ], "builder": "/nix/store/q1c2flcykgr4wwg5a6h450hxbk4ch589-bash-5.2-p15/bin/bash", "env": { "__structuredAttrs": "", "buildInputs": "", "builder": "/nix/store/q1c2flcykgr4wwg5a6h450hxbk4ch589-bash-5.2-p15/bin/bash", "cmakeFlags": "", "configureFlags": "", "depsBuildBuild": "", "depsBuildBuildPropagated": "", "depsBuildTarget": "", "depsBuildTargetPropagated": "", "depsHostHost": "", "depsHostHostPropagated": "", "depsTargetTarget": "", "depsTargetTargetPropagated": "", "doCheck": "1", "doInstallCheck": "", "mesonFlags": "", "name": "hello-2.12.1", "nativeBuildInputs": "", "out": "/nix/store/sbldylj3clbkc0aqvjjzfa6slp4zdvlj-hello-2.12.1", "outputs": "out", "patches": "", "pname": "hello", "propagatedBuildInputs": "", "propagatedNativeBuildInputs": "", "src": "/nix/store/pa10z4ngm0g83kx9mssrqzz30s84vq7k-hello-2.12.1.tar.gz", "stdenv": "/nix/store/wr08yanv2bjrphhi5aai12hf2qz5kvic-stdenv-linux", "strictDeps": "", "system": "x86_64-linux", "version": "2.12.1" }, "inputDrvs": { "/nix/store/09wshq4g5mc2xjx24wmxlw018ly5mxgl-bash-5.2-p15.drv": { "dynamicOutputs": {}, "outputs": [ "out" ] }, "/nix/store/cx5j3jqvvz8b5i9dsrn0z9cxhfd8r73p-stdenv-linux.drv": { "dynamicOutputs": {}, "outputs": [ "out" ] }, "/nix/store/qxxv8jm6z12vl6dvgnn3yjfqfgc68jhc-hello-2.12.1.tar.gz.drv": { "dynamicOutputs": {}, "outputs": [ "out" ] } }, "inputSrcs": [ "/nix/store/v6x3cs394jgqfbi0a42pam708flxaphh-default-builder.sh" ], "name": "hello-2.12.1", "outputs": { "out": { "path": "/nix/store/sbldylj3clbkc0aqvjjzfa6slp4zdvlj-hello-2.12.1" } }, "system": "x86_64-linux" } } `` ### `execve`的记忆化与纯化 Nix 实现的核心思想是使`execve`变得纯化。这是一个绝妙的主意,使得它可以与现有软件一起使用,并且在后现代构建系统中可能在某种程度上也是必需的。 Nix 纯化`execve`的方式是通过“未知输入 xor 未知输出”的概念。派生要么是输入寻址的,其`execve`运行环境完全指定(即无网络),要么是输出寻址的,其输出具有已知哈希(允许网络)。 通过`ca-derivations`(https://www.tweag.io/blog/2021-12-02-nix-cas-4/)(RFC(https://github.com/nixos/rfcs/blob/master/rfcs/0062-content-addressed-paths.md)),Nix 额外对具有相同输出的输入寻址派生进行去重,这样即使构建方式发生变化,只要输出不变,就避免重新构建。这在实际中解决了一个大问题,因为 NixOS 拥有庞大的构建集群,需要应对当 glibc 的一个字节变化时整个已知宇宙都需要重新构建的情况,即使这不会影响下游组件。目前有项目计划对公共二进制缓存(大小达数百 TB)进行去重,将其放置在内容寻址存储之上,这与 Nix 自身的内容寻址无关。 这种记忆化纯`execve`的想法非常出色,因为它通过确保构建在一致的环境中运行来纯化不纯的构建系统,同时提供了某种程度的增量机制。Nix 是一个基于源码的构建系统,但实际上大多数东西都可以从二进制缓存中获取,这使得使用二进制还是源码构建仅仅是一个时间问题。 后现代构建系统将需要类似记忆化`execve`的东西,以便能够与当今的工具配合使用并获得采纳,从而实现粗粒度的增量构建。 ### 递归 Nix、从派生导入、动态派生 Nix 的构建守护进程/execve 记忆化器本身并非单子式构建的问题,问题完全出在 Nix 语言及其 C++ 实现上。 Nix 是一个单子式构建系统,但在许多上下文(例如 nixpkgs 中),由于 NixOS Hydra 使用了`restrict-eval`(https://nixos.org/manual/nix/stable/command-ref/conf-file.html#conf-restrict-eval)(我认为这没有文档说明,但 IFD 是被禁止的),它在实践中大多被限制为仅应用式操作。 有一些改进这种状况的想法,它们在某些层面上是同构的: - **递归 Nix**(https://github.com/NixOS/nix/issues/13):Nix 构建可以访问守护进程套接字,并执行其他 Nix 评估和 Nix 构建。由于实现问题,该功能已从 Lix 中移除。 - **从派生导入**(https://nixos.org/manual/nix/unstable/language/import-from-derivation):Nix 评估可以在继续进一步评估之前要求进行构建。不幸的是,Nix 评估器在等待构建时**无法恢复其他剩余评估**(https://jade.fyi/blog/nix-evaluation-blocking/),因此实际上 IFD 往往会因重复阻塞和并行度损失而严重拖慢评估时间。 - **动态派生**(https://github.com/NixOS/nix/issues/6316):派生的构建计划本身可以是派生,这些派生随后被实际构建以继续构建。这关键地允许 Nix 评估器在构建图的动态部分放置占位符,从而在完整构建计划未完全解析时不会阻塞单子依赖的评估。这使得可以将构建图序列化成一个文件,稍后执行,从而避免了在执行构建时需要评估器持续存活。不过,这在某种程度上是对 Nix 评估器缺陷的变通方案——IFD 是串行化的,而像`tvix`(https://tvix.dev/)这样的替代实现或像`Zilch`(https://media.ccc.de/v/nixcon-2023-36425-reinventing-the-wheel-with-zilch)这样的替代表层语言不受此限制。在这种情况下,单子依赖本质上被替换为**占位符**,这使得构建不需要任何评估器来驱动执行,这与 IFD 形式的方法不同。也就是说,动态派生允许拥有更完整的构建图,可以序列化成文件并在之后运行,包括在另一台机器上。由于实现问题,该功能已从 Lix 中移除。 ### 内部构建系统——扫兴者 Nix 可以在派生**内部**运行单子式构建系统,但由于当前使用中并非单子式,大型软件的构建最终在一个**庞大的**派生/构建目标中执行,这意味着先前的构建结果不会被复用。 这浪费了大量计算,每次运行构建时派生内部的相同目标都会被重新构建,因为构建系统的记忆化粒度太粗。 Puck 的`Zilch`(https://media.ccc.de/v/nixcon-2023-36425-reinventing-the-wheel-with-zilch)旨在通过将 Nix 的表层语言替换为支持原生评估延续的 Scheme,并集成/替换诸如 Ninja、Make、Go 等内部构建系统,从而将内部目标转化为派生,进而立即获得增量构建和跨机器并行性(使用 Nix 守护进程)。 ### `execve`记忆化的限制 即使我们修复了 Nix 表层以使用单子式构建使内部构建系统变得纯化和高效,在某种程度上,**我们的问题构建系统变成了编译器**。在许多非 C/C++/Java 的编译器中,构建单元要大得多(例如 Rust 的编译单元是整个 crate!),因此编译器本身就成为具有增量构建支持的构建系统。而由于我们是可信增量构建系统,我们不能复用之前的构建产物,也不能使用编译器的增量机制——因为它们几乎都期望构建身份。 也就是说,编译器迫使我们使用过粗的增量粒度,而我们的构建系统无法窥探它们内部,对此我们无能为力。 例如,在 Haskell 中,有两种运行构建的方式:运行`ghc --make`以单进程构建整个项目,或运行`ghc -c`(类似于 C 中的`gcc -c`)来生成`.o`文件。 - 前者会承受与每个 GHC 包大小成正比的`O(模块数)`(或超线性)开销。这个开销比编译器启动开销小得多,通常可以忽略。在 Nix 没有增量机制且使用旧版本代码的情况下,`ghc --make`模式具有更好的吞吐量,但需要构建整个项目,因此延迟非常糟糕。 - 后者会承受总共`O(模块数)`的 GHC 启动开销,这很成问题,因为该开销大约相当于构建几个完整的小模块。`ghc -c`的延迟尚可,但构建系统需要以比 Nix 快得多的速度评估一个庞大的构建图,才能实际调用编译器。 (Glorious Glasgow)Haskell 编译器可以复用之前的构建产物并提供考虑语义等价性的增量编译,但这在可信增量世界中无法用于生产构建,因为你必须显式声明对先前构建产物的依赖,并接受增量编译 bug 的可能性。因此,`ghc --make`的增量模式与可信增量构建系统存在矛盾:由于无法提供先前的构建产物,每次都需要重新构建整个项目。 这种增量机制与执行开销之间的紧张关系,需要在后现代构建系统中通过集成内部构建系统来解决。 ### `execve`记忆化的另一种解决方案:持久工作者 在本文的原始版本中,我并未考虑第三种秘密选项,因为我过于专注于完全不信任编译器。实际上,存在一种折中方案,由 Bazel(https://bazel.build/remote/persistent)和 Buck2(https://buck2.build/docs/prelude/rules/core/worker_tool/)实现:**持久工作者**。 持久工作者摊销了编译器的启动成本,因此可以使用更小的增量操作,从而获得良好的延迟特性,同时不会像将增量构建委托给编译器那样严重损害封闭性/可靠性。持久工作者的设计方式是批量编译模式,无法读取特定目标的先前构建产物,但可能复用一些经过非常谨慎选择的缓存。也就是说,它们是 RPC 服务器,执行相当于`gcc -c`的操作,只不过是通过 RPC 而非`execve`。 服务器设计使得无构建身份的封闭构建系统能够拥有小而正确的操作,并且通过消除需要更大操作的理由来快速运行: - 操作可以小而增量,因为消除了启动开销(一个 RPC,**而非**`execve`) - 完成一个工作单元的延迟远优于更大的操作 - 与运行更大操作相比,吞吐量损失相对较小 - 通过精心设计的缓存,诸如“预编译头”之类的概念可以成为编译器服务器的实现细节,其中公共依赖的反序列化可能在多个操作间复用 - 无需选择之前的操作版本,因为操作**本身**不是增量的,因此构建身份的需求消失 - 编译器不会收到文件的先前版本,因此无法复用,也不太可能出现增量 bug 持久工作者的一些例子包括: - Java(https://blog.bazel.build/2015/12/10/java-workers.html) - Kotlin(https://github.com/bazelbuild/rules_

相似文章

不到100行代码实现nix-build

Lobsters Hottest

本文通过用不到100行Go代码重新实现nix-build,揭示了Nix构建过程,表明将派生转换为存储路径本质上就是一次执行。

Nix for Haskell: 静态构建

Lobsters Hottest

本教程介绍如何使用 Nix 为 Haskell 项目创建静态链接的可执行文件,涵盖 GHC 的静态构建配置以及与 Docker 的集成。

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

Michael Stapelberg

本教程演示了使用Nix设置开发环境的四种方法,包括交互式一次性使用、配置文件以及密封的Nix Flakes,并以GoCV和OpenCV为例。

我喜欢的 NixOS 声明式安装方式

Michael Stapelberg

一份关于使用 nixos-anywhere 等工具通过网络声明式安装 NixOS 的指南,重点强调在版本控制下管理配置文件。