Day 24 — Benchmarks, GC Tuning & Containers

Updated

July 30, 2025

Day 24 — Benchmarks, GC Tuning & Containers

Day 66 — Benchmarks & allocations

Stage VII · ~3h
Goal: Write honest microbenchmarks with testing.B, report allocations, compare before/after with benchstat, and land one evidence-backed improvement from Day 65’s hypothesis.

Why this day exists

pprof told you where time and allocations go. Benchmarks answer:

  • Is change X faster or smaller?
  • By how much, across noise?
  • Did I accidentally make the common path worse?

Without discipline, benchmarks lie: they optimize the benchmark, not the product. Today you produce a paper trail (BENCH.md) a reviewer would accept.


Theory 1 — Anatomy of a Go benchmark

func BenchmarkName(b *testing.B) {
    // setup (not timed after ResetTimer)
    data := prepare()
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sink = work(data) // prevent compiler eliminating work
    }
}
Rule Why
Loop b.N times Framework scales N until timing is stable
Setup outside timed region Or call b.ResetTimer() after setup
ReportAllocs() Shows B/op and allocs/op
Avoid dead-code elimination Assign to package-level sink or use result
var sink string

func BenchmarkProcess(b *testing.B) {
    raw := mustFixture(b)
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        s, err := Process(raw)
        if err != nil {
            b.Fatal(err)
        }
        sink = s
    }
}

Run flags

go test -bench=BenchmarkProcess -benchmem ./...
go test -bench=. -benchmem -count=10 ./pkg > old.txt
# after change:
go test -bench=. -benchmem -count=10 ./pkg > new.txt

-count matters: one run is noise. Quiet the machine (close browsers, pin GOMAXPROCS if comparing laptops).

go test -bench=. -benchmem -count=10 -cpu=1,4 ./pkg

Theory 2 — Reading the output

BenchmarkProcess-10   123456   9612 ns/op   4096 B/op   24 allocs/op
Field Meaning
-10 GOMAXPROCS used
iterations How many times b.N landed
ns/op Nanoseconds per iteration
B/op Bytes allocated per iteration
allocs/op Distinct allocations per iteration

Latency vs throughput: microbenchmarks are usually “time per op.” Real services also care about p99 under concurrency (Day 80).

Watch for timer bugs: if ns/op is absurdly small, work may be optimized away or setup is wrong.


Theory 3 — benchstat for honest comparison

go install golang.org/x/perf/cmd/benchstat@latest
benchstat old.txt new.txt

Example output shape:

name       old time/op    new time/op    delta
Process-10   9.61µs ± 2%   6.20µs ± 3%  -35.5%  (p=0.000 n=10+10)

If delta is within noise, do not claim a win. Ship clarity, not theater. benchstat reports geometric mean and confidence—use it.


Theory 4 — Sub-benchmarks and inputs

func BenchmarkConcat(b *testing.B) {
    sizes := []int{16, 256, 4096}
    for _, n := range sizes {
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            s := strings.Repeat("x", n)
            b.ReportAllocs()
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                sink = s + s
            }
        })
    }
}

Always state input shape. “Faster” without size is meaningless for parsers, JSON, crypto, compression.

Parallel benchmarks

func BenchmarkHandler(b *testing.B) {
    h := newHandler()
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            // concurrent work — use independent buffers if needed
            _ = h
        }
    })
}

Use when the real path is concurrent and you care about scaling—not for every micro-op.


Theory 5 — Common micro-optimizations that sometimes pay

Only after measurement:

Technique When
strings.Builder / bytes.Buffer Repeated concatenation
sync.Pool Short-lived buffers under high churn
Pre-size slices (make([]T, 0, n)) Known final length
Avoid encoding/json reflection on hot path After pprof shows it dominates
Reuse []byte / scanners Streaming parsers
strconv.AppendInt into buf Formatting hot loops

Do not pool prematurely. Pooling bugs (retaining large buffers, races) are worse than a few allocs.


Theory 6 — Fairness rules

  1. Same machine, same go version, same GOMAXPROCS story
  2. Same fixture data (check into testdata/)
  3. No logging / no network in the timed loop
  4. Do not compare Debug vs optimized mixed builds—use go test defaults consistently
  5. Document wall-clock environment briefly in BENCH.md
