Combining Bazel with Docker


Welcome to the next pikoTutorial!

In one of the recent articles, I showed how to build and run Docker containers using CMake. Today, we will see how to do a similar thing, but using Bazel. Today’s project structure:

MDX
project/
├── app1/
│   ├── BUILD
│   ├── main.py
│   ├── run_container.sh
│   ├── some_config.json
├── app2/
│   ├── BUILD
│   ├── main.py
│   ├── run_container.sh
│   ├── some_config.json
├── MODULE.bazel
└── WORKSPACE

Note: on the contrary to the project structure used in the article about CMake, this file tree does not contain any Dockerfile. It’s because Bazel supports building OCI-compliant images which are independent from a specific tool (like Docker). At the very last step, however, I will use Docker to run the container.

The MODULE.bazel file

First of all, we need to specify the dependencies of our build. They contain rules which I will use in the Bazel files:

Python
# dependency necessary for creating a python binary and a python
# application layer in the image
bazel_dep(name = "aspect_rules_py", version = "1.4.0")
# dependency necessary for building OCI images
bazel_dep(name = "rules_oci", version = "2.2.6")
# dependency necessary for pkg_tar rule which we will use to create
# an additional image layer
bazel_dep(name = "rules_pkg", version = "1.1.0")

The WORKSPACE file

Next is the WORKSPACE file. Certain operations (like pulling a base image) can be executed only at the stage of loading the workspace, so they must be contained in the WORKSPACE file.

Python
load("@rules_oci//oci:dependencies.bzl", "rules_oci_dependencies")
rules_oci_dependencies()

load("@rules_oci//oci:repositories.bzl", "oci_register_toolchains")
oci_register_toolchains(name = "oci")

load("@rules_oci//oci:pull.bzl", "oci_pull")
oci_pull(
    name = "python_base",
    image = "python",
    tag = "3.9-slim",
    platforms = ["linux/amd64"],
)

In this article, there are two Python applications, so I pull a Python-specific base image, but in case you use another language, this is the place to specify a proper base image.

app1/main.py and app2/main.py files

Nothing special here, just some Python code printing the content of the configuration file to test if the container works:

Python
import json

with open("some_config.json", "r") as file:
    print(f"Configuration from app 1: {json.load(file)}")

The some_config.json file is a single-key dictionary:

JSON
{
    "Hello": "World"
}

Similarly, the content of app2/some_config.json is:

JSON
{
    "Bye": "World"
}

BUILD files

Both app1/BUILD and app2/BUILD files look similar with the only difference being the application name:

Python
load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_image_layer")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load")
load("@rules_pkg//:pkg.bzl", "pkg_tar")
# create a Python application binary
py_binary(
    name = "main",
    srcs = ["main.py"],
)
# create a Python application layer in the image
py_image_layer(
    name = "application_layer",
    binary = ":main",
)
# create an example additional layer in the image
pkg_tar(
    name = "configuration_layer",
    srcs = ["some_config.json"],
)
# create OCI image
oci_image(
    name = "image_definition",
    base = "@python_base",
    entrypoint = ["/app1/main"],
    tars = [":configuration_layer", ":application_layer"],
)
# load the image into the local runtime
oci_load(
    name = "image",
    image = ":image_definition",
    repo_tags = ["image_app_1:latest"],
)

With such a BUILD file, I defined an example two-layer OCI image which can be then loaded to the local runtime (and accessed e.g. with Docker).

Building the images

Now when everything’s ready, you can build the app1 image by calling:

Bash
bazel run //app1:image

This will produce the following output:

MDX
INFO: Analyzed target //app1:image (152 packages loaded, 3970 targets configured).
INFO: Found 1 target...
Target //app1:image up-to-date:
  bazel-bin/app1/image.sh
INFO: Elapsed time: 5.106s, Critical Path: 4.20s
INFO: 40 processes: 25 internal, 14 linux-sandbox, 1 local.
INFO: Build completed successfully, 40 total actions
INFO: Running command line: bazel-bin/app1/image.sh
6c4c763d22d0: Loading layer [==================================================>]  28.23MB/28.23MB
41757dc445c9: Loading layer [==================================================>]  3.512MB/3.512MB
529e75018436: Loading layer [==================================================>]  14.93MB/14.93MB
678221e973fe: Loading layer [==================================================>]     249B/249B
c025797afb0a: Loading layer [==================================================>]  10.24kB/10.24kB
7e7cd3ed4a69: Loading layer [==================================================>]  2.483MB/2.483MB
f465bed610cd: Loading layer [==================================================>]  23.19MB/23.19MB
7141b744acb3: Loading layer [==================================================>]  1.103MB/1.103MB
Loaded image: image_app_1:latest

And similarly for the app2 image:

Bash
bazel run //app2:image

Note for beginners: notice that oci_load rule that I used to define targets //app1:image and //app2:image must be called using bazel run. Calling bazel build on these targets will not load the images to the local runtime.

After the build is done, you can run:

Bash
docker images

to check that 2 new images appeared:

MDX
REPOSITORY    TAG       IMAGE ID       CREATED         SIZE
image_app_1   latest    95ef84a311e5   2 minutes ago   214MB
image_app_2   latest    8d757bc74a7e   2 minutes ago   214MB

You can run one of them by calling, e.g.:

Bash
docker run --rm --name container_app_1 image_app_1

And you see that not only does our application work inside the container, but it also has the access to the configuration file added to our image in the configuration layer:

MDX
Configuration from app 1: {'Hello': 'World'}

Running Docker container with Bazel

But why stop here? We have a nice abstraction for the image creation (in a form of Bazel //app1:image target), so let’s add a similar abstraction for running the container from Bazel level like this:

Bash
bazel run //app1:container

In order to do that, there needs to be some kind of wrapper script which will be then used in the Bazel BUILD file, let’s call it run_container.sh:

Bash
#!/bin/bash

docker run --rm --name container_app_1 image_app_1

Then, it can be used within the sh_binary rule in the app1/BUILD file:

Python
sh_binary(
    name = "container",
    srcs = ["run_container.sh"],
    data = [":image"],
)

Now, when you call:

Bash
bazel run //app1:container

Bazel will handle running the specified container:

MDX
INFO: Analyzed target //app1:container (0 packages loaded, 24 targets configured).
INFO: Found 1 target...
Target //app1:container up-to-date:
  bazel-bin/app1/container
INFO: Elapsed time: 0.275s, Critical Path: 0.09s
INFO: 5 processes: 5 internal.
INFO: Build completed successfully, 5 total actions
INFO: Running command line: bazel-bin/app1/container
Configuration from app 1: {'Hello': 'World'}

Note for beginners: target //app1:container will not detect any changes done in the image (e.g. in the application code), so with such setup, if you want to run the newest version of the image, you must make sure that bazel run //app1:image has been called before running bazel run //app1:container.

Don’t miss next tips!

By joining you agree with our privacy policy. You may unsubscribe at any time.