☰
Bazel构建系统:从缓存原理到多语言项目实践
2026/10/11 5:22:48 网站建设 项目流程

1. 一次构建事故引发的出走:我为什么换到了 Bazel

事情要从一次让我印象极其深刻的周五下班前说起。当时我在维护一个多语言混合的仓库,里面有 Java 服务、Python 数据处理脚本、几段 C++ 算法库,还有前端资源。那一天我只改了一个 Python 文件里的日志级别,顺手改了 Java 侧一个接口注释,然后满怀期待地按下了构建按钮。

结果整个增量构建跑了将近四十分钟。当时用的还是传统的 Make 加少量脚本的组合方案,Make 既不知道 Python 文件变了之后哪些衍生任务受影响,也没有办法精确判断 Java 工程里某个类变了之后,到底要对哪些下游模块做重编译、哪些可以跳过。它只会机械地按时间戳更新依赖关系,最后的结果就是大量无关任务被白白执行了一遍。

更让我崩溃的是,这种问题在代码量稍微涨上来之后变得更加严重。到了后面我们仓库里共享库的数量超过了四十个,彼此之间的依赖关系像蜘蛛网一样缠在一起,Make 的依赖图完全维护不住了。我开始认真调研替代方案,最后注意力落在了 Bazel 上。

Bazel 和我之前接触的所有构建工具都不一样。它最核心的思路不是“告诉构建引擎要执行什么命令”,而是“告诉构建引擎你声明了哪些构建单元、它们之间依赖什么,然后让引擎自己去算执行计划”。这个转变听起来简单,但背后牵扯出的规则约定、缓存机制、沙箱隔离、远程执行,几乎把整个构建系统的底层逻辑都重新定义了一遍。

这篇内容我想把学习和落地 Bazel 的过程完整复盘一遍。不是只讲概念,而是尽量把我踩过的坑、验证过的配置、对缓存原理的理解都写清楚。如果你也在维护一个体量不小、语言混杂、构建动不动就超时的项目,那 Bazel 的思路值得你花一个下午好好研究。

2. 理解 Bazel 的基础模型:工作区、包、目标与规则

Bazel 有一套自己的概念体系,和 Make、Gradle 都不太一样。上手之前如果不先把这套体系和传统工具做映射,很容易看文档看得一头雾水。

2.1 工作区是构建的绝对边界

工作区这个概念可以理解为整个项目的根容器。你会在项目根目录放一个WORKSPACE文件(新版本也可以叫MODULE.bazel),它的作用就是标记“从这里开始的整个目录树,属于同一个 Bazel 工作区”。

和 Make 不一样的是,工作区不是随便一个目录就行。Bazel 在执行构建时不会轻易读取工作区之外的文件,所有依赖都必须在工作区内部或者通过显式声明的外部依赖引入。这个边界感初看会觉得限制很大,但实际用下来反而是保护。因为有了明确的边界,Bazel 才能放心地对整个构建过程做缓存和并发调度。

2.2 包目录与 BUILD 文件

在工作区内部,每个包含BUILD文件的目录就是一个“包”。包与包之间,通过//包名:目标名这种标签互相引用。

举个例子,假设你的仓库结构是这样的:

myproject/ WORKSPACE src/ BUILD main.py lib/ BUILD utils.py

那么src目录和src/lib目录就分别是一个包。src/BUILD里可以声明一个 Python 目标,utils.py归src/lib这个包管。

一个容易被忽略的细节是,Bazel 默认不允同一个包里的目标访问另一个包的私有文件。所有文件访问都必须通过 BUILD 文件里显式声明的依赖关系来表达。如果你在代码里偷偷import了隔壁包的文件而没在 BUILD 里写对应依赖,构建时会直接给出一句类似“目标声明了不存在的依赖”的报错。

2.3 规则是构建动作的模板

包里的BUILD文件并不是像 Shell 脚本那样直接写命令,而是按规则来声明目标。比如 Python 的py_binary、C++ 的cc_library、Java 的java_library,这些都是内置规则。

规则定义了输入是什么、输出是什么、中间要经历哪些动作。规则的执行过程在 Bazel 内部是可捕捉、可缓存、可并发的。这正是 Bazel 和脚本式构建完全不同的地方:脚本只是“能跑”,而规则是可验证、可复现的构建单元。

