1. 为什么我们需要重新审视构建系统这件事如果你写过C或者维护过大型多语言项目大概率经历过这样的场景改了一行头文件全量编译跑了四十分钟团队里新来的同事配环境配了整整两天最后发现是某个依赖库版本对不上CI上本地能过的构建到服务器上就挂排查半天发现是环境变量差异。这些问题看起来是工程问题实际上根源都在构建系统上。Bazel就是冲着这些痛点来的。它最早是某搜索引擎巨头内部使用的构建工具后来开源现在由社区维护。它的核心定位很明确面向大型代码仓库、多语言、可复现、增量构建。说白了就是当你代码量到了一定规模Makefile写不动了CMake维护起来越来越痛苦的时候Bazel是一个值得认真考虑的选项。这篇文章适合谁看如果你满足以下任意一条那接下来的内容应该对你有用项目代码量超过十万行、涉及两种以上编程语言、构建时间超过五分钟、团队超过五个人、对构建产物的可复现性有要求。如果你只是写个小Demo坦白说Bazel可能有点杀鸡用牛刀但了解一下它的设计思路也没坏处。我自己的经历是从一个几千行的C项目开始用Bazel中间踩了不少坑也积累了一些经验。这篇文章会从设计思路、核心概念、实操步骤、常见问题几个维度展开尽量把我知道的都倒出来。2. Bazel的核心设计思路拆解2.1 为什么是“可扩展”而不是“快”很多人第一次听说Bazel印象是“构建快”。但快只是结果不是设计目标。Bazel真正的设计目标是可扩展性和正确性。这两个词听起来很虚但落到实际工程里含义非常具体。可扩展性指的是当你的代码库从一万行涨到一百万行从一个人变成一百个人构建系统不需要推倒重来。Makefile在这个维度上是有天花板的因为它的依赖关系是手动维护的人一多、代码一多依赖图就会变成一团乱麻。Bazel的做法是让你用声明式的方式描述依赖剩下的交给它来算。正确性指的是同样的输入永远得到同样的输出。这听起来是理所当然的但实际操作过的人都知道传统构建系统里“本地能过、CI挂掉”是家常便饭。Bazel通过沙箱机制和严格的依赖声明把这类问题从根源上掐掉了。2.2 三个核心概念Workspace、Package、TargetBazel的整个模型建立在三个概念上理解了这三个词基本就理解了一半。Workspace是根目录通过一个叫WORKSPACE的文件来标识。这个文件里声明的是外部依赖比如你需要从某个代码仓库拉一个第三方库就在这里写。一个Workspace可以包含多个Package。Package是包含BUILD文件的目录。BUILD文件是Bazel的核心里面用一套类似Python的DSL来描述构建规则。每个Package是一个独立的命名空间Package之间通过标签来互相引用。Target是构建的基本单元。一个Target可以是一个库、一个可执行文件、一个测试甚至是一个文件集合。Target通过标签来标识标签的格式是//package:target比如//src/main:server。这套模型的好处是层次清晰。Workspace管全局Package管模块Target管具体产物。依赖关系是显式声明的Bazel根据这些声明构建一张有向无环图然后按照拓扑顺序执行构建。2.3 沙箱机制为什么它能让构建结果可复现沙箱是Bazel最被低估的特性之一。简单说Bazel在执行每个构建动作时会把所需的输入文件复制到一个临时目录里在这个隔离环境中执行编译命令。这意味着你的编译过程看不到系统里其他乱七八糟的东西只能看到你显式声明的依赖。这个机制直接解决了“本地能过、CI挂掉”的问题。因为CI环境和本地环境的差异被沙箱屏蔽了只要依赖声明是正确的结果就是一致的。当然代价是每次构建都要复制文件有一定的I/O开销。但相比于排查环境问题花的时间这点开销完全值得。2.4 增量构建与缓存快在哪里Bazel的增量构建是基于内容哈希的。每个Target的输入源文件、依赖、编译选项会被计算出一个哈希值如果哈希没变就直接复用缓存结果。这个缓存可以是本地的也可以是远程的。远程缓存是团队协作的杀手锏。假设A同学编译了某个Target产物会被上传到远程缓存。B同学如果依赖没变直接下载缓存产物就行不需要重新编译。在大型项目里这能省下大量时间。但这里有个坑缓存命中率取决于依赖声明的精确程度。如果你在BUILD文件里写了一个过宽的依赖比如把整个目录都声明为输入那任何文件变动都会导致缓存失效。所以写BUILD文件时依赖要尽量精确。3. 核心概念与实操要点详解3.1 WORKSPACE文件怎么写WORKSPACE文件是Bazel的入口。一个最简单的WORKSPACE文件可以是空的但实际项目里通常需要声明外部依赖。比如你需要用某个规则集可以这样写load(bazel_tools//tools/build_defs/repo:http.bzl, http_archive) http_archive( name rules_cc, urls [https://example.com/rules_cc.tar.gz], sha256 abc123..., )这里的关键是sha256它保证了下载内容的完整性。Bazel会校验哈希值如果不匹配就报错。这个机制防止了依赖被篡改或下载不完整的情况。注意WORKSPACE文件里的依赖声明是全局的所有Package都能看到。但这不意味着你应该把所有东西都往这里塞。只放真正全局的依赖模块级别的依赖应该放在各自的BUILD文件里。3.2 BUILD文件的编写逻辑BUILD文件是日常打交道最多的。一个典型的C库的BUILD文件长这样cc_library( name math_utils, srcs [math_utils.cc], hdrs [math_utils.h], deps [ //base:logging, com_google_absl//absl/strings, ], visibility [//visibility:public], )逐行拆解一下。cc_library是规则类型表示构建一个C库。name是Target的名字在同一个Package内必须唯一。srcs是源文件hdrs是头文件。deps是依赖的其他Target可以是本仓库的用//开头也可以是外部仓库的用开头。visibility控制这个Target能被哪些其他Package引用。这里有个经验deps要尽量精确。如果你只用了某个库的一个头文件就只声明那个头文件对应的Target不要图省事把整个库都声明进来。依赖越精确增量构建的粒度就越细缓存命中率就越高。3.3 依赖管理的几个关键决策依赖管理是Bazel使用中最容易出问题的地方。有几个决策点需要提前想清楚。第一个决策用http_archive还是用包管理器。Bazel支持通过rules_jvm_external管理Java依赖通过rules_python管理Python依赖但C生态里通常还是用http_archive直接拉源码编译。两种方式各有优劣前者方便但灵活性差后者灵活但需要自己处理依赖树。第二个决策依赖的版本锁定。http_archive通过sha256锁定版本这比git_repository用commit hash更严格因为哈希值直接对应文件内容。建议优先用http_archive。第三个决策依赖的可见性。默认情况下Target的可见性是private只有同一个Package内的其他Target能引用。要跨Package引用必须显式设置visibility。这个设计强制你思考哪些接口是公开的哪些是内部的长期来看对代码质量有好处。3.4 工具链配置跨平台构建的关键工具链是Bazel里比较高级的话题但如果你需要跨平台构建就绕不开。工具链的本质是告诉Bazel用哪个编译器、哪些编译选项、怎么链接。一个典型的C工具链配置大概长这样load(bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl, tool_path) def _impl(ctx): tool_paths [ tool_path(name gcc, path /usr/bin/gcc), tool_path(name ld, path /usr/bin/ld), tool_path(name ar, path /usr/bin/ar), ] return cc_common.create_cc_toolchain_config_info( ctx ctx, toolchain_identifier local, host_system_name local, target_system_name local, target_cpu k8, target_libc local, compiler gcc, tool_paths tool_paths, ) cc_toolchain_config rule( implementation _impl, attrs {}, provides [CcToolchainConfigInfo], )这段代码看起来有点吓人但逻辑其实简单定义编译器路径、链接器路径、目标平台信息。Bazel根据这些信息决定怎么调用编译命令。实操心得如果你只是在本机开发不需要自定义工具链Bazel会自动检测系统默认的编译器。只有当你需要交叉编译或者使用特定版本的编译器时才需要配置工具链。4. 从零搭建一个Bazel项目的完整流程4.1 环境准备与安装安装Bazel有几种方式。最推荐的是用Bazelisk它是一个版本管理工具会根据项目里的.bazelversion文件自动下载对应版本的Bazel。这样团队里每个人用的Bazel版本都是一致的避免了版本差异导致的问题。安装Bazelisk很简单以Linux为例# 下载Bazelisk wget https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-linux-amd64 # 赋予执行权限 chmod x bazelisk-linux-amd64 # 放到PATH里 sudo mv bazelisk-linux-amd64 /usr/local/bin/bazel然后在项目根目录创建一个.bazelversion文件写上版本号比如7.0.0。之后每次运行bazel命令Bazelisk会自动确保用的是这个版本。注意不要直接用系统包管理器安装Bazel因为版本更新不及时而且不同项目可能需要不同版本。Bazelisk是更稳妥的选择。4.2 项目结构规划一个典型的Bazel项目结构是这样的my_project/ ├── WORKSPACE ├── .bazelversion ├── .bazelrc ├── src/ │ ├── BUILD │ ├── main/ │ │ ├── BUILD │ │ └── server.cc │ └── lib/ │ ├── BUILD │ ├── math_utils.cc │ └── math_utils.h └── tests/ ├── BUILD └── math_utils_test.ccWORKSPACE在根目录标识整个项目。每个子目录如果有BUILD文件就是一个Package。.bazelrc是配置文件可以设置默认的编译选项、缓存路径等。.bazelrc的一个典型配置build --cxxopt-stdc17 build --host_cxxopt-stdc17 build --disk_cache~/.cache/bazel test --test_outputerrors这几行的含义分别是编译时用C17标准、宿主工具也用C17、启用本地磁盘缓存、测试失败时输出错误信息。4.3 编写第一个BUILD文件假设我们要构建一个简单的C库和一个可执行文件。先写库的BUILD文件# src/lib/BUILD cc_library( name math_utils, srcs [math_utils.cc], hdrs [math_utils.h], visibility [//visibility:public], )再写可执行文件的BUILD文件# src/main/BUILD cc_binary( name server, srcs [server.cc], deps [ //src/lib:math_utils, ], )然后运行构建bazel build //src/main:server如果一切正常Bazel会输出构建成功的提示产物在bazel-bin/src/main/server。4.4 添加测试与持续集成Bazel对测试的支持是一等公民。写一个测试Target# tests/BUILD cc_test( name math_utils_test, srcs [math_utils_test.cc], deps [ //src/lib:math_utils, com_google_googletest//:gtest_main, ], )运行测试bazel test //tests:math_utils_testBazel会自动处理测试依赖的下载和编译。如果测试失败会输出详细的日志。在CI里集成Bazel也很简单基本就是三步安装Bazelisk、运行bazel build、运行bazel test。如果配置了远程缓存CI的构建速度会有明显提升。4.5 远程缓存配置实战远程缓存是团队协作的加速器。配置方式是在.bazelrc里加上build --remote_cachegrpc://cache.example.com:9092 build --remote_upload_local_resultstrue第一行指定缓存服务器地址第二行表示本地构建的产物也上传到远程缓存。实操心得远程缓存服务器可以用开源的bazel-remote部署很简单一个二进制文件加一个配置文件就能跑起来。存储后端支持本地磁盘、S3、GCS等。小团队用本地磁盘就够了大团队建议用对象存储。配置好之后第一次构建会正常编译并上传缓存第二次构建或者在其他机器上构建就能直接命中缓存。实测在中等规模项目里构建时间能从十几分钟降到一两分钟。5. 常见问题与排查技巧实录5.1 构建失败排查思路Bazel构建失败时第一步是看错误信息。Bazel的错误信息通常比较详细会告诉你哪个Target失败、失败原因是什么。常见的失败类型有几种依赖缺失是最常见的。错误信息通常是no such target或者no such package。这时候检查BUILD文件里的deps是否写对了标签格式是否正确。编译错误是第二常见的。这时候可以用bazel build --verbose_failures来获取更详细的输出包括完整的编译命令。把这条命令复制出来手动跑一遍通常能定位到问题。沙箱问题比较隐蔽。有时候编译命令在沙箱外能跑通在沙箱里就失败通常是因为依赖了沙箱外的文件。这时候要检查BUILD文件里的srcs和deps是否完整声明了所有输入。5.2 缓存失效的常见原因缓存失效是Bazel使用中最让人头疼的问题之一。明明只改了一个文件却触发了大量重新编译。常见原因有这几个依赖声明过宽。比如在srcs里用了glob模式匹配整个目录任何文件变动都会导致缓存失效。解决办法是尽量显式列出文件或者用更精确的glob模式。编译选项不稳定。比如在编译命令里嵌入了时间戳或者随机数每次编译的输入都不同缓存自然失效。检查.bazelrc和BUILD文件里有没有这类东西。环境变量泄漏。Bazel默认会把一些环境变量传递给编译动作如果这些变量在不同机器上不同缓存就会失效。可以用--action_env显式控制哪些环境变量参与构建。5.3 性能调优的几个方向如果构建速度不理想可以从几个方向调优。并行度。Bazel默认会根据CPU核心数自动设置并行度但有时候需要手动调整。--jobsN可以指定并行任务数--local_ram_resources可以限制内存使用。远程缓存。前面说过了这是提升团队整体效率最有效的手段。精简依赖。依赖越少构建图越小构建越快。定期审查BUILD文件移除不再使用的依赖。使用--experimental_ui或者--cursesno。前者提供更丰富的进度信息后者在CI环境里减少输出噪音。5.4 常见问题速查表问题现象可能原因排查方法构建报错“no such target”依赖标签写错或Target不存在检查BUILD文件中的deps用bazel query验证本地能过CI挂掉环境差异或依赖未声明检查沙箱依赖用--verbose_failures查看详情缓存命中率低依赖声明过宽或编译选项不稳定检查srcs和deps审查.bazelrc构建速度慢并行度不足或缺少远程缓存调整--jobs配置远程缓存测试超时测试用例执行时间过长用--test_timeout调整超时时间内存不足并行任务过多用--local_ram_resources限制内存5.5 几个容易踩的坑第一个坑在BUILD文件里用绝对路径。Bazel的沙箱机制要求所有路径都是相对于Workspace的绝对路径会导致构建失败或者缓存失效。第二个坑忽略visibility设置。默认是private跨Package引用会报错。如果确实需要跨Package引用记得设置visibility。第三个坑在WORKSPACE里声明了依赖但忘记在BUILD里引用。这不会导致构建失败但会导致依赖被下载却没用上浪费时间和带宽。第四个坑用git_repository而不是http_archive。前者依赖网络和Git服务后者只需要一个HTTP链接和哈希值更稳定。第五个坑不写.bazelversion。团队里每个人用的Bazel版本不同构建结果可能不一致。用Bazelisk加.bazelversion可以避免这个问题。6. 一些个人体会和后续扩展方向用Bazel这几年最大的感受是它的学习曲线确实陡但一旦跨过去回报是巨大的。前期花时间理解Workspace、Package、Target这套模型理解沙箱和缓存的机制后面维护成本会低很多。对于刚开始用的团队我的建议是从小项目开始试水不要一上来就把整个代码库迁移过去。先在一个模块里用起来跑通了再逐步扩大范围。迁移过程中BUILD文件的编写是最耗时的部分但也是最值得投入的因为这部分写好了后面的增量构建和缓存才能发挥最大效果。后续如果还想深入可以研究几个方向自定义规则用Starlark写自己的构建规则、远程执行把构建任务分发到多台机器上并行执行、以及和CI系统的深度集成。这些话题每一个都够写一篇文章有机会再展开聊。最后分享一个小技巧bazel query和bazel cquery是排查依赖问题的利器。前者查静态依赖图后者查配置后的依赖图。遇到依赖相关的问题先用这两个命令把依赖关系理清楚往往比看错误信息更高效。