Bazel 增量构建与远程执行深度实战:从 Skyframe 增量模型、Action 图缓存到 Remote Execution API 与分布式构建集群的工程全解
如果你所在的团队代码规模超过百万行,一定经历过这样的循环:本地全量构建慢到让人绝望,CI 每次推送都要跑 40 分钟,而开发者的共识变成了"改一行也得泡杯咖啡"。换更快的机器是最容易想到的答案,但它只是把常数除小,没有改变构建的算法复杂度。Bazel 的思路完全不同——它把构建建模成一个可增量求解的纯函数图,再把这个图搬到远端集群上并行执行。这篇文章我们深入到 Bazel 的内核:Skyframe 增量计算模型、Action 图的内容寻址缓存、沙箱化执行的封闭性保证,以及 Remote Execution API 如何把单机构建变成分布式集群作业。
一、构建的本质:一张有向图上的函数求值
传统 Makefile 的模型是「目标 → 依赖 + 命令」,CMake 在其上加了配置期抽象。它们的共同弱点在于依赖必须由人声明,且不完整就不保证正确。漏写一个头文件依赖,增量构建就会静默产出错误结果,这是无数"clean 一下就好了"的根源。
Bazel 的模型更严格:
- Artifact(制品):不可变的、内容寻址的文件或目录。
- Action:描述如何从一组输入 Artifact 生成输出 Artifact 的纯函数
(command, inputs) → outputs。 - Target:BUILD 文件里的声明节点(如
cc_library)。 - Rule:把 Target 展开成若干 Action 的生成器,用 Starlark(Python 方言的子集)编写。
关键区别在于:依赖是求值过程中自动发现的,而不是靠人写全。当你遗漏一个头文件时,Bazel 不会"猜",而是通过沙箱直接让 Action 看不到未声明的文件,编译立即失败——把运行时的不确定性提前到构建期暴露。
一个典型的 C++ 目标:
# BUILD.bazel
cc_library(
name = "tensor",
srcs = ["tensor.cc"],
hdrs = ["tensor.h"],
deps = [
":logging",
"@abseil-cpp//absl/status",
],
copts = ["-O2", "-std=c++20"],
)
cc_test(
name = "tensor_test",
srcs = ["tensor_test.cc"],
deps = [
":tensor",
"@googletest//:gtest_main",
],
)
注意 hdrs 与 deps 的分离:hdrs 是"我提供给使用者的接口",deps 是"我依赖别人的接口"。Bazel 会据此推导出 严格传递依赖闭包,任何未被声明却出现在 #include 里的头文件,在沙箱中都不可见。
二、Skyframe:把增量构建变成通用增量图求解器
Skyframe 是 Bazel 最核心、也是最容易被忽视的部分。它不是一个构建算法,而是一个通用的增量计算框架——可以理解为「构建领域的 React 协调器」。
Skyframe 的三个基本类型:
// 概念示意(Bazel 内部实现为 SkyKey / SkyValue / SkyFunction)
SkyKey // 图节点的唯一标识符(如 ConfiguredTargetKey、FileStateValue)
SkyValue // 该节点的求值结果,一旦算出不可变
SkyFunction // 求值逻辑,只能通过 Skyframe 提供的依赖读取 API 访问其他节点
求值过程是这样的:
- 请求顶层节点(如
//app:main对应的ConfiguredTargetKey)。 - SkyFunction 执行过程中声明式地读取它依赖的其他节点(
SkyframeLookup)。 - Skyframe 记录这次求值所读取的全部依赖边,形成 dep graph。
- 结果连同依赖边一起写入
Skyframe内存图。
第二次构建时,Skyframe 不做"从头算",而是:
for each node in dirty_set:
if node's SkyFunction re-evaluates to same SkyValue (值相等,非引用相等):
剪枝,子树无需重算 ← 关键
else:
标记为 dirty,将其反向依赖加入队列
这里有个非常巧妙的工程点:SkyFunction 如果重算得到的值与旧值相等(equals() 为真),Skyframe 会剪掉整棵子树。这意味着改了一个 BUILD 文件里的注释、或者调整了不影响最终 Action 描述的 copts 顺序,下游成千上万个节点都不会重新执行 Action——它们的 SkyValue(Action 描述 + 输入摘要)没变。
与之配套的还有几个 IO 层优化:
FileStateValue:通过stat(而非读取内容)得到(size, mtime/inode),先用廉价的系统调用判断文件是否可能变化。FileContentsProxy:当FileStateValue变化时,再读取内容计算 xattr 摘要或内容哈希,避免全文件内容比对。配合--experimental_guard_against_concurrent_changes还能捕捉"改了内容又改回 mtime"的作弊行为。- 本地执行前的输出检查:本地 action cache 记录
(action_key) → (输出文件摘要列表),即使 Skyframe 判定需要执行,也会先看本地磁盘缓存有没有同样的输出,命中则直接 hard-link/符号链接过去。
这三层的组合,才是 Bazel "看起来慢、用起来快"的真相。它的快不是来自少干活,而是来自精确地知道哪些活不必干。
三、Action Key:内容寻址的缓存标识
每一个 Action 都被序列化成一个唯一的 Action Key(通常是一个 SHA-256 摘要),它由以下部分决定:
ActionKey = Hash(
command 命令行(argv 全序列),
input artifacts 的内容摘要(Merkle tree / digest),
output paths,
环境变量白名单,
执行平台约束(exec properties),
工具链版本(toolchain digest)
)
两个不同开发者的机器上,只要 Action Key 相同,这个 Action 的输出必定逐字节相同。这是远程缓存与远程执行能够成立的理论前提。
推论很重要:任何未被捕获的非确定性都会击穿缓存。常见的破坏者:
| 污染源 | 症状 | 解法 |
|---|---|---|
| 时间戳注入二进制 | 每次 Action Key 相同但输出不同 | -Wno-builtin-macro-redefined / SOURCE_DATE_EPOCH |
__FILE__ 绝对路径 | 换目录后误命中旧缓存 | -ffile-prefix-map 归一化 |
| 归档文件内文件顺序不稳 | tar/zip 不可复现 | 强制排序 + 固定 mtime |
Action 偷偷读未声明文件(如 /usr/local/include) | 沙箱拦截或误命中 | 显式声明为 input 或使用 toolchain |
Bazel 提供了 --experimental_action_listener 与 --execution_log_binary_file 来导出每个 Action 的实际输入摘要,排查"为什么没命中缓存"时极其有用——对比两次执行的 action key diff 即可定位污染源。
四、沙箱:封闭性(Hermeticity)是缓存正确性的前提
如果 Action 可以随机访问文件系统, inputs 摘要相同 ⟹ 输出相同 这个命题就不成立。因此 Bazel 在 Linux 上默认使用多级沙箱策略:
# .bazelrc —— 生产环境推荐配置
build --spawn_strategy=linux-sandbox
build --sandbox_tmpfs_path=/tmp
build --incompatible_strict_action_env # 清空继承的环境变量白名单
build --experimental_reuse_sandbox_directories
linux-sandbox 的实际行为是:用 mount namespace 挂载一个只含显式声明输入的目录树,把 /tmp、/home、$PATH 之外的绝大部分路径遮蔽或未挂载,再 unshare PID namespace 后 exec 目标命令。
# 手工复现 Bazel sandbox 的挂载策略
unshare --mount --pid --fork --map-root-user \
sh -c 'mount -t tmpfs none /tmp && \
mount --bind /proc /proc && \
exec ./compiler @params'
这也是很多项目迁移 Bazel 时最痛的一步:Makefile 时代"能编过就行"的工程,在沙箱里会暴露大量隐式依赖(系统 curl、不加前缀的 python、随机读 /etc/ssl)。这个痛是有价值的——它把"在我机器上能跑"这个不可测问题,转化成了可枚举的依赖清单。
五、Remote Execution API:把 Action 搬到集群上
Remote Execution API(简称 REAPI,Google 定义、现成行业标准)把构建拆分成了几个正交的 gRPC 服务:
service ContentAddressableStorage { // CAS:存输入文件与输出结果的 blob
rpc FindMissingBlobs(...) returns (FindMissingBlobsResponse);
rpc BatchUpdateBlobs(...) returns (BatchUpdateBlobsResponse);
rpc GetTree(...) returns (stream GetTreeResponse);
}
service ActionCache { // 动作缓存:ActionKey → ActionResult
rpc GetActionResult(GetActionResultRequest) returns (ActionResult);
rpc UpdateActionResult(...) returns (ActionResult);
}
service Execution { // 执行:跑真正干活的部分
rpc Execute(ExecuteRequest) returns (stream Operation);
rpc WaitExecution(...) returns (stream Operation);
}
service Capabilities { // 协商:摘要算法、最大 batch size 等
rpc GetCapabilities(...) returns (ServerCapabilities);
}
一次远程执行的完整链路:
- 上传输入:客户端把 Action 的所有输入文件按内容分片,计算 digest 后
FindMissingBlobs询问服务端缺哪些,只上传缺失部分(这一步本身就是巨大的优化——我们有 30GB 的依赖,首次全传,之后每次只传改动的几 KB)。 - 查询 Action Cache:用 Action Key 调
GetActionResult。命中则跳过执行,直接拿到输出的 digest 列表。 - 调度执行:未命中则
Execute,服务端从 CAS 拉取输入,在 worker 上的沙箱里跑命令。 - 回传输出:worker 把输出写回 CAS,
ActionResult里带上每个输出的 digest 与stdout/stderr的 digest(注意:日志也是内容寻址的 blob,不直接内联,避免大日志拖垮 gRPC 消息)。 - 下载输出:客户端决定是否真正落地某些文件(Bazel 有"只下载顶层产物,中间产物留在远端"的优化,
--remote_download_minimal)。
.bazelrc 上的典型配置:
build --remote_instance_name=projects/my-proj/instances/default
build --remote_executor=grpc://build-cluster.internal:8980
build --remote_cache=grpc://build-cluster.internal:8980
# 只下载测试产物与最终二进制,中间 .o 留在远端
build --remote_download_minimal
# 本地/远端竞速,谁先完成用谁(dynamic execution)
build --experimental_remote_strategy=dynamic
build --experimental_local_execution_delay=200
build --remote_timeout=600
--remote_download_minimal 是被低估的性能开关。 一个 linker 的输入可能有 2 万个 .o 文件,如果全部下载到本地,网络会成为瓶颈;改为「只有最终的 .so/可执行文件落盘」后,CI 端到端时间常常能再降 30%–50%。
六、Starlark 规则实战:把重复劳动下沉到构建层
理解了底层模型,再看自定义规则就会清晰很多——Rule 的职责是:在分析期(analysis phase)生成 Action,而不是在执行期干活。
下面是一个把 .proto 风格 PromQL 告警规则编译成 YAML 的规则骨架:
# alerting/defs.bzl
def _prometheus_rule_impl(ctx):
outs = []
for src in ctx.files.srcs:
out = ctx.actions.declare_file(src.basename + ".yaml")
# 注意:这里是「声明 Action」,此刻并未执行
ctx.actions.run(
mnemonic = "PromRuleGen",
executable = ctx.executable._compiler,
arguments = [
"--input", src.path,
"--output", out.path,
"--cluster", ctx.attr.cluster,
],
inputs = [src],
outputs = [out],
# 把编译器自身也计入 action key,工具升级自动触发重算
tools = [ctx.executable._compiler],
progress_message = "Generating prometheus rule %{input}",
execution_requirements = {"no-remote": ""} if ctx.attr.local_only else {},
)
outs.append(out)
return [DefaultInfo(files = depset(outs))]
prometheus_rules = rule(
implementation = _prometheus_rule_impl,
attrs = {
"srcs": attr.label_list(allow_files = [".rules"]),
"cluster": attr.string(mandatory = True),
"local_only": attr.bool(default = False),
"_compiler": attr.label(
default = "//tools:rulegen",
executable = True,
cfg = "exec", # 关键:标注为「执行平台」配置
),
},
)
这里有几个容易踩的点:
depset而非list:DefaultInfo(files = depset(...))用有向无环集合传递信息,避免依赖图深度导致的 O(n²) 内存与构建时间爆炸。这是 Bazel 性能文档反复强调的头号反模式。cfg = "exec":工具链构建要用"执行机平台"的配置,而不是"目标机平台"的。做交叉编译(在 x86 上编 ARM64)时,这是必须的,否则编译出来的工具跑不起来。mnemonic要短且稳定:它出现在--profile输出和--explain日志里,是排查构建瓶颈的主要抓手。
七、工程落地 checklist
从 0 到 1 引入 Bazel,按我们的经验,收益最大的顺序是:
- 先只用远程缓存,不开远程执行。用
buildfarm-server或 BuildBarn 起一个集群(甚至可以先用 S3/GCS 作为 HTTP 缓存后端),把 CI 的重复计算消除。这一步几乎零侵入。 - 修复封闭性。跑一遍
--spawn_strategy=linux-sandbox,把所有沙箱失败列出来,逐个补齐依赖。这一步做完,缓存命中率通常能从 40% 跳到 85%+。 - 引入
deps卫生检查。误声明的依赖会让 action key 带上无关输入,降低命中率。 - 再开远程执行,并调优 worker 规格:Action 是高度异构的(有的 100ms、有的编译 3 分钟),需要建立 exec property 分级(
cpu:8/cpu:32/gpu)。 - 最后才是裁剪 action 粒度。过细的 action 会让调度开销超过计算收益,一般建议单个 compile action 不要低于 100ms。
一个容易被忽略的观察:Bazel 的最大收益往往不来自"构建变快",而来自"构建变可信"。 当 action key 相同、输出必然逐字节一致时,"增量构建结果和 clean 构建不一致"这类幽灵 bug 就彻底消失了,这在持续优化部署时可复现性的收益远超省下的几分钟。
八、结语
Bazel 的三个层次,其实对应三个正交的工程问题:
- Skyframe 解决的是「哪些工作不必重复做」——用通用增量图求解器,把依赖追踪从人的责任变成机器的责任。
- 内容寻址 + 沙箱 解决的是「同样的输入能否信任同样的输出」——用封闭性换取缓存的正确性。
- Remote Execution API 解决的是「剩下的工作能不能并行」——把纯函数调度到集群,用 RPC 协议标准化了这套交互。
理解这三层,你就不会再把 Bazel 当成"一个难用的构建工具",而是一个把构建明确建模为分布式纯函数求值的运行时。迁移成本确实存在,但对于任何"构建时间已经成为协作瓶颈"的团队,这笔账在三个月内基本都能算平。

发表评论 取消回复