Slack工程团队揭秘:如何运用Bazel和软件工程原则将构建速度提升六倍

本文详细阐述了Slack工程团队如何通过重构构建系统,引入Bazel工具,并严格应用软件工程的关注点分离、缓存和并行化等原则,成功将Quip和Canvas后端构建时间从60分钟大幅缩短的实践历程与技术架构。

构建更优软件,以更好地构建软件 | Slack 工程实践

2025年11月6日 18分钟阅读

构建更优软件,以更好地构建软件 David Reed,高级软件工程师

我们负责交付 Quip 和 Slack Canvas 后端的构建流水线。一年前,我们正追逐着激动人心的想法,以帮助工程师更快地交付更好的代码。但我们面临一个巨大的问题:构建需要 60 分钟。构建如此缓慢,整个流水线变得不那么敏捷,反馈要到很晚才能到达工程师那里。

我们通过结合现代化高性能构建工具(Bazel)与经典的软件工程原则解决了这个问题。以下是我们如何做到的。

思考构建(与代码)性能

想象一个简单的应用程序。它有一个提供 API 和数据存储的后端服务器,以及一个呈现用户界面的前端。与许多现代应用一样,前端和后端是解耦的;它们可以独立开发和交付。

此构建的依赖图如下所示:

我们用箭头表示构建元素(如源文件和可部署工件)之间的依赖关系,形成一个有向无环图。这里,我们的后端依赖于一组 Python 文件,这意味着每当 Python 文件更改时,我们需要重新构建后端。同样,当 TypeScript 文件更改时,我们需要重新构建前端——但 Python 文件更改时则不需要。

将我们的构建建模为具有明确定义工作单元的图,让我们能够应用与加速应用程序代码类似的性能优化技术:

  1. 减少工作量。存储已完成的高成本工作的结果,以便只需执行一次,用内存换取时间。
  2. 分担负载。将正在进行的工作分散到更多的计算资源上并行执行,以更快地完成,用计算资源换取时间。

缓存与并行化

大多数工程师对这些技术都很熟悉,通过代码视角来思考有助于巩固它们如何应用于构建系统。以这个 Python 示例为例:

1
2
def factorial(n):
    return n * factorial(n-1) if n else 1

计算阶乘可能非常昂贵。如果我们需要经常计算,运行速度会相当慢。但直观上,我们知道一个数的阶乘不会改变。这意味着我们可以应用策略 1 来缓存此函数的结果。那么对于任何给定的输入,它只需要运行一次。

1
2
3
@functools.cache
def factorial(n):
    return n * factorial(n-1) if n else 1

缓存存储我们的输入 (n) 并将其映射到相应的输出(factorial() 的返回值)。n 是缓存键:它的功能类似于构建图中的源文件,函数的输出对应于构建的工件。

细化我们的直觉,我们可以说这个函数需要具备几个特定属性才能使缓存生效。它需要是密封的:它只使用明确给定的输入来产生特定的输出值。并且它需要是幂等的:对于任何给定的输入集,输出始终相同。否则,缓存就是不合理的,并会产生相当令人惊讶的效果。

一旦有了缓存,我们就希望最大化命中率:即调用 factorial() 时从缓存中获得答案(而非通过计算)的比例。定义工作单元的方式可以帮助我们保持高命中率。

为了说明这个想法,让我们看一段更复杂的代码:

1
2
3
4
5
@functools.cache
def process_images(
  images: list[Image],
  transforms: list[Transform]
) -> list[Image]: ..

这个函数附加了缓存,但缓存效果不佳。该函数接受两个输入集合:一个图像列表和一个要应用于每个图像的变换列表。如果调用者更改了其中任何一个输入,比如在列表中添加一个图像,他们同时也更改了缓存键。这意味着我们无法在缓存中找到结果,必须从头开始所有工作。

换句话说,这个缓存粒度不够细,因此命中率很低。更有效的代码可能如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def process_images(
  images: list[Image],
  transforms: list[Transform]
) -> list[Image]:
  new_images = []
  for image in images:
    new_image = image
    for transform in transforms:
       new_image = process_image(new_image, transform)
    new_images.append(new_image)

  return new_images

@functools.cache
def process_image(image: Image, transform: Transform) -> Image:
  ...

我们将缓存移至一个更小的工作单元——将单个变换应用于单个图像——并对应使用更小的缓存键。我们仍然可以保留更高级别的 API;只是不在那里缓存。当调用者使用一组输入调用高级 API 时,我们可以通过仅处理我们以前未见过的 ImageTransform 组合来满足该请求。其他一切都来自缓存。我们的缓存命中率和性能都应该得到提升。

