How to build multi-platform Docker image with sbt and Docker buildx
Recently, I had to add a Docker image supporting ARM architecture in our ElasticMQ project. It’s possible with buildx — a Docker CLI plugin that extends the Docker build command. In this post, I will show you how to automate creating Docker images for different architectures using sbt-native-packager.
Firstly, what we need is the SBT Native Packager plugin, so add it in project/plugins.sbt
:
addSbtPlugin(“com.typesafe.sbt” % “sbt-native-packager” % “1.8.1”)
and also docker plugin, so moving to build.sbt
:
.enablePlugins(DockerPlugin)
We may provide many additional settings like dockerRepository
or dockerUsername
to change the repository or username to which the image is pushed when the docker:publish
task is run. We may also override the Docker build options dockerBuildOptions
which are passed to docker build
command and by default is
Seq("--force-rm", "-t", "[dockerAlias]")
The Docker plugin provides many useful commands like docker:stage
to generate a directory with the Dockerfile and an environment prepared for creating a Docker image. Running docker:publishLocal
builds an image using the local Docker server. If you want to build and also push an image to the configured remote repository — simply run docker:publish
. But inspecting the image Architecture
will reveal that we have a single value like amd64
. To extend it — let’s use buildx!
Docker Buildx is included in Docker Desktop and Docker Linux packages when installed using the DEB or RPM packages. You can check if it is already installed running docker buildx version
. To use it, we may set buildx as the default builder with docker buildx install
. This results in the ability to have docker build use the current buildx builder. Or we may create a new builder instance with
docker builder create --use --name multi-arch-builder
Now let’s switch to build.sbt
and see the final configuration. I will analyze its parts afterwards.
lazy val ensureDockerBuildx = taskKey[Unit]("Ensure that docker buildx configuration exists")
lazy val dockerBuildWithBuildx = taskKey[Unit]("Build docker images using buildx")
lazy val dockerBuildxSettings = Seq(
ensureDockerBuildx := {
if (Process("docker buildx inspect multi-arch-builder").! == 1) {
Process("docker buildx create --use --name multi-arch-builder", baseDirectory.value).!
}
},
dockerBuildWithBuildx := {
streams.value.log("Building and pushing image with Buildx")
dockerAliases.value.foreach(
alias => Process("docker buildx build --platform=linux/arm64,linux/amd64 --push -t " +
alias + " .", baseDirectory.value / "target" / "docker"/ "stage").!
)
},
publish in Docker := Def.sequential(
publishLocal in Docker,
ensureDockerBuildx,
dockerBuildWithBuildx
).value
)
Finally value dockerBuildxSettings
is provided for an appropriate project type like that:
.settings(dockerBuildxSettings)
The first part defines the ensureDockerBuildx
task. It is just creating a new buildx instance. Before that, we are checking whether the instance already exists. Calling Process(...).!
results with an Int
. In that case, 0
means that the instance exists. For 1
— we should create a new one.
The second part defines dockerBuildWithBuildx
— that is the main action. Here for every dockerAlias
we have to execute building and pushing the image. Each docker alias has the form of:
[dockerRepository/][dockerUsername/][packageName]:[version]
In our case, we have two aliases because of two (versions) tags: 1.1.1
and latest
. To specify the target platform, we have to provide flag with the desired values:
--platform=linux/arm64,linux/amd64
Since we are using a docker-container
driver with buildx
(thanks to creating a new buildx instance), the flag --platform
can accept multiple values as an input separated by a comma. We are also providing the flag --push
to push images to the remote repository. It’s shorthand for --output=type=registry
. You have to remember that images built like that won’t be available when you type docker images
! If you want to load the build result to docker images
use the flag --load
instead of --push
. However, that works only for single-platform builds.
We are executing building and pushing in the directory /target/docker/stage
because there is a Dockerfile generated by the command docker:publishLocal
.
Lastly, we are redefining the task docker:publish
. Using the Def.sequential
function, we can run tasks under semi-sequential semantics. In the beginning, we need to execute Docker / publishLocal
to create the needed Dockerfile. Remember that the base image in Dockerfile must also support configured architectures with flag --platform
. Next, there are ensureDockerBuildx
and dockerBuildWithBuildx
. After that, executing docker:publish
will generate all images for configured architectures.
Hope that it is clear and helpful. For more information about Docker plugin for sbt and buildx, you may find documentation here and here.