Frontend Labs
⋯

4. Production-grade Dockerfiles

4.1 Start the Orbit codebase

Before containerizing, we need something to containerize. The shared platform package is written once and reused by all three services; it already contains the hooks (health, graceful shutdown, telemetry) that later chapters rely on.

mkdir -p orbit && cd orbit
go mod init github.com/acme/orbit
go get github.com/jackc/pgx/v5 github.com/redis/go-redis/v9

internal/platform/config.go: twelve-factor config from the environment.

package platform

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

// Env returns the value of key or def if unset.
func Env(key, def string) string {
	if v, ok := os.LookupEnv(key); ok && v != "" {
		return v
	}
	return def
}

// MustEnv returns the value of key or exits: fail fast on missing config,
// so a misconfigured pod crashes at start instead of serving errors.
func MustEnv(key string) string {
	v := os.Getenv(key)
	if v == "" {
		fmt.Fprintf(os.Stderr, "missing required env var %s\n", key)
		os.Exit(1)
	}
	return v
}

func EnvDuration(key string, def time.Duration) time.Duration {
	if d, err := time.ParseDuration(os.Getenv(key)); err == nil {
		return d
	}
	return def
}

func EnvInt(key string, def int) int {
	if n, err := strconv.Atoi(os.Getenv(key)); err == nil {
		return n
	}
	return def
}

internal/platform/health.go: separate liveness and readiness (§21 explains why they must differ).

package platform

import (
	"context"
	"encoding/json"
	"net/http"
	"sync/atomic"
	"time"
)

// Check is a named readiness dependency check.
type Check struct {
	Name string
	Fn   func(ctx context.Context) error
}

type Health struct {
	draining atomic.Bool
	checks   []Check
}

func NewHealth(checks ...Check) *Health { return &Health{checks: checks} }

// SetDraining flips readiness to false during shutdown so the load balancer
// stops sending new traffic before the server closes.
func (h *Health) SetDraining() { h.draining.Store(true) }

// Livez answers "is this process wedged?". It never checks dependencies:
// a database outage must not cause every pod to be restarted.
func (h *Health) Livez(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

// Readyz answers "should I receive traffic right now?".
func (h *Health) Readyz(w http.ResponseWriter, r *http.Request) {
	if h.draining.Load() {
		http.Error(w, "draining", http.StatusServiceUnavailable)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
	defer cancel()
	failed := map[string]string{}
	for _, c := range h.checks {
		if err := c.Fn(ctx); err != nil {
			failed[c.Name] = err.Error()
		}
	}
	if len(failed) > 0 {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusServiceUnavailable)
		_ = json.NewEncoder(w).Encode(failed)
		return
	}
	_, _ = w.Write([]byte("ok"))
}

internal/platform/server.go: an HTTP server with timeouts and graceful shutdown that follows the Kubernetes termination sequence (§13.5).

package platform

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os/signal"
	"syscall"
	"time"
)

type ServerConfig struct {
	Addr            string        // ":8080"
	DrainDelay      time.Duration // time to keep serving after SIGTERM while endpoints update
	ShutdownTimeout time.Duration // max time to finish in-flight requests
}

// Run serves h until SIGTERM/SIGINT, then drains gracefully.
func Run(cfg ServerConfig, h http.Handler, health *Health) error {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	srv := &http.Server{
		Addr:              cfg.Addr,
		Handler:           h,
		ReadHeaderTimeout: 5 * time.Second, // slowloris protection
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       90 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() {
		slog.Info("http server listening", "addr", cfg.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
		close(errCh)
	}()

	select {
	case err := <-errCh:
		return err // failed to bind etc.
	case <-ctx.Done():
	}

	// 1. Fail readiness so endpoints/LBs remove us.
	health.SetDraining()
	slog.Info("shutdown signal received, draining", "delay", cfg.DrainDelay)
	// 2. Keep serving while that propagates (kube-proxy, ingress, LB).
	time.Sleep(cfg.DrainDelay)
	// 3. Stop accepting, finish in-flight requests.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return err
	}
	slog.Info("shutdown complete")
	return nil
}

cmd/catalog/main.go (first version; we add the database and telemetry later):

package main

import (
	"encoding/json"
	"log/slog"
	"net/http"
	"os"
	"time"

	"github.com/acme/orbit/internal/platform"
)

var version = "dev" // set with -ldflags at build time

func main() {
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
	health := platform.NewHealth()

	mux := http.NewServeMux()
	mux.HandleFunc("GET /livez", health.Livez)
	mux.HandleFunc("GET /readyz", health.Readyz)
	mux.HandleFunc("GET /products", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{"id": 1, "name": "Rocket", "price_cents": 9900},
		})
	})
	mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(version))
	})

	err := platform.Run(platform.ServerConfig{
		Addr:            ":" + platform.Env("PORT", "8080"),
		DrainDelay:      platform.EnvDuration("DRAIN_DELAY", 5*time.Second),
		ShutdownTimeout: platform.EnvDuration("SHUTDOWN_TIMEOUT", 20*time.Second),
	}, mux, health)
	if err != nil {
		slog.Error("server failed", "err", err)
		os.Exit(1)
	}
}

4.2 The production Dockerfile (Go, multi-stage, distroless, non-root)

One Dockerfile builds every service; SERVICE selects which cmd/ to compile. This is the standard monorepo pattern.

# syntax=docker/dockerfile:1.7
# ---- build stage --------------------------------------------------------
ARG GO_VERSION=1.25
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-bookworm AS build
WORKDIR /src

# 1) dependencies: cached until go.mod/go.sum change
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

