Day 24 — Benchmarks, GC Tuning & Containers
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 ./pkgTheory 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.txtExample 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
- Same machine, same
go version, same GOMAXPROCS story
- Same fixture data (check into
testdata/)
- No logging / no network in the timed loop
- Do not compare Debug vs optimized mixed builds—use
go testdefaults consistently
- Document wall-clock environment briefly in
BENCH.md
## Environment
- go version go1.26.x darwin/arm64
- quiet laptop, AC power
- count=10Worked 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.txtDocument 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 — whyLab
Suggested workspace: ~/lab/90daysofx/01-go/day66 (or the package you profiled).
- Write at least two benchmarks: baseline hot path + one alternative.
- Use
-benchmemand-count=10(or at least 5).
- Compare with
benchstat(install if needed).
- Land one improvement or write why you rejected the change.
- Keep
old.txt/new.txt/BENCH.md(results can be committed as text).
- Note
go versionand 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.mdlinks hypothesis → result
go versionnoted
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.
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 planProgrammatic (rare in apps):
import "runtime/debug"
debug.SetGCPercent(50)
// return previous percentPrefer 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.txtFill 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:
- How often cycles appear (density of lines)
- Whether heap goal / live sizes trend upward unbounded (leak smell)
- 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
- Implement the churn program or instrument your service with periodic MemStats logs under load.
- Run at least three configs (default +
GOGCchange +GOMEMLIMIT).
- Capture either gctrace snippets or MemStats rows.
- Write a short recommendation: default is fine or set GOMEMLIMIT to match container with numbers.
- Explicitly note why you would not set
GOGC=offin a long-running service.
- Record
go env GOGC GOMEMLIMIT(often empty → defaults) next to results.
Stretch
- Align
GOMEMLIMITwith a Docker--memoryexperiment (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=100vsdebug.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
GOGCin one sentence with trade-off
- Explain
GOMEMLIMITin one sentence
- Measurement table with ≥3 configs
- Written recommendation for your workload
go versionrecorded 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.
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:
- Builder stage: full Go toolchain, compile with
CGO_ENABLED=0
- 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 imagesTheory 3 — Non-root, read-only, signals
- Run as nonroot (distroless provides
nonroot).
- Listen on non-privileged ports (
8080).
- Go’s
http.Server.Shutdownneeds 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-appBuild 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:devLeave 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 day68Record 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 cleanLab
Suggested workspace: ~/lab/90daysofx/01-go/day68 (or containerize Stage VI service).
- Multi-stage Dockerfile; runtime distroless or scratch (+ certs if needed).
CGO_ENABLED=0build; non-root user.
- Prove
/healthz(or equivalent) viadocker run+curl.
- Note image size in
CONTAINER.md(also note single-stage comparison if you build one once).
- Run once with
-mmemory limit andGOMEMLIMITset.
- Add
.dockerignoreexcluding_book,.git, bin artifacts.
- Confirm
docker stopsends 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
.dockerignorepresent
- 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.