Docker Bake is here!

Docker Bake is here!

Docker just announced that Docker Bake has reached the GA. And that is huge news! As a docker captain and containers fan in general, I've already experimented with it in the past, and in my personal view, it's amazing.

In this blog post, I'm going to start with a simple example and then offer one complex solution for very specific problem at the end.

What is Bake?

Bake is a feature of Docker Buildx that lets you define your build configuration using a declarative file, as opposed to specifying a complex CLI expression. It also lets you run multiple builds concurrently with a single invocation.

A Bake file can be written in HCL, JSON, or YAML formats, where the YAML format is an extension of a Docker Compose file.

Bake
″”

Simple example

In this example, we are defining two different targets, using two different Dockerfiles to build both the frontend and the backend. This flow is very useful in a monorepo scenarios.

group "default" {
  targets = ["frontend", "backend"]
}

target "frontend" {
  context = "./frontend"
  dockerfile = "frontend.Dockerfile"
  args = {
    NODE_VERSION = "22"
  }
  tags = ["myapp/frontend:latest"]
}

target "backend" {
  context = "./backend"
  dockerfile = "backend.Dockerfile"
  args = {
    GO_VERSION = "1.23"
  }
  tags = ["myapp/backend:latest"]
}

This file allows to build both images with a single execution of docker buildx bake, and if you want to push images to the registry, just append the --push in the end.

As a comparison, to achieve the same with simple docker commands:

# Usual docker commands

docker build -t myapp/frontend:latest -f frontend.Dockerfile ./frontend
docker build -t myapp/backend:latest -f backend.Dockerfile ./backend

# The buildx

docker buildx bake

It get's even better if you are building cross-platform images.

Cross-platform image

In this example, we are going to make a simple Go based web server and compile it for both ARM and AMD64 architectures.

Let's start by making basic HTTP server.

package main

import (
	"fmt"
	"net/http"
	"runtime"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello from %s/%s\n", runtime.GOOS, runtime.GOARCH)
}

func main() {
	http.HandleFunc("/", handler)
	http.ListenAndServe(":8080", nil)
}

main.go

And a Dockerfile

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM golang:alpine AS build

ARG TARGETOS
ARG TARGETARCH

WORKDIR /app
COPY . .

RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o server .

FROM alpine
COPY --from=build /app/server /server

ENTRYPOINT ["/server"]

Dockerfile

Last part is the docker-bake.hcl

group "default" {
  targets = ["multi-platform"]
}

target "multi-platform" {
  platforms = ["linux/amd64", "linux/arm64"]
  output = ["type=image"]
}

docker-bake.hcl

Once we have all these pieces in place, a simple docker buildx bake would make a cross-platform image which can run both on ARM64 and AMD64.

Getting artifacts out of docker build to the host

In some cases, you might want to extract build artifacts back to the host system. For example, in CI enviroments building the code, running the unit tests, or sonarqube analysis, you want to extract these results back to the host which did the build (CI/CD machine). With standard docker commands it's quite complicated, but the docker bake here helps a lot.

With a simple extension of previous docker-bake.hcl by adding new target, with output of type=local we can get stuff back

target "bin" {
  platforms = ["linux/amd64", "linux/arm64"]
  output = ["type=local,dest=./build/bin"]
}

Now, if we build and check the working dir, we should have binaries available.

building multiple bake targets at once, producing both docker image & binaries on the host

Another use case, quite common in CI/CD environments - build a web app, run unit tests and static analysis tools.

Performing these steps in a multi-stage Docker build is not very hard. The issue is way bigger if you need some of these reports (xml/json/etc). to be available on the host machine, so the CI/CD system can access and process them.

Spring-Boot scenario

In my work, I work a lot with SpringBoot & Atlassian Bamboo, and we all know how "flexible" bamboo is.

And docker bake makes this task so simple. For this demo, we are going to download a basic SpringBoot app using start.spring.io create the Dockerfile & docker-bake.hcl definition

# syntax=docker/dockerfile:1

# Base build stage
FROM maven:3.8.6-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline

COPY src/ ./src/

# Main application build target
FROM build AS application
RUN mvn clean package -DskipTests

# Test execution target
FROM build AS tests
RUN mvn test

# Gather test results in scratch image
FROM scratch AS test-results
COPY --from=tests /app/target/surefire-reports/ /

# SonarQube analysis target
FROM build AS sonar
ARG SONAR_TOKEN
ARG SONAR_PROJECT_KEY
ARG SONAR_SERVER
RUN mvn sonar:sonar \
    -Dsonar.projectKey=${SONAR_PROJECT_KEY} \
    -Dsonar.host.url=${SONAR_SERVER} \
    -Dsonar.login=${SONAR_TOKEN}


