Golang Docker Setup for Development and Production with Multi-stage Builds

Published on
Authors

Summary

Create an optimized Docker Golang image for production and development with live / hot reload using multi-stage builds. The aim is to seamlessly develop locally and push to production with one dockerfile/docker-compose setup.

For most developers, the size of a docker image is usually not a concern during development. We focus more on the DX and go for all available bells and whistles, such as hot reloading. In production, however, the smart thing to aim for is tiny build artifacts, and a great way to achieve this is to ensure you only include files necessary for your application to function correctly in the final build.

This article assumes you have some prior Golang and Docker / Docker-compose experience, but it presents details simply enough for beginners to easily follow along.

Obligatory go pun ahead!

You need to have Go installed to get going ;) Confirm this by running go version After this, create a directory and initialize a go module in it by running go mod init github.com/[username]/[repo_name], this creates go.mod and go.sum files. Alternatively, you can clone the base-app branch of the repo for this blog. If you opted for the repo clone, you should skip to the dockerfile section.

Create a main.go file at the directory root and populate it with this simple server:

main.go
package main

import (
	"log"
	"net/http"
	"time"
)

type healthHandler struct {
}

func (hh healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Server Running"))
}

func main() {
	m := http.NewServeMux()
	m.Handle("/healthz", healthHandler{})

	s := &http.Server{
		Handler: m,
		Addr:    ":8080",
		// Good practice: enforce timeouts for servers you create!
		WriteTimeout: 15 * time.Second,
		ReadTimeout:  15 * time.Second,
	}

	log.Println("Listening on port 8080")
	log.Fatal(s.ListenAndServe())
}

All this code block above does is that it creates an HTTP server and listens on port 8080. Requests to the healthz endpoint return a status of 200 otherwise, you get a 404. For this post, I have used the standard net/http package, although, in a real-world app, I'd reach for a better-featured web framework like gorilla/mux or gin.

Next, run the command go run main.go if successful, you should get a printout in the terminal indicating that the server is up. Visiting localhost:8080/healthz returns a status of 200 and the text: Server Running.

Docker + Compose setup

Now that we have a working web server, create a Dockerfile in the directory root containing the contents of the codeblock below.

Dockerfile
FROM golang:1.18-alpine

WORKDIR /app
COPY . /app/

RUN go mod download

Explaining what we have here:

  • We use the golang alpine image as our base image, as this already has Go installed, we're gucci.
  • Define the working directory, then copy our local files into it.
  • Finally, we download external dependencies (at this point, we have none).

We can build and run the image directly, but I enjoy using docker-compose for local development. So let's create a docker-compose.yml file and copy in the contents below.

docker-compose.yml
version: '3.9'
services:
  app:
    build:
      context: .
    command: go run main.go
    volumes:
      - ./:/app
    ports:
      - '8080:8080'

The gist of the compose file is that we

  • Define a build context pointing to the current directory,
  • Provide a command that is run on instantiation,
  • Load files into the container as a volume this would come in handy when we add live reloading.
  • Finally, we expose the port our server is listening on. This value should ideally be pulled in from a .env file.

At this point you can run docker-compose up to build the image and start the container. If all goes well, you should see the current time and Listening on port 8080 printed in the terminal. Also, visiting the localhost:8080/healthz endpoint should be successful.

Something missing is our beloved hot reloading functionality, so let's add that! There are a couple of libraries to use for this such as Fresh, and Reflex. Air seems to be winning the popularity contest with 7.2k stars (when writing this), so we'll use that.

To install air in our image, we need to modify the Dockerfile as shown below:

Dockerfile
FROM golang:1.18-alpine
RUN go install github.com/cosmtrek/air@latest

WORKDIR /app
COPY . /app/

RUN go mod download

The highlighted line comes before we copy src files so this step can be cached between builds.

The command in the docker compose file has to be modified also.

docker-compose.yml
version: '3.9'
services:
  app:
    build:
      context: .
    command: air
	...

Stop the running docker container and rebuild the image with docker-compose build. Once this is completed, run the container again and while it's running update the message returned by the web server and save. Without restarting, send a request to the server and confirm the hot reload works.

Congratulations!! The development stage is now complete!

Optimizing for Production

As mentioned in the introduction, we want the tiniest possible final build for production. To achieve this we modify the dockerfile:

Dockerfile
FROM golang:1.18-alpine as dev
RUN go install github.com/cosmtrek/air@latest

WORKDIR /app
COPY . /app/
RUN go mod download

RUN CGO_ENABLED=0 go build -o /go/bin/app

FROM gcr.io/distroless/static-debian11 as prod

COPY --from=dev go/bin/app /
CMD ["/app"]

What just happened?

  • We label the initial stage as dev so we can refer to it later while copying artifacts.
  • We build our application binary
  • The prod stage uses google's distroless image which according to their repo is a "Language focused docker image, minus the operating system"
  • Finally, we add the default command to run the binary.

To build the production image run the command docker build -t golang_docker_multibuild_prod . This command builds and tags the image as the latest.

ImageBuild Size
golang_docker_multibuild_prod8.6 MB
golang_docker_multibuild_app376.57 MB
Table 1: Build size comparison between development and production images

The production build was about 98% times smaller when compared to our development image. You might be able to further optimize this using a scratch image as base, but this is a lot smaller already.

The docker compose file also needs to be updated to target the dev stage.

docker-compose.yml
version: '3.9'
services:
  app:
    build:
      context: .
      target: dev
	...

That's it! A production and development ready docker setup for golang. Let me know what you think in the comments.

The complete code can be found here.