Photo by Markus Spiske on Unsplash
Multi-stage Docker builds can greatly reduce the size of final built images, and the savings can be extreme with Go.
It's important to keep the size of Docker images small as it reduces the size that needs to be published, hosted, and downloaded. Publishing is usually infrequent, and hosting is free through a number of container registries, so those aren't a big deal - but the download size will affect everyone and every cluster that wants to use your image. As a general rule of thumb, reducing the number of layers in a final image helps reduce the size.
Docker has a feature called multi-stage builds where multiple FROM
instructions can be used in a single Dockerfile, each with an optional alias. Each stage can use a different base (e.g. golang:1.14
, node:lts
, alpine:latest
, etc.), and each stage can copy files from previous stages with the COPY
instruction.
Go is a great candidate for multi-stage Docker builds because compiled Go binaries are statically-linked. That means you can execute the same, single binary on its own in other similar environments - like containers!
Getting started with Go
Let's go ahead and create the simplest main.go
we can, a hello world program:
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
And assuming you have Go installed locally, running the file will produce the expected output:
$ go run main.go
hello world
Single-stage build
Create a Dockerfile
that compiles our Go application and sets it up to be run with docker run
:
FROM golang:1.14.4-alpine
WORKDIR /go/src/app
COPY main.go .
RUN go build -o /go/bin/app main.go
CMD ["app"]
Build the image and tag it as example
:
$ docker build -t example .
...
Successfully tagged example:latest
And then running the container will produce the expected output:
$ docker run example
hello world
But let's check out the size of the final image we built:
$ docker image ls example
REPOSITORY TAG IMAGE ID CREATED SIZE
example latest 9bc90e495f18 9 seconds ago 372MB
372MB! That's astronomically large for such a small application. That's because the base image is so large due to all the Go binaries and libraries:
$ docker run golang:1.14.4-alpine sh -c "du -sh /usr/local/go/*"
56.0K /usr/local/go/AUTHORS
4.0K /usr/local/go/CONTRIBUTING.md
88.0K /usr/local/go/CONTRIBUTORS
4.0K /usr/local/go/LICENSE
4.0K /usr/local/go/PATENTS
4.0K /usr/local/go/README.md
4.0K /usr/local/go/SECURITY.md
4.0K /usr/local/go/VERSION
6.9M /usr/local/go/api
18.0M /usr/local/go/bin
4.2M /usr/local/go/doc
8.0K /usr/local/go/favicon.ico
780.0K /usr/local/go/lib
4.8M /usr/local/go/misc
238.7M /usr/local/go/pkg
4.0K /usr/local/go/robots.txt
88.2M /usr/local/go/src
13.3M /usr/local/go/test
Let's find out how many layers our image has:
$ docker inspect example --format '{{range .RootFS.Layers}}{{println .}}{{end}}'
sha256:50644c29ef5a27c9a40c393a73ece2479de78325cae7d762ef3cdc19bf42dd0a
sha256:0f7493e3a35bab1679e587b41b353b041dca1e7043be230670969703f28a1d83
sha256:1ba1431fe2ba3d4eb50dfcc11980e8f6146fa71f67bd3eb30d3b82e77fb3cdc9
sha256:35d1ab51a96b944692fe512bebb6e8a0303534d76257c3750f721bd16919e219
sha256:82a15a1dbd15547643e8602a025316323bd48236a0f972fe7628e1cd002139ea
sha256:3513f690faf76b8a7f6df1e00e6f5d061fa9b9bd5d5a7b4601895f4c95983c43
sha256:024c58834d196a578ca2cc5816e0d07a68694a4d5460c560d855718558f08978
sha256:36e38ccc3746d8a7355cd8a84bf2b95c9b961c38f7044948784bce077c655bc7
8 layers! Granted, 5 of them are coming from the base image - but that's a lot, especially for such a small application.
Multi-stage build
Time to figure out just how much space a multi-stage build could save us.
Change the Dockerfile
to add an extra FROM
instruction that will create a second stage that will COPY
the built binary from the first stage:
FROM golang:1.14.4-alpine AS builder
WORKDIR /go/src/app
COPY main.go .
RUN go build -o /go/bin/app main.go
FROM alpine:latest
COPY --from=builder /go/bin/app /usr/local/bin/app
CMD ["app"]
Build the image like before:
$ docker build -t example .
...
Successfully tagged example:latest
Make sure it has the same output:
$ docker run example
hello world
And look at the space-saving magic:
$ docker image ls example
REPOSITORY TAG IMAGE ID CREATED SIZE
example latest ecd4076f7d37 About a minute ago 7.64MB
7.64MB! Roughly 2% of the size it was before!
What about how many layers that is:
$ docker inspect example --format '{{range .RootFS.Layers}}{{println .}}{{end}}'
sha256:50644c29ef5a27c9a40c393a73ece2479de78325cae7d762ef3cdc19bf42dd0a
sha256:0c2a04464c15287d9674ad2bccdcc5b1e15e05eb53e465595da78856468f1c5e
Only 2! One from the base image and one from our second build stage. That's also a huge savings.
Summary
Multi-stage builds help cut out a lot of unnecessary files and reduce final image sizes. They work especially well with Go because of its static linking, though it's possible to use them with other compiled languages as well.