我做第一份 BUILD 文件时闹过一个笑话:我尝试在py_binary里写 shell 命令去做文件拷贝,结果搞了半天不生效。后来翻文档才明白,文件拷贝也应该用规则来表达,比如native.genrule或者自定义规则。规则这一层抽象,是强迫开发者把构建逻辑声明化的关键。

2.4 标签与目标名的完整语法

目标在 Bazel 里的完整表示方式是//src/lib:utils,其中//src/lib表示包路径,utils是目标名。

如果包名是仓库的根目录,可以简写成//:目标名。同一个目标还可以有@仓库名//包名:目标名这种三段的写法,用于引用外部依赖。

标签语法虽然简单,但它是整个依赖图能够运行起来的基础。所有真实的依赖关系最终都会落实到标签上,Bazel 内部会把这些标签解析成具体的文件集合,再去做内容摘要和缓存判断。

3. 内容寻址与沙箱执行:Bazel 缓存的底层真相

Bazel 最吸引我的一点,也是它和传统构建工具拉开代差的地方,就是缓存机制。这部分属于不太容易从快速入门文档里领悟到的核心知识,我尽量讲透。

3.1 为什么时间戳和命令字符串都不靠谱

传统构建工具做增量判断时,普遍依赖文件的时间戳。只要源文件的修改时间比产物新,就认为需要重新构建。这在小型项目里问题不大,但一旦代码规模变大,时间戳本身就会成为最大的不可靠来源。

想象一下:你 pull 了一份同事的代码,Git 在做 checkout 操作时经常会把所有文件的修改时间更新成当前时刻。于是构建工具误以为所有文件都变了,触发一次全量重建。更麻烦的是,有些工具链会在构建过程中修改源文件的时间戳,导致每次构建都认为“有东西需要重做”。

Bazel 采用的方法是内容寻址。它在计算缓存 key 时,不看文件修改时间,而是对输入文件内容做哈希摘要,再把这个摘要作为缓存编号的一部分。同样的输入内容,不管放在哪个目录,不管时间戳是什么,只要哈希一致,构建结果就是可以复用的。这一步直接淘汰了时间戳带来的不确定性。

3.2 沙箱让构建动作变得纯净

光有内容哈希还不够。另外一个核心设计是沙箱执行,也就是在 Linux 上用 namespace 和只读文件系统把每个构建动作包起来。

想象你在执行一个 C++ 编译动作,传统构建工具直接在你的项目目录里跑gcc,编译器可以随意读写文件。Bazel 会把编译动作放进一个临时目录,把你声明的源文件、依赖库、工具链全部以只读方式映射进去,编译过程只能在预设的沙箱目录里产生输出,不同动作之间互相隔离。

这样一来,构建动作之间就不会因为某个步骤意外修改了公共目录而互相污染,也不会出现“明明没有声明依赖,却凑巧能编译通过”的巧合。沙箱是 Bazel 敢做高度并发的安全感来源,因为它确定了每个动作都有可重复性的边界。

3.3 缓存命中失败时的排查思路

内容寻址加上沙箱执行,听上去很理想,但实际使用中你会遇到一个非常常见的现象:明明代码没动,重新构建时缓存却没有命中,一切从头执行。排查这类问题,有一些非常实用的规律。

首先确认构建动作是否被标记为“远程可缓存”。Bazel 里有些动作带no_cache属性,或者产生了不稳定的输出,主动跳过缓存,这种情况需要去看动作本身的定义。

其次检查动作的输入是否真正稳定。有些工具链在编译期间会读取环境变量、读取系统配置文件、甚至读取当前用户目录下的隐藏配置,这些内容不在你声明的输入集里,所以不会进入缓存 key。但它们一旦发生变化,Bazel 是无法感知的。你需要在规则层面把这些外部影响全部显式地纳入输入。

最后检查输出是否稳定。如果某个动作每次生成的输出文件都不同,比如嵌入了时间戳或者随机数,那么后续动作的缓存就会连续失效。这种情况在代码生成器、文档生成器里非常常见,解决方法是把非确定性的部分从动作里剥离出来。

3.4 顺手给缓存机制配一个工具链注册

想要让缓存机制发挥最大威力,需要把工具链也纳入到 Bazel 的管理体系里。

