At InPlayer, we have a platform that is built using a microservice architectural style which basically structures an application as a collection of many different services. In this post I will talk about how we structure, build and deploy our Go applications.
Every bit of Go code that we write, resides in a single Git repository - A monorepo. Since every library and service is in a single project, it allows us to make cross cutting changes without the need of some external package management tools. Basically it is impossible for the code to be out of sync and each change that we make can be considered as a single unit.
While the benefits are clear, the challenge in working with a Go monorepo is how to build and test each package efficiently. The answer - Bazel.
What is Bazel?
Bazel is a build tool which is blazingly fast. It only rebuilds what is necessary and it leverages advanced caching mechanisms and parallel execution which make your builds very, very fast. Besides those features, it can also manage your code dependencies, call external tools and plugins, and it can build Docker images from your binary executables as well. It uses
go build under the hood but it can also support many different languages, not just go. You can use it for Java, C++, Android, iOS and a variety of other language platforms. You can run Bazel on the three major operating systems - Windows, macOS and Linux.
Before we dive deeper into Bazel, first let's discuss our project structure:
platform |-- src | |-- foo | | |--cmd | | | `--bar | | | |--BUILD | | | `--main.go | | `--pkg | |-- utils | |-- vendor | |-- Gopkg.lock | |-- Gopkg.toml | |-- BUILD | `-- WORKSPACE |-- README.md `-- gitlab-ci.yml
platform directory is our root, everything starts from there. In that folder we have our CI configuration and the
src directory where we keep all of our code. Each service is a sub-directory in the
src folder, and in every service we have two top-level directories, the
pkg folders. Underneath
cmd we have directories for our binaries (our main programs) and the
pkg directory is used for our service libraries.
Bazel builds software from code organized in a directory called a workspace, which is basically our src directory. Here, our workspace directory must contain a file named
WORKSPACE which may have references to external dependencies required to build the outputs as well as build rules.
Here is an example WORKSPACE file:
http_archive( name = "io_bazel_rules_go", url = "https://github.com/bazelbuild/rules_go/releases/download/0.9.0/rules_go-0.9.0.tar.gz", sha256 = "4d8d6244320dd751590f9100cf39fd7a4b75cd901e1f3ffdfd6f048328883695", ) http_archive( name = "bazel_gazelle", url = "https://github.com/bazelbuild/bazel-gazelle/releases/download/0.9/bazel-gazelle-0.9.tar.gz", sha256 = "0103991d994db55b3b5d7b06336f8ae355739635e0c2379dea16b8213ea5a223", ) git_repository( name = "io_bazel_rules_docker", remote = "https://github.com/bazelbuild/rules_docker.git", tag = "v0.3.0", ) load("@io_bazel_rules_go//go:def.bzl", "go_rules_dependencies", "go_register_toolchains") go_rules_dependencies() go_register_toolchains() load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies") gazelle_dependencies() load( "@io_bazel_rules_docker//go:image.bzl", _go_image_repos = "repositories", ) _go_image_repos()
In this file there are several dependencies added to the workspace. We are specifically declaring that we will use the rules_go and rules_docker dependencies, as well as Gazelle, which will help us generate some files needed for Bazel. Don't worry if this syntax is not familiar to you right away, it takes some time getting used to it.
Bazel has a concept around packages which are defined as a collection of related files and a specification of the dependencies among them. If a directory inside a Bazel workspace contains a file named
BUILD, it will consider that directory as a package. A package includes all files in its directory, as well as all sub-directories beneath it, except those which themselves contain a BUILD file.
A BUILD file contains build rules which define how we should build our packages. You can read more about the concepts and terminology here.
When starting a new project, the first thing that we need to do is add a BUILD file inside the root directory, which will load the gazelle rule to be used later to run Gazelle with Bazel.
package(default_visibility = ["//visibility:public"]) load("@io_bazel_rules_docker//container:container.bzl") load("@io_bazel_rules_go//go:def.bzl", "go_prefix", "gazelle") go_prefix("github.com/example/project") gazelle( prefix = "github.com/example/project/src", name = "gazelle", command = "fix", external = "vendored" )
After adding this file, we can run Gazelle using this command:
bazel run //:gazelle
This will generate new BUILD files, based on the go files that you have inside your project. When you later add new programs and libraries, you should update the existing BUILD files using the same command, otherwise your builds can fail.
As an example (based on our project structure shown earlier), gazelle will generate a BUILD file for our
bar program that resides in the
foo package, which would look something like this:
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") package(default_visibility = ["//visibility:public"]) go_library( name = "go_default_library", srcs = ["main.go"], importpath = "github.com/example/project/src/foo/cmd/bar", visibility = ["//visibility:private"], deps = [ #Any dependencies that our library has will be loaded here ], ) go_binary( name = "bar", embed = [":go_default_library"], importpath = "github.com/example/project/src/foo/cmd/bar", visibility = ["//visibility:public"], )
Now by running the command
bazel build //foo/... bazel will build our Go program and save the binary in the output directory. If you want to build the whole project, just simply run
bazel build //... inside the root folder.
If you write tests for your libraries and programs (which you should), gazelle will generate
go_test rules for them, and then you can run
bazel test //... which will run all tests.
Bazel's advanced caching makes running the
test commands for the whole workspace super fast, because it will only build or test the files which you have changed, as well as the files that are dependent on those changed files.
⚠️ Note: Make sure you setup your CI server to cache the output directory, otherwise you won't see much benefit from running bazel.
In a case where we want to build and deploy our binaries as docker images, bazel has a nice set of rules to do just that. What's more important is that bazel does not require Docker for pulling, building or pushing images. This means that you can use these rules to build Docker images on Windows/OSX without the use of
boot2docker, also it will not require root access on your laptop.
The full example of a BUILD file for our
bar program looks like this:
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") # First we load the go_image and container_push rules load("@io_bazel_rules_docker//go:image.bzl", "go_image") package(default_visibility = ["//visibility:public"]) go_library( name = "go_default_library", srcs = ["main.go"], importpath = "github.com/example/project/src/foo/cmd/bar", visibility = ["//visibility:private"], deps = [ #Any dependencies that our library has will be loaded here ], ) go_binary( name = "bar", embed = [":go_default_library"], importpath = "github.com/example/project/src/foo/cmd/bar", visibility = ["//visibility:public"], ) go_image( name = "docker", binary = ":bar", )
go_image rule uses a distroless image as a base and just adds the binary file as the command to be run. You can use the
container_push rule as well if you want to push your image to a remote repository.
In order to run the binary as a docker image, just type the
bazel run //foo/cmd/bar:docker command. You can also build a tar bundle, which you can then manually load into docker by using these commands:
bazel build //foo/cmd/bar:docker.tar
docker load -i bazel-output/foo/cmd/bar/docker.tar
You can find more information about the rules here.