Bazel是Google公司于2015年开源的一款构建框架,至今收获了21k的star数,远超gradle、maven、cmake等同类产品。近几年来,字节阿里腾讯等互联网大厂也逐步拥抱Bazel,搭建自己的构建体系。
Bazel为什么如此受欢迎,原因正如它的宣传: "Correct & Fast, Choose Two",这并不是一句口号,从实际的用户体验也能看出。
(1) 得益于强大的增量构建机制,几万个文件的大型项目,可以做到秒级构建。
(2) Bazel的封闭性设计,使得增量构建和缓存可信赖,用户不需要通过clean操作在构建前清理环境。
本文将分两部分阐述文章的主题。第一部分将分析Bazel高性能,高可靠的原理;第二部分则结合实际场景,聊一聊如何挖掘Bazel的极致性能。
Bazel的优势
基于制品(Artifact)的构建系统
传统构建系统有很多是基于任务的,例如Ant,Maven,Gradle。用户可以自定义"任务"(Task),例如执行一段shell脚本。用户配置它们的依赖关系,构建系统则按照顺序调度。
图1 基于Task的调度模型
这种模式对使用者很友好,他可以专注任务的定义,而不用关心复杂的调度逻辑。构建系统通常给予任务制定者极大的"权利",比如Gradle允许用户用Java代码编写任务,原则上可以做任何事。
如果一个任务,在输入条件不变的情况下,永远输出相同的结果,我们就认为这个任务是"封闭"(Hermeticity)的。构建系统可以利用封闭性提升构建效率,例如第二次构建时,跳过某些输入没变的Task,这种方式也称为增量构建。
不满足封闭性的任务,则会导致增量构建失效,例如Task访问某个互联网资源,或者Task在执行时依赖随机数或时间戳这样的动态特征,这些都会导致多次执行Task得到不同的结果。
Bazel采用了不同的调度模型,它是基于制品(Artifact)的。Bazel官方定义了一些规则(rule),用于构建某些特定产物,例如c++的library或者go语言的package,用户配置和调用这些规则。他仅仅需要告诉Bazel要构建什么Artifact,而由Bazel来决定如何构建它。
规则由官方和可信赖第三方维护,规则产生的任务,满足封闭性需求,这使得用户可以信赖系统的增量构建能力。
用户需要构建的Artifact,在Bazel概念里被称为Target,基于Target的调度模型如下图所示:
图2 基于Target的调度模型
图2中,File表示原始文件,Target表示构建时生成的文件。当用户告诉Bazel要构建某个Target的时候,Bazel会分析这个文件如何构建(构建动作定义为Action,和其他构建系统的Task大同小异),如果Target依赖了其他Target,Bazel会进一步分析依赖的Target又是如何构建生成的,这样一层层分析下去,最终绘制出完整的执行计划。
并行编译
Bazel精准的知道每个Action依赖哪些文件,这使得没有相互依赖关系的Action可以并行执行,而不用担心竞争问题。基于任务的构建系统则存在这样的问题:
图3 基于任务的构建系统存在竞争问题
如图3所示,两个Task都会向同一个文件写一行字符串,这就造成两个Task的执行顺序会影响最终的结果。要想得到稳定的结果,就需要定义这两个Task之间的依赖关系。
Bazel的Action由构建系统本身设计,更加安全,也不会出现类似的竞争问题。因此我们可以充分利用多核CPU的特性,让Action并行执行。
通常我们采用CPU逻辑核心数作为Action执行的并发度,如果开启了远端执行(后面会提到),则可以开启更高的并发度。
增量编译
对Bazel来说,每个Target的构建过程,都对应若干Action的执行。Action的执行本质上就是"输入文件 + 编译命令 + 环境信息 = 输出文件"的过程。
图4 Action的描述
如果本地文件系统保留着上一次构建的outputs,此时Bazel只需要分析inputs, commands和envs和上次相比有没有改变,没有改变就直接跳过该Action的执行。
这对于本地开发非常有用,如果你只修改了少量代码,Bazel会自动分析哪些Action的inputs发生了变化,并只构建这些Action,整体的构建时间会非常快。
不过增量构建并不是Bazel独有的能力,大部分的构建系统都具备。但对于几万个文件的大型工程,如果不修改一行代码,只有Bazel能在一秒以内构建完毕,其他系统都至少需要几十秒的时间,这简直就是降维打击了。
Bazel是如何做到的呢?
首先,Bazel采用了Client/Server架构,当用户键入bazel build命令时,调用的是bazel的client工具,而client会拉起server,并通过grpc协议将请求(buildRequest)发送给它。由server负责配置的加载,ActionGraph的生成和执行。
图5 Bazel的C/S架构
构建结束后,Server并不会立即销毁,而ActionGraph也会一直保存在内存中。当用户第二次发起构建时,Bazel会检测工作空间的哪些文件发生了改变,并更新ActionGraph。如果没有文件改变,就会直接复用上一次的ActionGraph进行分析。
这个分析过程完全在内存中完成,所以如果整个工程无需重新构建,即便是几万个Action,也能在一秒以内分析完毕。而其他系统,至少需要花费几十秒的时间来重新构建ActionGraph。
远程缓存与远程执行
远程缓存
增量构建极大的提升了本地研发的构建效率,但有些场合它的效果不是很好,例如CI环境通常采用“干净”的容器,此时没有上一次的构建数据,只能全量构建。
即使是本地研发,如果从远端同步代码时修改了全局参数,也会导致增量构建失效。缓存(Remote Cache)与远程执行(Remote Execution)可以很好的解决这个问题。
前面聊到,Action满足封闭性,即相同的Action信息一定产生相同的结果。因此可以建立Action到ActionResult的映射。为了便于索引,Bazel把Action信息通过sha256哈希算法压缩成摘要(Digest),把Digest到ActionResult的映射存储在云端,就可以实现Action的跨构建共享。
图6 Action共享示意图
这里的Storage是完全基于内容寻址的,即“一个Digest唯一对应一个ActionResult”,内容寻址的好处是不容易污染存储空间,因为修改任何一行代码会计算出不同的Digest,不用担心污染别人的ActionResult。内容寻址的存储引擎,被称为Content Addressable Storage(CAS),如果没有特别强调,本文后续使用简称CAS来表述。
CAS里存放的任何文件,无论是Action的Meta信息还是编译产物二进制,都被称为Blob。
为保证CAS的存储空间被有效利用,通常会使用LRU算法管理CAS里存储的Blob,当存储空间写满时,最久没被访问的Blob就会被自动淘汰,这样就保证了空间里的Blob是最活跃的。
远程执行
既然ActionResult可以被不同的Bazel任务共享,说明ActionResult和Action在哪里执行并没有关系。因此,Bazel在构建时,可以把Action发送给另一台服务器执行,对方执行完,向CAS上传ActionResult,然后本地再下载。
这种做法减少了本地执行Action的开销,使得我们设置更高的构建并发度。
Bazel为Remote Cache和Remote Execution设计了专门的协议Remote Execution API,用于规范协议的客户端和服务端的行为。
完整的流程如下图所示:
图7 远程执行流程
可以看到,Client和Server的直接交互是很少的,大部分情况还是和CAS交互,这部分采用了增量的设计,Client先调用findMissingBlobs接口,该接口的请求参数是一堆Blob Digest列表,返回值是CAS缺失的Digest列表。这样Client只上传这些Blob,可以减少网络传输的浪费。
Remote Execution API是一套通用的远程执行协议,客户端部分由Bazel实现,服务端部分可自行定制。Bazel团队开发两款开源实现,分别是Bazel Remote(CAS) 和 Buildfarm (Remote Executoin & CAS),除此之外也有Buildbarn,Buildgrid等开源实现以及Engflow,Buildbuddy这样的企业版。
企业版除了提供更稳定,弹性的远程执行服务外,通常还提供数据分析能力,用户可以根据自己的条件选择合适的开源软件或企业版服务。
外部依赖缓存(repository_cache)
前面我们主要分析了基于Action的增量构建,缓存和远程执行机制。现在让我们看看Bazel是如何管理外部依赖的。
大部分项目都没法避免引入第三方的依赖项。构建系统通常提供了下载第三方依赖的能力。为了避免重复下载,Bazel要求在声明外部依赖的时候,需要记录外部依赖的hash,例如下面的这种形式:
图8 外部依赖描述
Bazel会将下载的依赖,以CAS的方式存储在内置的repository_cache目录下。你可以通过bazel info repository_cache
命令查看目录的位置。
Bazel认为通过checksum机制,外部依赖应该是全局共享的,因此无论你的本地有多少个工程,哪怕使用的是不同的Bazel版本,都可以共享一份外部依赖。
除此之外,Bazel也支持通过1.0.0这样的SerVer版本号来声明依赖,这是Bazel6.0版本加入的功能,也是官方推荐使用的,具体做法可以查看官网相关部分。
如何高效使用Bazel
Bazel为了正确性和高性能,做了很多优秀的设计,那么我们如何正确的使用这些能力,让我们的构建性能“起飞”呢, 我们将从本地研发和CI pipeline两种场景进行分析。
本地研发
本地研发通常采用默认的Bazel配置即可,无需为增量构建和repository_cache做额外配置,Bazel默认就处理的很好。
使用时应该信任bazel的增量构建机制,即便是从远端仓库同步了代码,也可以直接build,无须先通过bazel build清理环境。
至于 Remote Cache和Remote Execution,则需要结合网络状况和Action的执行开销,决定是否开启,参数是 --remote_cache
和 --remote_execution
。
正确开启bazel的remote能力
正确开启remote_cache和remote_execution对构建效率有显著作用,但网络或Action特性,也可能导致收益不明显甚至劣化。
举个例子说明使用remote_cache的利弊:
我们假设Action的执行时间是a,上传缓存和下载缓存的时间分别是b和c, 缓存命中率是μ
如果不使用remote cache,耗时恒定为a,如果使用remote cache, 命中缓存耗时是c,不命中则是a+ba + ba+b, 结合命中率,可以求出耗时的数学期望是 μc+(1−μ)(a+b)μc + (1 - μ)(a + b)μc+(1−μ)(a+b)
也就是说,只有 μc+(1−μ)(a+b)