Christian Giacomi

Multistage Docker image for Go

Posted — Mar 10, 2021

I love working with Go, especially when I am building Microservices, and I usually use Docker to help me both during development and in stage/production environments.

So I thought I would share my little Docker recipe for what I like to use. I know there are a ton of other docker tutorials out there, but when it comes to Go, there is two small things to remember before you go to production or you might stumble like I did.

So I hope that this will help you avoid debugging the issues I ran into.

Multistage

I mentioned that this would be a post for a multistage Docker image, and since I use Docker both for local development and and running my services in stage and production I use three stages.

I use a base builder image which will be foundation of my multistage images, I have a second image which I use for local development and finally a third image for my stage and production environments.

The difference is that my local development image contains a hot-reload feature, so that I don’t have to manually start and stop and recompile the image and the code every time I change something in my code. This makes working with Docker locally a lot more interesting.

I will also show you how I tie it all together to make working with Docker very simple and fun.

Builder image

This is the base builder image that I use.

# filename: Dockerfile.dev
################################################
## Builder STAGE used for building the binary

FROM golang:1.15.12-alpine3.13 AS builder

# Add Maintainer Info
LABEL maintainer="Christian Giacomi"

# Install dependencies to be able to build our code
RUN apk add --no-cache --update git && apk add build-base

# Set the working directory
WORKDIR /app

# Copy go mod and sum files
COPY ./go.mod ./go.sum ./

# Download all required packages
RUN go mod download

# Copy the source from the current directory to the Working Directory inside the container
COPY ./ .

# Compile the code
RUN go build -o .bin/server server.go

# Expose the port for the server
EXPOSE 8080

CMD []

This image is meant as the base for the other two images, and ensures that we can download the Go packages we need and that the code can actually build.

Local Development image

This is my local development Docker image with hot-reload. I define the two images in the same Dockerfile as they are for development purposes.

# filename: Dockerfile.dev
################################################
## Developer STAGE used for development and hot-reload of the app

FROM golang:1.15.12-alpine3.13

# Add Maintainer Info
LABEL maintainer="Christian Giacomi"

# Install dependencies to be able to build our code
RUN apk add --no-cache --update git && apk add build-base

# Add bash to the hot-reload image
RUN apk add bash

# Set the working dir
WORKDIR /app

# Copy the Pre-built binary file from the previous stage
COPY --from=builder /app/.bin/server ./.bin/

# Support hot-reload
RUN go get github.com/githubnemo/CompileDaemon

# Expose the port for the server
EXPOSE 8080

ENTRYPOINT CompileDaemon --directory=. --build="go build -o .bin/server server.go" --command=.bin/server

We need to make sure bash is available in this image to help us if we need to debug into the container.

Note how this image does NOT run go mod download as we have already downloaded all the needed packages and ensured that they are available and downloadable.

We then proceed to copy the output from the builder image to this image and download a Go package called CompileDaemon which will do all the heavy lifting for the hot-reload. We finally expose the entry point for the container and let CompileDaemon help us out.

ENTRYPOINT CompileDaemon --directory=. --build="go build -o .bin/server server.go" --command=.bin/server`

For more information checkout the documentation for CompileDaemon.

Stage and Production image

This is the lean and mean stage/production image which I use. This is the actual image which I will use for my staging and production environments, which usually weighs around 6Mb. The reduced size is perfect for a quick startup and reduced attack surface of the OS thanks to the Alpine distro.

# filename: Dockerfile.prd
################################################
## Docker Image for STAGE/PROD environments

FROM service-builder AS builder
FROM alpine:3.13

# Add Maintainer Info
LABEL maintainer="Christian Giacomi"

# Ensure updated CA certificates
RUN apk --no-cache add ca-certificates

# needed for timezones
RUN apk add --no-cache tzdata

# Set the working dir
WORKDIR /app

# Copy the Pre-built binary file from the previous stage
COPY --from=builder /app/.bin/server .

# Expose the port for the server
EXPOSE 8080

# Start the server
CMD ["./server"]

There are four important parts in this image.

First, we are referencing the base Docker image as the ‘builder’ image FROM service-builder AS builder, which as stated previously will be responsible for the actual building and dependency resolution.

Second, we are only using the lean and mean Alpine distro, which does not contain any Go runtime, libraries and or packages.

Third, the inclusion of the root CA certificates package RUN apk --no-cache add ca-certificates, to make sure that all the root certificates are up to date for security reasons.

Finally the inclusion of the timezone package RUN apk add --no-cache tzdata to ensure that any time.Now() operations in Go actually succeed as they depend on the OS packages. Without this you will find that any call to the time package will fail. And since I seem to always have a property in my code that uses a timestamp, I include the timezone package by default.

Orchestration

So the final part is putting it all together and getting it to work. You should have a docker directory in the root of your project, and in that directory you should have two files Dockerfile.dev with the development images and Dockerfile.prd with the stage/production image.

Local Development

To use the development images you will need to create a docker-compose.yml file in the root of your project.

# Docker Compose file Reference (https://docs.docker.com/compose/compose-file/)
version: '3'

services:

  app:
    build:
      context: .
      dockerfile: ./docker/Dockerfile.dev
    container_name: service
    ports:
      - 8080:8080
    volumes:
      - ./:/app
    networks:
      - backend

networks:
  backend:

This will be the way you run the local development container, with hot-reload support. Simply run the following docker-compose command:

$ docker-compose up

Or if you are running the latest version of Docker, (Engine v20.10.6, Compose v1.29.1) as of this post, compose is now part of Docker. So simply:

$ docker compose up

Stage & Production

While to run the stage/production image we will use the following commands:

$ docker build -f docker/Dockerfile.dev --target builder -t service-builder .
$ docker build -f docker/Dockerfile.prd -t <tag-name> .

The first command builds the base image and creates a tag for the image with the value of service-builder. The second command builds the final production lean image which references service-builder directly in the Dockerfile.prd file. Once the commands have completed you will have a super lightweight, fast and secure stage and production ready image that you can easily spin up.

You can then run your production container like so:

$ docker run -p 80:8080 --name <service-name> <tag-name>

Remember <tag-name> here is the name you specified when building the image.

Conclusion

As you can see there are a few commands and files that need to be put in place to be able to do magic, but once you have an understanding of the entire process you can of course tweak things to your liking.

Of course I don’t use the docker commands directly, but I use a Makefile to simplify things. The advantage of using the Makefile is that it can also be called from a CI/CD pipeline which automates the entire process, and always follows the same flow.

I hope this helps, and I hope you find Docker as exciting as I do.

Btw, if you spin up the local docker container, when you save any of your Go files, the daemon will automatically re-compile the Go files and relaunch your service.

And make sure you included the time zone package in the production Dockerfile or you might find that your service fails in the strangest of ways.

If this post was helpful tweet it or share it.

See Also