1. Cross-Compiling & Multi-Stage Docker Builds
Go compiles to a single, statically-linked binary with no runtime dependency — which means a Docker image for a Go service doesn't need Go installed in it at all, only the compiled binary. A multi-stage build takes advantage of exactly that.
# --- build stage ---
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/api
# --- final stage ---
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The build stage has the full Go toolchain and every source file; the final stage
has nothing but the compiled binary, on top of a minimal (or, here,
distroless — no shell, no package manager, nothing beyond what's
needed to run the binary) base image. The result is often under 20MB, with a
dramatically smaller attack surface than shipping a full OS alongside the binary.
CGO_ENABLED=0 is what guarantees a fully static binary with no
dynamic-linking surprises against the minimal base image.
2. Environment Configuration
A container's configuration — database URL, port, log level — should come from its environment, never be hardcoded, per the widely-followed twelve-factor app principle: the same image should run correctly in every environment purely by changing what's injected around it.
type Config struct {
Port string
DatabaseURL string
LogLevel string
}
func LoadConfig() (*Config, error) {
cfg := &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
if cfg.DatabaseURL == "" {
return nil, errors.New("DATABASE_URL is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Failing fast (with a clear error) on a missing required variable at startup — rather
than limping along and failing confusingly on the first request that needed it — is
the same "fail fast at startup" instinct as Week 12's db.Ping().
3. Health-Check Endpoints
An orchestrator (Kubernetes, ECS, anything else) needs a way to ask "is this
container actually ready to receive traffic" — a plain 200 OK on a
well-known path is the simplest version, and a real one checks that the service's
own dependencies (like the database) are actually reachable.
func healthHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
writeError(w, http.StatusServiceUnavailable, "database unreachable")
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
}
Distinguishing liveness ("is the process still running") from readiness ("is it ready to serve real traffic") as two separate endpoints matters once deployed for real — a service that's alive but whose database connection just dropped should fail readiness (so traffic stops routing to it) without necessarily being killed and restarted, which failing liveness would trigger.
4. A CI Pipeline
name: CI
on:
push:
branches: [main]
jobs:
build-test-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- run: go build ./...
- run: go vet ./...
- run: go test -race -cover ./...
- name: Build and push image
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/yourname/my-service:${{ github.sha }}
-race enables Go's race detector during tests — genuinely worth
running in CI given everything from Weeks 6–7, since a data race (two goroutines
touching shared memory without a mutex) can pass every functional test and still be
a real bug the race detector catches directly. Every push to main now
builds, vets, tests, and — only if all of that passes — publishes a container image
tagged with the exact commit that produced it.
5. Hands-on Exercise
Containerize and automate the task API
Package Weeks 10–13's API into a real, minimal container with a working CI pipeline.
Requirements:
- A multi-stage Dockerfile producing a minimal final image, with
CGO_ENABLED=0and a distroless or scratch-based final stage. - All configuration (database URL, port, log level) read from environment variables, with a clear startup error if a required one is missing.
- A
/healthendpoint that actually checks database connectivity, returning503if unreachable rather than an unconditional200. - A CI workflow that runs
go vetandgo test -raceon every push, failing the pipeline if either fails. - Confirm the built image runs correctly with
docker run, connecting to a real (or containerized) database purely through environment variables passed at run time.
If docker build succeeds but the container immediately exits or fails to bind to its port, check that your Dockerfile's EXPOSE and your app's actual listen address agree, and that you're passing -p host:container correctly on docker run — a service listening on localhost:8080 inside the container (rather than 0.0.0.0:8080) is a common mistake that makes it unreachable from outside the container even though it's running fine.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why can a Go service's Docker image skip installing Go (or any language runtime) entirely in its final stage?
Why can a Go service's Docker image skip installing Go (or any language runtime) entirely in its final stage?
Go compiles to a single, statically-linked binary with no external runtime dependency — unlike an interpreted or VM-based language, there's nothing left to interpret or execute the source at container run time. The final image only needs the already-compiled binary plus whatever minimal OS-level pieces it directly depends on (often nothing, with CGO_ENABLED=0 and a distroless base).
Q2
Why load configuration from environment variables instead of a hardcoded config file baked into the image?
Why load configuration from environment variables instead of a hardcoded config file baked into the image?
The same built image then works correctly across every environment — local, staging, production — purely by changing what's injected around it at run time, with zero rebuild required to change a database URL or log level. A hardcoded config would require a separate image build per environment, which defeats the point of having one tested, promotable artifact.
Q3
Why might a real deployment want separate liveness and readiness checks, rather than one combined health endpoint?
Why might a real deployment want separate liveness and readiness checks, rather than one combined health endpoint?
Liveness ("is the process still running") and readiness ("can it actually serve traffic right now") answer different questions with different consequences — failing liveness typically triggers a restart, while failing readiness just routes traffic elsewhere. A service whose database connection temporarily dropped should stop receiving traffic (fail readiness) without necessarily being killed and restarted (which failing liveness would trigger), since the process itself might still be healthy and about to reconnect.
Q4
Why does running tests with -race in CI matter specifically for a service using goroutines and channels?
Why does running tests with -race in CI matter specifically for a service using goroutines and channels?
A data race — two goroutines touching the same memory without proper synchronization — can easily pass every functional test, since the bug's symptom depends on unlucky timing that a given test run might never happen to hit. Go's race detector instruments the actual memory accesses during a test run and catches the race directly, regardless of whether it happened to produce a visibly wrong result that run, which is exactly the class of bug Weeks 6–7's concurrency work can introduce.