缓存帮助我们减少工作量。现在让我们看看如何分担负载。如果我们通过线程将图像处理分散到多个 CPU 核心上会怎样?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def process_images_threaded(
  images: list[Image],
  transforms: list[Transform]
) -> list[Image]:
  with ThreadPoolExecutor() as executor:
    futures = []
    for image in images:
      futures.append(executor.submit(process_images, [image], transforms))

    # 返回的图像可能是任意顺序!
    return [future.result() for future in futures.as_completed(futures)]

让我们仔细分析为了能够并行运行工作所需具备的特性,同时注意一些关键注意事项。

  • 与缓存类似,我们需要严格且完整地定义我们想要并行化的工作的输入和输出。
  • 我们需要能够跨越某种边界移动这些输入和输出。在这里是线程边界,但也可能是进程边界或到其他计算节点的网络边界。
  • 我们并行运行的工作单元可能以任何顺序完成或失败。我们的代码必须管理这些结果,并清楚地记录它所提供的保证。在这里,我们注意到图像可能不会以相同顺序返回的注意事项。这是否可以接受取决于 API。

我们工作单元的粒度也在我们能够有效并行化的程度上发挥作用,尽管其中涉及很多细微差别。如果我们的工作单元数量少但规模大,我们就无法将它们分散到尽可能多的计算资源上。任务大小和数量之间的适当权衡因问题而异,但这是我们在设计 API 时需要考虑的问题。

转向构建性能

如果你是一名软件工程师,你可能已经多次应用这些技术来改进代码性能。让我们将这些原则和直觉应用到构建系统上。

在 Bazel 构建中,你可以定义构成有向无环图的目标。每个目标都有三个关键要素:

  1. 作为此构建步骤依赖项或输入的文件。
  2. 此构建步骤输出的文件。
  3. 将输入转换为输出的命令。

(实际情况稍微复杂一些,但目前理解这些就足够了)。

让我们回到最初的那个示例应用程序。

Bazel 目标定义可能看起来像这样的伪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
python_build(
    name = "backend",
    srcs = ["core/http.py", "lib/options.py", "data/access.py"],
    outs = ["backend.tgz"],
    cmd = "python build.py",
)
ts_build(
    name = "frontend",
    srcs = ["cms/cms.ts", "collab/bridge.ts", "editing/find.ts"],
    outs = ["frontend.tgz"],
    cmd = "npm build"
)

你可以将构建目标视为定义(但不调用!)一个函数。是否注意到一些从 Python 示例中延续下来的主题?我们详尽地定义了每个构建步骤的输入 (srcs) 和输出 (outs)。我们可以将一个目标的 srcs(以及构建它们的任何目标的传递性 srcs,稍后会看到更多)视为我们希望缓存的函数的输入。

因为 Bazel 在沙箱中构建,所以将输入转换为输出的 cmds 只能访问我们声明的输入文件。而且因为我们的输入和输出只是磁盘上的文件,我们解决了跨越边界移动它们的问题:直接复制即可!最后,我们必须向 Bazel 做出承诺:我们的构建步骤的 cmds 确实是幂等且密封的。

当我们做到这些时,我们会免费获得一些非凡的能力:

  • Bazel 自动缓存我们构建操作的结果。当目标的输入没有变化时,使用缓存的输出——无需构建成本!
  • Bazel 将构建操作分散到我们允许的尽可能多的 CPU 核心上,甚至可以跨构建集群中的多台机器。
  • Bazel 只执行我们需要的、用于生成目标输出工件的那些操作。换句话说,我们总是为想要的输出运行最少必要的工作量。

就像在我们的代码示例中一样,当我们拥有一个定义良好的依赖图,由幂等、密封且粒度适中的构建单元组成时,我们将从 Bazel 的魔法中获得最大收益。这些特性使得 Bazel 的原生缓存和并行化能够带来巨大的速度提升。

理论已经足够多了。让我们深入研究实际问题。

为什么 Quip 和 Canvas 更难构建

诀窍在于:Quip 和 Canvas 比我们之前看到的简单示例复杂得多。这是我们为了理解构建过程而绘制的真实图表。不用担心阅读所有细节——我们将使用一个示意图来深入探讨问题和我们的解决方案。

当我们分析该图时,发现了一些关键缺陷,这意味着我们不具备从 Bazel 获得速度提升所需的特性:

  • 一个有向无环依赖图 → 该图定义不明确,实际上包含循环!
  • 幂等、密封、大小合适的工作单元。 → 构建执行单元非常庞大,并非所有单元都是幂等的,并且密封性是一个挑战,因为许多构建步骤会改变工作目录。
  • 细粒度的缓存键以保持高缓存命中率。 → 我们的构建是如此紧密相连,以至于缓存命中率为零。想象一下,我们尝试调用的每个缓存“函数”都有 100 个参数,其中总有 2-3 个参数发生变化。

