DEV Community

loading...
Cover image for Dockerize your Go app

Dockerize your Go app

Karan Pratap Singh
Software Engineer & Solutions Architect
・3 min read

Go is quickly becoming one of my favorite languages to work with. So, today we'll dockerize our Go app by taking advantage of builder pattern and multistage builds to reduce our docker image from 850mb to just 15mb!

This article is part of the Dockerize series, make sure to checkout the Introduction where I go over some concepts which we are going to use. Code from this article is available here

I've also made a video, if you'd like to follow along

Project setup

I've initialized a simple api using Mux

├── main.go
├── go.mod
└── go.sum
Enter fullscreen mode Exit fullscreen mode

Here's our main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/gorilla/mux"
)

func main() {
    router := mux.NewRouter()
    router.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
        response := map[string]string{
            "message": "Hello Docker!",
        }
        json.NewEncoder(rw).Encode(response)
    })

    log.Println("Server is running!")
    http.ListenAndServe(":4000", router)
}
Enter fullscreen mode Exit fullscreen mode

For development

We'll be using Reflex as part of our development workflow. If you're not familiar, Refelx provides live reload when developing.

Let's continue our docker setup by adding a Dockerfile

FROM golang:1.16.5 as development
# Add a work directory
WORKDIR /app
# Cache and install dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy app files
COPY . .
# Install Reflex for development
RUN go install github.com/cespare/reflex@latest
# Expose port
EXPOSE 4000
# Start app
CMD reflex -g '*.go' go run api.go --start-service
Enter fullscreen mode Exit fullscreen mode

Let's create a docker-compose.yml. Here we'll also mount our code in a volume so that we can sync our changes with the container while developing.

version: "3.8"

services:
  app:
    container_name: app-dev
    image: app-dev
    build:
      context: .
      target: development
    volumes:
      - .:/app
    ports:
      - 4000:4000

Enter fullscreen mode Exit fullscreen mode

Start! Start! Start!

docker-compose up
Enter fullscreen mode Exit fullscreen mode

we can also use the -d flag to run in daemon mode

Great, our dev server is up!

app-dev  | Starting service...
app-dev  | 2021/07/04 12:50:06 Server is running!
Enter fullscreen mode Exit fullscreen mode

Let's checkout our image using docker images command

REPOSITORY          TAG                   IMAGE ID       CREATED         SIZE
app-dev             latest                3063740d56d8   7 minutes ago   872MB
Enter fullscreen mode Exit fullscreen mode

Over 850mb for a hello world! While this might be okay for development, but for production let's see how we can reduce our image size

For production

Let's update our Dockerfile by adding a builder and production stage

Update: Notice how we define CGO_ENABLED 0 with ENV in Dockerfile rather than doing directly before go build command. Also, we will be using alpine instead of scratch as it's really hard to debug containers in production with scratch

FROM golang:1.16.5 as builder
# Define build env
ENV GOOS linux
ENV CGO_ENABLED 0
# Add a work directory
WORKDIR /app
# Cache and install dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy app files
COPY . .
# Build app
RUN go build -o app

FROM alpine:3.14 as production
# Add certificates
RUN apk add --no-cache ca-certificates
# Copy built binary from builder
COPY --from=builder app .
# Expose port
EXPOSE 4000
# Exec built binary
CMD ./app
Enter fullscreen mode Exit fullscreen mode

Let's add a build our production image

docker build -t app-prod . --target production
Enter fullscreen mode Exit fullscreen mode

Let's check out our built production image

docker images
Enter fullscreen mode Exit fullscreen mode

Using builder pattern we reduced out image size to just ~15mb!!

REPOSITORY                    TAG                   IMAGE ID       CREATED          SIZE
app-prod                      latest                ed84a3896251   50 seconds ago   14.7MB
Enter fullscreen mode Exit fullscreen mode

let's start our production container on port 80

docker run -p 80:4000 --name app-prod app-prod
Enter fullscreen mode Exit fullscreen mode

We can also add a Makefile to make our workflow easier

dev:
  docker-compose up

build:
  docker build -t app-prod . --target production

start:
  docker run -p 80:4000 --name app-prod app-prod 