FROM scratch AS sonar-results
COPY --from=sonar /app/target/sonar/ /

As you see, we have a few targets, one is building the actual application, second one is running all the tests, and yet another is running sonar scan. I've added a few extra targets from scratch just to have the target with only the results of the scans.

variable "SONAR_TOKEN" {}
group "default" {
#  targets = ["build", "tests", "sonar"]
  targets = ["build", "tests"]
}

target "build" {
  dockerfile = "Dockerfile"
  target = "application"
  tags = ["spring-app:latest"]
}

target "tests" {
  dockerfile = "Dockerfile"
  target = "test-results"
  output = ["type=local,dest=./test-results"]
}

target "sonar" {
  dockerfile = "Dockerfile"
  target = "sonar-results"
  output = ["type=local,dest=./sonar-reports"]
  args = {
    SONAR_TOKEN = "${SONAR_TOKEN}"
    SONAR_PROJECT_KEY = "my-spring-app"
    SONAR_SERVER = "https://sonarqube.example.com" # http://localhost:9000
  }
  depends_on = ["tests"]
}

Now, this docker-bake definition allows us to use simple docker buildx bake to perform all the actions, and bring back the results back to the host system. Isn't that cool?

Cross-compiling against shared libraries

Have you ever had to build an app againsts system libraries? Like using glibc versus musl, or simply referencing librdkafka / opencv / tesseract?

Most of the OS'es ship different versions of these libraries, and compiling a binary against them – requires a build environment on each of them, after which you need to collect all the artefacts, and ship them in single place. Imagine a scenario were you need to build a set of binaries for each major OS / release / arch - Like two latest releases of Debian, Ubuntu, Rocky, Alpine on both architectures...

One of examples - is libtesseract. Let's try to build minimal proof-of-concept app. We going to start with simple golang app.

package main

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"

	"github.com/otiai10/gosseract/v2"
	log "github.com/sirupsen/logrus"
)

func downloadFile(filepath string, url string) error {
	log.Infof("Downloading: %v", url)
	// Get the data
	resp, err := http.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// Create the file
	out, err := os.Create(filepath)
	if err != nil {
		return err
	}
	defer out.Close()

	// Write the body to file
	_, err = io.Copy(out, resp.Body)
	return err
}

func parseTextFromFile(imageFileName string, tessdata string) (string, error) {
	client := gosseract.NewClient()

	_ = client.SetTessdataPrefix(tessdata)
	_ = client.SetLanguage("eng")
	_ = client.SetPageSegMode(gosseract.PageSegMode(gosseract.PSM_OSD_ONLY))

	defer client.Close()

	_ = client.SetImage(imageFileName)

	text, err := client.Text()
	if err != nil {
		return "", err
	}
	return text, nil
}

func downloadTesseractData() {
	for _, lang := range []string{"eng"} {
		path := filepath.Join(".", fmt.Sprintf("%v.traineddata", lang))
		if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
			_ = downloadFile(path, fmt.Sprintf("https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/%v.traineddata", lang))
		}
	}
}

func main() {
	downloadTesseractData()
	for _, arg := range os.Args[1:] {
		text, _ := parseTextFromFile(arg, ".")
		log.Infof("[%s] %s", filepath.Base(arg), text)
	}
}

In order to build this for different OS'es, we need a bit complicated Dockerfile.

# syntax=docker/dockerfile:1

ARG OS_FAMILY=debian
ARG OS_VERSION=bullseye

FROM ${OS_FAMILY}:${OS_VERSION} AS builder
ARG OS_FAMILY
ARG OS_VERSION
ARG PACKAGES

# OS-specific package installation
RUN if [ "${OS_FAMILY}" = "debian" ] || [ "${OS_FAMILY}" = "ubuntu" ]; then \
        apt-get update && apt-get install --no-install-recommends -y ${PACKAGES} \
        ; \
    elif [ "${OS_FAMILY}" = "alpine" ]; then \
        apk add --no-cache ${PACKAGES}; \
    elif [ "${OS_FAMILY}" = "rockylinux" ]; then \
        yum install -y epel-release; crb enable; \
        yum install -y ${PACKAGES}; \
    fi

# Set Go version
ARG GO_VERSION=1.23.5

# Determine architecture
ARG TARGETARCH
RUN wget https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz && \
    tar -C /usr/local -xzf go${GO_VERSION}.linux-${TARGETARCH}.tar.gz && \
    rm go${GO_VERSION}.linux-${TARGETARCH}.tar.gz

