Producing smaller docker images for go applications

Table of contents

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

More articles

Optimizing SQL queries with ChatGPT

Take advantage of ChatGPT's processing capabilities to make sense of complex query costs and optimization opportunities

Avoiding the 3 most common python pitfalls

Learn how to overcome the most notorious coding pitfalls instead of being surprised by bugs

Dealing with pagination queries in SQL

The difficult choice behind chunking results into simple pages

Understanding postgres query plans

Making sense of postgres explanations

Fixing mixed content issues

Fixing holes in encrypted web traffic