Building Minimal Docker Containers for Go Applications

Written by: Liza McGraw
6 min read

Editor's Note: This is a guest blog by Nick Gauthier , published by permission. It was originally posted on the CloudBees Codeship website .

There are several great official and community-supported containers for many programming languages, including Go, but these containers can be quite large. Let’s walk through a comparison of methods for building containers for Go applications, then I’ll show you a way to statically build Go apps for containerization that are extremely small.

Part One: Our “app”

We need something to test for our app, so let’s make something pretty small: we’re going to fetch google.com and output the size of the html we fetch:

package main

import (

"fmt"
"io/ioutil"
"net/http"
"os"

) func main() {

resp, err := http.Get("https://google.com")
check(err)
body, err := ioutil.ReadAll(resp.Body)
check(err)
fmt.Println(len(body))

}

func check(err error) {

if err != nil {

fmt.Println(err)
os.Exit(1)

}

}

If we run this, it will just print out some numbers. For me, it was around 17k. I purposefully decided to do something with SSL for reasons I promise to explain later.

Part 2: Dockerize

Following the official Docker image for Go, we would write an “onbuild” Dockerfile like this:

FROM golang:onbuild

The “onbuild” images assume your project structure is standard and will build your app like a generic Go app. If you want more control, you could use their standard Go base image and compile yourself:

FROM golang:latest
RUN mkdir /app
ADD . /app/
WORKDIR /app
RUN go build -o main .
CMD ["/app/main"]

This is nice if you have a Makefile or something else nonstandard you have to do when you’re building your app. We could download some assets from a CDN or maybe add them in from another project, or maybe we want to run our tests within the container.

As you can see, Dockerizing Go apps is pretty straightforward, especially since we don’t have any services or ports we need access to or to export. But there’s one big drawback to the official images : they’re really big. Let’s take a look:

REPOSITORY SIZETAGIMAGE IDCREATED
example-onbuildlatest9dfb1bbac2b819 minutes ago
example-golanglatest02e19291523e19 minutes ago
golangonbuild3be7ee2ec1ae9 days ago
golang1.4.2121a93c904639 days ago
golanglatest121a93c904639 days ago

The bases are 514.9MB and our app adds just 5.8MB to that. Wow. So for our compiled application we still need 514.9MB of dependencies? How did that happen?

The answer is that our app was compiled inside the container. That means the container needs Go installed and that means it needs Go’s dependencies, which means we need a package manager and really an entire OS. In fact, if you look at the Dockerfile for golang:1.4, it starts with Debian Jessie, installs the GCC compiler and some build tools, curls down Go and installs it. So we pretty much have a whole Debian server and the Go toolkit just to run our tiny app. What can we do?

Part 3: Compile!

The way to improve is to do something a little… off the beaten path. What we’re going to do is compile Go in our working directory, then add the binary into the container. That means a simple docker build won’t work. We need a multi-step container build:

<span style="font-family:Courier New,Courier,monospace;">go build -o main . </span> 
<span style="font-family:Courier New,Courier,monospace;">docker build -t example-scratch -f Dockerfile.scratch .</span> 

And Dockerfile.scratch is simply:

<span style="font-family:Courier New,Courier,monospace;">FROM scratch </span> 
<span style="font-family:Courier New,Courier,monospace;">ADD main / </span> 
<span style="font-family:Courier New,Courier,monospace;">CMD ["/main"]</span> 

So what’s scratch? Scratch is a special docker image that’s empty. It’s truly 0B:

REPOSITORYTAGIMAGE IDCREATED
example-scratchlatestca1ad50c9256About a minute ago
scratchlatest511136ea3c5a22 months ago

Also, our container is just that 5.6MB! Cool! But there’s one problem:

$ docker run -it example-scratch
no such file or directory

Huh? What does that mean? Took me a while to figure it out, but our Go binary is looking for libraries on the operating system it’s running in. We compiled our app, but it still is dynamically linked to the libraries it needs to run (i.e., all the C libraries it binds to). Unfortunately, scratch is empty, so there are no libraries and no loadpath for it to look in. What we have to do is modify our build script to statically compile our app with all libraries built in:

CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

We’re disabling cgo which gives us a static binary. We’re also setting the OS to Linux (in case someone builds this on a Mac or Windows) and the -a flag means to rebuild all the packages we’re using, which means all the imports will be rebuilt with cgo disabled. These settings changed in Go 1.4 but I found a workaround in a GitHub Issue . Now we have a static binary! Let’s try it out:

$ docker run -it example-scratch
Get https://google.com: x509: failed to load system roots and no roots provided

Great, now what? This is why I chose to use SSL in our example. This is a really common gotcha for this scenario: to make SSL requests, we need the SSL root certificates. So how do we add these to our container?

Depending on the operating system, these certificates can be in many different places. If you look at Go’s x509 library, you can see all the locations where Go searches. For many Linux distributions, this is /etc/ssl/certs/ca-certificates.crt. So first, we’ll copy the ca-certificates.crtfrom our machine (or a Linux VM or an online certificate provider) into our repository. Then we’ll add an ADD to our Dockerfile to place this file where Go expects it:

FROM scratch
ADD ca-certificates.crt /etc/ssl/certs/
ADD main /
CMD ["/main"]

Now just rebuild our image and run it, and it works! Cool! Let’s see how big our app is now:

REPOSITORYTAGIMAGE IDCREATED
example-scratchlatestca1ad50c9256About a minute ago
example-onbuildlatest9dfb1bbac2b819 minutes ago
example-golanglatest02e19291523e19 minutes ago
golangonbuild3be7ee2ec1ae9 days ago
golang1.4.2121a93c904639 days ago
golanglatest121a93c904639 days ago
scratchlatest511136ea3c5a22 months ago

We’ve added a little more than half a meg (and most of this is from the static binary, not the root certs). This is a really nice little container — it’ll be really easy to push and pull between registries.

Conclusion

Our goal in this post was to whittle down the container size for a Go application. Go is special in that it can create a statically linked binary that fully contains the application. Other languages can do this, but certainly not all of them. If we were to apply this technique of reducing container size to other languages, it would depend on what their minimal requirements are. For example, a Java or JVM app could be compiled outside a container and then be injected into a container that only has a JVM (and its dependencies). This is at least smaller than a container with the JDK present.

I’m really looking forward to the strides the community makes in creating both minimal OSes for container guests, as well as aggressively trimming down the requirements for all kinds of languages. The great thing about the public Docker hub is these can be shared with everyone easily.

Links to additional resources

Learn how to do better CI/CD by converting apps into separate containers

Read how DevOps and continuous delivery can transform businesses

Find out how CloudBees DevOptics offer CI/CD platform monitoring insights

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.