Docker images are widely used to deploy apps in staging and production. The typical approach is to build a new image locally or within a CI pipeline, store it in a docker registry, from which the production server will pull it later. To enable reverting to earlier versions in case of errors or incompatibilities, the registry will usually keep multiple older versions of the same docker image around for some time. The size of each individual image adds considerable cost to this infrastructure: Increased bandwidth usage on production/registry servers and more storage space usage for the registry. Let's look at the options of optimizing image size for docker containers containing go applications.
A sample application
To evaluate the effectiveness of each optimization step, we first need an application. In this scenario, we use a very basic web application written in go:
package main
import "log"
import "fmt"
import "html"
import "net/http"
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})
http.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello world!")
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Next, we build it with a very basic Dockerfile
:
FROM golang:1.20
RUN mkdir /app
COPY go.mod go.sum main.go /app/
RUN cd /app && go build -o main main.go
CMD [/app/main]
This results in an 845MB image. We can do better than that!
Switching the base image
Our sample Dockerfile
used golang:1.20
as it's base image, which in turn is based on the linux debian
image. While debian is a popular choice for ease of use and compatibility, it is also quite heavy with a size of 123MB. There are significantly smaller images to use as a base, the most popular used to produce smaller docker images is alpine
linux, which has a size of just 7.33MB.
The official golang docker repository contains an alternative image tag built with alpine as it's base out of the box, so switching base images is as simple as changing the first line in our Dockerfile
to use golang:1.20-alpine
instead of golang:1.20
(debian):
FROM golang:1.20-alpine
RUN mkdir /app
COPY go.mod go.sum main.go /app/
RUN cd /app && go build -o main main.go
CMD [/app/main]
This reduces our image size from 845MB to 322MB - almost have a GB smaller!
Seperating builder and distribution images
The golang
images contain all the tools you need to run and build go applications. This is necessary to compile the application of course, but not for running the compiled binary in production, so why not strip the unnecessary tooling? To do this, we seperate our Dockerfile
into a 2-step process: First we use the golang:1.20-alpine
image to compile the application, then we copy only the compiled binary to a clean alpine
image:
# Compile the binary
FROM golang:1.20-alpine AS builder
WORKDIR /build
COPY go.mod go.sum main.go /build/
RUN cd /build && go build -o main main.go
# copy to lean production image
FROM alpine:3
COPY --from=builder /build/main /app/
WORKDIR /app
ENTRYPOINT ["/app/main"]
The resulting image contains only our binary without the tooling, bringing the output image size down to 13.9MB.
Omitting debugging symbols and DWARF table
Even though our image is already less than 15MB in size, we can further reduce it. While we have exhausted most of our options with docker itself, the compiled binary can be improved, as it currently makes up 6.4MB of the image's size. One of the more obvious improvements we can make is to omit debugging symbols and the DWARF table from being included during build using the linker flags -s
and -w
. You can pass linker flags to go using the -ldflags
flag in the build command, for example:
go build -ldflags "-s -w" main.go
Note the quotation around "-s -w"
- this is important, because we want to pass these to the linker, not the go command directly.
Our adjusted Dockerfile
now looks like this:
# Compile the binary
FROM golang:1.20-alpine AS builder
WORKDIR /build
COPY go.mod go.sum main.go /build/
RUN cd /build && go build -ldflags "-s -w" -o main main.go
# copy to lean production image
FROM alpine:3
COPY --from=builder /build/main /app/
WORKDIR /app
ENTRYPOINT ["/app/main"]
And just like that, the binary size shrinks by 2MB, bringing the binary down to 4.4MB, leaving the total image size at 11.9MB.
Compressing the binary
The last trick up our sleeve is to compress the compiled binary using a tool called upx. This tool will alter the binary in a way that does not change it's functionality or cause cpu/memory penalties but still reduces the file size. Running upx is as simple as
upx -9 main
This command will take a moment while it figures out what compression approach is best for your binary, but it is completely automatic and doesn't require any of your attention. To add it to our Dockerfile, we need to first install it in the builder step using the apk command. Then we can call it after building our binary:
# Compile the binary
FROM golang:1.20-alpine AS builder
RUN apk add upx
WORKDIR /build
COPY go.mod go.sum main.go /build/
RUN cd /build && go build -ldflags "-s -w" -o main main.go && upx -9 main
# copy to lean production image
FROM alpine:3
COPY --from=builder /build/main /app/
WORKDIR /app
ENTRYPOINT ["/app/main"]
This reduces the binary down to 1.9MB, putting our final image size at just 9.24MB. That's 91x smaller than what we started with!
By stripping unnecessary information from the image and compressing the binary, we have produced a final image just over 1% of the original file size. This decrease in file size results in 99% less bandwidth and storage cost when pushing, storing and pulling that image, simply by adjusting a few lines in your Dockerfile
!
To sum it up, here is an overview of all image optimization steps and their output sizes:
REPOSITORY TAG IMAGE ID CREATED SIZE myapp 5_compress_binary 206bf9073be9 49 seconds ago 9.24MB myapp 4_linker_flags 421ade6410cc 2 minutes ago 11.9MB myapp 3_builder_approach 97793914045a 2 minutes ago 13.9MB myapp 2_alpine_base_image 578e2d19f332 3 minutes ago 322MB myapp 1_no_optimizations d90230bdca61 4 minutes ago 845MB