Multi-stage go docker builds

Golang is supposed to be a lightweight binary once compiled, as most depenedencies are resolved during development.

Yet, quite a few docker images of Golang end up being heavier in size than they need to be. Here's a quick overview of how to acheive it.

Some basics of Go in terms of project structure and dependencies:

  • Go compiles to a static binary that includes everything needed to run an application.

  • Go mod

Multi-Stage Docker Builds

Docker allows builds to be multi-staged. i.e. In one of the stages, we can create an output and use it in other stage and export only the final stage as an image. It helps in creating build environment and runtime environment isolation.

Here's an example of a multi-stage Dockerfile:

# Stage 1: Build the application; Uses golang docker image.
FROM golang:1.21-alpine AS builder

WORKDIR /app

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

# Copy the source code
COPY . .

# Build the application with optimizations
# CGO_ENABLED=0 ensures static linking; -ldflags="-s -w" to strip debug info.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o santa .

# Stage 2: Create the minimal runtime image
FROM scratch

# Copy the binary from the builder stage
COPY --from=builder /app/santa /santa

# Optional: Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Command to run
ENTRYPOINT ["/santa"]

In the above, the build stage exports a binary called santa, and in the second stage, we use scratch, a lightweight base container to export final image.

Is scratch for everyone? It depends. Some applications might need more linux utilities, that aren't always available as Go packages.

Managing Dependencies with Go Modules:

While gomod needs a separate post by itself, a well maintained go.mod file ensures reproducible builds:

module github.com/yourusername/myapp

go 1.21

require (
    github.com/some/dependency v1.2.3
)

Size Comparison:

Approach Image Size
Go with standard base image ~850MB
Go with Alpine base image ~300MB
Go with multi-stage and Alpine ~15MB
Go with multi-stage and scratch ~7MB

Other things to keep in mind:

  • Not all applications can leverage scratch but alpine-linux or other linux flavours with need-basis tools added, is likely sufficient solution.
  • Sometimes, the builds are to be made for the destination architecture. Usually, Go binary doesn't care about underlying host's linux architecture.