Enter fullscreen mode Exit fullscreen mode

Next steps

With that, we should be able to take advantage of docker in our workflow and deploy our production images faster to any platform of our choice.

Feel free to reach out to me on Twitter if you face any issues.

Discussion (16)

Collapse
andreidascalu profile image
Andrei Dascalu

Cgo enabled to 0 is important.
I'm never quite sure why you'd need to copy the go.mod/sum separately and then still copy everything back anyway.
It's much easier to dockerignore any local build artefacts/cache and save yourself a couple of layers.
In production you only need ca-certificates if and only if you make external https calls.
Otherwise you're better off doing it from "scratch" since you also don't need a package manager at runtime.

Collapse
karanpratapsingh profile image
Karan Pratap Singh Author

Hey, thanks for the feedback. ENV CGO_ENABLED 0 is already defined in the Dockerfile for builder stage. I copy go.mod and go.sum separately for caching. I included ca-certificates as it's better to have it just in case (imo). Also I avoid from "scratch" as it's quite limiting when you're trying to debug in production

Collapse
andreidascalu profile image
Andrei Dascalu

Yeah, it's probably a matter of micro optimisation, caching the few bytes those files have vs having an extra layer that means an extra http call when get by the image.
But on the production build things, having things just in case is a bad practice. Just as is allowing yourself the possibility to install stuff for debug production. That's a pretty gaping security hole alongside not running your application under a limited user (alongside disabling root altogether). Containers are meant for running a single isolated process, debugging in a containerised environment should be done via a dedicated container.

Collapse
yoursunny profile image
Junxiao Shi

ENV CGO_ENABLED 0 is very important.
If it's forgotten, the program won't start.

Alpine and ca-certificates could be avoided if you mount /etc/ssl/certs directory from the host system.

Collapse
karanpratapsingh profile image
Karan Pratap Singh Author

Agreed! however as the production stage would be built on CI, I don't think it we would want to mount the /etc/ssl/certs host system

Collapse
yoursunny profile image
Junxiao Shi

/etc/ssl/certs isn't necessary when you build the production container image. It can be mounted when you run the container in production.

Thread Thread
karanpratapsingh profile image
Karan Pratap Singh Author

sure, but why? I'm trying to understand why will I mount it when it's running on kubernetes?

Thread Thread
yoursunny profile image
Junxiao Shi

It's a trade-off between fewer dependency on the host (by including ca-certificates in the image) and smaller image size (by excluding ca-certificates in the image).

Collapse
cctechwiz profile image
Josh Maxwell

Do you always use a docker image for running your dev build locally instead of just using 'go run'? I tried that because it seems like good practice to use the same env as prod. But it really killed my flow waiting the extra time between small changes for docker to start the image.

Collapse
karanpratapsingh profile image
Karan Pratap Singh Author

Hi Josh, yes I use docker to develop in Go. What issue are you facing? are you using docker-compose and develoment stage? for me it's pretty much instant restart. You can use normal go run if it works best for you

Collapse
cctechwiz profile image
Josh Maxwell

I'm not yet using the multistage build I'll give that a whirl on Monday. My main hang up is that it takes like 10-15 seconds to run my 'make run' ( which uses docker) and that little delay really builds up over the day. I'll give the multistage build a try thought. And I'm.not using docker-compise since I only have one container so far.

Thread Thread
karanpratapsingh profile image
Karan Pratap Singh Author

Hey, “make dev” is recommended for development as it uses docker compose and mounts a volume which is faster than rebuilding with “make run”

Collapse
clavinjune profile image
Clavin June

you may use -ldflags "-s -w" on go build to make the binary smaller and use distroless container for running the binary, it makes the image result smaller too.

You can use upx to compress the binary also

Collapse
narven profile image
Pedro Luz

Another good alternative for live reload: github.com/cosmtrek/air

Collapse
jhelberg profile image
Joost Helberg

Why would you use docker? The go binary is sufficient, not? What is the added value of docker when using a go program?

Collapse
clavinjune profile image
Clavin June

when in production, I use docker to containerize my go apps to limit the app resources & auto-start the apps