我第一次迁移时使用了系统全局安装的 GCC,结果发现在 A 机器上编译出的对象文件和 B 机器上总对不上哈希。原因很简单:两台机器的 GCC 小版本不一致,Bazel 无法感知系统级工具链的变化。

后来我改用 Bazel 管理的工具链,比如在构建配置里注册特定版本的 Clang 或者 Go SDK。这样工具链本身也成为一个内容可寻址的依赖,版本一变,缓存自然失效;版本不变,不同机器上获取的结果就能互相复用。这一改动对远程缓存的命中率提升非常显著。

4. 从零搭建一个多语言项目:BUILD 文件实战复盘

理论部分看再多,不如实际写一次 BUILD 文件来得实在。我拿一个同时包含 Python 和 C++ 组件的项目举例,完整走一遍配置流程。

4.1 工作区初始化与依赖声明

首先创建一个WORKSPACE文件。Bazel 在 7.x 之后逐步推荐使用MODULE.bazel做外部依赖管理,不过为了兼容性我这里还用WORKSPACE的经典写法。

# WORKSPACE workspace(name = "my_mixed_project") load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") http_archive( name = "rules_python", url = "https://github.com/bazelbuild/rules_python/releases/download/0.27.1/rules_python-0.27.1.tar.gz", sha256 = "xxx", ) load("@rules_python//python:repositories.bzl", "py_repositories") py_repositories()

这里有一个新手特别容易犯的错:只加载规则包却不调用初始化函数。Bazel 的很多外部规则包都有对应的_repositories或_deps初始化函数,必须先执行才能把工具链和默认依赖注册好。漏掉这一步,后面构建时会出现一堆类似于“找不到 @rules_python 里的某个 target”的错误。

4.2 一个 C++ 库加一个 Python 可执行目标

假设项目结构是:

my_mixed_project/ WORKSPACE BUILD include/ math_util.h src/ BUILD math_util.cc py/ BUILD main.py

根目录的BUILD文件可以先只声明一个空包,或者把整体文件组暴露出来。重点是src/BUILD里如何写 C++ 库,以及py/BUILD里如何写 Python 入口。

# src/BUILD cc_library( name = "math_util", srcs = ["math_util.cc"], hdrs = ["//include:math_util.h"], visibility = ["//py:__pkg__"], )

这里我故意把hdrs写成了跨包引用,这在实际项目里很常见。头文件放在单独的include包里管理,让多个语言模块都能引用。visibility字段用来控制谁能依赖这个目标,不加的话默认只能在同一个包内引用。

再看 Python 这边:

# py/BUILD py_binary( name = "main", srcs = ["main.py"], deps = [ "//src:math_util", ], )

一个 Python 二进制目标直接依赖一个 C++ 库目标,在 Bazel 的世界里是合法且合理的。实际执行时,Bazel 会找到合适的工具链把所有需要的共享库打包进运行时环境。

4.3 动手跑一次构建并观察缓存与并发

配置写好后,最简单的执行命令是:

bazel build //py:main

第一次构建会下载外部依赖、初始化工具链,速度通常比较慢。第二次执行时,你会发现输出信息里的状态变成CACHED或者直接不打印动作。这就是缓存命中。

我比较推荐在项目里开启--remote_cache之前,先用本地缓存把构建跑顺。本地缓存目录默认在~/.cache/bazel,如果这个目录被清理了,Bazel 就会触发重新构建,但不是全量,而是只重建受影响的部分。这种“精确到动作级别”的增量化体验,是 Make 和传统 Gradle 很难提供的。

4.4 依赖管理经验:显式依赖是唯一正确的依赖

用 Bazel 之后,最大的思维转变是要接受“隐式依赖都是 bug”这个观点。

传统 Python 项目里,你可以随便import任意路径下的模块,只要sys.path里能看见就行。Bazel 不允许这种随意的行为,你必须在py_binary的deps里显式声明对每个库的依赖。

这样的约束确实增加了初期配置的工作量,但在项目变大之后非常值。因为你获得了一个精确的依赖图,可以准确知道某个文件变动会影响到哪些目标。以前在 Make 里我经常为了“保险”触发大范围的无关构建,现在 Bazel 会非常精确地告诉你:只有这几个目标需要重建。

