Building cross CPU Docker images for Go programs is not a trivial task.
With the excellent Go compiler and the recent improvements of Docker building, quite an advanced setup can be achieved to build Docker images for all CPU architectures supported by Docker and Go.
What we’ll do
We will design a Dockerfile cross building a simple Go program for Docker images supporting multiple CPU architectures.
The aim is to have the statically compiled Go program in a final Docker image based on the alpine:3.13
image.
Initial setup
You should have this minimal file structure at the end of this section:
.
├── Dockerfile
└── main.go
Go program
Our Go program will just be main.go
:
package mainz
import "fmt"
func main() {
fmt.Println("Hello world")
}
Dockerfile
Let’s start with an initial Dockerfile
ARG GO_VERSION=1.16
ARG ALPINE_VERSION=3.13
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gobuilder
WORKDIR /tmp/build
COPY main.go .
RUN go build -o app
FROM alpine:${ALPINE_VERSION}
ENTRYPOINT [ "/usr/local/bin/app" ]
COPY --from=gobuilder /tmp/build/app /usr/local/bin/app
Go cross CPU building
Not only the Go compiler gc
is very fast, it’s also able to cross compile programs very easily.
If you want to compile your program for arm64
for example, you just need to set GOARCH=arm64
.
For example:
GOARCH=arm64 go build -o app
Docker cross CPU building
Docker can cross build docker images using the --platform
flag.
For example:
docker build --platform=linux/arm64 .
will build our image for arm64
.
However, this runs go build
by fully emulating the build process using QEMU.
Instead, we should take advantage of Go’s cross CPU building which is much faster.
BUILDPLATFORM
build argument
The BUILDPLATFORM
build argument is injected by docker build
when cross building with the --platform
flag.
It is the CPU architecture you are building on, for example linux/amd64
.
Modify your Dockerfile by adding ARG BUILDPLATFORM=linux/amd4
as well as --platform=${BUILDPLATFORM}
between the FROM
and the image name for our Go builder stage.
Your Dockerfile should look like:
# ...
ARG BUILDPLATFORM=linux/amd64
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gobuilder
# ...
Small note that you have to set BUILDPLATFORM
to a default for compatibility reasons.
This tells docker build
to run the gobuilder
stage on your native platform.
Because we leave the last stage without precising FROM --platform=...
, this one however will be emulated on the target platform.
Now the problem left is that the Go binary built will always be built for your build platform, and not the target platform.
This is where TARGETPLATFORM
, GOARCH
and GOARM
come into play!
TARGETPLATFORM
build argument
The TARGETPLATFORM
build argument is injected by docker build
when cross building.
It is your target CPU architecture, for example linux/arm/v7
when using --platform=linux/arm/v7
Modify your Dockerfile by adding ARG TARGETPLATFORM
inside the gobuilder
stage.
Your Dockerfile should look like:
# ...
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gobuilder
WORKDIR /tmp/build
ARG TARGETPLATFORM
# ...
Now we want to transform TARGETPLATFORM
in GOARCH
and GOARM
.
But here’s the problem, TARGETPLATFORM
can be one of:
linux/amd64
linux/386
linux/arm64
linux/arm/v7
linux/arm/v6
linux/ppc64le
linux/s390x
linux/riscv64
For ARMv6 and ARMv7, Go expected GOARCH=arm
and GOARM=6
or GOARM=7
.
To convert from one string to the two others, I wrote a small Go program: xcputranslate
A little Go static binary tool to convert Docker’s buildx CPU architectures such as linux/arm/v7 to strings for other compilers.
That way it removes a lot of potential horribly nested shell scripting in your Dockerfile.
Use it in your Dockerfile like so:
FROM --platform=${BUILDPLATFORM} qmcgaw/xcputranslate:v0.6.0 AS xcputranslate
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gobuilder
COPY --from=xcputranslate /xcputranslate /usr/local/bin/xcputranslate
WORKDIR /tmp/build
COPY main.go .
RUN GOARCH="$(xcputranslate translate -targetplatform ${TARGETPLATFORM} -language golang -field arch)" \
GOARM="$(xcputranslate translate -targetplatform ${TARGETPLATFORM} -language golang -field arm)" \
go build -o app
💁 Note that FROM --platform=${BUILDPLATFORM} qmcgaw/xcputranslate:v0.6.0 AS xcputranslate
pulls the binary for your build platform automagically, since there is an image built for each CPU architecture.
😢 Also note you cannot set GOARCH
or GOARM
as ENV
or ARG
in your Dockerfile since these are dynamically evaluated at build time.
Final Dockerfile
Your Dockerfile should be now:
ARG GO_VERSION=1.16
ARG ALPINE_VERSION=3.13
ARG BUILDPLATFORM=linux/amd64
FROM --platform=${BUILDPLATFORM} qmcgaw/xcputranslate:v0.6.0 AS xcputranslate
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS gobuilder
COPY --from=xcputranslate /xcputranslate /usr/local/bin/xcputranslate
WORKDIR /tmp/build
COPY main.go .
RUN GOARCH="$(xcputranslate translate -targetplatform ${TARGETPLATFORM} -language golang -field arch)" \
GOARM="$(xcputranslate translate -targetplatform ${TARGETPLATFORM} -language golang -field arm)" \
go build -o app
FROM alpine:${ALPINE_VERSION}
ENTRYPOINT [ "/usr/local/bin/app" ]
COPY --from=gobuilder /tmp/build/app /usr/local/bin/app
Try it
Let’s build it for the armv7 architecture for example:
docker build -t goarmv7 --platform=linux/arm/v7 .
Run it with emulation:
docker run -it --rm --platform=linux/arm/v7 goarmv7
And that should print out Hello world
🚀
Conclusion
You can now build cross CPU architecture Docker images by taking advantage of the Go cross compiler.
That reduced my Docker build times from 15 minutes to 5 minutes for github.com/qdm12/gluetun.
Enjoy the time saved!
Comments