## Environment
- go version go1.26.x darwin/arm64
- quiet laptop, AC power
- count=10

Worked example — before / after with evidence

Before (chatty concat):

func JoinTags(tags map[string]string) string {
    var s string
    for k, v := range tags {
        s += k + "=" + v + ";"
    }
    return s
}

After:

func JoinTags(tags map[string]string) string {
    var b strings.Builder
    // optional: estimate size
    for k, v := range tags {
        b.WriteString(k)
        b.WriteByte('=')
        b.WriteString(v)
        b.WriteByte(';')
    }
    return b.String()
}
func BenchmarkJoinTags(b *testing.B) {
    tags := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4"}
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = JoinTags(tags)
    }
}
go test -bench=BenchmarkJoinTags -benchmem -count=10 > new.txt
benchstat old.txt new.txt

Document in BENCH.md: hypothesis from Day 65, command lines, benchstat snippet, decision (merge / revert).


Worked example — allocation-sensitive JSON path

When Day 65 pointed at JSON, compare encode strategies fairly:

func BenchmarkMarshalItem(b *testing.B) {
    it := Item{ID: "01H", Name: "widget", Tags: []string{"a", "b", "c"}}
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf, err := json.Marshal(it)
        if err != nil {
            b.Fatal(err)
        }
        sink = string(buf)
    }
}

func BenchmarkEncodeItem(b *testing.B) {
    it := Item{ID: "01H", Name: "widget", Tags: []string{"a", "b", "c"}}
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var buf bytes.Buffer
        if err := json.NewEncoder(&buf).Encode(it); err != nil {
            b.Fatal(err)
        }
        sink = buf.String()
    }
}

Do not declare a winner from folklore—benchstat decides for your struct shape. Sometimes Marshal wins on small objects; sometimes pooled buffers + Encoder win under concurrency (measure b.RunParallel only if production is parallel).

Template for BENCH.md

# Day 66 bench notes

## Hypothesis
(from Day 65 pprof) …

## Commands
go test -bench=BenchmarkJoinTags -benchmem -count=10 ./... > old.txt
# change code
go test -bench=BenchmarkJoinTags -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt

## Result
(paste benchstat)

## Decision
merge | revert — why

Lab

Suggested workspace: ~/lab/90daysofx/01-go/day66 (or the package you profiled).

  1. Write at least two benchmarks: baseline hot path + one alternative.
  2. Use -benchmem and -count=10 (or at least 5).
  3. Compare with benchstat (install if needed).
  4. Land one improvement or write why you rejected the change.
  5. Keep old.txt / new.txt / BENCH.md (results can be committed as text).
  6. Note go version and machine briefly.

Stretch

  • Add sub-benchmarks for small vs large inputs.
  • Correlate: does the win show up in a second pprof capture?
  • b.SetBytes(n) for throughput-style reporting on parsers.
b.SetBytes(int64(len(raw)))

Common gotchas

Gotcha Fix
Setup inside the loop Move out; ResetTimer
Compiler eliminates work Use sink / check errors
One-shot timing noise -count + benchstat
Benchmarking the mock not the code Keep fixtures realistic
“Allocs went up but ns down” Product decision; document trade-off
Global mutable state across iterations Reset or use local copies
Including I/O in microbench Split pure CPU vs I/O benches
Claiming wins on ±2% noise Require clear benchstat delta

Checkpoint

  • Benchmarks run with -benchmem
  • Multiple counts recorded
  • benchstat (or careful manual compare) documented
  • One change merged or rejected with evidence
  • BENCH.md links hypothesis → result
  • go version noted

Commit

git add .
git commit -m "day66: benchmarks allocs evidence"

Write three personal gotchas before continuing.


Tomorrow

Day 67 — GC, GOGC, GOMEMLIMIT: understand the garbage collector knobs and measure one workload under different memory policies.


Day 67 — GC, GOGC & GOMEMLIMIT

Stage VII · ~3h
Goal: Build a correct mental model of Go’s garbage collector, measure a workload under default settings, then experiment with GOGC and GOMEMLIMIT—recording latency/memory trade-offs without cargo-cult tuning.

Why this day exists

