Welcome fellow coders! In this tutorial, we are going to be looking at multi-stage Docker images and how you can use them to minimize the size of the container needed for your production Go applications.

By the end of this tutorial, we will have covered the following concepts:

What Multi-stage Dockerfiles are.

How we can build simple multi-stage Dockerfiles for our Go Apps

Docker is a seriously power containerization technology that can be used to easily spin up isolated and reproducible environments in which our applications can be built and run. It’s growing in popularity and more and more cloud service providers are providing native docker support to allow you to easily deploy your containerized apps for the world to see!

Note - This tutorial is a follow up to my previous Go + Docker tutorial which can be found here: Containerizing Your Go Applications with Docker

What is The Need for Multi-Stage Dockerfiles?

In order to see why multi-stage Dockerfiles are useful, we’ll be creating a simple Dockerfile that features one stage to both build and run our application, and a second Dockerfile which features both a builder stage and a production stage.

Once we’ve created these two distinct Dockerfiles, we should be able to compare them and hopefully see for ourselves just how multi-stage Dockerfiles are preferred over their simpler counterparts!

So, In my previous tutorial, we created a really simple Docker image in which our Go application was both built and run from. This Dockerfile looked something like this:

## We specify the base image we need for our ## go application FROM golang:1.12.0-alpine3.9 ## We create an /app directory within our ## image that will hold our application source ## files RUN mkdir /app ## We copy everything in the root directory ## into our /app directory ADD . /app ## We specify that we now wish to execute ## any further commands inside our /app ## directory WORKDIR /app ## we run go build to compile the binary ## executable of our Go program RUN go build -o main . ## Our start command which kicks off ## our newly created binary executable CMD [ "/app/main" ]

And with this we could subsequently build our Docker image using a very simple docker build command:

$ docker build - t go - simple .

This would create an image and store it within our local docker image repository and would end up looking something like this:

$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE go - simple latest 761 b9dd5f9a4 4 seconds ago 793 MB

You should hopefully notice that last column states that the size of this image is 793MBs in size. This is absolutely massive for something that builds and runs a very simple Go application.

Within this image will be all the packages and dependencies that are needed to both compile and run our Go applications. With multi-stage dockerfiles, we can actually reduce the size of these images dramatically by splitting things up into two distinct stages.

A Simple Multi-Stage Dockerfile

Using multi-stage Dockerfiles, we can pick apart the tasks of building and running our Go applications into different stages. Typically, we start off with a large image which includes all of the necessary dependencies, packages, etc. needed to compile the binary executable of our Go application. This would be classed as our builder stage.

We then take a far more lightweight image for our run stage which includes only what is absolutely needed in order to run a binary executable. This would typically be classed as a production stage or something similar.

## We use the larger image which includes ## all of the dependencies that we need to ## compile our program FROM bigImageWithEverything AS Builder RUN go build -o main ./... ## We then define a secondary stage which ## is built off a far smaller image which ## has the absolute bare minimum needed to ## run our binary executable application FROM LightweightImage AS Production CMD [ "./main" ]

By doing it this way, we benefit from a consistent build stage and we benefit from having absolutely tiny images in which our application will run in a production environment.

Note - In the above psuedo-Dockerfile, I’ve aliased my images using the AS keyword. This can help us differentiate different stages of our Dockerfile and we can use the --target flag to build specific stages.

A Real-Life Example

Now that we’ve covered the basic concepts, let’s take a look at how we could define a real multi-stage Dockerfile that will first compile our application and subsequently run our application in a lightweight Docker alpine image.

For the purpose of this tutorial, we’ll be stealing the code from my new Go WebSockets Tutorial as this demonstrates downloading dependencies and is a non-trivial example and thus closer to a real Go application than a standard hello world example.

Create a new file within your project’s directory called main.go and add the following code:

package main import ( "fmt" "log" "net/http" "github.com/gorilla/websocket" ) // We'll need to define an Upgrader // this will require a Read and Write buffer size var upgrader = websocket . Upgrader { ReadBufferSize : 1024 , WriteBufferSize : 1024 , } // define a reader which will listen for // new messages being sent to our WebSocket // endpoint func reader ( conn * websocket . Conn ) { for { // read in a message messageType , p , err := conn . ReadMessage () if err != nil { log . Println ( err ) return } // print out that message for clarity fmt . Println (string( p )) if err := conn . WriteMessage ( messageType , p ); err != nil { log . Println ( err ) return } } } func homePage ( w http . ResponseWriter , r * http . Request ) { fmt . Fprintf ( w , "Home Page" ) } func wsEndpoint ( w http . ResponseWriter , r * http . Request ) { upgrader . CheckOrigin = func ( r * http . Request ) bool { return true } fmt . Println ( r . Host ) // upgrade this connection to a WebSocket // connection ws , err := upgrader . Upgrade ( w , r , nil ) if err != nil { log . Println ( err ) } // listen indefinitely for new messages coming // through on our WebSocket connection reader ( ws ) } func setupRoutes () { http . HandleFunc ( "/" , homePage ) http . HandleFunc ( "/ws" , wsEndpoint ) } func main () { fmt . Println ( "Hello World" ) setupRoutes () log . Fatal ( http . ListenAndServe ( ":8080" , nil )) }

Note - I have initialized this project to use go modules using the go mod init command. This can be run locally outside of a docker container using Go version 1.11 and by calling go run ./...

Next, we’ll create a Dockerfile in the same directory as our main.go file above. This will feature a builder stage and a production stage which will be built from two distinct base images:

## We'll choose the incredibly lightweight ## Go alpine image to work with FROM golang:1.11.1 AS builder ## We create an /app directory in which ## we'll put all of our project code RUN mkdir /app ADD . /app WORKDIR /app ## We want to build our application's binary executable RUN CGO_ENABLED = 0 GOOS = linux go build -o main ./... ## the lightweight scratch image we'll ## run our application within FROM alpine:latest AS production ## We have to copy the output from our ## builder stage to our production stage COPY --from = builder /app . ## we can then kick off our newly compiled ## binary exectuable!! CMD [ "./main" ]

Now that we’ve defined this multi-stage Dockerfile, we can proceed to build it using the standard docker build command:

$ docker build - t go - multi - stage .

Now, when we compare the sizes of our simple image against our multi-stage image, we should see a dramatic difference in sizes. Our previous, go-simple image was roughly 800MB in size, whereas this multi-stage image is about 1/80th the size.

$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE go - multi - stage latest 12 dd51472827 24 seconds ago 12.3 MB

If we want to try running this to verify it all works, we can do so using the following docker run command:

$ docker run - d - p 8080 : 8080 go - multi - stage

This will kick off our docker container running in -d detached mode and we should be able to open up http://localhost:8080 in our browser and see our Go application returning the Hello World message back to us!

Exercise - copy the index.html from the Go WebSockets Tutorial and open that in a browser, you should see that it connects into our containerized Go application and you should be able to view the logs using the docker logs command.

Conclusion

To wrap things up, in this tutorial, we looked at how we could define a really simple Dockerfile which creates a heavy Docker image. We then looked at how we could optimize this by using multi-stage Dockerfile s which left us with incredibly lightweight images.

If you enjoyed this tutorial, or if you have any comments/feedback/suggestions, then I’d love to hear them in the suggestion box below!