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:
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:
# 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.
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:
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:
{
"Hello": "World"
}
Similarly, the content of app2/some_config.json is:
{
"Bye": "World"
}
BUILD files
Both app1/BUILD and app2/BUILD files look similar with the only difference being the application name:
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:
bazel run //app1:image
This will produce the following output:
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:
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 usingbazel run
. Callingbazel build
on these targets will not load the images to the local runtime.
After the build is done, you can run:
docker images
to check that 2 new images appeared:
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.:
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:
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:
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:
#!/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:
sh_binary(
name = "container",
srcs = ["run_container.sh"],
data = [":image"],
)
Now, when you call:
bazel run //app1:container
Bazel will handle running the specified container:
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 thatbazel run //app1:image
has been called before runningbazel run //app1:container
.