containers

Optimize Your Docker Images with Multi-Stage Builds and Bitnami Containers

Introduction

If you’ve tried creating a Docker image for a Go application (or any other application created as a statically-linked binary), you’ve probably already seen that your image ends up holding a large number of build files that are unnecessary for the application to run. To counter this, you’ve probably needed to run manual cleanup code or create a separate Dockerfile to produce a final image with only the minimum necessary files.

Docker multi-stage builds provide an elegant solution to this problem, by allowing you to manage build and production images for your application in a single Dockerfile. This guide will show you how to use Bitnami containers and a Docker multi-stage build to create a clean, cruft-free and production-ready Go application image.

Assumptions and prerequisites

This guide will assume that you already have a Docker environment with a recent version of Docker and that you are familiar with the basics of container usage.

TIP: Windows users can refer to our guide for installing Docker Toolbox for Windows.

Step 1: Obtain the application source code

This guide uses a Go example application written with Negroni. This application is very simple: all it does is read an HTML file and return its contents using a ResponseWriter. The HTML is maintained in a separate file purely for illustrative purposes, to create an additional asset that will be separately managed in the Dockerfile.

First, create the Go server file server.go and fill it with the following code:

package main

import (
  "fmt"
  "io/ioutil"
  "net/http"
  "github.com/urfave/negroni"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    t, _ := ioutil.ReadFile("page.html")
    fmt.Fprintf(w, string(t))
  })

  n := negroni.Classic()
  n.UseHandler(mux)

  n.Run()
}

Nothing very complex here: when the application receives an HTTP request, it reads the contents of the HTML file and sends the data back as an HTTP response. It uses the Run() function both for simplicity, and also because Run() respects the PORT environment variable which can be set in the Dockerfile, providing another point of configuration.

You should now also create the HTML page returned by the server, as page.html:

<html>
  <head>
    <title>Hello From Bitnami</title>
  </head>
  <body>
    <strong>Hello from Bitnami!</strong>
  </body>
</html>

Step 2: Create the initial Dockerfile (first stage)

The next step is to create a Dockerfile that builds the Go application. Follow the steps below:

  • This Dockerfile should start with a base image that includes all the typical development tools you will need. Bitnami provides a Go base image that includes all these tools.

    The Bitnami Go base image is available in Docker Hub so using it is as simple as adding the following first line in your Dockerfile:

    FROM bitnami/golang:1.13 as builder
    
  • Now, you need to run the Go commands to build the application. This involves running go get to add the Negroni library to the image, then copying and compiling the server.go source file. Here are the Dockerfile instructions to accomplish these tasks:

    RUN go get github.com/urfave/negroni
    COPY server.go /
    RUN go build /server.go
    

The resulting Dockerfile should look like this:

FROM bitnami/golang:1.13 as builder
RUN go get github.com/urfave/negroni
COPY server.go /
RUN go build /server.go

Step 3: Build and test the application image

To build the Docker image, run the docker build command in the directory containing the Dockerfile:

$ docker build -t hello .

Run the image in a container with docker run and start a Bash shell so you can access it from the command line:

$ docker run -it hello /bin/bash

While at the command prompt, inspect the filesystem with ls and verify that the server.go file is present in the default /go directory, which indicates that it was successfully compiled. You can also run it to verify that everything is working correctly, using the following command:

$ /go/server

Here’s an example of the output you should see:

Image contents

Stop the container and check the size of the image with the docker images command, as shown below:

$ docker images hello

Image file size

Step 4: Update the Dockerfile (second stage)

Now that you have a “build” Docker image that can compile the Go application, the next step is to create a “production” image that includes the compiled binary and any related assets, but excludes all the development tools (as they are unnecessary once the application has been compiled). Follow the steps below:

  • Begin with a slimmed-down version of the Bitnami base image that does not include any development tools but is optimized for just running code. Start a new section in the Dockerfile by adding the instruction below:

    FROM bitnami/minideb:stretch
    

    This tells Docker to throw away the image that was created previously, and create a brand new one starting with this base image. Each new image created in this way corresponds to a separate “stage”. Docker keeps track of all the images/stages created in the Dockerfile and numbers them sequentially, with the first one numbered as 0.

  • Create a directory for the Go application and set it as the working directory:

    RUN mkdir -p /app
    WORKDIR /app
    
  • Copy the Go binary that was compiled in the previous image, into the new image. This is achieved by using the --from option in the COPY instruction. The --from option accepts a value corresponding to the name of the stage to copy files from - in this case, builder, being the name of the first stage.

    COPY --from=builder /go/server .
    
  • Copy the HTML page needed by the application. For security reasons, it’s also best practice to create a non-root user account under which the application will run.

    COPY page.html .
    RUN useradd -r -u 1001 -g root nonroot
    RUN chown -R nonroot /app
    USER nonroot
    
  • Finally, run the application. You can use the PORT environment variable to tell the container to serve the application on port 8080:

    ENV PORT=8080
    CMD /app/server
    

The final Dockerfile should now look like this:

FROM bitnami/golang:1.13 as builder
RUN go get github.com/urfave/negroni
COPY server.go /
RUN go build /server.go

FROM bitnami/minideb:stretch
RUN mkdir -p /app
WORKDIR /app
COPY --from=builder /go/server .
COPY page.html .
RUN useradd -r -u 1001 -g root nonroot
RUN chown -R nonroot /app
USER nonroot
ENV PORT=8080
CMD /app/server

Step 5: Build and test the production image

Rebuild the Docker image:

$ docker build -t hello .

Run the new image in a container and start a Bash shell so you can access it from the command line:

$ docker run -it hello /bin/bash

If you inspect the file system and compare the results with what you saw previously, you’ll see that this container is much simpler. It has the server binary and the HTML page, but it lacks Go and related tools. As a result, the image is also much smaller in size. You can verify this with the docker images command as before:

$ docker images hello

Image file size

As a final test, run the image using the command below and bind the server to port 8080 of the Docker host:

$ docker run -p 8080:8080 hello

If you open a browser and navigate to the IP address of the Docker host at port 8080, you should see the Bitnami welcome message, as shown below:

Test of final container

It should be clear that using Docker’s multi-stage builds will significantly optimize your Docker images and will also result in a more maintainable source repository by avoiding unnecessary Dockerfile duplication.

To learn more about the topics discussed in this guide, use the links below:

Last modification February 25, 2020