Luis Rascão

Backend Developer @ Miniclip

View on GitHub
14 May 2021

Yet another post about reducing golang docker image build times

Yes, this is another post on how you can reduce golang docker build times.

There’s one thing that grinds my gears and that’s a long development workflow loop, a lot of posts tell you to go get a cup of coffee while something is compiling/building and i hate it. I want to get this shit done so THEN i go and get that cup of coffee. It’s very easy to lose focus of what you’re trying to accomplish if every time you say build you have to wait longer than 30s. There’s no reason to always be compiling Go packages when building the docker image when changed nothing in them and you’re just tweaking your application, so here’s a nifty trick to cut down on that.

Docker buildkit

You’ll want to be using it, as the doc says:

Docker Build enhancements for 18.09 release introduces a much-needed overhaul of the build architecture. By integrating BuildKit, users should see an improvement on performance, storage management, feature functionality, and security.

Docker images created with BuildKit can be pushed to Docker Hub just like Docker images created with legacy build the Dockerfile format that works on legacy build will also work with BuildKit builds The new –secret command line option allows the user to pass secret information for building new images with a specified Dockerfile

For that you can specify at the top of your Dockerfile:

# syntax = docker/dockerfile:experimental

And when building:

$ DOCKER_BUILDKIT=1 docker build -t tracer-service .

Build stages

Now we can make use of docker build stages that are cached and only rebuilt if something changes, the next snippet creates the initial build stage that derives out of the stock golang:1.16 image.

Notice how only three files get copied there: go.mod and go.sum that contain the declaration of your dependencies and a specially generated docker_deps.go file, when this is done we’ll have a layer that will only get rebuilt if you add more dependencies to your project.

# syntax = docker/dockerfile:experimental
FROM golang:1.16 as deps-build-env
LABEL stage=deps-build-env

WORKDIR /go/src/app

ADD go.mod go.sum /go/src/app

# docker_deps.go has been dinamically generated and
# contains all package imports of this project, eg:
#
# package main
# import _ "fmt"
# import _ "github.com/Bose/go-gin-opentracing"
# import _ "github.com/gin-gonic/gin"
# import _ "github.com/opentracing/opentracing-go"
# import _ "github.com/prometheus/client_golang/prometheus/promhttp"
# import _ "github.com/sirupsen/logrus"
# import _ "github.com/toorop/gin-logrus"
# import _ "net/http"
# import _ "os"
# func main() {}
ADD docker_deps.go /go/src/app

# download all the dependencies, build them and then delete docker_deps.go
RUN go mod download && go build -v

The only thing left to uncover now is how did this docker_deps.go file get generated, it’s just a bit of shell script around the json output of

go list -f "\{\{.Imports\}\}" ./...

We also need to inject an empty main entry point or the compiler will refuse to build and our packages won’t be compiled as well.

docker_deps.go:
	@echo "package main" > docker_deps.go
	@for dep in `go list -f "" ./... | tr --delete '[]'`; do echo "import _ \"$$dep\"" >> docker_deps.go; done
	@echo "func main() {}" >> docker_deps.go

We can now derive a new layer out of the previous one that will have all the dependency packages already built. Only the changes to your code will get built, that will decrease by a lot your build times and your workflow will be faster.

# now build the app re-using the deps layer
FROM deps-build-env as app-build-env
LABEL stage=app-build-env

ADD . /go/src/app
RUN rm -f docker_deps.go && \
    go build -v -o /go/bin/app
tags: