Just Give Me the Code

Fine...here you go.

Motivation

Consider the following Dockerfile:

FROM scratch  
ADD ca-certificates.crt /etc/ssl/certs/  
ADD main /  
CMD ["/main"]  

Since go is a compiled language, we can make our image lightweight by basing it from scratch and simply copying in our binary along with a common ca certificates bundle and that's it!

This is what I did up until the very first keynote of DockerCon 2017 where multi-stage builds were introduced!

Before we go further, we need to identify a problem, if any, with the above Dockerfile that might motivate a move towards multi-stage builds.

Before we're able to copy a binary into our container, we obviously need to build it. This would most likely be done by our build tool. We would install our dependencies, build a compiled binary for our target platform, run our test suite, and finally take our tested, compiled binary and build our lightweight container image.

We can improve this workflow by doing all of the above inside of our container, instead of our build tool. This is desirable because now you can build, compile, test, and run all on the same target platform. This would prevent a developer from mistakenly building and testing their binary for a different target platform than what will ultimately end up inside our final container. This approach is also independent of a build tool and declaratively has our entire build process in one place. Our Dockerfile to accomplish this might look like this:

FROM golang:1.8.1-alpine  
MAINTAINER fbgrecojr@me.com  
WORKDIR /go/src/github.com/frankgreco/gobuild/  
COPY ./ /go/src/github.com/frankgreco/gobuild/  
RUN apk add --update --no-cache \  
        wget \
        curl \
        git \
    && wget "https://github.com/Masterminds/glide/releases/download/v0.12.3/glide-v0.12.3-`go env GOHOSTOS`-`go env GOHOSTARCH`.tar.gz" -O /tmp/glide.tar.gz \
    && mkdir /tmp/glide \
    && tar --directory=/tmp/glide -xvf /tmp/glide.tar.gz \
    && rm -rf /tmp/glide.tar.gz \
    && export PATH=$PATH:/tmp/glide/`go env GOHOSTOS`-`go env GOHOSTARCH` \
    && glide update -v \
    && glide install \
    && CGO_ENABLED=0 GOOS=`go env GOHOSTOS` GOARCH=`go env GOHOSTARCH` go build -o foo \
    && go test $(go list ./... | grep -v /vendor/) \
    && apk del wget curl git
ENTRYPOINT ["/go/src/github.com/frankgreco/gobuild/foo"]  
$ docker images
REPOSITORY                               TAG                 IMAGE ID            CREATED              SIZE  
fbgrecojr/gobuild                        latest              9c9c0ba4a97a        19 seconds ago       277MB  



This build uses the golang:1.8.1-alpine base image which results in an image size of 277MB. While this might be considered a lightweight image, it's nothing compared to the 6.12MB image that would have been produced had we used the first Dockerfile. This is where Docker's multi-stage builds can help us out.

Requirements

Multi-stage builds were officially released April 18, 2017 during the opening keynote session of DockerCon 2017. It is part of the 17.05 releases. To follow along in my demo, simply make sure that your Docker for Mac or Docker for Windows version is at least 17.05. At the time of writing this, this requires using the edge release instead of the stable release.

Demo

For this demo, we'll be working with an extremely basic go webservice (this is the code that was used in the above baselines):

package main

import (  
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {  
  fmt.Fprintf(w, "Hello World!")
}

func main() {  
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}



We'll also have an extremely basic test file:

package main

import (  
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestMain(t *testing.T) {  
    assert := assert.New(t)
    assert.Equal(0, 0, "zero should equal zero")
}



Alright so let's start with the ugly, there is zero documentation about multi-stage builds...yet. Even after scouring Docker's vnext branches on GitHub, it seems as though the docs are only current up to version 17.04 (I'll attempt to keep this post up to date to reflect the current status).

Alright so let's get started. Based on our above examples, here is how our Dockerfile looks after incorporating multi-stage builds:

# build stage
ARG GO_VERSION=1.8.1  
FROM golang:${GO_VERSION}-alpine AS build-stage  
MAINTAINER fbgrecojr@me.com  
WORKDIR /go/src/github.com/frankgreco/gobuild/  
COPY ./ /go/src/github.com/frankgreco/gobuild/  
RUN apk add --update --no-cache \  
        wget \
        curl \
        git \
    && wget "https://github.com/Masterminds/glide/releases/download/v0.12.3/glide-v0.12.3-`go env GOHOSTOS`-`go env GOHOSTARCH`.tar.gz" -O /tmp/glide.tar.gz \
    && mkdir /tmp/glide \
    && tar --directory=/tmp/glide -xvf /tmp/glide.tar.gz \
    && rm -rf /tmp/glide.tar.gz \
    && export PATH=$PATH:/tmp/glide/`go env GOHOSTOS`-`go env GOHOSTARCH` \
    && glide update -v \
    && glide install \
    && CGO_ENABLED=0 GOOS=`go env GOHOSTOS` GOARCH=`go env GOHOSTARCH` go build -o foo \
    && go test $(go list ./... | grep -v /vendor/) \
    && apk del wget curl git

# production stage
FROM alpine:3.5  
MAINTAINER fbgrecojr@me.com  
COPY --from=build-stage /go/src/github.com/frankgreco/go-docker-build/foo .  
ENTRYPOINT ["/foo"]  



The first thing you'll notice is that we can specify build args that are global to all of our build stages. This allows us dynamically set our base image. We can also alias our different stages using the AS keyword. This becomes useful when copying artifacts from one stage to the next.

Besides that, everything looks similar except that is seems we have multiple Dockerfiles in one? Each traditional Dockerfile "block" represents a stage. Artifacts can be copied from one stage to the next but only the last stage will represent the final image.

Each traditional Dockerfile "block" represents a stage.

This means that we can still follow the pattern of building and testing our application inside of Docker on the same target architecture while still having our final image be minimal and lightweight! (golang1.8.1-alpine is also based from alpine:3.5) After building this image, the final size is only 6.12MB.

$ docker images
REPOSITORY                               TAG                 IMAGE ID            CREATED             SIZE  
fbgrecojr/gobuild                        latest              75402a06f11a        About an hour ago   6.12MB  



If you look closely, I did change one other thing. I am no longer basing my image from scratch but from alpine:3.5. After a constructive debate with a coworker, I agree with him that for the extra ~2MB it adds, having the ability to use a package manager for debugging purposes adds a lot of value!

What's Next

Want to start using multi-stage builds? Check out my example on GitHub and use is as a base to building more effective images for your applications.