5. 自定义规则:把构建逻辑从脚本升级为可扩展系统

Bazel 内置规则只能覆盖通用场景。真正让它在企业内部发挥能量的,是自定义规则。这一部分内容比较多,我挑出最有价值的一条路径详细介绍。

5.1 Starlark:Bazel 的规则语言

Bazel 的规则文件后缀通常是.bzl,这种语言叫 Starlark。从语法上讲它是 Python 的一个受限子集,不支持 class、try-except、循环也只支持有限的模式。

和普通 Python 不同的是,Starlark 代码的执行发生在“加载阶段”,这一阶段的运行时间会被限制,不能做网络请求,不能读写工作区任意文件,所有信息都必须通过规则参数传递。正是这种限制保障了构建逻辑的可分析性。

5.2 一个解析 JSON 并生成头文件的规则示例

我这里用一个可以带入实际工作的示例:写一个规则,读取一个 JSON 文件,然后生成 C++ 头文件。有些项目需要在编译期把配置注入代码,这个规则就很有用。

首先写一个.bzl文件:

# tools/json_header.bzl def _json_header_impl(ctx): # 声明输入文件 src = ctx.file.src out = ctx.actions.declare_file(ctx.attr.name + ".h") # 构造命令行参数 args = ctx.actions.args() args.add(src.path) args.add(out.path) # 注册一个构建动作 ctx.actions.run( outputs = [out], inputs = [src], executable = "python3", arguments = [args], use_default_shell_env = True, ) return [DefaultInfo(files = depset([out]))] json_header = rule( implementation = _json_header_impl, attrs = { "src": attr.label(mandatory = True, allow_single_file = True), }, )

这个规则本身就定义了一个构建动作:它把一个 JSON 文件当作输入,通过python3执行一段命令生成头文件输出。

注意这里是直接用python3作为可执行文件,实际项目中更专业的做法是定义一个自己的脚本工具,再让规则去引用那个工具目标。这样工具本身的变更也会进入缓存 key,避免“工具改了但缓存还是旧的”这种尴尬。

5.3 宏与规则组合的实战价值

除了规则,.bzl文件里还能定义宏。宏的本质是生成多个更基础的目标,再把这些目标打包成一个逻辑整体。比如给你的项目定义一个cc_component宏,内部自动创建cc_library、cc_test和一个格式化目标:

def cc_component(name, srcs, hdrs, deps = None): native.cc_library( name = name, srcs = srcs, hdrs = hdrs, deps = deps or [], ) native.cc_test( name = name + "_test", srcs = srcs + ["test/%s_test.cc" % name], deps = [":" + name], )

宏和规则配合起来,就可以在企业里沉淀出一套适合自己团队规范的上层抽象。团队里大多数开发者只需要写一行cc_component(...),不用关心底层细节。这是 Bazel 能从一个“单仓库构建工具”变成“平台型构建系统”的关键能力。

5.4 自定义规则常见的设计误区

初次写自定义规则,最常见的误区是把规则写得和脚本一样:在实现函数里写一大堆条件判断和文件操作。

正确的思考方式是把规则当作一个“工厂”,它只负责声明动作,不负责真正执行。在ctx.actions.run里执行的命令,才是真正的动作。这两个阶段必须分清楚。如果你在加载阶段去调用外部命令,Bazel 会直接报错,因为加载阶段必须是纯函数式的。

另一个常见问题是忽略输出文件的确定性。如果你的自定义规则里调用的脚本生成了包含时间戳的输出,那么缓存基本永远不命中。我在做代码生成器时遇到过这个问题,解决方法是给脚本增加一个--deterministic参数,把时间戳替换成输入内容的哈希。

6. 从单机到集群:远程缓存与远程执行落地指南

很多团队跑到这一步,已经享受到本地增量构建带来的提速。但如果参与人数多、构建任务重,本地上限就会很快触顶。这就要引入远程执行能力了。

6.1 远程缓存与远程执行的本质区别

先理清两个容易混淆的概念。

远程缓存是只缓存构建结果,本机构建动作仍然在本地执行;执行完成后,把产物上传到远程,下次其他机器要相同输入时可以直接下载产物,不需要再执行动作。

