Introduction

This blog will explain how to create small docker images for Go applications. Small docker image sizes are desirable for several reasons.

  • Small attack surface: Selective tool installation on the docker images to provide better control on the tools available to the container runtime.
  • Quicker rollouts: Smaller images are quicker to pull from the container registry and deploy.
  • Cost optimizations: Control costs associated with storage, network egress, and ingress on the public cloud providers.
  • Faster build pipelines: Imagine a CI pipeline that has to pull 1GB docker images on every push to the main branch.

The working code used in this blog post is available for reference here. Interested readers can clone the repository and follow the steps in the README.md file.

Sample code

Go code directly compiles to the native binary executable for the intended platform (Linux, Mac, windows, arm64, etc). This removes the requirement to install virtual runtime (like JVM, CLR, Mono, V8, etc) completely. This provides a unique advantage to the Go apps as native applications can be directly run inside the containers without requiring any additional tools thus keeping the docker image size small and manageable.

For this blog post, we will use the following basic hello-world Go code.

1
2
3
4
5
6
package main
import "fmt"

func main() {
  fmt.Println("Hello world!!")
}

The project structure looks like the below with the main.go file and several docker files for each scenario.

1
2
3
4
5
6
7
8
.
├── Dockerfile-appuser
├── Dockerfile-basic
├── Dockerfile-multistage
├── Dockerfile-multistage-scratch
├── README.md
├── go.mod
└── main.go

First attempt

To create a docker image for our application we will use the Dockerfile-basic docker-file. Its contents are:

1
2
3
4
5
FROM golang:1.17-alpine3.15             # base image
WORKDIR /app                            # working directory inside docker
COPY . .                                # copy content to working dir
RUN go build -o application main.go     # create the native binary
CMD [ "/app/application" ]              # run the native binary

Build the docker image by running the command docker build -t basic:latest -f Dockerfile-basic . from the project root. This will build and tag the docker image as basic:latest. Check the docker image size by running docker images.

1
2
3
4
docker images
REPOSITORY   TAG               IMAGE ID       CREATED         SIZE
basic        latest            422263a9761f   2 minutes ago   317MB
golang       1.17-alpine3.15   011dbc9d894d   11 days ago     315MB

This docker file generates a docker image of size 317 Mb. Let’s try to reduce the size of this in the next sections.

Multistage docker build

Let us try a multi-stage build by using the Dockerfile-multistage. The contents of the file are as follows

1
2
3
4
5
6
7
8
9
FROM golang:1.17-alpine3.15             # base image - stage 0
WORKDIR /app                            # current working dir
COPY . .                                    
RUN go build -o application main.go     # build native binary

FROM alpine:latest                      # runtime image
WORKDIR /app                            # workdir in the runtime image
COPY --from=0 /app ./                   # copy contents from stage 0
CMD ["./application"]                   # run

In this Dockerfile, we use 2 docker images. We use golang:1.17-alpine3.15 in the first stage to compile the code and prepare the native binary. This docker image contains all the tools required to compile and build the native binary. The next stage is the bare minimum alpine:latest image where we copy the binary file created in the earlier stage. The earlier stage is referenced in the final stage by providing the flag --from=0.

Let’s build the docker container using the command docker build -t multistage:latest -f Dockerfile-multistage . from the project root. Check the docker image size by running docker images.

1
2
3
4
5
6
❯ docker images
REPOSITORY   TAG               IMAGE ID       CREATED          SIZE
multistage   latest            924c9310ddae   18 seconds ago   7.39MB
basic        latest            422263a9761f   18 minutes ago   317MB
golang       1.17-alpine3.15   011dbc9d894d   11 days ago      315MB
alpine       latest            c059bfaa849c   3 months ago     5.59MB

We managed to reduce the build size from 317 Mb to 7.36 Mb by using the multistage build.

Multistage docker with scratch

We can further trim down the size of the docker image by providing additional build flags while building the native binary. This will strip down any debug information from the binary (this should be ok in some cases is not recommended for all scenarios) . Additionally, in the second stage, we use scratch image which is the bare minimum docker image.

We will build the docker image build by using the Dockerfile-multistage-scratch. The contents of the file are as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM golang:1.17-alpine3.15
WORKDIR /app
COPY . .
# strip additional debug information from
RUN go build -ldflags '-w -s -extldflags "-static"' -a -o application main.go

FROM scratch            # using scratch image
WORKDIR /app
COPY --from=0 /app ./
CMD ["./application"]

Create the docker image by running the command docker build -t multistage-scratch:latest -f Dockerfile-multistage-scratch . from the project root. Check the docker image size by running docker images

1
2
3
4
5
6
7
❯ docker images
REPOSITORY           TAG               IMAGE ID       CREATED          SIZE
multistage-scratch   latest            d29a663b349c   4 seconds ago    1.23MB
multistage           latest            924c9310ddae   12 minutes ago   7.39MB
basic                latest            422263a9761f   31 minutes ago   317MB
golang               1.17-alpine3.15   011dbc9d894d   11 days ago      315MB
alpine               latest            c059bfaa849c   3 months ago     5.59MB

Here we have reduced the docker image size to 1.23 Mb. To run binary files on a scratch image, your executables need to be statically compiled and self-contained. This means there is no compiler in the image so you’re left with just system calls. This also means that there is no login shell, no user, ca-certificates, and just a native binary running on the scratch image. As a developer, you should be aware of what tools might require on the container and install them explicitly.

In the next section, we will make some improvements so that our images are safer to use.

Some more improvements

Let’s modify the docker image to add the following improvements.

  • Add a user (appuser): We should run the binary with a low privilege user.
  • Add timezone and ca certificates: Necessary for tls and timezone information.

To do this we use the file Dockerfile-appuser. Create a docker image by running the command docker build -t appuser:latest -f Dockerfile-appuser . from the project root.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FROM golang:1.17-alpine3.15
WORKDIR /app
COPY . .
ENV USER=appuser
ENV UID=10001
RUN apk update && apk upgrade && \      # install additional tools
    apk add --no-cache \
    git ca-certificates tzdata \
    update-ca-certificates
RUN adduser \                           # create a user
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    "${USER}"
RUN go build -ldflags \                 # build the native binary
    '-w -s -extldflags "-static"' \
    -a -o application main.go

FROM scratch
ENV USER=appuser                        # copy the essential tools
COPY --from=0 /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=0 /etc/passwd /etc/passwd   # copy user info from stage 0
COPY --from=0 /etc/group /etc/group
WORKDIR /app
COPY --from=0 /app ./
USER appuser                            # Run as appuser
CMD ["./application"]

Let’s check the image size by running the docker images. The corresponding image size is 2.59 Mb. This is a great improvement from our first docker image size which was 317Mb.

1
2
3
4
5
6
7
8
❯ docker images
REPOSITORY           TAG               IMAGE ID       CREATED          SIZE
appuser              latest            e8cc7e176934   2 minutes ago    2.59MB
multistage-scratch   latest            d29a663b349c   22 minutes ago   1.23MB
multistage           latest            924c9310ddae   34 minutes ago   7.39MB
basic                latest            422263a9761f   53 minutes ago   317MB
golang               1.17-alpine3.15   011dbc9d894d   11 days ago      315MB
alpine               latest            c059bfaa849c   3 months ago     5.59MB

Conclusion

In this blog post, we have seen how to keep the docker image size minimum for the Go applications. Smaller the image size better the resource utilization and faster the operations with lesser vulnerabilities. This is useful when you want to have more responsive and quicker feedback from your CI-CD pipelines.

References