Bazel-Next Generation of Build?
Bazel: Next Generation of Build?
综述
Bazel 是 Google 开源的一款与 Make、Maven 和 Gradle 类似的开源构建和测试工具, 它使用人类可读的高级构建语言 Starlark,支持多种语言,可为多个平台构建输出。 Bazel 支持任意大小的构建目标,并支持跨多个代码库和大量用户的大型代码库,是 Google 主推的一种构建工具。
Bazel 与其他构建工具的区别
- 基于任务(task)的构建系统
在基于任务的构建系统中,基本的工作单元是任务。每个任务都是可以执行任何类型的逻辑的脚本,而任务将其他任务指定为必须在其之前运行的依赖项。目前使用的主要大多数构建系统(例如 Ant、Maven、Gradle)都基于任务。
基于任务的构建系统往往都会面临一个问题:程序员的权力过大。因为脚本能执行的动作太丰富,构建系统本身完全不知道脚本在做什么,所以它必须在如何规划和执行构建步骤时非常保守,从而导致性能很差。此外,系统也没有办法确认每个脚本的确在做以及正确完成它声称要完成的工作,因此系统往往会变得非常复杂。这就产生了两个问题:难以并发。因为系统无法了解脚本是否并发安全,因此往往采用串行的方案。难以增量构建。
- 基于制品(Artifact)的构建工具
基于制品的构建工具采用了完全不一样的逻辑,在这样的系统中,程序员的权力被大幅缩减,「任务」转为由构建系统制定,程序员可以对任务做有限的配置,但不能决定任务何时执行、如何执行。程序员只能指定「依赖项」和「构建目标」,由构建系统决定如何执行构建。
具体到 Bazel 来说,Bazel 提供了一系列「rule」,这些 rule 既可以是官方团队维护的又有可以是第三方维护的。程序员在使用 Bazel 时,只需要指定所使用的 rule,构建目标以及依赖项;Bazel 将自动解析依赖关系,并尽可能以最高效率来完成构建。
Why Bazel?
- fast & correct
- 高扩展性,多语言支持
- 对大仓友好
- 限制程序员的权力
构建
原理
当用户告诉 Bazel 要构建某个 Target 的时候,Bazel 会分析这个文件如何构建(构建动作定义为 Action,和其他构建系统的 Task 大同小异),如果 Target 依赖了其他 Target,Bazel 会进一步分析依赖的 Target 又是如何构建生成的,这样一层层分析下去,最终绘制出完整的执行计划。
这里的 Action 是 Bazel 的核心概念,也是 Bazel 区分于其他构建工具的重要因素:Action 由 Rule 决定,本质上是 Rule 的实例化;而 Rule 不由构建者决定,构建者只能声明使用了某个 Rule。更具体的来说,Rule 是剥离于项目外的,由 Bazel 官方开发,同时对外暴露相关定义,从而允许第三方开发者开发自定义 Rule。
举个🌰,下面是两个 BUILD.Bazel 文件( Bazel 描述文件)
1 |
|
最终,Bazel 会生成类似于如下的编译产物:
并行编译
Bazel 精准的知道每个 Action 依赖哪些文件,这使得没有相互依赖关系的 Action 可以并行执行,而不用担心竞争问题。因此我们可以充分利用多核 CPU 的特性,尽可能提高并发,优化构建效率。
增量构建
Bazel 会检测本地文件系统是否保留着上一次构建的 outputs,如果有,此时 Bazel 只需要分析 inputs, commands 和 envs 和上次相比有没有改变,没有改变就直接跳过该 Action 的执行。尽管这个功能很多现代构建工具都支持,但 Bazel 仍有独特的优势:更快,更准。
这是因为:Bazel 采用了 Client/Server 架构,当用户键入 Bazel build 命令时,调用的是 Bazel 的 client 工具,而 client 会拉起 server,并通过 grpc 协议将请求 (buildRequest) 发送给它。由 server 负责配置的加载,ActionGraph 的生成和执行。构建结束后,Server 并不会立即销毁,而 ActionGraph 也会一直保存在内存中。当用户第二次发起构建时,Bazel 会检测工作空间的哪些文件发生了改变,并更新 ActionGraph。如果没有文件改变,就会直接复用上一次的 ActionGraph 进行分析。
封闭性
Bazel 将构建分为多个 action,单个 action 被要求是「封闭」的,这意味着:在任何时间、任何场景,相同的 action 总应该获得相同的结果。封闭性是 Bazel 远程缓存与远程执行的基础。通常来说,Bazel 的 rule 会尽最大可能满足封闭性。
远程执行
在上述的架构中,我们发现,既然 Bazel Action 的执行是封闭的,那么它在哪里执行,有谁执行,便不再重要,因此,一个直观的想法便出现了:为什么不能在远程执行构建,本地只需要下载产物即可呢?远程缓存和远程构建便是 bazel 在这方面的实践。
远程缓存
因为 Action 满足封闭性,即相同的 Action 信息一定产生相同的结果,因此可以建立 Action 到 ActionResult 的映射。为了便于索引,Bazel 把 Action 信息通过 sha256 哈希算法压缩成摘要 (Digest),把 Digest 到 ActionResult 的映射存储在云端,就可以实现 Action 的远程缓存。其他用户/机器在执行某个 Action 的时候,可以先查询此 Action 是否存在。如果存在,直接复用即可。
远程缓存的更多信息可以参见:https://km.sankuai.com/collabpage/2006036512
远程构建
更进一步的,既然 ActionResult 可以被不同的 Bazel 任务共享,说明 ActionResult 和 Action 在哪里执行并没有关系。因此,Bazel 在构建时,可以把 Action 发送给另一台服务器执行,对方执行完,向 CAS 上传 ActionResult,然后本地再下载。
远程构建的更多信息可以参见:https://km.sankuai.com/collabpage/2113624250
挑战- No Silver Bullet
- 封闭性
毫无疑问,「封闭」是远程执行的生命线,无论是增量构建还是远程执行,但是,想要真正做到封闭性并不容易,尤其是对于 C++ 这类语言,大部分 C++ 项目都对本地缓存存在严格的要求,如 gcc 版本,lib 库等等,这些都是封闭性的天敌。如何提供封闭性更好的 rule,以及解决不封闭带来的诸多问题,始终是 Bazel 的重要挑战。
- 远程构建
远程构建是 Bazel 的核心发展目标之一,如果没有远程构建,Bazel 只是一个优秀的构建工具,但远程构建则使得 Bazel 能够发展为支撑企业级别的大型分布式构建系统。但是,必须承认的是,远程构建仍然还在发展阶段,许多问题仍然有待解决。
- 规模
事实上,Bazel 的优势也存在一定的局限性,因为其最初设计目标是为了解决谷歌内部大仓的构建问题,因此天然倾向于大规模、多语言、多维护人员的大型仓库,为了对大仓友好付出了许多其他方面的代价,尽管对于大仓而言,这些代价相较于得到的优势可接受甚至微不足道,但对于普通仓库而言则未必如此。归根结底,这是规模的问题。
2020年,有 issue 要求在 k8s 项目中移除 Bazel (https://github.com/kubernetes/kubernetes/issues/88553),此 issue 在社区引发了热烈的讨论,并且最终在 2021 年宣布通过并合入主分支。总结此 issue,核心原因是:
k8s 本身是单语言(Go)项目,早期引入 Bazel 的是为了使用构建缓存,但 Go 后续支持了构建缓存,此时 Bazel 对于 k8s 而言几乎没有优势,反而会为了维护 Bazel 和 go-build 两套构建工具付出很大的代价。