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
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" }}'