如果我们一开始就强行将 Bazel 用于构建,那将是无效的。缓存命中率为零,Bazel 的高级缓存管理将无济于事,而 Bazel 的并行化相比构建代码中已有的临时并行化也几乎没有任何帮助。为了获得 Bazel 的魔力,我们需要先做一些工程工作。

分离关注点

我们的后端代码和构建代码是深度交织在一起的。在没有类似 Bazel 的构建系统的情况下,我们默认使用应用程序内的框架来协调构建图中的各个步骤。我们使用 Python 的多进程策略和核心代码库内置的异步例程来管理并行化。Python 业务逻辑汇集了 Protobuf 编译以及构建 Python 和 Cython 工件。最后,由更多 Python 脚本编排的 tscwebpack 等工具将 TypeScript 和 Less 转换为 Slack Canvas 和 Quip 桌面及 Web 应用程序中使用的独特前端包格式。

当我们开始解开这个戈尔迪之结时,我们重点关注后端和构建代码的结合如何扭曲了前端构建的图。以下是该图的一个更易于理解的表示形式。

请注意,在我们的前端包“上方”的依赖树有多大。它不仅包括它们的 TypeScript 源代码和构建过程,还包括整个构建好的 Python 后端!这些 Python 源文件和工件是每个前端包的传递性源文件。这意味着,不仅是 TypeScript 的更改,每一个 Python 更改,都会改变前端的缓存键(我们之前提到的百个参数之一),需要昂贵的完全重建。

这个构建问题的关键在于 Python 应用程序和 TypeScript 构建之间的那条依赖边。

那条边平均每次构建花费我们 35 分钟——占总成本的一半以上!——因为每次更改都会导致完全的后端和前端重建,而前端重建尤其昂贵。

在处理这个问题的过程中,我们意识到性能成本不仅仅是一个构建问题。它是我们在整个应用程序(后端、前端和构建代码)中普遍未能分离关注点的一个症状。我们存在耦合:

  • 后端与前端之间
  • Python 和 TypeScript 基础设施及工具链之间
  • 构建系统与应用程序代码之间

除了使构建性能在数量上更差之外,这些耦合在我们的软件开发生命周期中也产生了质的影响。工程师无法推理他们对后端所做更改的爆炸半径,因为它也可能破坏前端。或者破坏构建系统。或者两者都破坏。而且因为我们的构建需要一个小时,我们无法在拉取请求级别运行构建来向他们发出早期警告。缺乏健康信号,以及难以推理潜在后果,意味着我们会过于频繁地破坏构建和主分支,而这并非工程师的过错。

一旦我们通过关注点分离的视角理解了问题,就变得很清楚,仅靠构建系统的更改我们无法成功。我们必须干净利落地切断前端和后端之间、Python 和 TypeScript 之间、应用程序和构建之间的依赖关系。这意味着我们必须投入比最初计划多得多的时间。

在几个月的时间里,我们煞费苦心地梳理了每个构建步骤的实际需求。我们用 Starlark(Bazel 用于构建定义的语言)重写了 Python 构建编排代码。Starlark 是一种故意受限的语言,其限制旨在确保构建满足 Bazel 生效的所有要求。在 Starlark 中构建有助于我们强制执行与应用程序代码的完全分离。在需要保留 Python 脚本的地方,我们重写了它们以移除所有依赖,只保留 Python 标准库:没有与我们后端代码的链接,也没有额外的构建依赖。我们省略了所有并行化代码,因为 Bazel 会为我们处理。我们将在下面思考分层时重新审视这种范式。

原始构建代码的复杂性使得定义“正确”行为具有挑战性。我们的构建代码大多没有测试。判断正确性的唯一标准是现有构建系统在特定配置下产生的结果。为了让自己放心,并给工程师灌输信心,我们用 Rust 构建了一个工具,用于比较现有流程生成的工件与我们的新代码生成的工件。我们利用差异来引导我们找到新逻辑不太正确的地方,然后迭代,再迭代。

当我们终于能够绘制出新的构建图时,这些工作得到了回报:

我们已经切断了那三个关键的耦合,并排除了 Python 更改可能破坏构建或改变前端输出的任何顾虑。我们的构建逻辑位于与其构建单元同位置的 BUILD.bazel 文件中,具有定义良好的 Starlark API,并且构建代码和应用程序代码之间实现了清晰的分离。我们的缓存命中率大幅提升,因为 Python 更改不再构成 TypeScript 构建缓存键的一部分。

这项工作的成果是构建时间的大幅减少:如果前端被缓存,我们可以在短短 25 分钟内构建整个应用程序。这是一个很大的改进,但仍然不够!

通过分层设计实现组合

一旦我们切断了后端与前端之间的耦合,我们更仔细地审视了前端构建。为什么它需要 35 分钟?当我们梳理构建脚本时,发现了更多关注点分离方面的挑战。

我们的前端构建器曾非常努力地追求高性能。它接受大量输入(TypeScript 源代码、LESS 和 CSS 源代码,以及由环境变量和命令行选项控制的各种开关),计算需要完成的构建活动,将这些活动并行化到一组工作进程,并将输出编排成可部署的 JavaScript 包和 CSS 资源。这是构建图该部分的草图。

与我们在该项目期间发现的许多情况一样,这个策略代表了编写时一系列合理的权衡:在没有更大的构建框架的情况下,它是一种加快构建某一部分的实用尝试。而且它比不并行化相同工作要快!

这个实现有两个关键挑战。

  1. 像我们上面的 process_image() 示例一样,我们的可缓存单元太大了。我们接收所有源代码并产生所有包。如果只有一个输入文件更改了怎么办?这会改变缓存键,我们必须重建所有内容。或者,如果我们想只构建一个包以满足构建过程中其他地方的需求怎么办?我们运气不佳;我们必须依赖整个大杂烩。
  2. 我们正在跨进程并行化工作,这很好——除非我们可以跨机器并行化,这些机器拥有更多的 CPU 核心。如果我们有这些可用资源,我们在这里无法使用它们。实际上,我们正在降低 Bazel 在其核心功能(并行化独立的构建步骤)上的有效性:Bazel 和脚本的工作进程正在争夺同一组资源。该脚本甚至可能在并行化 Bazel 已经知道不需要的工作!

在这两方面,我们都很难以新的方式组合构建功能:无论是不同形式的编排多个包构建,还是不同的并行化策略。这是一个分层违规

如果我们像这里一样,绘制从操作系统到应用层的能力层级,我们可以看到构建器的边界横跨了多个层级。它结合了业务逻辑、任务编排框架的重要部分以及并行化。我们真正想要的只是顶层——业务逻辑——这样我们就可以在新的编排上下文中重新组合它。我们最终得到了一个工作编排器(构建器)嵌套在另一个工作编排器(Bazel)内部,这两个层级争夺着同一资源池的份额。

为了更有效,我们真正需要的是做得更少。我们删除了大量代码。新版本的前端构建器要简单得多。它不进行并行化。它有一个小得多的“API”接口。它接收一组源文件,并构建一个输出包,TypeScript 和 CSS 独立处理。

这个新的构建器具有高度可缓存性和高度可并行性。每个输出工件都可以独立缓存,其缓存键仅基于其直接输入。一个包的 TypeScript 构建和 CSS 构建可以并行运行,既可以相互并行,也可以与其他包的构建并行。而且我们的 Bazel 逻辑可以决定范围(一个包?两个?全部?),而不是试图管理所有包的构建。

再次看到这与我们的示例 process_images() API 的共鸣吗?我们创建了细粒度、可组合的工作单元,这极大地提高了我们并行化和缓存的能力。我们也分离了业务逻辑与其编排的关注点,使我们能够在新的 Bazel 构建中重新组合逻辑。

这个改变带来了一些非常好的成果:

  • 因为包构建、TypeScript 构建和 CSS 构建可以相互独立地缓存,我们的缓存命中率上升了。
  • 如果有足够的资源,Bazel 可以同时并行化所有包构建和 CSS 编译步骤。这为我们带来了完全重建时间的显著减少。

作为一个额外的好处,我们不再运行自己的并行化代码。我们将该职责委托给了 Bazel。我们的构建脚本只有一个关注点:编译前端包的业务逻辑。这对可维护性来说是一个胜利。

成果与启示

我们让构建快了很多。更快的构建带来了工程师更短的循环时间、更快的故障解决以及更频繁的发布。将这些原则应用到我们的整个构建图之后,我们得到的构建速度比开始时快了多达六倍。

从定性角度来看,我们获得的关键原则是:软件工程原则适用于整个系统。而整个系统不仅仅是我们的应用程序代码。它还包括我们的构建代码、发布流水线、开发和生产环境的设置策略,以及这些组件之间的相互关系。

所以,无论你是在编写应用程序代码、构建代码、发布代码,还是所有这些代码,我们都向你提出建议:分离关注点思考整个系统为可组合性而设计。当你这样做时,应用程序的每个方面都会变得更强大——并且,作为一个愉快的副作用,你的构建也会运行得快得多。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计