Skip to main content

Multi-stage builds

Multi-stage builds are useful to anyone who has struggled to optimize Dockerfiles while keeping them easy to read and maintain.

Without Multi-stage builds

Before Docker 17.05, there were typically two ways to build Docker images:

Put everything in one Dockerfile

One way was to include all the build steps in a single Dockerfile, including the compilation, testing, and packaging of the project and its dependencies. This could lead to some issues:

  • Multiple image layers, larger image size, and longer deployment times

  • Risk of source code leakage

For example, write an app.go file that prints Hello World!

package main

import "fmt"

func main(){
fmt.Printf("Hello World!");
}

Write a Dockerfile.one file

FROM golang:alpine

RUN apk --no-cache add git ca-certificates

WORKDIR /go/src/github.com/go/helloworld/

COPY app.go .

RUN go get -d -v github.com/go-sql-driver/mysql \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . \
&& cp /go/src/github.com/go/helloworld/app /root

WORKDIR /root/

CMD ["./app"]

Build the image

$ docker build -t go/helloworld:1 -f Dockerfile.one .

Split into multiple Dockerfiles

The other way was to first compile, test, and package the project and its dependencies in one Dockerfile, and then copy the artifacts to the runtime environment in another Dockerfile. This approach required writing two Dockerfiles and some build scripts to automate the integration of the two stages, which made the deployment process more complex, although it avoided the risks of the first approach.

For example, write a Dockerfile.build file

FROM golang:alpine

RUN apk --no-cache add git

WORKDIR /go/src/github.com/go/helloworld

COPY app.go .

RUN go get -d -v github.com/go-sql-driver/mysql \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

Write a Dockerfile.copy file

FROM alpine:latest

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY app .

CMD ["./app"]

Create a build.sh script

#!/bin/sh
echo Building go/helloworld:build

docker build -t go/helloworld:build . -f Dockerfile.build

docker create --name extract go/helloworld:build
docker cp extract:/go/src/github.com/go/helloworld/app ./app
docker rm -f extract

echo Building go/helloworld:2

docker build --no-cache -t go/helloworld:2 . -f Dockerfile.copy
rm ./app

Now run the script to build the image

$ chmod +x build.sh

$ ./build.sh

Compare the image sizes generated by the two approaches

$ docker image ls

REPOSITORY TAG IMAGE ID CREATED SIZE
go/helloworld 2 f7cf3465432c 22 seconds ago 6.47MB
go/helloworld 1 f55d3e16affc 2 minutes ago 295MB

Using Multi-stage builds

To solve the above problems, Docker v17.05 introduced support for multi-stage builds. Using multi-stage builds, we can easily solve the problems mentioned earlier, and only need to write a single Dockerfile:

For example, write a Dockerfile file

FROM golang:alpine as builder

RUN apk --no-cache add git

WORKDIR /go/src/github.com/go/helloworld/

RUN go get -d -v github.com/go-sql-driver/mysql

COPY app.go .

RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest as prod

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=0 /go/src/github.com/go/helloworld/app .

CMD ["./app"]

Build the image

$ docker build -t go/helloworld:3 .

Compare the sizes of the three images

$ docker image ls

REPOSITORY TAG IMAGE ID CREATED SIZE
go/helloworld 3 d6911ed9c846 7 seconds ago 6.47MB
go/helloworld 2 f7cf3465432c 22 seconds ago 6.47MB
go/helloworld 1 f55d3e16affc 2 minutes ago 295MB

It is clear that the image built using multi-stage builds is smaller in size, while also perfectly solving the problems mentioned earlier.

Use a previous stage as a new stage

When using multi-stage builds, you aren't limited to copying from stages you created earlier in your Dockerfile. You can use the COPY --from instruction to copy from a separate image, either using the local image name, a tag available locally or on a Docker registry, or a tag ID. The Docker client pulls the image if necessary and copies the artifact from there. The syntax is:

FROM golang:alpine as builder

For example, if we only want to build the image for the builder stage, add the --target=builder parameter:

$ docker build --target builder -t username/imagename:tag .

Use an external image as a stage

You can pick up where a previous stage left off by referring to it when using the FROM directive.

In the above example, we used COPY --from=0 /go/src/github.com/go/helloworld/app . to copy files from the previous stage's image. We can also copy files from any other image.

$ COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf