Congrats! You have created a wonderful go application, and now you want to create a docker image to distribute your application.

But, how do you create the lightest image possible for your Golang application? HINT: We will use multi-stage builds (a functionality available since version 17.05 of Docker)

Introduction

Multi-what?

Multi-stage. Easy!

Multi-stage allows us to create Dockerfile with multiple from. It is very useful, as it enables us to build the app with all the required tooling, for example using the golang base image, and then having a second stage with just the binary built from the intermediate/first image. The last stage of a multi-stage build will be the image you will publish to your repository and deploy.

In our case, we will have 3 stages:

  • build
  • certs (optional, more on that later)
  • prod

The build stage will compile our application. The certs stage will be used to install the required CA certificates. The last and final stage prod will be the image we will publish to our repository. It uses the binary from the build stage, and the certificates from the certs stage.

The different build stages of our project

Example project

For this how-to, we will use a very simple project I have created. It is a simple HTTP server running on port 8080 that will return the body from the URL passed as in the query.

Example:
GET http://localhost:8080?url=https://google.com returns the content of the google webpage.

You can find the repository here.

The master branch contains only the application, and the final branch contains the Dockerfile used in this tutorial.

If you want to follow this how-to, just stay on master and create your Dockerfile with me!

Step 1 - Build stage

The first stage is responsible of building our Golang binary from the golang base image. This base image contains all the required tooling to compile our application to a binary executable.

This is our initial Dockerfile:

#
# BUILD STAGE
# 
FROM golang:1.10 AS build

# Setting the working directory of our application
WORKDIR /go/src/github.com/scboffspring/blog-multistage-go

# Adding all the files required to compile the application
ADD . .

# Compiling a static go application (include C libraries in the built binary)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo .

# Set the startup command to be our application
CMD ["./blog-multistage-go"]

  • Line 4: The base image to use (golang:1.10) and we use the syntax as to give a name to this stage. It is also possible to reference previous stage using the stage index, but this makes it way clearer.
  • Line 7: We set our working directory to be the directory of our application in the default $GOPATH of the golang image.
  • Line 10: We add the source files of our application.
  • Line 13: We build our binary. The different parameters are to create a totally static library, as our scratch image in prod will not contain the C libraries and everything the Golang VM may require.
  • Line 16: We set the default command to run our application

We can now build and this image with docker. Our application works as expected.

docker build -t scboffspring/blog-multistage-go .
docker run --rm -ti -p 8080:8080 \
scboffspring/blog-multistage-go

We can try a quick curl request, which will return the content of http://google.com.

Run curl localhost:8080 in your terminal.

<html itemscope="" itemtype="http://schema.org/WebPage" lang="de-CH">
  <head>
  <meta content="text/html; charset=UTF-8" 
      http-equiv="Content-Type">
  <meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" 
  itemprop="image"><title>Google</title>
....

Let's have a look at the image size, using docker images

REPOSITORY                                       ... SIZE
scboffspring/blog-multistage-go                  ... 818MB

818MB on disk for this little server? Ridiculous. Absolutely ridiculous.

After pushing to a repository, the size once compressed for download is 309MB.

309MB on docker hub

Let's improve this situation, and reduce the size of our image to under 10MB!

Step 2 - Prod stage

The previous image can definitely be distributed, but it will relatively big. Imagine downloading 309MB every time you need to start your container on Kubernetes? That's a lot of bandwith and time spent for nothing.

Let's implement a prod stage for our image. As explained above, this stage will just copy the binary from the build stage into the container.

Our new Dockerfile is the following:

#
# BUILD STAGE
# 
FROM golang:1.10 AS build

# Setting the working directory of our application
WORKDIR /go/src/github.com/scboffspring/blog-multistage-go

# Adding all the files required to compile the application
ADD . .

# Compiling a static go application (include C libraries in the built binary)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo .

# Set the startup command to be our application
CMD ["./blog-multistage-go"]



#
# PRODUCTION STAGE
# 
FROM scratch AS prod

