Building smaller docker images using multi-stage builds

When you start packaging your application in docker containers you may or may not have noticed the size of your images. Building images is not that hard if you know what the app needs.

In this post I will take a basic HelloWorld golang example and guide you from an image that is 812MB to an image that is 2.01MB. Note that the language does not make a difference and the following approach can be done with any language.

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

Getting a working build

Let’s build a Dockerfile that will create an image containing the binary.

First, we start by locating a community image that can build golang binaries. I would suggest depending on official images whenever possible. To find these navigate to docker hub and use the Official Images 1. It is best practice to lock your images to a specific version tag. By doing this you ensure your builds are consistent and reproducible.

FROM golang:1.12.14-buster

Next is to set the workdir for your build. This changes to (and creates if needed) a directory. It’s a good idea to keep this directory consistent over all your projects.

WORKDIR /app

Now it’s time to add the source to the image. Nothing special here, make sure you include all files needed to build your application. For projects that have local libraries like node_modules for node projects, these should not be added. You can run npm inside the image.

COPY main.go /app/

After all the source files are added to the image, it’s time to build the artifact.

RUN go build main.go

As a final step, we will add a command to define how to run the image.

CMD ["/app/main"]

If you followed the previous steps, you should have something like this:

FROM golang:1.12.14-buster
WORKDIR /app
COPY main.go  /app
CMD ["/app/main"]

Now we can build the image and test it. While we are at it, let’s also have a look at the size of the image we just created.

$ docker build -t docker-multistage:1 .
Sending build context to Docker daemon  3.072kB
Step 1/5 : FROM golang:1.12.14-buster
...
Successfully built 8dea0fad83db
Successfully tagged docker-multistage:1

$ docker run docker-multistage
Hello world

$ docker images docker-multistage:1
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker-multistage   1                   8dea0fad83db        2 seconds ago       812MB

Smaller build image

A first thing we can do, it picking a smaller golang image. Most official images have a choice of multiple platforms. We will pick alpine here as it’s a Linux distribution with a very small footprint. Let’s update

FROM golang:1.13.5-alpine

If we rebuild the image using our new base image, it will be smaller.

$ docker build -t docker-multistage:2 .
Building docker-multistage:2
Sending build context to Docker daemon  3.072kB
Step 1/5 : FROM golang:1.13.5-alpine3.10
...
 ---> cb95a5047faf
Successfully built cb95a5047faf
Successfully tagged docker-multistage:2

$ docker images docker-multistage
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker-multistage   1                   8dea0fad83db        1 minute ago        812MB
docker-multistage   2                   cb95a5047faf        4 seconds ago       361MB

By using a smaller base image we’re able to reduce the image size by 451MB. So far, we have not used any multistage build features, so let’s do that now.

Multistage

By adding AS <name> to the FROM statement, we’re able to reference this at a later stage.

FROM golang:1.13.5-alpine AS build

In the same Dockerfile we can add another FROM statement using a small alpine image. Note that this image only contains the runtime needed for the project and no additional build tools. In the case of golang this will be an empty os. If you are using php, you can use just the interpreter and can leave any tooling like composer out. In the case of java you can use the jre version of the java runtime instead of the jdk you may use for building the image.

FROM alpine:3.10.3
COPY --from=build /app/main /app/main

Now your Dockerfile should look like this:

FROM golang:1.13.5-alpine AS build
WORKDIR /app
COPY main.go  /app
RUN go build main.go

FROM alpine:3.10.3
COPY --from=build /app/main /app/main
CMD ["/app/main"]

We did not define a CMD in the build image. This is because we will not be running the build image.

$ docker build -t docker-multistage:3 .
Sending build context to Docker daemon  3.072kB
Step 1/7 : FROM golang:1.13.5-alpine AS build
 ...
Step 5/7 : FROM alpine:3.10.3
...
Successfully built 39b9e1ffb59a
Successfully tagged docker-multistage:3

$ docker images docker-multistage
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker-multistage   1                   8dea0fad83db        2 minutes ago       812MB
docker-multistage   2                   cb95a5047faf        1 minute ago        361MB
docker-multistage   3                   39b9e1ffb59a        4 seconds ago       7.56MB

If we look at our newly created image, it just got a lot smaller. This is because we only added the alpine os and our artifact, no additional packages are included in the image.

Scratch

Although we already have a very small image, we can do even better. This option however, is most likely not be possible for most applications.

We can update our runtime base image to scratch. This is an image made for base images and super minimal images that contain a single binary. Because of the nature of golang, this is possible.

FROM scratch
$ docker build -t docker-multistage:4 .
Sending build context to Docker daemon  3.072kB
Step 1/7 : FROM golang:1.13.5-alpine AS build
...
Successfully built 42ef8addf7b5
Successfully tagged docker-multistage:4

$ docker images docker-multistage
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker-multistage   1                   8dea0fad83db        3 minutes ago       812MB
docker-multistage   2                   cb95a5047faf        2 minutes ago       361MB
docker-multistage   3                   39b9e1ffb59a        1 minute ago        7.56MB
docker-multistage   4                   42ef8addf7b5        2 seconds ago       2.01MB

By looking at the result, we now see the image has about the same size as the original artifact we are building.

Dries De Peuter | June 17, 2019