How to create the smallest possible Docker image for your Golang application.

This blog post will guide you to create the ultimate smallest possible Docker image for your Golang application using the Build flow tool Habitus!

In the container ecosystem, there’s a lot of chatter about security and best practices to build the ultimate container image. The main goal is to create an image which is slim, secure, speedy, stable and set.

I didn’t have time to create a slim image, so I created a fat one instead.

Shortcuts are evil and we need to aim for slim images instead of fat ones which cause problems (security, performance, maintaining) in the long run.

Let get started!

Scratch for the win!

The first step is to understand how to create a Docker image with no base like ubuntu or alpine for example. We want the bare minimal. The goal is to isolate our process with NO dependencies or stuff we don't need. The scratch base image is your answer.

You can use Docker’s reserved, minimal image, scratch, as a starting point for building containers. Using the scratch image signals to the build process that you want the next command in the Dockerfile to be the first filesystem layer in your image.

Each Docker image references a list of read-only layers that represent filesystem differences. Layers are stacked on top of each other to form a base for a container’s root filesystem.

Behold!

The smallest possible Docker image for an executable. The executable should be a static build.

A static build is a compiled version of a program which has been statically linked against libraries. In computer science, linking means taking one or more objects generated by compilers and assembling them into a single executable program.

Steps to take:

  • Download the smallest hello world app in the world.
  • Create a Dockerfile
# start from scratch FROM scratch # copy our static linked executable COPY helloworld helloworld # tell how to run this container CMD ["./helloworld"]
  • Build the image $ docker build . -t helloworld:smallest
Sending build context to Docker daemon 3.072 kB Step 1/3 : FROM scratch ---> Step 2/3 : COPY myapp myapp ---> Using cache ---> c3e978eab3c8 Step 3/3 : CMD ./myapp ---> Using cache ---> cc6eb6cc3479 Successfully built cc6eb6cc3479
  • Run the container $ docker run helloworld:smallest
Hi World
  • Check the size of each layer $ docker history helloworld:smallest
IMAGE CREATED CREATED BY SIZE COMMENT cc6eb6cc3479 9 minutes ago /bin/sh -c #(nop) CMD ["./myapp"] 0 B c3e978eab3c8 11 minutes ago /bin/sh -c #(nop) COPY file:863b4441410c89... 142 B

A docker image of 142 Bytes. Eat that!

The two-stage rocket build.

The 1 million dollar question is? How to build our Golang application, get the executable and put it inside a container in one command?

Unfortunately, you can’t do this in an automated fashion using the standard Docker tooling. Luckily we create a project called Habitus to automate this process.

Basically, we need two stages. Build the artefact using $ go build and copy the executable into our final image. Let create both stages using Dockerfiles and glue them together with the Habitus rocket!

Let start with a simple HTTP service written in Golang:

main.go

package main import ( "fmt" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "hello world!") } func main() { http.HandleFunc("/", handler) fmt.Println("Simple Helloworld server is running on port 8080") http.ListenAndServe(":8080", nil) }

We need to build the executable first before we can run it as an isolated process using containers. Enter stage #1!

Stage #1

The responsibility of this stage is to build an image which can build your Golang executable and extract the artefact.

Dockerfile.builder

# start a golang base image, version 1.8 FROM golang:1.8 #switch to our app directory RUN mkdir -p /go/src/helloworld WORKDIR /go/src/helloworld #copy the source files COPY main.go /go/src/helloworld #disable crosscompiling ENV CGO_ENABLED=0 #compile linux only ENV GOOS=linux #build the binary with debug information removed RUN go build -ldflags '-w -s' -a -installsuffix cgo -o helloworld

To build it manually run this command to build it. 
 $ docker build -f Dockerfile.builde -t builder:latest .

Copy the compiled artifact to your local disk 
 $ docker container cp [id_of_container]:/go/src/helloworld/helloworld helloworld

Stage #2

The responsibility of this stage is to copy the artefact into the smallest possible image.

Dockerfile.production

# start with a scratch (no layers) FROM scratch # copy our static linked library COPY helloworld helloworld # tell we are exposing our service on port 8080 EXPOSE 8080 # run it! CMD ["./helloworld"]

To build it manually run this command to build it. 
 $ docker build -f Dockerfile.production -t helloworld:latest .

Building your rocket to create the smallest possible Docker image

With Habitus you need a build.yml to tell which steps are necessary for the Docker build flow. Habitus give you the power to handle complex build flow without getting into bash hell mess.

