Small docker images for Go
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.
1package main
2import "fmt"
3
4func main() {
5 fmt.Println("Hello world!!")
6}
The project structure looks like the below with the main.go file and several docker files for each scenario.
1.
2├── Dockerfile-appuser
3├── Dockerfile-basic
4├── Dockerfile-multistage
5├── Dockerfile-multistage-scratch
6├── README.md
7├── go.mod
8└── main.go
First attempt⌗
To create a docker image for our application we will use the Dockerfile-basic docker-file. Its contents are:
1FROM golang:1.17-alpine3.15 # base image
2WORKDIR /app # working directory inside docker
3COPY . . # copy content to working dir
4RUN go build -o application main.go # create the native binary
5CMD [ "/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.
1docker images
2REPOSITORY TAG IMAGE ID CREATED SIZE
3basic latest 422263a9761f 2 minutes ago 317MB
4golang 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
1FROM golang:1.17-alpine3.15 # base image - stage 0
2WORKDIR /app # current working dir
3COPY . .
4RUN go build -o application main.go # build native binary
5
6FROM alpine:latest # runtime image
7WORKDIR /app # workdir in the runtime image
8COPY --from=0 /app ./ # copy contents from stage 0
9CMD ["./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❯ docker images
2REPOSITORY TAG IMAGE ID CREATED SIZE
3multistage latest 924c9310ddae 18 seconds ago 7.39MB
4basic latest 422263a9761f 18 minutes ago 317MB
5golang 1.17-alpine3.15 011dbc9d894d 11 days ago 315MB
6alpine 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:
1FROM golang:1.17-alpine3.15
2WORKDIR /app
3COPY . .
4# strip additional debug information from
5RUN go build -ldflags '-w -s -extldflags "-static"' -a -o application main.go
6
7FROM scratch # using scratch image
8WORKDIR /app
9COPY --from=0 /app ./
10CMD ["./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❯ docker images
2REPOSITORY TAG IMAGE ID CREATED SIZE
3multistage-scratch latest d29a663b349c 4 seconds ago 1.23MB
4multistage latest 924c9310ddae 12 minutes ago 7.39MB
5basic latest 422263a9761f 31 minutes ago 317MB
6golang 1.17-alpine3.15 011dbc9d894d 11 days ago 315MB
7alpine 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.
1FROM golang:1.17-alpine3.15
2WORKDIR /app
3COPY . .
4ENV USER=appuser
5ENV UID=10001
6RUN apk update && apk upgrade && \ # install additional tools
7 apk add --no-cache \
8 git ca-certificates tzdata \
9 update-ca-certificates
10RUN adduser \ # create a user
11 --disabled-password \
12 --gecos "" \
13 --home "/nonexistent" \
14 --shell "/sbin/nologin" \
15 --no-create-home \
16 --uid "${UID}" \
17 "${USER}"
18RUN go build -ldflags \ # build the native binary
19 '-w -s -extldflags "-static"' \
20 -a -o application main.go
21
22FROM scratch
23ENV USER=appuser # copy the essential tools
24COPY --from=0 /usr/share/zoneinfo /usr/share/zoneinfo
25COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
26COPY --from=0 /etc/passwd /etc/passwd # copy user info from stage 0
27COPY --from=0 /etc/group /etc/group
28WORKDIR /app
29COPY --from=0 /app ./
30USER appuser # Run as appuser
31CMD ["./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❯ docker images
2REPOSITORY TAG IMAGE ID CREATED SIZE
3appuser latest e8cc7e176934 2 minutes ago 2.59MB
4multistage-scratch latest d29a663b349c 22 minutes ago 1.23MB
5multistage latest 924c9310ddae 34 minutes ago 7.39MB
6basic latest 422263a9761f 53 minutes ago 317MB
7golang 1.17-alpine3.15 011dbc9d894d 11 days ago 315MB
8alpine 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.