Building Go Applications with Bazel

May 12, 2018

Let’s admit it - managing dependencies and building binaries is quite possibly the most frustrating and least fulfilling part of software development. To make matters worse, these frustrations only compound as your application grows - resulting in a hornet’s nest of bash scripts and fatalist debug instructions like “just clean the entire project and re-download all dependencies.”

In the last few years, a variety of tools which aim to break down this complexity have been developed and open sourced. Facebook’s buck and Foursquare’s pants are two popular derivatives of Google’s internal blaze build tool, components of which have been open sourced under the bazel project. These tools require a pretty hefty upfront investment to get setup, but will quickly pay dividends time and time over again as your builds are faster, easier to debug, and consistent across operating systems and architectures.

This post presents bazel as a viable alternative to the native go toolchains and walks through the process of setting up and using bazel to build a real-world application.

The Benefits of Bazel

Fast and Reproducible Builds

The core selling point of bazel is that, if set up correctly, your application’s build process is guaranteed to be completely reproducible and consistent - meaning no more afternoons wasted trying to figure out why the code you wrote behaves differently in CI / your boss’s laptop / prod (!). On top of this ambitious promise, bazel also takes strides to make your builds faster, spreading work across all of your machine’s processing power, and ensuring that only the necessary files are rebuilt between runs.

Language Agnostic, Extensible Tooling

bazel can be used for more than just go projects, and is configured via the powerful skylark language, a breath of fresh air for accustomed to hacking together bespoke bash scripts for every repository. Beyond just being able to build code, bazel can also be used to manage more complex workflows, such as building and pushing docker containers and even integration tests.

Consistent UX

One of the hardest parts of developing and maintaining a suite of projects spanning multiple programming languages is the constant burden of context switching between the different frameworks and toolchains. bazel attempts to solve this issue by providing a consistent and familiar user experience and workflow, no matter if you are building a Javascript web app or a fleet of Scala microservices. For many projects, developers can hit the ground running with only two commands, bazel build and bazel test.

Setting up Bazel for Go

This post details setting up bazel for the popular groupcache project. If you want to follow along or reference this project later, you can check out the code on github.

The first step to setting up a bazel repo is creating what is known as a WORKSPACE file. This file contains a manifest of all of you external dependencies and bazel libraries. Our project will build the groupcache binary and then package it into a Docker container for other developers to use. As such, our WORKSPACE file will look something like this:

# download go bazel tools
http_archive(
    name = "io_bazel_rules_go",
    url = "https://github.com/bazelbuild/rules_go/releases/download/0.11.0/rules_go-0.11.0.tar.gz",
    sha256 = "f70c35a8c779bb92f7521ecb5a1c6604e9c3edd431e50b6376d7497abc8ad3c1",
)
# download the gazelle tool
http_archive(
    name = "bazel_gazelle",
    url = "https://github.com/bazelbuild/bazel-gazelle/releases/download/0.11.0/bazel-gazelle-0.11.0.tar.gz",
    sha256 = "92a3c59734dad2ef85dc731dbcb2bc23c4568cded79d4b87ebccd787eb89e8d0",
)

# load go rules
load("@io_bazel_rules_go//go:def.bzl", "go_rules_dependencies", "go_register_toolchains", "go_repository")
go_rules_dependencies()
go_register_toolchains()

# load gazelle
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")
gazelle_dependencies()

# load go docker rules
load(
    "@io_bazel_rules_docker//go:image.bzl",
    _go_image_repos = "repositories",
)
_go_image_repos()

# external dependencies

go_repository(
    name = "com_github_golang_protobuf",
    importpath = "github.com/golang/protobuf",
    tag = "v1.0.0",
)

The syntax of this file should be very familiar with those who have written python before - the skylark language is essentially a pared down version of the python language.

In addition to creating a WORKSPACE file, each directory or “package” in a bazel project needs to have a BUILD.bazel file. These files declare how to build and test each package, along with any dependencies and additional tasks.

BUILD files are the lifeblood of bazel - but they are also a huge pain to initially write and then keep up to date as dependencies change - especially for go programmers who are used to just declaring import () blocks and having the compiler figure out all of the semantics for you. Luckily, the bazel team has recognized this pain point and has written a nifty tool called gazelle which can completely automate this process for you! For the sake of brevity (and sanity), the rest of this walkthrough will use the gazelle tool, something which I strongly recommend you adopt in your own projects as well.

Scaffolding Dependencies with gazelle

To use gazelle and generate BUILD files for your project you must first create BUILD.bazel file in the root of your repo and configure the gazelle tool.

load("@bazel_gazelle//:def.bzl", "gazelle")

gazelle(
    name = "gazelle",
    # you project name here!
    prefix = "github.com/brendanjryan/groupcache-bazel",
)

After this brief setup, invoking gazelle is simple straightforward - just "run" the job via bazel.

