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 访问其他节点

求值过程是这样的:

  1. 请求顶层节点(如 //app:main 对应的 ConfiguredTargetKey)。
  2. SkyFunction 执行过程中声明式地读取它依赖的其他节点(SkyframeLookup)。
  3. Skyframe 记录这次求值所读取的全部依赖边,形成 dep graph。
  4. 结果连同依赖边一起写入 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);
}

一次远程执行的完整链路:

  1. 上传输入:客户端把 Action 的所有输入文件按内容分片,计算 digest 后 FindMissingBlobs 询问服务端缺哪些,只上传缺失部分(这一步本身就是巨大的优化——我们有 30GB 的依赖,首次全传,之后每次只传改动的几 KB)。
  2. 查询 Action Cache:用 Action Key 调 GetActionResult。命中则跳过执行,直接拿到输出的 digest 列表。
  3. 调度执行:未命中则 Execute,服务端从 CAS 拉取输入,在 worker 上的沙箱里跑命令。
  4. 回传输出:worker 把输出写回 CAS,ActionResult 里带上每个输出的 digest 与 stdout/stderr 的 digest(注意:日志也是内容寻址的 blob,不直接内联,避免大日志拖垮 gRPC 消息)。
  5. 下载输出:客户端决定是否真正落地某些文件(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,按我们的经验,收益最大的顺序是:

  1. 先只用远程缓存,不开远程执行。用 buildfarm-server 或 BuildBarn 起一个集群(甚至可以先用 S3/GCS 作为 HTTP 缓存后端),把 CI 的重复计算消除。这一步几乎零侵入。
  2. 修复封闭性。跑一遍 --spawn_strategy=linux-sandbox,把所有沙箱失败列出来,逐个补齐依赖。这一步做完,缓存命中率通常能从 40% 跳到 85%+。
  3. 引入 deps 卫生检查。误声明的依赖会让 action key 带上无关输入,降低命中率。
  4. 再开远程执行,并调优 worker 规格:Action 是高度异构的(有的 100ms、有的编译 3 分钟),需要建立 exec property 分级(cpu:8 / cpu:32 / gpu)。
  5. 最后才是裁剪 action 粒度。过细的 action 会让调度开销超过计算收益,一般建议单个 compile action 不要低于 100ms。

一个容易被忽略的观察:Bazel 的最大收益往往不来自"构建变快",而来自"构建变可信"。 当 action key 相同、输出必然逐字节一致时,"增量构建结果和 clean 构建不一致"这类幽灵 bug 就彻底消失了,这在持续优化部署时可复现性的收益远超省下的几分钟。

八、结语

Bazel 的三个层次,其实对应三个正交的工程问题:

  • Skyframe 解决的是「哪些工作不必重复做」——用通用增量图求解器,把依赖追踪从人的责任变成机器的责任。
  • 内容寻址 + 沙箱 解决的是「同样的输入能否信任同样的输出」——用封闭性换取缓存的正确性。
  • Remote Execution API 解决的是「剩下的工作能不能并行」——把纯函数调度到集群,用 RPC 协议标准化了这套交互。

理解这三层,你就不会再把 Bazel 当成"一个难用的构建工具",而是一个把构建明确建模为分布式纯函数求值的运行时。迁移成本确实存在,但对于任何"构建时间已经成为协作瓶颈"的团队,这笔账在三个月内基本都能算平。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部