build.yml

build: version: 2016-03-14 # version of the build schema. steps: builder: name: builder dockerfile: Dockerfile.builder artifacts: - /go/src/helloworld/helloworld production: name: helloworld:latest dockerfile: Dockerfile.production depends_on: - builder

Build everything in one command! Easy as it gets.

$ habitus

017/04/05 10:14:01 ▶ Using '/Users/danielvangils/Desktop/Cloud 66/projects/go_projects/src/github.com/cloud66/helloworld/build.yml' as build file 
2017/04/05 10:14:01 ▶ Collecting artifact information
2017/04/05 10:14:01 ▶ Building 2 steps
2017/04/05 10:14:01 ▶ Step 0 - builder: builder
2017/04/05 10:14:01 ▶ Step 1 - production: helloworld:latest 2017/04/05 10:14:01 ▶ Parallel build for builder
2017/04/05 10:14:01 ▶ Building builder
2017/04/05 10:14:01 ▶ Parsing and converting 'Dockerfile.builder' 2017/04/05 10:14:01 ▶ Writing the new Dockerfile into Dockerfile.builder.generated
2017/04/05 10:14:01 ▶ Building the builder image from Dockerfile.builder.generated
Step 1/7 : FROM golang:1.8
---> 9ad50708c1cb
...
Step 7/7 : RUN go build -ldflags '-w -s' -a -installsuffix cgo -o helloworld
---> Running in 93778ba1c98c
---> b9736e1bf07c
Removing intermediate container 93778ba1c98c
Successfully built b9736e1bf07c
2017/04/05 10:14:12 ▶ Building container based on the image 2017/04/05 10:14:12 ▶ Starting container 3278c37f63092f70e3ef157268d3bc770f0333b550c9a10ec0527940dc677833 to fetch artifact permissions
2017/04/05 10:14:13 ▶ Permissions for /go/src/helloworld/helloworld is 755 2017/04/05 10:14:13 ▶ Stopping the container 3278c37f63092f70e3ef157268d3bc770f0333b550c9a10ec0527940dc677833 2017/04/05 10:14:14 ▶ Copying artifacts from 3278c37f63092f70e3ef157268d3bc770f0333b550c9a10ec0527940dc677833 2017/04/05 10:14:14 ▶ Copying from /go/src/helloworld/helloworld to /Users/danielvangils/Desktop/Cloud 66/projects/go_projects/src/github.com/cloud66/helloworld/helloworld 2017/04/05 10:14:14 ▶ Setting file permissions for /Users/danielvangils/Desktop/Cloud 66/projects/go_projects/src/github.com/cloud66/helloworld/helloworld to 755
2017/04/05 10:14:14 ▶ Removing built container 3278c37f63092f70e3ef157268d3bc770f0333b550c9a10ec0527940dc677833 2017/04/05 10:14:14 ▶ Parallel build for helloworld:latest 2017/04/05 10:14:14 ▶ Building helloworld:latest 2017/04/05 10:14:14 ▶ Parsing and converting 'Dockerfile.production' 2017/04/05 10:14:14 ▶ Writing the new Dockerfile into Dockerfile.production.generated 2017/04/05 10:14:14 ▶ Building the helloworld:latest image from Dockerfile.production.generated
Step 1/4 : FROM scratch
--->
...
Step 4/4 : CMD ./helloworld
---> Using cache
---> 31dd0a6f2cce Successfully built 31dd0a6f2cce

$ docker images | grep helloworld

helloworld latest 31dd0a6f2cce 24 minutes ago 3.9 MB

Now we got the smallest possible image with only one layer and it only contains our executable. You can even compress more to use upx to compress the executable even more, up to 40%.

To show all the layers, run the $ docker history command.

$ docker history helloworld

IMAGE COMMENT SIZE 31dd0a6f2cce /bin/sh -c #(nop) CMD ["./helloworld"] 0 B bc5aa13f774e /bin/sh -c #(nop) EXPOSE 8080/tcp 0 B 33a1c4891bb1 /bin/sh -c #(nop) COPY file:bc24b3193d1b79... 3.9 MB

Creating the smallest possible Docker image for your Golang application is easy with Habitus. Integrate Habitus with your CI/CD pipeline gives you control to create isolated processes with the minimal attack surface.

Good to know we support Habitus in our Buildgrid solution.

Happy welding your containers


Originally published at blog.cloud66.com on April 5, 2017.