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.
|
|
The project structure looks like the below with the main.go file and several docker files for each scenario.
|
|
First attempt⌗
To create a docker image for our application we will use the Dockerfile-basic docker-file. Its contents are:
|
|
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
.
|
|
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
|
|
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
.
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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.