Go’s GC is excellent for most services. Problems appear when:

  • Containers have a hard memory limit and the process OOMs
  • Latency spikes correlate with GC CPU
  • Someone set GOGC=off “for performance” and created a time bomb

You need awareness and measurement, not a permanent custom GC config for every binary.

Note

Go 1.26 / recent GC: release notes discuss ongoing GC work (including the “Green Tea” GC direction in the 1.25–1.26 era). Defaults change; measure on your toolchain (go version) and prefer official release notes over blog posts. For this volume, treat knobs below as still current unless your notes say otherwise.


Theory 1 — What the GC does

Go is garbage-collected. The runtime tracks heap pointers and reclaims unreachable objects. Cost shows up as:

  • CPU spent marking/sweeping
  • Latency effects (STW is short in modern Go, but assist and CPU steal still matter)
  • Memory retained as heap grows before next collection

You influence behavior primarily via:

Knob Role
Allocation rate / live set Your code (pools, reuse, fewer objects)
GOGC Target heap growth relative to live data
GOMEMLIMIT Soft memory limit the runtime tries to respect
GOMEMLIMIT + container cgroup Align process with actual limit

Best optimization remains fewer allocations (Days 65–66)—tuning is second-line.

Live set vs garbage

  • Live set: objects still reachable
  • Garbage: unreachable; reclaimed on next cycles
  • High allocation rate with small live set → frequent GC work
  • Large live set → large heap baseline regardless of GOGC

Theory 2 — GOGC

GOGC is a percentage. Rough mental model:

GC aims to keep the heap near live_set * (1 + GOGC/100).

Value Effect (simplified)
100 Default: allow ~100% growth over live set before collecting more eagerly
50 More frequent GC → lower peak heap, more CPU on GC
200 Larger heap, fewer GC cycles, more memory
off Disables GC (dangerous; only for special tools with care)
GOGC=50  ./server
GOGC=200 ./server
GOGC=off ./server   # do not use in real services without a hard plan

Programmatic (rare in apps):

import "runtime/debug"

debug.SetGCPercent(50)
// return previous percent

Prefer environment configuration for ops-owned knobs so you can change without rebuild.


Theory 3 — GOMEMLIMIT (Go 1.19+)

GOMEMLIMIT sets a soft memory limit. The runtime becomes more aggressive about GC and returning memory as you approach the limit—reducing OOM kills when the limit matches the container.

# 256 MiB soft limit
GOMEMLIMIT=256MiB ./server
Practice Detail
Set near cgroup limit Leave headroom for non-Go memory, stacks, caches
Combine with container Day 68: memory limits in Docker/K8s
Not a substitute for fixing leaks Limit delays death; leak still wins eventually

Also:

debug.SetMemoryLimit(256 << 20)

Interaction with GOGC

With a memory limit, the runtime can collect earlier than pure GOGC would suggest when under pressure. Read the official GC guide when tuning for production. Rule of thumb for containers: set GOMEMLIMIT to a fraction of the cgroup limit (e.g. 90%) and leave GOGC default unless measured otherwise.


Theory 4 — Observing GC

GODEBUG=gctrace=1

GODEBUG=gctrace=1 go run .

Lines show GC cycles, heap sizes, CPU fractions. Dense but authoritative. Redirect stderr to a file for comparison.

runtime.ReadMemStats

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc=%d KiB Sys=%d KiB NumGC=%d\n",
    m.Alloc/1024, m.Sys/1024, m.NumGC)

Useful fields for a lab table: HeapAlloc, HeapSys, PauseTotalNs, NumGC, GCCPUFraction (where available / meaningful).

Note: ReadMemStats stops the world briefly—fine for lab sampling, not for tight hot loops in production.

Metrics you will wire on Day 69

Prometheus go_* collectors already expose GC stats. Today, prefer direct MemStats / gctrace so the concept is clear.

runtime/metrics (modern)

import "runtime/metrics"

func sampleGCCycles() uint64 {
    samples := []metrics.Sample{
        {Name: "/gc/cycles/total:gc-cycles"},
    }
    metrics.Read(samples)
    return samples[0].Value.Uint64()
}

Exact metric names can vary by Go version—check metrics.All() on your toolchain. Optional stretch: sample one value instead of MemStats.


Theory 5 — What not to do