# Copy the binary built during the build stage
COPY --from=build /go/src/github.com/scboffspring/blog-multistage-go/blog-multistage-go .
CMD ["./blog-multistage-go"]

As you can see, in the same Dockerfile we added a second FROM clause. This time, we use from scratch, as we don't have any dependencies.

  • Line 23: The base image being scratch
  • Line 26: We copy from the build stage the file located at /go/src/github.com/scboffspring/blog-multistage-go/blog-multistage-go
  • Line 27: We set the default command to start our application

Easy.

Let's build and run our image, just like before:

docker build -t scboffspring/blog-multistage-go . 
docker run --rm -ti -p 8080:8080 \
scboffspring/blog-multistage-go

We can see Starting Server, which means it started correctly! Well done!

Let's have a quick look at the image size, using docker images:

REPOSITORY                                       ... SIZE
scboffspring/blog-multistage-go                  ... 6.65MB

Let's than 10MB, as promised! And on the repository, it is only 2MB. When you'll start your container, you will download only 2MB. So much bandwidth and time saved compared to the previous version!

Our build prod-1 is only 2MB of compressed size.

BUT, it doesn't work in our case. If you run curl localhost:8080, you will see that the response is a 500.

curl localhost:8080
500 - Something bad happened

If you look at the logs of the container, you will see the following error:

An error occured: Get https://google.com: x509: failed to load system roots and no roots provided

We are trying to contact the google server using https, but we don't have the CA (Certificate Authority) certificates used to validate the SLL certificates from Google, or any other website. If you application doesn't use SSL, you can skip the next stage. Otherwise, let's just fix this to allow our software to work properly.

Step 3 - (Optional) Certificates stage

In order to fix the previous issue, we will add a new stage to get the certificates, and we will copy them to our prod image.

Our new Dockerfile is as-follow:

#
# BUILD STAGE
# 
FROM golang:1.10 AS build

# Setting the working directory of our application
WORKDIR /go/src/github.com/scboffspring/blog-multistage-go

# Adding all the files required to compile the application
ADD . .

# Compiling a static go application (include C libraries in the built binary)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo .

# Set the startup command to be our application
CMD ["./blog-multistage-go"]


# 
# CERTS Stage
#
FROM alpine:latest as certs

# Install the CA certificates
RUN apk --update add ca-certificates

#
# PRODUCTION STAGE
# 
FROM scratch AS prod

# Copy the CA certificate from the certs stage
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# Copy the binary built during the build stage
COPY --from=build /go/src/github.com/scboffspring/blog-multistage-go/blog-multistage-go .
CMD ["./blog-multistage-go"]
  • Line 23: Our new stage certs, which uses the alpine image
  • Line 25: We install the latest version of ca-certificates
  • Line 33: We copy from the certs layer the certificates, and we save them into /etc/ssl/certs/ca-certificates.crt

Let's build and run our application again:

docker build -t scboffspring/blog-multistage-go . 
docker run --rm -ti -p 8080:8080 \
scboffspring/blog-multistage-go

And now, curl localhost:8080 will actually return a value! It worked!

The image size is still very small when running docker images.

REPOSITORY                                       ... SIZE
scboffspring/blog-multistage-go                  ... 6.89MB

BONUS: Tag an image at a specific stage

Sometimes you may want to create a tag from one of the stage. In our example, we may want to publish the build stage to Docker too, as it can be useful for development.

To do this, simply run the docker build command using --target=NAMEOFTHESTAGE.

For example:

docker build -t scboffspring/blog-multistage-go:build . --target=build

Conclusion

You are now able to create very lightweight image for your Golang application. The concept of stage build can be really useful for a lot of other use cases.

One of the usage I have in the NodeJS world is to compile the TypeScript project in the first stage. The first stage is then tagged to be able to run the test using this image. This image is also used in the development environment, as it contains all the dependencies required to develop the application.

When the test passes in the first stage, a second stage is installing only the dependencies (and not the dev-dependencies) of package.json. It the copies only the compiled and minified code into the image. This image is then pushed and deployed to production.