How to build multi-platform Docker image with sbt and Docker buildx

Piotr Rojek

17 Jun 2021.3 minutes read

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.

Blog Comments powered by Disqus.