Building Efficient Docker Images with Multi-stage Builds

🚀Introduction

Docker is an effective way of sharing and deploying our next big side project (pun intended). However, as the complexity of our applications increases, so does the size of the corresponding Docker image. Spinning up large Docker images can consume resources and lead to slower deployments. Enter Docker multistage builds, a feature that helps us create leaner and more efficient Docker images. 🐳

What are Multi-stage Builds?

Multi-staging refers to having multiple FROM statements in your Docker file. Each FROM statement refers to a new build stage. The idea is to build your image in stages, with each stage contributing sequentially to the final build. This reduces the size of the image exceptionally well if you are working with a compiled language (like Golang). 🚢

Advantages of Multi-stage builds

  • Reduced Image Size: By removing unnecessary build dependencies and including only necessary artifacts, it results in a reduced image size. 📉

  • Speed: Huge images are slower to spin up; hence, by reducing the size of the image, we can improve the speed of deployments. ⚡

  • Security: Since the final image contains only required dependencies, it reduces the attack surface of the image. 🔒

Implementation

Scenario: We are going to implement a very simple Golang server and build its Docker image, first without multi-staging and then with multi-staging. At the end, we are going to do a size comparison.

Prerequisites

Before proceeding, make sure you have following installed on your machine

  1. Golang (How to download and install go)

  2. Docker (How to install Docker Engine)

Once these things have been setup we can start writing our application.

Step 1 : Create a simple server in golang

We will start by writing a simple server in main.go file that responds with a Hello World when the endpoint '/' is invoked.

  1. Step 1-a :

    Create a new folder, then open the terminal in that folder and run the following commands

     go mod init github.com/your-github-id/your-repo
     touch main.go
    
  2. Step 1-b:

    In main.go file create a simple server using following program

     package main
    
     import (
         "log"
         "net/http"
     )
    
     func main() {
         mux := http.NewServeMux()
         mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
             w.Write([]byte("Hello World"))
         })
    
         log.Print("Server is listening on 8000")
         http.ListenAndServe(":8000", mux)
     }
    
  3. Step 1-c:

    Start your server by running the following command

     go run main.go
    
  4. Step 1-d:

    Test your go server by using cURL or whatever you like

     curl localhost:8000
     # output : Hello World
    

Now that we have tested our server lets build its docker image

Step 2 : Create the docker file (without multi-staging)

  1. Step 2-a

    Create a Dockerfile in the same folder and write the following code

     FROM golang:1.21.4-alpine
     RUN mkdir /app
     COPY . /app
     WORKDIR /app
     RUN go build -o main .
     CMD [ "/app/main" ]
    
  2. Step 2-b

    Run the following command to build the docker image

     docker build -t goapp:latest .
    
  3. Step 2-c

    After building the image, search for the image in your local registry by using docker images and note the size.

Step 3 : Edit the Dockerfile for multi-staging

  1. Step 3-a

    Update the Dockerfile with the following code

     #### Stage 1
     FROM golang:1.21.4-alpine AS build
     RUN mkdir /app
     COPY . /app
     WORKDIR /app
     RUN go build -o main .
     RUN chmod +x /app/main
    
     #### Stage 2
     FROM scratch
     COPY --from=build /app/main /main
     ENTRYPOINT [ "/main" ]
    

    you can see we have multiple FROMs in our Dockerfile and each FROM represents a new stage.

    • In first stage (alias build), we are pulling the alpine image (basic image) of golang in order to build the executable out of our golang application.

    • In second stage, we are using the scratch image (smallest possible image for Docker), then we copying our executable from our previous stage (called build) and putting it in the scratch image, which leads to smaller size of the image.

  2. Step 3-b:

    Run the following command to build the image

     docker build -t goapp-multi-stage:latest .
    
  3. Step 3-c

    Run the docker images command to view your image and notice its size.

Size Comparison

Soooo, are we ready for the big reveal! Drum rolls.... 🥁🥁🥁🥁

Before Multi-Staging

We see that even without our app requiring any external dependency its size reaches 291 MB (not good), but what happens when we use multiple stages for building the image?

After Multi-Staging

As evident from above screenshots the size was reduced by >90%.

Conclusion

Docker multistage builds provide a simple yet effective way to optimize your Docker images. By eliminating unnecessary dependencies and intermediate artifacts, you can create smaller, more secure, and faster-to-deploy containers. Consider incorporating multistage builds into your Docker workflow to enhance the efficiency of your containerized applications.

Happy building! 👋