Watch the replays from our Launchpad 2020 conference

WATCH NOW

Today I learned: How to make very small containers for golang binaries

TL;DR – Official Go Docker container images tend to be beefy. The standard image on Docker Hub is called golang (docker pull golang), and tossing in a Go program (such as for interactive execution) will bring it up above 800MB. But building images from scratch (for example, using the scratch container) using compiled binaries that don’t need complex, multi-layered OS and language environment support, will keep things slimmer.

I’ve been building microservices applications for demos lately. A lot of the work has been in Node.js, because it’s easy. But this past weekend, I started learning Go (because ‘all the cool kids,’ obviously) and so, there I was, figuring out how to containerize Go programs.

It turns out this is easy, too. But I was surprised to discover how large the resulting container images were. Suppose we have a minimal program, hello.go, like:

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println(os.Args)
}

… which prints the array containing its arguments. Running this on the command line with:

$ go run hello.go hi there
[/tmp/go-build289681080/b001/exe/hello hi there]

… gets you the standard argument array, beginning with the executable path.

Now you can put this into a golang container using the following Dockerfile…

FROM golang

COPY . .

CMD ["go","run","hello.go","hi","there"]

… then build it …

$ docker build -f Dockerfile.golang --tag hello:1.1 .
Sending build context to Docker daemon  2.073MB
Step 1/3 : FROM golang
 ---> 5fbd6463d24b
Step 2/3 : COPY . .
 ---> f72803dfaac0
Step 3/3 : CMD ["go","run","hello.go","hi","there"]
 ---> Running in 39e7765bec67
Removing intermediate container 39e7765bec67
 ---> f96c3d2c0861
Successfully built f96c3d2c0861
Successfully tagged hello:1.1

… and run it, and get the expected result:

$ docker run hello:1.1
[/tmp/go-build847833630/b001/exe/hello hi there]

But when you check with docker images, you see that the container is relatively yuge.

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello               1.1                 f96c3d2c0861        3 minutes ago       812MB

‘Kayso, you can make a much, much smaller container by changing your Dockerfile to this:

FROM scratch

COPY ./hello /go/bin/hello

CMD ["/go/bin/hello","hi","there"]

… and using the so-called ‘scratch’ image, which is basically an empty container without a shell.

But the scratch image also contains no Go language environment, so you need to compile your application into an executable first:

$ go build hello.go

… which gets you a hello binary in your local directory that you can execute like this …

$ ./hello hi there
[./hello hi there]

… and get back the (drum roll) expected result.

Looking back at the Dockerfile (called, in this case Dockerfile.scratch), you can see all it’s doing is copying the binary (hello) into a directory it creates in the container (/go/bin) and then running it from there. So build …

$ docker build -f Dockerfile.scratch --tag hello:1.2 .
Sending build context to Docker daemon  2.073MB
Step 1/3 : FROM scratch
 ---> 
Step 2/3 : COPY ./hello /go/bin/hello
 ---> Using cache
 ---> fbc88299067f
Step 3/3 : CMD ["/go/bin/hello","hi","there"]
 ---> Running in dd925ee8a3ab
Removing intermediate container dd925ee8a3ab
 ---> 4a049a401c79
Successfully built 4a049a401c79
Successfully tagged hello:1.2

… and run …

$ docker run hello:1.2
[/go/bin/hello hi there]

… and (by this time, I figure you’re not surprised) … the expected result. But look at the container image:

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello               1.2                 4a049a401c79        33 seconds ago      2.07MB

That’s a pretty substantial size reduction!

Flipping from shell-based execution to no-shell-in-sight execution naturally comes with many potential gotchas. In this case, for example, if you were to build the arguments passed by your CMD out of expressions that required evaluation/expansion by a shell, things wouldn’t work as planned in a no-shell container. You’ll need to construct code carefully to run in such a stripped-back environment.

Now, be advised: I haven’t thought about side-effects, security, stability, gotchas that would no doubt be obvious to a seasoned Go dev, and I’d love to hear about them in the comments. But as we say in the artisanal code-creation atelier: “It works on my machine.” 

Today I Learned (TIL) is an intermittent journal of somewhat half-baked solutions to “speed bump” problems encountered by Mirantis Technical Marketing folks working well beyond our native spheres of expertise for research purposes. None of what we write about here has been checked by grown-ups, or is appropriate for production without further validation. Use at own risk. Comments, cautions, and suggestions for improvement are very welcome! Email jjainschigg@mirantis.com (Twitter: @jjainschigg).

WEBINAR RECORDING
What's New in Kubernetes 1.18
WATCH NOW