Building Docker Images for Static Go Binaries

Building applications in Go enables the ability to easily produce statically linked binaries free of external dependencies. Statically linked binaries are much larger than their dynamic counterparts, but often weigh in at less than 10 MB for most real-world applications. The reason for such large binary sizes are because everything, including the Go runtime, is included in the binary. The large binary size is a tradeoff I’m willing to make since I gain the ability to deploy applications by copying a single binary into place and executing it.

So I can’t help but ask, If the process of building and deploying static binaries is so easy, why would I want to bring Docker into the mix? Well, Docker does offer the convenience of a standardized packaging format that makes it easy to share, discover, and install applications. I see Docker images being similar to rpms in concept, with Docker images having the advantage of packaging up my entire application in a single artifact. Hmm… this sounds familiar.

Deploying applications with Docker brings the benefits of Linux containers. I essentially gain the ability to leverage Linux namespaces and cgroups for free.

After being sold on the benefits of using Docker for Go apps, I started building containers for them. I was a bit surprised by the initial results. To help illustrate my journey I’ll use an example application called Contributors. The Contributors app is a simple web frontend that lists the contributors, along with their avatar photos, for a given GitHub repository.

I, like many people, followed examples for building Docker images using a Dockerfile that looks something like this:

FROM google/debian:wheezy
MAINTAINER Kelsey Hightower <kelsey.hightower@gmail.com>
RUN apt-get update -y && apt-get install —no-install-recommends -y -q curl build-essential ca-certificates git mercurial
# Install Go
# Save the SHA1 checksum from http://golang.org/dl
RUN echo '9f9dfcbcb4fa126b2b66c0830dc733215f2f056e go1.3.src.tar.gz' > go1.3.src.tar.gz.sha1
RUN curl -O -s https://storage.googleapis.com/golang/go1.3.src.tar.gz
RUN sha1sum —check go1.3.src.tar.gz.sha1
RUN tar -xzf go1.3.src.tar.gz -C /usr/local
ENV PATH /usr/local/go/bin:$PATH
ENV GOPATH /gopath
RUN cd /usr/local/go/src && ./make.bash —no-clean 2>&1
WORKDIR /gopath/src/github.com/kelseyhightower/contributors
# Build the Contributors application
RUN mkdir -p /gopath/src/github.com/kelseyhightower/contributors
ADD . /gopath/src/github.com/kelseyhightower/contributors
RUN CGO_ENABLED=0 GOOS=linux go build -a -tags netgo -ldflags '-w' .
RUN cp contributors /contributors
ENV PORT 80
EXPOSE 80
ENTRYPOINT ["/contributors"]

The above Dockerfile combines the Docker image creation process with the application build process. While this workflow has its advantages I consider it unnecessary when working with statically linked binaries. I personally build my applications in CI and save the resulting binaries as artifacts of the build, which allows me to package my application in any format including a Docker image, rpm, or tarball.

Another drawback to building the application during the image creation process is the size of the resulting Docker image. The above Dockerfile produces a Docker image that checks in around 500 MB in size. Earlier I mentioned that sized does not matter, but at almost 500 MB we better start paying attention.

Why is the Docker image so big?

Building the Contributor app as part of the image creation process means we must set up a working build environment including all the tools required to download and build the Go runtime. This requirement locks us into choosing a base image capable of installing all those extra bits, and that base image usually starts around the 100 MB range. Then every additional RUN command in the Dockerfile increases the overall file size of the image. Keep in mind I’m doing all of this so I can produce the same binary built by my CI system.

While I could add logic to the Dockerfile to clean up unneeded files I decided to keep the application build process in CI, and treat the Docker image as another target for packaging. At this point I was able to shrink my Dockerfile to the following:

FROM google/debian:wheezy
MAINTAINER Kelsey Hightower <kelsey.hightower@gmail.com>
ADD contributors contributors
ENV PORT 80
EXPOSE 80
ENTRYPOINT ["/contributors"]

Notice the addition of the ADD command which adds the contributors binary to the image. The build process now goes like this:

CGO_ENABLED=0 GOOS=linux go build -a -tags netgo -ldflags '-w' .
docker build -t kelseyhightower/contributors .

I’m now working with a much smaller Docker image, but at 120 MB the image is still too big. Especially since our binary doesn’t require any files in the image. The easy solution is to use a smaller base image such as busybox, considered tiny at 5 MB, or the absolute smallest image available, the scratch image:

FROM scratch
MAINTAINER Kelsey Hightower <kelsey.hightower@gmail.com>
ADD contributors contributors
ENV PORT 80
EXPOSE 80
ENTRYPOINT ["/contributors"]

Normally the scratch image will not work for typical Go applications because they are often built with cgo enabled, which results in a binary that dynamically links to a few dependencies. Also, some Go applications make external calls to SSL endpoints, which will fail with the following error when running from the scratch image:

x509: failed to load system roots and no roots provided

The reason for this is that on Linux systems the tls package reads the root CA certificates from /etc/ssl/certs/ca-certificates.crt, which is missing from the scratch image. The Contributors app gets around this problem by bundling a copy of the root CA certificates and configuring outbound calls to use them.

Bundling of the actual root CA certificates is pretty straightforward. The Contributor app takes a cert bundle, /etc/ssl/certs/ca-certificates.crt, from CoreOS Linux and assigns the text to a global variable named pemCerts. Then in the main init() the Contributor app initializes a cert pool and configures a HTTP client to use it.

func init() {
pool = x509.NewCertPool()
pool.AppendCertsFromPEM(pemCerts)
client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool},
},
}
}

From this point on all calls using the new HTTP client will work with SSL end-points. Checkout the source code for more details on how the the root CA certificates are wired up.

Using the updated Dockerfile and re-running the build process I end up with a Docker image that is slightly larger than the Contributor app binary. We are now down to a 6MB Docker image.

At this point I have a Docker image optimized for size that is ready to run and share with others.

docker run -d -P kelseyhightower/contributors