Static Go binaries with Docker on OSX

July 27th 2015 Nicola Paolucci in Golang, Docker

Recently I've been writing a service in Go to enhance the projects dashboard on Bitbucket - if you haven't heard we launched Atlassian Connect for Bitbucket as a way for anyone to build add-ons for three millions of Bitbucket users out there. Like many other Gophers I've been happily deploying my Go services using Docker. The process is smooth and pleasurable if not for one thing: the size of the official default Go image.

shrink go with docker

After all is said and done my application - which by itself would comprise of around ~6MB of static binary in size, becomes a whopping 642MB when using the default Go Docker image. Our internal Docker registry handles that with no problems but it seems such a waste of space.

Recently I found this clear article detailing a Go, Docker workflow with clear instructions and snippets showing how to statically compile an application and shrink it to 1% of the size. The technique is elegant and simple enough but because my development system is OSX that approach needs to be modified with an extra layer of complexity. I need to manage cross-compilation of my Go project across OS architectures (OSX, Linux). I did some research and attempts at using gonative, but ended up going the Docker route to solve everything.

While working through the problem I remembered an older article from Xebia that did something smart: perform the build and link step inside Docker containers and store the (now compatible) binary in a scratch image. The scratch image is the smallest possible Docker image and it's generally used to build base images or to contain single binaries.

So that's what I set out to replicate with the new insight from the former article. I ended up with a streamlined process which automates everything smoothly:

  • Write a multi-purpose Makefile to both setup the build environment inside Docker and statically compile the Go application (read more about it below).
  • Create a Dockerfile to build the statically linked Go binary (called "Dockerfile.build").
  • Run it and extract the Linux binary using "docker cp".
  • Create a bare bones Dockerfile that adds the binary to a "scratch" Docker image, plus the needed static web application files ("Dockerfile.static").
  • Profit! Run application using Docker.

Here a breakdown of the steps in detail.

Write a multi-purpose Makefile

The Makefile will be capable of doing several things:

  • Collect the dependencies needed by our Go application.
  • Assemble the right Docker container to build our statically linked Go binary.
  • Build our Go program.
  • Inject the binary and the application static assets into a minimal Docker image.

The interesting bit here is that the same Makefile will be used both to create the build container and as configuration inside the container for the compilation command (if you want you're free to split the two logical uses in separate Makefiles but I found it delightfully efficient to keep only one).

Here's how the Makefile looks like:

default: builddocker

setup:
    go get golang.org/x/oauth2
    go get golang.org/x/oauth2/jwt
    go get google.golang.org/api/analytics/v3

buildgo:
    CGO_ENABLED=0 GOOS=linux go build -ldflags "-s" -a -installsuffix cgo -o main ./go/src/bitbucket.org/durdn/project-name

builddocker:
    docker build -t durdn/build-project-name -f ./Dockerfile.build .
    docker run -t durdn/build-project-name /bin/true
    docker cp `docker ps -q -n=1`:/main .
    chmod 755 ./main
    docker build --rm=true --tag=durdn/project-name -f Dockerfile.static .

run: builddocker
    docker run \
        -p 8080:8080 durdn/project-name

The golang Docker image expects the Go code to be stored in "./go/src/..." The build flags specify you want a static binary. The builddocker step does the following:

  • Build a container (tagged durdn/build-project-name) with the Go tool chain and the dependencies included.
  • The build step will compile the Go application statically.
  • Generate a container from the resulting image: docker run durdn/build-project-name /bin/true.
  • Extract Linux static binary generated: docker cp $(docker ps -q -n=1):/main .
  • Make it executable: chmod 755 ./main.
  • Copy the binary and the static assets into a minimal image.

Run the Makefile with the simple:

make builddocker

Build the static Linux binary in a container

The Makefile uses two separate Dockerfiles as already mentioned. Let's have a look at the Dockerfile.build:


FROM golang

ADD Makefile /
WORKDIR /
RUN make setup

ADD ./collector /go/src/bitbucket.org/durdn/project-name/collector
ADD ./dashboard /go/src/bitbucket.org/durdn/project-name/dashboard
RUN make buildgo
CMD ["/bin/bash"]

This simple Dockefile allows us to build the static Go binary calling make. If you want to kick off the build manually you can simply type:

docker build -t durdn/app-name -f ./Dockerfile.build .

This will generate the cross-compiled binary executable as ./main inside the container.

Create tiny Go Docker image

The last step is to create a minimal Docker container and put our binary into it. For this we we can use the very tiny tianon/true or the scratch image mentioned before. This is the magical step that allows to shrink the application image hundredfold.

The Dockerfile.static for this step is pretty straight forward:

# Create a minimal container to run a Golang static binary

FROM tianon/true
MAINTAINER Nicola Paolucci "npaolucci@atlassian.com"
EXPOSE 8080

COPY certs/certs /etc/ssl/certs/ca-certificates.crt
COPY dashboard/config.json /config.json
COPY dashboard/properties.json /properties.json

ADD dashboard/dashboards /dashboards
ADD dashboard/public /public
ADD dashboard/widgets /widgets
ADD main /

ENV PORT=8080
CMD ["/main"]

Run it like this:

docker build --rm --tag=durdn/project-name -f Dockerfile.static .

As explained in the Docker workflow mentioned before, the certificates are needed if we want the application to run smoothly in a cross architecture setting.

The ADD and COPY lines here are for adding the configuration files and the web application folders that contain standard CSS, HTML and JavaScript files.

After the build command the application can be started as you would expect with:

    docker run -p 8080:8080 da-dashboard --config=config.json

Conclusions

The end result is beautiful, a Docker image weighting 8.6MB including all the static assets. I know it's a small thing but it makes me feel so accomplished.

Find an example of the setup in this small Git repository.

Liked this piece and want more Go content? Check out my recent article on Learning Go with flash cards.

In any case if you found this interesting at all and want more why not follow me at @durdn or my awesome team at @atlassiandev?