containersoptimize-docker-images-multistage-builds

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 Docker v17.05 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.

The example application’s source code is stored in our GitHub repository. Obtain the code by cloning the repository and navigating to the correct directory:

$ git clone https://github.com/bitnami/tutorials
$ cd multistage-docker

You should end up with a Go source code file (server.go) and an HTML file (page.html) containing a welcome message. Here’s what the application source code looks like:

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.

Step 2: Create the initial Dockerfile (first stage)

The first step is to create a Dockerfile that will build 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, such as compilers, a Git client, and so on. Bitnami provides a base image that includes all these tools. It’s built on top of minideb, which is a slimmed-down Debian distribution used as the base for all Bitnami containers.

    The base image containing minideb and additional build tools is available in Docker Hub so using it is as simple as adding the following first line in your Dockerfile:

    FROM bitnami/minideb-extras:jessie-r14-buildpack
    
  • Next, you need to install Go. The easiest way to do this is to use Bitnami’s Nami tool. The Nami tool is built into the base image, so you just need to add the line below to your Dockerfile to have it install Go:

    RUN bitnami-pkg install go-1.8.3-0 --checksum 557d43c4099bd852c702094b6789293aed678b253b80c34c764010a9449ff136
    

    This instruction tells Nami to download the Go module, do a check to ensure that it is the exact file that is expected, and then install it into your container…no compiling necessary!

    TIP: For other versions and languages, take a look at Bitnami’s other containers.

  • To make things easier, you should set the GOPATH environment variable and add the Go binary to your PATH as well:

    ENV GOPATH=/gopath
    ENV PATH=$GOPATH/bin:/opt/bitnami/go/bin:$PATH
    
  • 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/minideb-extras:jessie-r14-buildpack

RUN bitnami-pkg install go-1.8.3-0 --checksum 557d43c4099bd852c702094b6789293aed678b253b80c34c764010a9449ff136

ENV GOPATH=/gopath
ENV PATH=$GOPATH/bin:/opt/bitnami/go/bin:$PATH

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 .

You will see output like this during the build process, as the different layers of the base container are pulled:

Image build process

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, which indicates that it was successfully compiled. You can also run it to verify that everything is working correctly, using the following command:

$ ./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:latest
    

    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.

  • The next step is to 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 number of the stage to copy files from - in this case, 0 for the first one.

    COPY --from=0 /server /
    
  • Finally, copy the HTML page needed by the application and run the application. You can also use the PORT environment variable to tell the container to serve the application on port 80:

    COPY page.html /
    ENV PORT=80
    CMD /server
    

The final Dockerfile should now look like this:

FROM bitnami/minideb-extras:jessie-r14-buildpack

RUN bitnami-pkg install go-1.8.3-0 --checksum 557d43c4099bd852c702094b6789293aed678b253b80c34c764010a9449ff136

ENV GOPATH=/gopath
ENV PATH=$GOPATH/bin:/opt/bitnami/go/bin:$PATH

RUN go get github.com/urfave/negroni

COPY server.go /
RUN go build /server.go

FROM bitnami/minideb:latest

COPY --from=0 /server /

COPY page.html /

ENV PORT=80

CMD /server

TIP: You can download the final Dockerfile from the tutorial repository.

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 gopath or any of the other development tools.

Image contents

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 80 of the Docker host:

$ docker run -p 80:80 hello
[negroni] listening on :80

If you open a browser and navigate to the IP address of the Docker host, 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: