GoLang: Using multi-stage builds to create clean Docker images

The Go programming language is a natural fit for containers because it can compile down to a single statically-linked binary.  And if you place that single executable on top of scratch, a distroless image, or a small image like alpine, your final image has a minimal footprint which is great for consumption and reuse.

But the scenario above is predicated on the host machine doing the compilation of the Go source code, and having all the Go build tools and external packages so that it can produce that final executable; which it then copies onto the image.

However, this model is not a good fit for an automated CI/CD pipeline.  For an automated pipeline, it is better to have the source code copied into a base container that contains the exact set of build tools, packages, and dependencies that can then faithfully produce the final executable.

The only problem now is that the image produced may have tens or hundreds of megabytes of build tools and cruft sitting inside, while all we need is the final executable.

Luckily, multi-stage builds are able to address this issue.  We can use a temporary intermediate image to do the build/compilation/linking, and then take the result and copy it into a clean final image that only contains the final executable.

Multi-stage image build

The key to this work is in the Dockerfile.  See my example below from my golang-memtest project on github.

The first FROM statement  uses an alias “as builder” name so we can reference it later in the file.  This is the intermediate layer where the Go build tools and compilation happen.  It is about 360Mb.

The second FROM is a clean version of alpine, where we simply COPY “–from=builder” to get the executable from the intermediate layer.  This final image is the one presented to end users of the service, and only weighs 8Mb.

# builder image
FROM golang:1.13-alpine3.11 as builder
RUN mkdir /build
ADD *.go /build/
WORKDIR /build
RUN CGO_ENABLED=0 GOOS=linux go build -a -o golang-memtest .


# generate clean, final image for end users
FROM alpine:3.11.3
COPY --from=builder /build/golang-memtest .

# executable
ENTRYPOINT [ "./golang-memtest" ]
# arguments that can be overridden
CMD [ "3", "300" ]

Github project

If you want to see this project at work, pull it from my github repository.

# get source code, install make utility
git clone https://github.com/fabianlee/golang-memtest.git
cd golang-memtest
sudo apt-get install make -y

# do multi-stage build (make docker-build)
sudo docker build -f Dockerfile -t fabianlee/golang-memtest:1.0.0 .

# run test (make docker-test)
sudo docker run -it --rm fabianlee/golang-memtest:1.0.0

Notice that you do not need any of the Go language compilers or tools in order to run this test.   The intermediate layer “golang:1.13-alpine3.11”, contains all the necessary tools to construct the executable.

You could have just as easily based the final image on scratch or distroless but I prefer to have the convenience of alpine.

 

REFERENCES

docker, multi-stage builds

cloudreach, golang Dockerfile for multi-stage

pete-woods, multi-stage docker using maven

ops.tips, Dockerfile and Golang, good tips on module and vendor dependencies

chemidy, google distroless like scratch but: ca-certs, /etc/passwd, /tmp, tzdata and :debug modes

tutorialedge Elliot Forbes, go multi-stage tutorial

geshan.com, multi-stage builds for node.js

katacoda, multi-stage docker builds 10min tutorial

oprearocks, how to override ENTRYPOINT

stackoverflow, result matrix of combinations for CMD/ENTRYPOINT

NOTES

downloads all go dependencies (-u looks for updates to existing, -v verbose, -f run fix on download pkgs)

go get -u -v -f all

List of direct dependencies, then all dependencies

go list -f '{{join .Imports "\n" }}'
go list -f '{{join .Deps "\n" }}'