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.
.
├── 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:
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
.
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
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
.
❯ 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:
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
❯ 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.
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.
❯ 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.