Anti-pattern Why
GOGC=off on a server Heap grows until OOM
Limit = exact container bytes Non-heap + spikes → kill
Tuning every microservice differently without data Ops complexity
Ignoring leaks because “GC will handle it” GC does not free reachable leaks

Worked example — allocator churn workload

package main

import (
    "flag"
    "fmt"
    "os"
    "runtime"
    "time"
)

func main() {
    sec := flag.Int("sec", 10, "run seconds")
    size := flag.Int("size", 4096, "alloc size bytes")
    flag.Parse()

    deadline := time.Now().Add(time.Duration(*sec) * time.Second)
    var junk [][]byte
    var ops int
    for time.Now().Before(deadline) {
        b := make([]byte, *size)
        b[0] = 1
        // retain some to grow live set; drop some to create garbage
        if ops%3 == 0 {
            junk = append(junk, b)
            if len(junk) > 1000 {
                junk = junk[len(junk)/2:]
            }
        }
        ops++
    }

    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Fprintf(os.Stdout,
        "ops=%d HeapAlloc=%d MiB NumGC=%d PauseTotal=%v\n",
        ops, m.HeapAlloc>>20, m.NumGC, time.Duration(m.PauseTotalNs),
    )
}

Run matrix:

go version | tee GC.md
go build -o churn .
/usr/bin/time -l ./churn -sec=15            # macOS time -l; Linux: /usr/bin/time -v
GOGC=50         ./churn -sec=15
GOGC=200        ./churn -sec=15
GOMEMLIMIT=64MiB ./churn -sec=15
GODEBUG=gctrace=1 GOGC=50 ./churn -sec=5 2> gc50.txt

Fill a table in GC.md:

Config HeapAlloc end NumGC Notes
default
GOGC=50
GOGC=200
GOMEMLIMIT=64MiB

Worked example — periodic MemStats in a service

When you already have an HTTP service, sample under synthetic load instead of only the churn toy:

func logMem(log *slog.Logger, every time.Duration, stop <-chan struct{}) {
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-stop:
            return
        case <-t.C:
            var m runtime.MemStats
            runtime.ReadMemStats(&m)
            log.Info("memstats",
                "heap_alloc_mib", m.HeapAlloc>>20,
                "heap_sys_mib", m.HeapSys>>20,
                "num_gc", m.NumGC,
                "pause_total", time.Duration(m.PauseTotalNs).String(),
            )
        }
    }
}

Drive traffic with a loop of curl or a tiny client; change only one env knob between runs. Paste three log lines into GC.md next to the config name.

Interpreting a gctrace line (lab reading guide)

You do not need to memorize every field. For each run, note roughly:

  1. How often cycles appear (density of lines)
  2. Whether heap goal / live sizes trend upward unbounded (leak smell)
  3. Whether CPU fraction for GC dominates wall time under light load (unusual—check allocation rate)

If GOGC=50 shows more cycles and lower peak heap than default, your model is working. If numbers are noise-identical, extend -sec or increase allocation pressure.


Lab

Suggested workspace: ~/lab/90daysofx/01-go/day67

  1. Implement the churn program or instrument your service with periodic MemStats logs under load.
  2. Run at least three configs (default + GOGC change + GOMEMLIMIT).
  3. Capture either gctrace snippets or MemStats rows.
  4. Write a short recommendation: default is fine or set GOMEMLIMIT to match container with numbers.
  5. Explicitly note why you would not set GOGC=off in a long-running service.
  6. Record go env GOGC GOMEMLIMIT (often empty → defaults) next to results.

Stretch

  • Align GOMEMLIMIT with a Docker --memory experiment (pairs with Day 68).
  • Read the official GC guide section on memory limit soft vs hard OOM.
  • Force a GC with runtime.GC() between phases to stabilize lab readings (document that production rarely needs manual GC).
  • Compare GOGC=100 vs debug.SetGCPercent(100) equivalence with a one-line note.

Common gotchas

Gotcha Fix
Tuning without alloc reduction Fix hot allocs first (Days 65–66)
GOGC=off in server Almost never; prefer limits + good code
Limit = container limit exactly Leave headroom for non-heap memory
Comparing different workloads Same -sec, -size, machine quiet
Ignoring cgroup vs process In K8s, limit must match runtime awareness
Reading one MemStats sample Sample under steady load; note GC mid-cycle variance
Mixing Go versions in a table Record go version once per table

Checkpoint

  • Explain GOGC in one sentence with trade-off
  • Explain GOMEMLIMIT in one sentence
  • Measurement table with ≥3 configs
  • Written recommendation for your workload
  • go version recorded next to results
  • Explicit “no GOGC=off” rationale

Commit

git add .
git commit -m "day67: gc gogc gomemlimit measurements"

Write three personal gotchas before continuing.


Tomorrow

Day 68 — Containers & distroless: package a Go binary for production-like images with minimal attack surface.


Day 68 — Containers & distroless

Stage VII · ~3h
Goal: Multi-stage Docker builds that ship a small, non-root Go binary—preferring distroless or scratch—and document ports, health, and memory limits for the runtime.

Note

Today freezes how the process ships, not business logic. A bad Dockerfile undoes every Stage VII win: huge images, root processes, glibc surprises, and rebuilds that redownload the internet.

Why this day exists

Cross-compiled binaries (Day 63) still need a runtime environment for most services. Containers:

  • Freeze OS libs and layout
  • Make “works on my machine” less relevant
  • Are the unit of deploy for many teams

Go is unusually good at small images because CGO_ENABLED=0 produces a static binary. That advantage only appears if the Dockerfile is multi-stage and the runtime base is minimal.


Theory 1 — Why Go loves multi-stage builds

Typical pattern:

  1. Builder stage: full Go toolchain, compile with CGO_ENABLED=0
  2. Runtime stage: only the binary (+ certs, timezone data if needed)
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Base Use when
scratch Truly static binary; you must add CA certs if you dial TLS
distroless/static Static binary + minimal filesystem; common default
distroless/base Needs libc (rare if CGO_ENABLED=0)
alpine Shell debugging convenience; larger attack surface than distroless

What multi-stage actually removes

The builder image includes compilers, headers, package managers, and source. None of that belongs in production. The runtime image should answer only: “Can this process start, listen, and make required network calls?”


Theory 2 — Certificates and timezones

Many apps call HTTPS. Distroless static images usually include CA certs; pure scratch often does not:

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates tzdata

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

Timezone: prefer UTC in servers. If you need time.LoadLocation("America/New_York"), ensure zoneinfo is available or embed carefully (//go:embed zone files only when required).

Quick verification after run

# does outbound TLS work from inside the container?
docker run --rm day68:local /app -check-tls   # if you add a debug flag
# or exec into a debug sidecar; do not keep a shell in prod images

Theory 3 — Non-root, read-only, signals

  • Run as nonroot (distroless provides nonroot).
  • Listen on non-privileged ports (8080).
  • Go’s http.Server.Shutdown needs SIGTERM delivered to PID 1—containers send SIGTERM on stop; ensure your process is PID 1 (ENTRYPOINT binary, not a shell wrapper that ignores signals).
# Avoid: ENTRYPOINT ["sh", "-c", "./app"]  unless exec-form handles signals
ENTRYPOINT ["/app"]

Graceful shutdown code lands Day 78; today ensure the image can receive signals cleanly.

Read-only root filesystem (awareness)

Orchestrators often set readOnlyRootFilesystem: true. Your app must not write to / casually—use /tmp volume or emptyDir for scratch files. Design for that now:

// prefer explicit temp dir
dir := os.Getenv("TMPDIR")
if dir == "" {
    dir = os.TempDir()
}

Theory 4 — Build cache and reproducibility

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

Order matters: dependency layer caches until go.mod/go.sum change. Source copy invalidates later layers only.

docker build -t day68-app:dev .
docker run --rm -p 8080:8080 day68-app:dev
docker image ls day68-app

Build flags that matter

Flag Why
CGO_ENABLED=0 Static binary; scratch/distroless-friendly
-trimpath Remove local paths from binaries
-ldflags="-s -w" Strip symbol/DWARF tables (smaller; harder to debug)
GOOS=linux Cross-build from macOS host when needed

Memory alignment with Day 67

docker run --rm -p 8080:8080 -m 256m -e GOMEMLIMIT=200MiB day68-app:dev

Leave headroom under the hard cgroup limit. GOMEMLIMIT (soft) should sit below Docker’s -m (hard).


Theory 5 — .dockerignore and context size

Docker sends the build context to the daemon. Huge contexts slow every build:

.git
**/.git
**/_book
**/*.md
bin/
dist/
*.out
*.pb.gz
**/*_test.go
.idea
.vscode

Exclude test-only bulk when the image does not run tests. Keep go.sum in the context.

Comparing single-stage vs multi-stage

# naive (do not ship)
# FROM golang:1.26-bookworm
# COPY . /src && RUN go build -o /app ...
# → hundreds of MB, root, full toolchain

docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}' | grep day68

