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
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)
}
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
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
Start! Start! Start!
docker-compose up
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!
Let's checkout our image using docker images
command
REPOSITORY TAG IMAGE ID CREATED SIZE
app-dev latest 3063740d56d8 7 minutes ago 872MB
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
Let's add a build our production image
docker build -t app-prod . --target production
Let's check out our built production image
docker images
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
let's start our production container on port 80
docker run -p 80:4000 --name app-prod app-prod
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
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.
Top comments (16)
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.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/etc/ssl/certs
isn't necessary when you build the production container image. It can be mounted when you run the container in production.sure, but why? I'm trying to understand why will I mount it when it's running on kubernetes?
It's a trade-off between fewer dependency on the host (by including
ca-certificates
in the image) and smaller image size (by excludingca-certificates
in the image).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.
Hey, thanks for the feedback.
ENV CGO_ENABLED 0
is already defined in theDockerfile
forbuilder
stage. I copygo.mod
andgo.sum
separately for caching. I includedca-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 productionYeah, 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.
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
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.
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 youI'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.
Hey, “make dev” is recommended for development as it uses docker compose and mounts a volume which is faster than rebuilding with “make run”
Another good alternative for live reload: github.com/cosmtrek/air
Why would you use docker? The go binary is sufficient, not? What is the added value of docker when using a go program?
when in production, I use docker to containerize my go apps to limit the app resources & auto-start the apps