Docker Buildx Bake + Gitlab CI Matrix
Does building custom images with different build arguments or building for multiple architecture sound familiar? Well, this is the situation where Docker bake comes to the rescue. A new way to have more control over the creation of images.
In this article, you can expect to get familiar with Docker Bake and Gitlab CI Matrix. Also, an example is provided to grasp how to use these tools.
Introduction
Bake is a high-level building tool to support building images in parallel and lets the user define project-specific reusable build flows that anyone can easily invoke. On the other hand, the GitLab matrix enables you to run multiple jobs in parallel. With the power of these two, you can build a very flexible and customized pipeline for creating images.
To provide a better understanding, I will use a problem I solved using Docker bake and GitLab matrix. Consider a situation where you are maintaining multiple custom images and want to push them to various registries in an automated process. We will learn these concepts aligned with this example, but you can extend and adopt them for your needs. Let’s see how we can do that.
Docker Buildx Bake
Docker Bake gives you more control over building your images by defining arguments such as dockerfile, tags, and platforms for each build called a target. Each target corresponds to a unique build. Targets run separately but can be run in parallel using groups. Moreover, You have a Bake file where you specify different targets or groups for creating images, which can be run later. Bake files are usually JSON or HCL files.
Taking the following bake file as an example, tags ${REGISTRY}golang:1.19, ${REGISTRY}golang:1.19-alpine will be built from the given dockerfile if you bake the target “go-1–19”.
The REGISTRY is a variable with default value, used in case it is not provided, which is specified while building the target.
variable "REGISTRY" {
default = ""
}
target "go-1-19" {
dockerfile = "./Dockerfile"
tags = ["${REGISTRY}golang:1.19", "${REGISTRY}golang:1.19-alpine"]
args = {
IMAGE = "golang:1.19.3-alpine3.16"
}
}
Another way to build targets is to gather them into groups and build targets in parallel. The default group is called when no group or target is specified in the bake command. You can define groups in the following manner:
group "default" {
targets = ["alp-1-16", "go-1-19", "go-1-18", "go-1-17"]
}
group "golang" {
targets = ["go-1-19", "go-1-18", "go-1-17"]
}
group "alpine" {
targets = ["alp-1-16"]
}
To build any target, we can use the following command:
docker buildx bake -f bake.hcl <TARGET|GROUP>
# if <TARGET|GROUP> is not specified, the "default" group will be used
Gitlab CI Matrix
Matrix is a handy feature when running multiple jobs in parallel. You define the matrix variable, an array, in each stage. the script will run to combine all the defined variables in each element of the matrix array.
Here the matrix has a single element, which has build and registry attributes and runs the script for every combination between build and registry.
build:
extends: .build
script:
- echo "registry=${REGISTRY} , build=${BUILD}"
parallel:
matrix:
- BUILD: ['1-19', '1-18', '1-17']
REGISTRY: ['github-registry', 'private-registry']
# output
# job1 -> registry=github-registry , build=1-19
# job2 -> registry=github-registry , build=1-18
# job3 -> registry=github-registry , build=1-17
# job4 -> registry=private-registry , build=1-19
# job5 -> registry=private-registry , build=1-18
# job6 -> registry=private-registry , build=1-17
Glue Everything Together
To review everything with an example, we have Golang and Alpine Images with three and one versions, respectively. We will use a single dockerfile as they only differ in the image, and the image is defined as an argument, which will be passed to the dockerfile in build time.
Dockerfile
ARG IMAGE=golang:latest
FROM $IMAGE
LABEL maintainer="mehditeymorian322[at]gmail.com"
RUN apk --no-cache --update add \
bash \
git \
build-base \
openssh-client \
make \
curl \
Next, we need to create a bake.hcl file to define groups and targets to achieve desired builds.
The REGISTRY variable is used to tag the images after the dockerfile build. Its value will be overwritten if an environment variable exists with the same name as the variable while running the bake command.
Next, we have the groups. The default group will run if nothing is specified with the bake command. The golang and alpine groups will build different versions of Golang and Alpine images, respectively.
At last, we have targets defined for the custom builds we need. For example, target go-1-19 will build the dockerfile from golang:1.19.3-alpine3.16 and create ${REGISTRY}golang:1.19 and ${REGISTRY}golang:1.18-alpine tags. The same procedure will happen for other targets too.
For the sake of simplicity, we only use a couple of target attributes, but there are plenty more you can check here.
variable "REGISTRY" {
default = ""
}
group "default" {
targets = ["alp-3-16", "go-1-19", "go-1-18", "go-1-17"]
}
group "golang" {
targets = ["go-1-19", "go-1-18", "go-1-17"]
}
group "alpine" {
targets = ["alp-3-16"]
}
target "go-1-19" {
dockerfile = "./Dockerfile"
tags = ["${REGISTRY}golang:1.19", "${REGISTRY}golang:1.19-alpine"]
args = {
IMAGE = "golang:1.19.3-alpine3.16"
}
}
target "go-1-18" {
dockerfile = "./Dockerfile"
tags = ["${REGISTRY}golang:1.18", "${REGISTRY}golang:1.18-alpine"]
args = {
IMAGE = "golang:1.18.7-alpine3.16"
}
}
target "go-1-17" {
dockerfile = "./Dockerfile"
tags = ["${REGISTRY}golang:1.17", "${REGISTRY}golang:1.17-alpine"]
args = {
IMAGE = "golang:1.17-alpine3.14"
}
}
target "alp-3-16" {
dockerfile = "./Dockerfile"
tags = ["${REGISTRY}alpine:3.16"]
args = {
IMAGE = "alpine:3.16"
}
}
The last piece is the Gitlab Pipeline. In the build stage, we have two registries and multiple targets for golang and alpine images. Custom images are built, tagged, and pushed to the registry for each combination.
Note that the REGISTRY=${REGISTRY}/ is an environment variable that overwrites the variable REGISTRY in the bake.hcl file. Also, there is a when manual condition on the build stage which pauses all the jobs and gives you control over running them. This can be deleted if you want to automate the building process.
Because registries might have different usernames and passwords, The rule is required to update the authentication info based on the registry.
stages:
- build
.build:
image: docker:latest
stage: build
when: manual
before_script:
- docker info
script:
- docker login -u ${REGISTRY_USERNAME} -p ${REGISTRY_PASSWORD} ${REGISTRY}
- REGISTRY=${REGISTRY}/ docker buildx bake --progress tty --push -f bake.hcl $BUILD
build:
extends: .build
rules:
- if: $REGISTRY == 'github-registry'
variables:
REGISTRY_USERNAME: ${CI_GITHUB_REGISTRY_USER}
REGISTRY_PASSWORD: ${CI_GITHUB_REGISTRY_PASSWORD}
- if: $REGISTRY == 'private-registry'
variables:
REGISTRY_USERNAME: ${CI_PRIVATE_REGISTRY_USER}
REGISTRY_PASSWORD: ${CI_PRIVATE_REGISTRY_PASSWORD}
parallel:
matrix:
- BUILD: ['alp-3.-16', '1-19', '1-18', '1-17']
REGISTRY: 'github-registry'
- BUILD: ['alp-3.-16', '1-19', '1-18', '1-17']
REGISTRY: 'private-registry'
The pipeline looks like this at the end:
Summary
Docker Bake facilitates building complex images. On the other hand, Gitlab CI Matrix gives you a parallel option for running jobs. With the power of both of them, you have more flexibility over doing complex building pipelines.
In this example, we had multiple images built from a single dockerfile to push to different registries. We solved the image-building process by Docker Bake and handled multiple registries with Gitlab CI Matrix. Indeed, we could have done the job without these tools, but the cost and effort required to do so were higher.
You can find the project on my Github. Link to Project
Thank you for reading. Here are some helpful links to learn more about Docker bake and GitLab ci matrix.