# Set Go environment variables
ENV PATH="/usr/local/go/bin:${PATH}"
ENV GOPATH="/go"
ENV PATH="${GOPATH}/bin:${PATH}"

# Verify installation
RUN go version

WORKDIR /app
COPY . .
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -v -ldflags="-s -w" -o myapp .

FROM scratch AS bin
COPY --from=builder /app/myapp /

FROM ${OS_FAMILY}:${OS_VERSION} AS runtime
ARG OS_FAMILY
ARG OS_VERSION
ARG RUNTIME_PACKAGES

RUN if [ "${OS_FAMILY}" = "debian" ] || [ "${OS_FAMILY}" = "ubuntu" ]; then \
        apt-get update && apt-get install --no-install-recommends -y ${RUNTIME_PACKAGES} \
        ; \
    elif [ "${OS_FAMILY}" = "alpine" ]; then \
        apk add --no-cache ${RUNTIME_PACKAGES}; \
    elif [ "${OS_FAMILY}" = "rockylinux" ]; then \
        yum install -y ${RUNTIME_PACKAGES}; \
    fi


COPY --from=builder /app/myapp /usr/bin/mypp

In this Dockerfile, we have 3 targets - the one which does the build, one for exporting binaries to host, and one for resulting image without dev tools, but with required libs installed.

To build all these, we need more complex docker-bake.hcl:

group "default" {
  targets = ["binaries", "images"]
}

variable "flavors" {
  default = [
    {os_family = "ubuntu", os_version = "noble", packages = "wget ca-certificates build-essential pkg-config libtesseract-dev", runtime_packages = "libtesseract5"},
    {os_family = "debian", os_version = "bookworm", packages = "wget ca-certificates build-essential pkg-config libtesseract-dev", runtime_packages = "libtesseract5"},
    {os_family = "debian", os_version = "bullseye", packages = "wget ca-certificates build-essential pkg-config libtesseract-dev", runtime_packages = "libtesseract4"},
    {os_family = "rockylinux", os_version = "9", packages = "wget gcc g++ pkgconfig tesseract-devel", runtime_packages = "tesseract"},
    {os_family = "alpine", os_version = "latest", packages = "wget build-base pkgconf tesseract-ocr-dev", runtime_packages = "tesseract-ocr"},
  ]
}

target "base" {
  dockerfile = "Dockerfile"
  platforms = [
    "linux/amd64",
    "linux/arm64",
  ]
}

target "binaries" {
  inherits = ["base"]
  
  matrix = {
    item = flavors
  }

  args = {
    OS_FAMILY = item.os_family,
    OS_VERSION = item.os_version,
    PACKAGES = item.packages,
    RUNTIME_PACKAGES = item.runtime_packages,
    GO_VERSION = "1.23.5",
  }

  name = "bin-${item.os_family}-${item.os_version}"
  description = "Build binary for ${item.os_family}/${item.os_version}"
  target = "bin"

  output = [
    "type=local,dest=./artifacts/${item.os_family}-${item.os_version}",
  ]
}

target "images" {
  inherits = ["base"]
  
  matrix = {
    item = flavors
  }

  args = {
    OS_FAMILY = item.os_family,
    OS_VERSION = item.os_version,
    PACKAGES = item.packages,
    RUNTIME_PACKAGES = item.runtime_packages,
    GO_VERSION = "1.23.5",
  }

  labels = {
    "org.opencontainers.image.source" = "https://github.com/xor22h/docker-bake-quickstart"
  }

  name = "image-${item.os_family}-${item.os_version}"
  description = "Build image for ${item.os_family}/${item.os_version}"
  tags = ["ghcr.io/xor22h/docker-bake-quickstart/app:${item.os_family}-${item.os_version}"]
}

Now, without default target this definition has two primary targets:

  • images to build the docker containers for each OS/Version
  • binaries to build the os specific binaries.

This allows us to:

# build both binaries & images but don't push them
docker buildx bake 

# build binaries only
docker buildx bake binaries

# build & push images
docker buildx bake images --push

In conclusion, Docker Bake offers a powerful and flexible way to define and execute complex build processes, making it an invaluable tool for developers and DevOps professionals working with containerized applications.

Find more about bake in official announcement

Docker Bake: Now Generally Available | Docker
Learn more about the new release of Docker Bake, including what it is, how it simplifies complex builds, and where to get started.

You can find full code for examples above in the github repository.

GitHub - xor22h/docker-bake-quickstart
Contribute to xor22h/docker-bake-quickstart development by creating an account on GitHub.