DEV Community

Koen Verburg
Koen Verburg

Posted on • Edited on • Originally published at koenverburg.dev

Set up a Multi-Stage Docker Build for Go Applications

We are going to create a simple multi-stage Docker build setup for Go applications. It might sound daunting at first, but it's actually really easy.

A multi-stage Docker build serves a few purposes. First, the final image size is smaller than traditional images, second, it's more secure.

Using dockers layering, we can abstract the building processes into multiple stages. And only copy the output that we need for the next stage. For example, the binary that gets created we're running go build -o main.

The goal here is to end up with a docker image that only contains and runs the final binary. What that final binary is, does not really matter in this context. But in this article, I'm going to use a simple script that outputs text to the console.

Requirements

We are going to need the following tooling, so make sure you installed it on your machine.

  • Go 1.x or higher
  • Docker
  • VSCode or any other editor

Let's write some code

Let's create the folder structure so that we have a good base to build from.
Make a directory called src and cd into that.

Now, let's initialize our go package with the following command go mod init hello-world. This will create a file called go.mod.

With that part done, let's write a simple Hello World in Go.
Create a file called main.go in our src directory and add the following code.

package main

import "fmt"

func main() {
  fmt.Println("Hello World!")
}
Enter fullscreen mode Exit fullscreen mode

If you run this using, go run main.go the output would be Hello World!.
Great, with that part out of the way, we can start with diving into Docker!

Golang base image

Change directory (cd ..) to the project root, here we are going to create a dockerfile. Paste in the following code and then I will explain what we are doing.

FROM golang:1.16-alpine

# Create a workspace for the app
WORKDIR /app

# Download necessary Go modules
COPY src/go.mod .
RUN go mod download

# Copy over the source files
COPY src/*.go ./

# Build
RUN go build -o /main

ENTRYPOINT ["/main"]
Enter fullscreen mode Exit fullscreen mode

Here we are going to do a few things, on the FROM line we are going to specify which base image we are going to use.
Setting a WORKDIR is like setting a special folder from which we are going to work.
Then we are to copy over our go.mod and download our packages if needed. And then we are going to copy over our source code.

After this we are ready to build our go application, we do that using, RUN go build -o /main.
Now that we have created a single binary, we can set that as our entrypoint when we run our docker image.

To build our image, we will use docker build . -t hello-world-golang1.16, this will build the image.
After the build has finished. We can run our image as a container using the command docker run hello-world-golang1.16 to test if this works.
And you should get an output like Hello World!.

Let's also check what the size is of our image. Using the command, docker images we can see the size.

REPOSITORY               TAG       IMAGE ID       CREATED              SIZE
hello-world-golang1.16   latest    68b776701cbe   About a minute ago   304MB
Enter fullscreen mode Exit fullscreen mode

Wow, that is a heavy image! Coming in at 304 MB. Let's try to cut off some fat here.

During my research on using multi-stage Docker builds. I saw that the Docker documentation used a Debian base image. Because of that, I wanted to also include it here as well.

Let's replace our dockerfile that we created earlier with the following. Note here the two comments, Builder and Runner. The Builder part is for building the go application and nothing more. The same can be said for the Runner part because that's sole purpose is running our application.

The builder has golang:1.6-alpine as base image because it needs to have the Go binary in its path. Because Go compiles to a single binary, running it does not need the presence of Go. So we can use a base image from Debian for our final image that will run our application.

#
# Builder
#

FROM golang:1.16-alpine AS builder

# Create a workspace for the app
WORKDIR /app

# Download necessary Go modules
COPY src/go.mod .
RUN go mod download

# Copy over the source files
COPY src/*.go ./

# Build
RUN go build -o /main

#
# Runner
#

FROM gcr.io/distroless/base-debian10 AS runner

WORKDIR /

# Copy from builder the final binary
COPY --from=builder /main /main

USER nonroot:nonroot

ENTRYPOINT ["/main"]
Enter fullscreen mode Exit fullscreen mode

I'm changing the build command, so we don't overwrite the image we created earlier. This way we can see what our changes are doing to the final docker image.

Build our new image using docker build . -t hello-world-debian. Let's see if this changed the behavior of the container docker run hello-world-debian. And you should get the same output as before: Hello World!.

Let's check size using docker images.

REPOSITORY               TAG       IMAGE ID       CREATED              SIZE
hello-world-debian       latest    7ad8a5965a06   About a minute ago   21.1MB
hello-world-golang1.16   latest    68b776701cbe   About a minute ago   304MB
Enter fullscreen mode Exit fullscreen mode

Look at that, 21.1 MB, that is a significant reduction in size!

Awesome, but can we push it even more? Yes, we can!

Using the smallest docker base image that there is.
Which is scratch. This is as bare as it gets. In the runner part of the docker file, change out the FROMstatement to scratch like below.

#
# Runner
#

# FROM gcr.io/distroless/base-debian10 AS runner
FROM scratch AS runner
Enter fullscreen mode Exit fullscreen mode

Build our new image using docker build . -t hello-world-scratch.
Let's see if this changed the behavior of the container docker run hello-world-scratch. And you should get the same output as before: Hello World!.

Let's check the size once more, using docker images.

REPOSITORY               TAG       IMAGE ID       CREATED              SIZE
hello-world-scratch      latest    eb41c9777973   58 seconds ago       1.94MB
hello-world-debian       latest    7ad8a5965a06   About a minute ago   21.1MB
hello-world-golang1.16   latest    68b776701cbe   About a minute ago   304MB
Enter fullscreen mode Exit fullscreen mode

Would you look at that, 1.94 MB! That is perfect!

Wrapping it up

I hope I have inspired you to use multi-stage Docker builds for your next project. This is still a simple two-stage Docker build, you can even use more stages if you need or when your project asks for it.

Here is a few useful links and also the source code, so you can try it out for yourself.

Top comments (0)