background-shape
Containerizing a Go Service, A Sub-15MB Production Image
January 26, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — Distroless static + CGO_ENABLED=0 + -ldflags="-s -w" -trimpath + Buildx with registry cache. Final image: 14 MB. Trivy scan: zero vulnerabilities outside the toolchain. CI build time: 35 seconds for an unchanged-deps rebuild.

Earlier in the month we showed multi-stage builds in the abstract. This post is the production Dockerfile we actually ship for our Go services. Same shape as that earlier example, but with every choice spelled out and the supporting CI bits included. It’s what the billing service ships with, what the notifications service ships with, and what the next two Go services we extract will ship with.

The goal isn’t smallness for its own sake. The goal is: small enough that cold starts are negligible, narrow enough that CVE scans are quiet, and reproducible enough that “works on my laptop” and “works in prod” mean the same thing.

The full Dockerfile

# syntax=docker/dockerfile:1.4

# ---------- builder ----------
FROM golang:1.17-alpine AS builder

# Build-time tools only; nothing here ships.
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /src

# Cache deps. This layer is invalidated only when go.mod or go.sum changes.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

# Now bring in the source.
COPY . .

# Statically linked, stripped, trimmed, reproducible-ish.
ARG VERSION=dev
ARG COMMIT=unknown
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
        -trimpath \
        -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
        -o /out/server \
        ./cmd/server

# ---------- runtime ----------
FROM gcr.io/distroless/static-debian11:nonroot

# Copy the binary and nothing else.
COPY --from=builder /out/server /server

# Carry CA bundle + tzdata from the builder so outbound HTTPS works
# and time.LoadLocation("Asia/Jakarta") works.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

USER nonroot:nonroot

EXPOSE 8080

ENTRYPOINT ["/server"]

Several things doing the work:

golang:1.17-alpine for build. Smaller than golang:1.17 (Debian). The build is also faster because the Alpine base layers cache better in CI.

Separate go mod download step. If you COPY . . first, every commit invalidates the dependency cache. Splitting it out drops cold-rebuild time from 4 minutes to 35 seconds for our service.

Two BuildKit cache mounts. /go/pkg/mod is the module cache. /root/.cache/go-build is the compiler’s build cache. Together they make rebuilds basically incremental.

CGO_ENABLED=0. Static binary. No glibc/musl dependency. This is what lets us use distroless/static instead of distroless/base. Adds ~5 MB savings.

-trimpath. Strips local filesystem paths out of the binary. Smaller, more reproducible, doesn’t leak /Users/muhammad/dev/billing/... into stack traces.

-ldflags="-s -w". -s removes the symbol table, -w removes DWARF debug info. About 25% binary-size win. Cost: less useful panic stack traces. We re-add them in development builds via a make build-dev target that omits these flags.

Version + commit linker injection. -X main.version=${VERSION} -X main.commit=${COMMIT} lets the running binary report its build identity via /version endpoint. Indispensable for “which build is currently serving traffic” debugging.

Distroless static, nonroot variant. ~2 MB base, pre-created nonroot user (UID 65532). Together with our ~12 MB binary: 14 MB total image.

CA bundle + zoneinfo carried over. Distroless static does include both, actually, but I copy them from the builder explicitly so changes to the distroless image don’t surprise me.

CI wiring (GitHub Actions example)

name: build

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  image:
    runs-on: ubuntu-22.04
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v3

      - uses: docker/setup-buildx-action@v2

      - uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v4
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long
            type=ref,event=branch
            type=semver,pattern={{version}}

      - uses: docker/build-push-action@v3
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            VERSION=${{ github.ref_name }}
            COMMIT=${{ github.sha }}
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
          platforms: linux/amd64,linux/arm64

Three things to note. mode=max caches all intermediate layers, which makes repeat builds cheap. platforms: linux/amd64,linux/arm64 builds and pushes a multi-arch image — your Apple Silicon laptop pulls the arm64 variant, your prod nodes pull amd64, transparently. The metadata action tags the image consistently across SHA, branch, and semver releases.

What we measured

Before/after numbers from our billing service:

Metric Naive single-stage Multi-stage + distroless
Final image size 940 MB 14 MB
Cold pull time (1 Gbps) ~9 s ~0.2 s
Trivy CRITICAL CVEs 6 0
Trivy HIGH CVEs 28 1 (libc)
CI build cold 4m 12s 4m 03s
CI build warm cache 4m 05s 35s

The CI cold-build time is roughly the same because cold builds dominate the dependency download cost regardless. Warm rebuilds are where the cache pattern shines.

Common Pitfalls

CGO_ENABLED=1 by default. If you compile on a machine that has cgo enabled, your binary picks up dynamic linking and distroless/static will refuse to run it. Always set CGO_ENABLED=0 explicitly.

Forgetting ca-certificates. The first time you call an HTTPS endpoint from a distroless container without the CA bundle, you’ll get x509: certificate signed by unknown authority. Distroless static includes them, but it’s worth COPY --from=builder /etc/ssl/certs/ca-certificates.crt explicitly so it’s obvious.

Building with the cmd directory at the wrong path. ./cmd/server is the convention. Don’t put main.go at the repo root and package main everywhere; you’ll lose the ability to add a second binary later. Always ./cmd/<name>.

Using latest tag of distroless. It’s a moving target. Pin a digest in production: gcr.io/distroless/static-debian11:nonroot@sha256:.... Renovate or Dependabot can update the digest automatically.

No EXPOSE line. Cosmetic; doesn’t actually open the port. But tools like docker container ls use it to display the port, and orchestrators sometimes use it as a default. Include it.

Embedding the Dockerfile in source root without .dockerignore. The first COPY . . will pull in your .git, your IDE files, your node_modules if any, and bloat the build context. A real .dockerignore is non-optional.

Wrapping Up

14 MB Go images aren’t a magic trick; they’re what you get when you follow the pattern. Distroless static, no cgo, stripped binary, separate go mod download layer, BuildKit cache mounts, Buildx in CI. The same Dockerfile works for every Go service we ship this year. Next post: sharing a Postgres database between the monolith and the new services — the data layer story that runs underneath everything we’ve built so far.