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.7pins the Dockerfile frontend, so builds are reproducible across Docker versions.--platform=$BUILDPLATFORMruns the compiler natively and cross-compiles forTARGETARCH. 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=0gives a static binary, so it runs ondistroless/staticorscratch.-trimpath -s -wgives reproducible paths and drops debug symbols (smaller). Keep symbols if you rely onpprofsymbolization 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,curlorapt. CVE scanners report very few findings. Includes CA certificates (needed for outbound TLS) and tzdata. USER 65532:65532: numeric, so Kubernetes can enforcerunAsNonRoot.
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 ofkubectl 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 --checkneeds 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 withdocker imagesandtrivy image.