远程执行则更进一步:构建动作本身也被分发到远程集群执行,本地机器只负责调度和接收产物。远程执行对网络带宽、集群稳定性要求都更高,通常需要专门的构建集群。

6.2 一个基于 HTTP 服务的远程缓存部署方案

如果团队规模没到需要专门集群的程度,只搭一个远程缓存服务是完全可行的。用 Bazel 内置的 HTTP 缓存模式就可以,服务端只需要一个标准 HTTP 服务,很多对象存储网关都很适合。

启动参数可以写成这样:

bazel build //py:main \ --remote_cache=http://cache.internal:8080/cache \ --remote_upload_local_results=true \ --remote_download_minimal

其中--remote_upload_local_results=true表示本地构建后的产物也要上传到远程缓存,方便其他机器复用;--remote_download_minimal表示只下载必要的输出文件,其他中间产物不落盘。

我在实际项目中踩过一个大坑:没有配置认证和隔离策略,结果不同项目的缓存 key 互相交叉。好在 Bazel 的缓存键里天然包含目标名和仓库名,但依然建议为不同团队配置不同的缓存命名空间,避免策略变更时互相污染。

6.3 远程执行的代价与选择

远程执行看起来美好,但落地成本不低。你需要:

  • 统一的执行环境镜像,确保远程和本地工具链一致
  • 高可用的任务调度服务
  • 足够大的内网带宽
  • 一套完整的日志采集系统

如果团队人数少于二十人,或者项目编译时间本身不算太长,远程执行的收益可能不明显,先做好远程缓存就够了。我自己所在的团队就是在远程缓存稳定跑了一年之后,才开始小范围测试远程执行,而且只针对那些耗时最长的编译动作,不是全量分发。

6.4 执行环境的一致性检查手段

远程执行部署完后,最怕出现“本地能过、远程过不了”的问题。绝大多数原因都是环境差异:系统库版本不一致、环境变量不同、某个工具路径不对。

排查这类问题有一个非常有效的手段,就是强制 Bazel 在本地以严格沙箱模式运行,把本地环境尽量压到和远程一致。如果本地沙箱模式能通过,远程大概率也能通过;如果本地都能复现失败,调试起来就会方便很多。

我还习惯在 CI 里加一个“清晰环境构建”流程:定期删除本地缓存、重新拉取全部依赖、全量构建一次。这一步能暴露不少远程执行时才会出现的环境隐性问题。

7. 我从开始到跑通的完整教训:Bazel 值得用但别无脑用

最后这部分,我想把整个迁移过程中的教训和经验按优先级梳理一遍。内容不多,但每一条都是真金白银换回来的。

首先,一定要接受 Bazel 的学习曲线。它不是那种你也可以手动搜索命令,二十分钟就能跑通的工具。第一天你可能连BUILD文件和普通目录的边界都搞不清楚,这很正常。我建议先用一个星期把一个小项目完整迁移过来,不要一开始就动大型仓库。

其次,不要试图把历史包袱全部一次性搬进 Bazel。我们当时的做法是新的模块直接用 Bazel,旧模块继续留在原构建系统里,通过一个顶层调度脚本做衔接。这样两边都能运行,风险可控。等到旧模块被重构时再逐步迁移,每一次迁移的范围都限得很小。

第三,关于多语言混合项目,Bazel 的体验确实比其他工具好,但它对你的工程规范要求也更高。你的代码目录必须稳定、依赖声明必须齐全、测试数据要放在显式声明的路径里。这些都是长期巨有价值的整理工作,即便有一天你离开 Bazel,这些编号也不会白做。

最后我想提一个观点:构建系统不是银弹,但它确实是团队工程效率的基石。你在构建系统上付出的每一分心思,最终都会体现在“改动一行代码到看到测试结果”的这个循环时间里。Bazel 提供了一套底层的、可靠的、可扩展的机制,但它并不能替你解决依赖关系本身混乱的问题。依赖关系如何设计、组件如何拆分,这些还是需要工程师自己来思考。

总之,Bazel 这套系统让我重新理解了“构建”这件事的边界——它不只是跑几个编译命令,而是一个把项目结构、依赖关系、缓存能力、远程调度全部串联起来的大型工程问题。如果你正在被构建速度折磨,Bazel 值得成为你的下一个研究方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询