Record sizes in CONTAINER.md. Expect multi-stage distroless to be orders of magnitude smaller than a single-stage golang image.


Theory 6 — Health, ports, and compose sketch

Document for operators:

Item Example
Listen :8080 via ADDR
Health GET /healthz → 200
Metrics GET /metrics (Day 69)
Stop signal SIGTERM; grace ≥ shutdown timeout

Optional local compose.yaml (not required for the lab):

services:
  app:
    build: .
    ports: ["8080:8080"]
    environment:
      ADDR: ":8080"
      GOMEMLIMIT: "200MiB"
    mem_limit: 256m
    read_only: true
    tmpfs: ["/tmp"]

Worked example — minimal HTTP service

// cmd/app/main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
)

func main() {
    addr := env("ADDR", ":8080")
    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("ok"))
    })
    mux.HandleFunc("GET /v1/hello", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(map[string]string{"msg": "hello"})
    })
    log.Printf("listening on %s", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
}

func env(k, def string) string {
    if v := os.Getenv(k); v != "" {
        return v
    }
    return def
}
# Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
docker build -t day68:local .
docker run --rm -p 8080:8080 day68:local
curl -s localhost:8080/healthz
docker images day68:local --format '{{.Size}}'

Debug without polluting the prod image

# temporary shell image with the same binary
docker build --target build -t day68:build .
docker run --rm -it day68:build bash
# or copy binary out and run under local delve — keep prod tag clean

Lab

Suggested workspace: ~/lab/90daysofx/01-go/day68 (or containerize Stage VI service).

  1. Multi-stage Dockerfile; runtime distroless or scratch (+ certs if needed).
  2. CGO_ENABLED=0 build; non-root user.
  3. Prove /healthz (or equivalent) via docker run + curl.
  4. Note image size in CONTAINER.md (also note single-stage comparison if you build one once).
  5. Run once with -m memory limit and GOMEMLIMIT set.
  6. Add .dockerignore excluding _book, .git, bin artifacts.
  7. Confirm docker stop sends SIGTERM to the binary (process exits; no hung shell).

CONTAINER.md template

# Container notes — day68
Image: day68:local
Base: distroless/static-debian12:nonroot
Size:
CGO: 0
User: nonroot
Port: 8080
Health: GET /healthz
Memory test: -m 256m GOMEMLIMIT=200MiB → result:
Signal: docker stop behavior:

Stretch

  • Multi-arch build: docker buildx build --platform linux/amd64,linux/arm64.
  • SBOM note: docker sbom / generate cyclonedx later—awareness only.
  • CI job that builds and smoke-tests /healthz.

Common gotchas

Gotcha Fix
Dynamic link against glibc then scratch CGO_ENABLED=0 or use correct base
TLS fails in scratch Install CA bundle
Shell needed for debug Ephemeral debug image; keep prod distroless
Running as root USER nonroot
Signal not reaching app Exec-form ENTRYPOINT binary as PID 1
Huge context upload .dockerignore
go.sum missing Commit it; go mod download fails cleanly otherwise
Listening on :80 as nonroot Use 8080+; map ports at the edge
Timezone panics UTC or ship zoneinfo
Assuming macOS binary runs in Linux container Build with GOOS=linux in Dockerfile

Checkpoint

  • Multi-stage Dockerfile committed
  • Distroless or scratch runtime
  • Non-root process
  • Health endpoint works in container
  • Size + memory-limit notes written
  • .dockerignore present
  • SIGTERM path understood for Day 78

Commit

git add .
git commit -m "day68: multi-stage distroless container"

Write three personal gotchas before continuing.


Tomorrow

Day 69 — Prometheus metrics: export RED/USE-style metrics from the process so load tests and production share a language.