極光日記

Building Go Applications with Distroless Images

Created:

Comparing Methods for Minimizing Go Container Images

Reducing the size of container images leads to faster deployments, improved security, and a smaller attack surface.

One popular approach is to use Google’s Distroless images, which are designed to contain only the minimum runtime needed to execute an application.

Key characteristics of distroless:

  • 🚫 No package managers (no apt/yum/apk)
  • 🚫 No interactive shells (no sh/bash)
  • 📦 Only includes the minimal runtime environment

Variants of Distroless Images for Go

When building Go applications, you’ll typically choose between:

  • gcr.io/distroless/static
  • gcr.io/distroless/base

The difference is documented here: Documentation for base, base-nossl and static

Summary:

Image Characteristics
static Designed for fully statically-linked binaries. Includes no glibc/libssl. Smallest footprint.
base Includes glibc, libssl, and other shared libs. Suitable for CGO-enabled applications.

Building a Sample Go Application with Lightweight Images

Example Source Code

We will build a simple HTTP server in Go, using logrus for logging. To test CGO behavior, the app performs DNS lookup via net.LookupHost.

Running go run main.go and then curl localhost:8080 returns Hello World!.

package main

import (
	"fmt"
	"github.com/sirupsen/logrus"
	"net"
	"net/http"
)

var log = logrus.New()

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello World!")
}

func main() {
	log.SetLevel(logrus.InfoLevel)

	hosts, err := net.LookupHost("localhost")
	if err != nil {
		log.WithError(err).Error("DNS lookup failed")
	} else {
		log.WithField("hosts", hosts).Info("DNS lookup succeeded")
	}

	log.Info("Starting server on :8080")

	http.HandleFunc("/", hello)
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.WithError(err).Fatal("Server failed to start")
	}
}

Build Configuration

All images are built using:

go build -ldflags="-s -w" -trimpath -o main .
  • -ldflags="-s -w" removes debugging symbols (reduces binary size)
  • -trimpath strips file paths from the binary

distroless images provide a nonroot user, so we use it for runtime execution.

Usage:

docker build -t golang-example .
docker run -p 8080:8080 golang-example

Building with Each Base Image

distroless (static)

FROM golang:1.23.1-bookworm AS builder
WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main .

FROM gcr.io/distroless/static@sha256:765ef30a...
COPY --from=builder /build/main ./main

USER nonroot
ENTRYPOINT ["./main"]

Important: CGO_ENABLED=0 is required. Without static linking, the container starts with:

exec ./main: no such file or directory

The binary exists, but shared libs are missing because static contains no glibc. If DNS lookup via net is removed, CGO-less builds may work without issue.

distroless (base)

FROM golang:1.23.1-bookworm AS builder
WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN go build -ldflags="-s -w" -trimpath -o main .

FROM gcr.io/distroless/base@sha256:76acc04...
COPY --from=builder /build/main ./main

USER nonroot
ENTRYPOINT ["./main"]

Because base includes glibc, CGO can remain enabled (no need of CGO_ENABLED=0).


Alpine

Uses musl instead of glibc → static build required(CGO_ENABLED=0 is needed).

FROM golang:1.23.1-bookworm AS builder
WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main .

FROM alpine:3.21.3
COPY --from=builder /build/main ./main

ENTRYPOINT ["./main"]

Debian-Slim

Includes glibc → works with CGO enabled (no need of CGO_ENABLED=0).

FROM golang:1.23.1-bookworm AS builder
WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN go build -ldflags="-s -w" -trimpath -o main .

FROM debian:12.10-slim
COPY --from=builder /build/main ./main

ENTRYPOINT ["./main"]

Scratch

Contains nothing at all (includes glibc). Requires statically-linked binaries (CGO_ENABLED=0 is needed).

FROM golang:1.23.1-bookworm AS builder
WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main .

FROM scratch
COPY --from=builder /build/main ./main

ENTRYPOINT ["./main"]

Image Size Comparison

Built from the same sample app:

Base Image Size Requires CGO_DISABLED? Notes
distroless/static 8.3MB ✔ Required Minimal, static-only
distroless/base 27.1MB Includes glibc
alpine:3.21.3 13MB musl-based
debian:12.10-slim 80MB Small Debian base
scratch 5.1MB Smallest, very limited
golang:1.23.1-bookworm 843MB Build-stage only

Scratch is the smallest, while distroless/static is tiny with better practicality. The full Go image is massive but only intended for building.

Notes on CGO Libraries (e.g., mattn/go-sqlite3)

mattn/go-sqlite3 requires CGO_ENABLED=1 and a compiler toolchain.
In README,

Important: because this is a CGO enabled package, you are required to set the environment variable CGO_ENABLED=1 and have a gcc compiler present within your path.

Example Dockerfile for static CGO builds on distroless/static:

FROM golang:1.23.1-bookworm AS builder
WORKDIR /build

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libc6-dev libsqlite3-dev \
    && rm -rf /var/lib/apt/lists/*

COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=1 go build -ldflags='-s -w -extldflags "-static"' -trimpath -o main .

FROM gcr.io/distroless/static@sha256:765ef30a...
COPY --from=builder /build/main ./main

USER nonroot
ENTRYPOINT ["./main"]

For distroless/base, no special static flags are required.


Choosing the right base image for deploying Go applications via Docker

While this example focused on go-sqlite3, many other libraries require CGO and involve complex build processes. If a slightly larger footprint (~20MB) is acceptable, distroless/base is generally the most reliable option.

Using distroless/base future-proofs the application against potential CGO dependencies. If minimizing image size is the top priority, static or scratch are better alternatives. However, if the environment requires a shell or other utilities, debian-slim remains the standard choice.