Docker: Base image when deploying a GoLang binary in a container

Update Oct 2020: multi-stage builds now provide a standard way to leverage a fat build image, while allowing your published image to remain small.  This article is still useful for comparing base image sizes.

GoLang applications are a great architectural fit for a Docker container because of their single binary executable. But you need to be aware that the base image that you choose for your GoLang binary will greatly affects the size of the Docker image.

You can use the heavy weight golang base image available from Docker Hub.  This contains an entire base OS filesystem along with the an installation of the GoLang tools and packages.  Your Dockerfile copies the .go source code into the container, downloads any dependencies, and does the full build inside the container itself.  This can result in an image hundreds of megabytes large.

The alternative is that you can install the GoLang compiler to your local host computer, and use the local compiler to build the GoLang binary.  Then using either the minimalist scratch image or a purposely small filesystem like Alpine as the base, you can copy just your single GoLang binary to the container.  This results in a very small docker image.

As long as there are no arcane library dependencies or filesystem path assumptions introduced by your custom code, going with the smaller image size is typically advantageous.

Prerequisite

This article assumes you have installed Docker as described in my article here.

OPTION 1: Build binary inside container

Download my Docker hello world github project.

$ sudo apt-get install git -y
$ git clone https://github.com/fabianlee/docker-my-hello-world-golang.git
$ cd docker-my-hello-world-golang

If you cat the “Dockerfile” you will see it tells Docker to use the base image “golang:latest”, copy the contents of the current directory into the container, run the GoLang compiler to build the executable, and then set the binary as the main process of the container.  This all happens inside the container, and not on your host machine.

FROM golang:latest

RUN mkdir /app
WORKDIR /app
COPY . .
RUN go build -o main .

CMD ["/app/main"]

Run the steps below to build the Docker image, this will take a while if you have never run this step before because the “golang:latest” image is hundreds of megabytes.

$ docker build -t my-hello-world .

If you want to see the image run, use this command:

$ docker run -it my-hello-world
hello, world

To view the Docker image size, use the command below.

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
 my-hello-world latest f447222c719e 11 minutes ago 798MB

You can see that this image is 789MB (bigger than a CD), and that is because it contains an entire operating system as a base layer along with all the GoLang tools and package dependencies.

OPTION 2: Build binary locally, put binary into container

The other approach would be to build the GoLang binary locally, and then place this single non-linked executable into a minimalist container.

Prerequisites

You need to install GoLang on your local host machine.  Follow the instructions for Ubuntu 16.04 as described in my article here, or Ubuntu 14.04.  This includes setting the $GOPATH and $GOBIN environment variables.

Build the GoLang binary locally

First, grab the project off github:

$ sudo apt-get install git -y
$ mkdir -p $GOPATH/src; cd $_
$ git clone https://github.com/fabianlee/docker-my-hello-world-golang.git
$ cd docker-my-hello-world-golang

Then build the static, non-linked binary using the instructions below:

$ export CGO_ENABLED=0
$ go build -o main .
$ ./main
hello, world

$ ldd ./main
       not a dynamic executable
Put Binary into scratch container

The scratch image, available in the Docker Hub is meant for the most minimal image requirements.  Our hello world project certainly falls under that category, so let’s see how small an image we can construct.

Below is the Dockerfile.scratch which copies the “main” executable to the root path.

FROM scratch

ADD main /
CMD ["/main"]

Build/run the image:

$ docker build -t my-hello-world-scratch -f Dockerfile.scratch .

$ docker run -it my-hello-world-scratch
hello, world

And check the image size:

$ docker images
 REPOSITORY TAG IMAGE ID CREATED SIZE
 my-hello-world-scratch latest cee1f1ea8163 About a minute ago 2.02MB
 my-hello-world latest f447222c719e 21 minutes ago 798MB

The “my-hello-world-scratch” image is only 2.02Mb compared to the 798Mb of the “golang:latest” image.  If we truly don’t need any of the supporting libraries or niceties of Ubuntu inside the container, this is a huge savings.

Put binary into Alpine container

Instead of using the scratch image, let’s use an alpine base image that is purpose-made to have a light footprint (~5Mb) but still provide tools and utilities that make working within a container possible.

Below is the Dockerfile.alpine which copies the “main” executable to the /app path.

FROM alpine:latest

RUN mkdir /app
WORKDIR /app
COPY . .

CMD ["/app/main"]

Build/run the image:

$ docker build -t my-hello-world-alpine -f Dockerfile.alpine .

$ docker run -it my-hello-world-alpine
hello, world

And check the image size:

$ docker images
 REPOSITORY TAG IMAGE ID CREATED SIZE
 my-hello-world-alpine latest 65de4ed20940 19 seconds ago 6.17MB
 my-hello-world-scratch latest cee1f1ea8163 2 minutes ago 2.02MB
 my-hello-world latest f447222c719e 22 minutes ago 798MB

The image based on Alpine is only 6.17Mb, which is slightly larger than scratch, but orders of magnitude smaller than one based on the full blown GoLang image.

 

REFERENCES

https://blog.codeship.com/building-minimal-docker-containers-for-go-applications/ (build inside heavy container vs copy binary into light container)

https://docs.docker.com/samples/library/golang/

https://hub.docker.com/_/golang/

NOTES

removes stopped containers

docker rm $(docker ps -aq)

removes all stopped containers, volumes not used, images without container associated

docker system prune