Why Bazel?
In this post, we’ll cover what Bazel is, how to use it, and why I chose to use it.
What is Bazel?
Bazel is a build-system released by Google in 2015. It actually is derived from the internal build-system Google uses internally for most of its own code-base, called Blaze.
Building at scale
Bazel has a huge focus on hermetic builds, and reproducibility. Every build step is, from a really broad perspective, defined as a list of inputs, tools, and outputs. This allows for efficient and robust caching (if no inputs nor tools changed, then this target doesn’t need to be rebuilt, and this cascades through the whole build graph). Let’s see a sample definition of a C++ library, as well as a C++ binary depending on it:
BUILD
cc_library(
name = "my_feature"
srcs = [
"feature_impl.cpp",
"utils.cpp",
],
hdrs = [
"feature.hpp",
"utils.hpp",
],
)
cc_binary(
name = "my_app",
srcs = ["main.cpp"],
deps = [
":my_feature",
],
)
cc_library
and cc_binary
are both depending an implicit dependency on a C++
toolchain (I won’t enter into any language-specific features in this post, but
if you don’t tell Bazel to use a specific C++ toolchain, it will try to use your
system compiler - which is convenient, but loses a bit of hermeticity and
reproducibility). Everything else is pretty obvious here: we defined two
different build targets, one of them being a library called my_feature
, and
the other one a binary called my_app
, depending on my_feature
. If we build
my_app
, Bazel will automatically build my_feature
first as you would expect,
and then proceed to build my_app
. If you change the main.cpp
and re-build
my_app
, it will skip the compilation of my_feature
entirely, as nothing
changed.
Bazel’s cache handling is really reliable. During the past few months, I’ve done
a lot of diverse things (writing my own rules, compiling a bunch of different
languages, depending on third-party libraries and rules…), and never had a
single time to run bazel clean
. Now I didn’t use a lot of other build systems
in the recent past, but from someone who has been using
Gradle for Android previously, this feels really weird.
Integrating tools and other languages
Another great aspect of Bazel is its extensibility. It works with rules defined in a language called Starlark, which syntax is a subset of Python’s. It comes without a lot of standard Python features, as I/O, mutable collections, or anything that could affect build hermeticity. While this isn’t the focus of this article (I will cover the writing of a rule to run a simple tool in a later article), here is what an example rule can look like (from Bazel’s samples):
rules.bzl
def _convert_to_uppercase_impl(ctx):
# Both the input and output files are specified by the BUILD file.
in_file = ctx.file.input
out_file = ctx.outputs.output
ctx.actions.run_shell(
outputs = [out_file],
inputs = [in_file],
arguments = [in_file.path, out_file.path],
command = "tr '[:lower:]' '[:upper:]' < \"$1\" > \"$2\"",
)
# No need to return anything telling Bazel to build `out_file` when
# building this target -- It's implied because the output is declared
# as an attribute rather than with `declare_file()`.
convert_to_uppercase = rule(
implementation = _convert_to_uppercase_impl,
attrs = {
"input": attr.label(
allow_single_file = True,
mandatory = True,
doc = "The file to transform",
),
"output": attr.output(doc = "The generated file"),
},
doc = "Transforms a text file by changing its characters to uppercase.",
)
Once it’s defined, it’s re-usable to define actual build targets in a simple way:
BUILD
load(":rules.bzl", "convert_to_uppercase")
convert_to_uppercase(
name = "foo_but_uppercase",
input = "foo.txt",
output = "upper_foo.txt",
)
As a result of this simple extensibility, while Bazel ships only with C++ and Java support (which are actually getting removed and rewritten in Starlark, to decouple them from Bazel itself), a lot of rules have been written either by the Bazel team or by the community, to integrate languages and tools. You can find rules for NodeJS, Go, Rust, packaging (generating debs, zips…), generating Docker images, deploying stuff on Kubernetes, and a bunch of other things. And if there are no rules to run/build what you want, you can write your own!
A three-steps build
Bazel runs in three distinct phases. Each of them has a specific role, and specific capabilities.
Loading
The loading phase is parsing and evaluating all the BUILD
files required to
build the requested target(s). This is typically the step during witch any
third-party dependency would be fetched (just downloaded and/or extracted,
nothing more yet).
Analysis
The second phase is validating any involved build rule, to generate the actual build graph. Note that both of those two first phases are entirely cached, and if the build graph doesn’t change from one build to another (e.g. you just changed some source files), they will be skipped entirely.
Execution
This is the phase that checks for any out-of-date output (either non-existent, or its inputs changed), and runs the matching actions.
Great tooling
Bazel comes with some really cool tools. Without spending too much time on that, here’s a list of useful things:
- ibazel is a filesystem-watcher that will rebuild a target as soon as its inputs files or dependencies changed.
- query is a built-in sub-command that helps to analyse the build graph. It’s incredibly feature-packed.
- buildozer is
a tool to edit
BUILD
files at across a whole repository. It can be used to add dependencies to specific targets, changing target visibilities, adding comments… - unused_deps
is detecting unused dependencies for Java targets, and displays
buildozer
commands to remove them. - Integration with different IDEs.
- A set of APIs for remote caching and execution, with a few implementations, as well as an upcoming service on Google Cloud called Remote Build Execution, leveraging GCP to build remotely. The loading and analysis phases are still running locally, while the execution phase is running remotely.
Choosing a build system
At the time I started thinking about working on this blog again, I had a small private repository with a bunch of stuff, all compiled with Bazel. I also noticed a set of Starlark rules integrating Hugo. While I didn’t need a build system, Bazel seemed to be interesting for multiple aspects:
- I could leverage my existing CI system
- While Hugo comes with a bunch of features to e.g. pre-process Sass files, it has some kind of lock-in effect. What if I eventually realise that Hugo doesn’t fill my need? What’s the cost of migrating to a new static site generator? The less I rely on Hugo-specific features, the easier this would be
- I could integrate some custom asset pipelines. For example, I could have a diagram written with PlantUML or Mermaid and have it part of the Bazel graph, as a dependency of this blog
- Bazel would be able to handle packaging and deployment
- It sounded stupid enough to be a fun experiment? (Let’s be honest, that’s the only real reason here.)
Closing thoughts
Bazel is quite complex, and this article only scratches the surface. The goal was not to teach you how to use Bazel (there are a lot of existing resources for that already), but to give a quick overview of the core ideas behind it.
If you found it interesting, here are some useful links:
- Bazel’s getting started
- A list of samples using different languages as well as defining some rules
- A (non-exhaustive) list of rules, as well as the documentation of all the built-in rules
In the next article, we’ll see how to build a simple Kotlin app with Bazel, from scratch all the way to running it.
This is a post in the Creating a blog with Bazel series.
Other posts in this series:
- 16 May 2020 - Writing a Bazel rule set
- 8 December 2019 - Compiling a Kotlin application with Bazel
- 2 November 2019 - Why Bazel? (this article)
- 31 October 2019 - A new beginning