# 2) source + build. TARGETOS/TARGETARCH are set by buildx for multi-arch.
COPY . .
ARG SERVICE
ARG VERSION=dev
ARG TARGETOS TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
      -o /out/app ./cmd/${SERVICE}

# ---- runtime stage ------------------------------------------------------
# distroless/static: CA certs, tzdata, /etc/passwd with a nonroot user. No shell, no package manager.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]

Line by line, why it is production-grade:

  • # syntax=docker/dockerfile:1.7 pins the Dockerfile frontend, so builds are reproducible across Docker versions.
  • --platform=$BUILDPLATFORM runs the compiler natively and cross-compiles for TARGETARCH. Multi-arch (amd64 + arm64) builds take seconds, without QEMU emulation.
  • Cache mounts keep the Go module and build caches across builds without putting them in a layer.
  • CGO_ENABLED=0 gives a static binary, so it runs on distroless/static or scratch.
  • -trimpath -s -w gives reproducible paths and drops debug symbols (smaller). Keep symbols if you rely on pprof symbolization in prod; Go's runtime still has function names for stack traces either way.
  • distroless:nonroot: no shell means an attacker with RCE has no sh, curl or apt. CVE scanners report very few findings. Includes CA certificates (needed for outbound TLS) and tzdata.
  • USER 65532:65532: numeric, so Kubernetes can enforce runAsNonRoot.

Build and run:

docker build --build-arg SERVICE=catalog --build-arg VERSION=0.1.0 -t orbit-catalog:0.1.0 .
docker run --rm -p 127.0.0.1:8080:8080 orbit-catalog:0.1.0
curl localhost:8080/products
docker images orbit-catalog      # ~10 MB

Typical image sizes for the same Go service:

Base Size Shell? Typical CVE count
golang:1.25 (single-stage) ~900 MB yes dozens–hundreds
debian:bookworm-slim ~80 MB yes some
alpine:3.20 ~15 MB yes (busybox) few
gcr.io/distroless/static:nonroot ~10 MB no ~0
scratch ~8 MB no 0 (but no CA certs/tzdata/users unless you copy them)

Real-world example: a platform team mandates distroless or Chainguard base images for all services. The CVE backlog drops from thousands of "fix available" findings to nearly zero, and patching becomes "rebuild weekly" instead of triaging tickets. The cost: you need kubectl debug (§33.3) instead of kubectl exec ... sh.

4.3 Python equivalent (for comparison)

# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS build
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY src/ ./src/

FROM python:3.12-slim
ENV PATH=/venv/bin:$PATH PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN useradd --uid 10001 --no-create-home app
COPY --from=build /venv /venv
COPY --from=build --chown=10001:10001 /app/src /app/src
WORKDIR /app
USER 10001
EXPOSE 8000
# gunicorn as PID 1 handles SIGTERM gracefully (finishes requests up to --graceful-timeout)
ENTRYPOINT ["gunicorn", "src.main:app", "-k", "uvicorn.workers.UvicornWorker", \
            "-b", "0.0.0.0:8000", "--workers", "2", "--graceful-timeout", "20"]

PYTHONUNBUFFERED=1 matters: without it, logs sit in a buffer and appear late (or never, if the container is killed).

4.4 Secrets at build time: never ARG, always --mount=type=secret

# private Go modules: a token is needed during download only
RUN --mount=type=secret,id=gh_token \
    git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/" \
 && go mod download
docker build --secret id=gh_token,env=GH_TOKEN -t orbit-orders .

The secret is mounted only for that RUN and never written to a layer. One caveat: the git config line above does write the token to /root/.gitconfig in that layer. This is fine in a build stage that is discarded, but never do it in the final stage. For SSH: RUN --mount=type=ssh go mod download with docker build --ssh default.

4.5 .dockerignore: the build context

docker build . sends the context to the builder. Without a .dockerignore, you ship .git, node_modules, local .env files (secrets!), and test fixtures, and every change to them busts the COPY . . cache.

# .dockerignore
.git
.github
**/*.md
**/.env*
**/*_test.go
deploy/
load/
bin/
tmp/
compose*.yaml
Dockerfile*

Best practice: use an allow-list for large repos: * first, then !go.mod, !go.sum, !cmd/, !internal/.

4.6 Reproducibility and provenance

  • Pin base images by digest. Let Renovate or Dependabot bump the digests with PRs so you still get patches: FROM gcr.io/distroless/static-debian12:nonroot@sha256:...
  • Tag images with the git SHA (immutable) and optionally semver. Never deploy :latest: you cannot tell what's running or roll back to it.
  • Attach an SBOM and provenance at build time: docker buildx build --sbom=true --provenance=mode=max ... (§8.3).
  • Add OCI labels so tools can trace an image back to its source:
LABEL org.opencontainers.image.source="https://github.com/acme/orbit" \
      org.opencontainers.image.revision="${GIT_SHA}"

4.7 Dockerfile linting

docker build --check .                         # BuildKit's built-in checks (Docker 27+)
docker run --rm -i hadolint/hadolint < Dockerfile

Hadolint catches missing --no-install-recommends, unpinned apt packages, shell-form CMD, cd instead of WORKDIR, and more. Run it in CI (§35).

⚠️ Caveat (verify): docker build --check needs a recent Docker Engine/Buildx (introduced around Docker 27). If the flag is unknown, upgrade or rely on hadolint. The image-size and CVE-count table gives typical orders of magnitude, not measurements; check yours with docker images and trivy image.


Generated from real-life-k8s-guide.md · 8 Oct 2026 16:36