$ bazel run //:gazelle

That’s it! You should now see BUILD files in each package of your project. Take a few minutes to check these out and bask in the power of gazelle.

Building your application

Now that we have set up our BUILD files, the process of building our application is extremely straightforward. By running commands of the form bazel build <target>, you can build any package or target declared in your project.

bazel build //lru/...
INFO: Analysed 2 targets (3 packages loaded).
INFO: Found 2 targets...
INFO: Elapsed time: 0.628s, Critical Path: 0.04s
INFO: Build completed successfully, 1 total action

N.B. In bazel’s vernacular // denotes the “root” of your project and ... denotes all “child” packages of the specified package. For example, the command bazel build //lr/... will build the lru package and all sub-packages underneath it.

Note that subsequent builds of the same target should be significantly faster:

bazel build //lru/...
INFO: Analysed 2 targets (0 packages loaded).
INFO: Found 2 targets...
INFO: Elapsed time: 0.268s, Critical Path: 0.01s
INFO: Build completed successfully, 1 total action

If you want to build the entire project, you can run the following command - note the significant speedups gained from using bazel.

bazel build //...
INFO: Analysed 19 targets (64 packages loaded).
INFO: Found 19 targets...
INFO: Elapsed time: 8.206s, Critical Path: 3.24s
INFO: Build completed successfully, 35 total actions

bazel build //...
zsh: correct '//...' to '//..' [nyae]? n
INFO: Analysed 19 targets (0 packages loaded).
INFO: Found 19 targets...
INFO: Elapsed time: 0.382s, Critical Path: 0.00s
INFO: Build completed successfully, 1 total action

Testing your applications

Under the hood bazel runs tests using the same go test tools that you should be familiar with but exposes them under the same bazel <command> <taget> pattern used by the build process.

For example, to test the consistenthash package you would run:

bazel test //consistenthash/...
INFO: Analysed 2 targets (0 packages loaded).
INFO: Found 1 target and 1 test target...
INFO: Elapsed time: 0.502s, Critical Path: 0.15s
INFO: Build completed successfully, 2 total actions

Executed 1 out of 1 test: 1 test passes.

And to test the entire project:

bazel test //...
INFO: Analysed 19 targets (0 packages loaded).
INFO: Found 15 targets and 4 test targets...
INFO: Elapsed time: 1.733s, Critical Path: 0.91s
INFO: Build completed successfully, 4 total actions

Executed 4 out of 4 tests: 4 tests pass.

Note that we get the same benefits of cached results as we do with bazel build

bazel test //...
INFO: Analysed 19 targets (0 packages loaded).
INFO: Found 15 targets and 4 test targets...
INFO: Elapsed time: 0.381s, Critical Path: 0.00s
INFO: Build completed successfully, 1 total action

Executed 0 out of 4 tests: 4 tests pass.

The bazel testrunner also provides additional functionality on top of go test - for instance you can pass the --runs-per-test flag to run your suite multiple times in parallel – very useful for catching flaky tests and data races between test runs.

bazel test --runs_per_test=10 //...
INFO: Analysed 19 targets (0 packages loaded).
INFO: Found 15 targets and 4 test targets...
INFO: Elapsed time: 7.456s, Critical Path: 1.10s
INFO: Build completed successfully, 41 total actions

Executed 4 out of 4 tests: 4 tests pass.

Packaging your application

Now that we’ve gotten our project building with bazel - publishing the final binary as a docker container is surprisingly little work. To do so, we just declare each of the layers of the final image and then how and where the image will be published, like so:

# load bazel rules for docker images
load("@io_bazel_rules_docker//go:image.bzl", "go_image")
load("@io_bazel_rules_docker//container:container.bzl", "container_push", "container_image")

# declare the base `go` image - this is the same format as the standard
# `go_binary` rule.
go_image(
    name = "groupcache_image_base",
    embed = [":go_default_library"],
)

# wrapper image used to expose ports to the underlying go_image
container_image(
    name = "groupcache_image",
    base = ":groupcache_image_base",
    ports = ["8080"],
)

# declare where and how the image will be published
container_push(
    name = "push",
    format = "Docker",
    image = ":groupcache_image",
    registry = "index.docker.io",
    repository = "brendanjryan/groupcache-bazel",
    tag = "master",  # don't use this on production image :)
)

One of the strengths of this process over the standard docker workflow is that no Dockerfiles are required and you can easily build and publish multiple images to multiple repositories - all in parallel!

In our case, pushing our image up to Dockerhub is as simple as:

$ bazel run //example:push

Caveat: I do not recommend pushing images from your local workstation. This step should be part of your CI workflow.

Final Words

Hopefully this walkthrough gives you enough to start integrating bazel into one of your own go projects - or conversely know that you never want to :)

Feel free to reach out on Twitter or Github if you have any questions!

Further Readings

Want to learn more? Here are a few great links: