Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 18 / 18 40 MIN

Production

Structured Logging, Configuration and Containers

Make one request findable in a thousand log lines, refuse to start on a bad setting, and ship the binary in a scratch image that still receives SIGTERM.

An alert fires at 02:00 and you open the log stream. Four hundred lines from the last minute, six replicas, and the only thing you know is that one customer's checkout call failed. Without a request ID the search is hopeless: you want a line you have never seen in a stream where every line looks the same. With one, you paste sixteen characters into the search box and get the four lines that belong to that call.

Logging, configuration and packaging decide whether a service can be operated, and all three share a failure shape: the mistake costs nothing today and everything on the day you need it.

A request ID makes one call findable, and the context carries it

The identifier has to reach every line the request writes, including lines five calls deep in code that has never heard of HTTP. That rules out a package-level variable, which two concurrent calls would overwrite, and a global logger reconfigured per request, which has the same problem. An id string argument works and pollutes every signature between the handler and the database.

The context is already threaded from the handler through everything that touches the request. Put the ID and a logger carrying it there:

func Middleware(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := r.Header.Get("X-Request-ID")
			if id == "" {
				id = NewID() // "req-" + 8 random bytes, hex encoded
			}
			logger := base.With(slog.String("request_id", id))
			ctx := WithID(r.Context(), id)
			ctx = WithLogger(ctx, logger)
			w.Header().Set("X-Request-ID", id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Two details matter. The ID comes from the incoming header when a proxy or caller supplies one, because that is the ID somebody else is already searching for. And the response carries it back, so the person reporting the failure can be asked for it instead of a timestamp.

Handlers take the logger from the context. From(ctx) returns the default logger when there is none, so a background job can call the same helper:

func From(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok && l != nil {
		return l
	}
	return slog.Default()
}

The handler that writes the line never mentions the ID: With baked it into the logger once.

time=2026-10-16T20:00:08.152493Z level=INFO msg="pprof listening" addr=127.0.0.1:16070 scope="loopback only"
time=2026-10-16T20:00:09.361435Z level=INFO msg=request request_id=7f3a1c9e method=GET path=/work status=200 duration=17µs
time=2026-10-16T20:00:09.371616Z level=INFO msg=request request_id=req-16c2d60448f6aeae method=GET path=/work status=200 duration=21µs

The first line came from a client that sent its own X-Request-ID; the second was given one. Both are greppable.

Two handlers, one decision, and the level comes from outside

The standard library's log/slog gives you one logging call and a choice of handler. slog.NewJSONHandler writes one JSON object per line, which a log collector wants. slog.NewTextHandler writes key=value pairs, which a person wants. Pick per deployment:

opts := &slog.HandlerOptions{Level: cfg.LogLevel}
if cfg.LogFormat == "text" {
	return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
return slog.New(slog.NewJSONHandler(os.Stdout, opts))

The level comes from the same configuration, which makes debug logging an environment change and a restart rather than a code change and a deploy. That is the argument against making it dynamic: a level that changes while the service runs is a variable every goroutine reads.

One less obvious part of the API is worth adopting immediately: the DebugContext, InfoContext and ErrorContext variants make a handler that forgot to pass the context visible at the call. WithGroup nests attributes under a name, so a subsystem's fields cannot collide with the transport's:

db := logger.WithGroup("db")
db.Info("query finished", slog.Duration("took", d), slog.Int("rows", n))

// {"msg":"query finished","db":{"took":1200000,"rows":3}}

Some values must never reach a log line, and the fix is upstream of the logger

Credentials, session tokens, API keys, Authorization headers, passwords, connection strings with the password in them, and personal data the service has no business retaining: none belongs in a log. Logs are copied, and one line lands in a collector, a dashboard, a ticket and a laptop. Revoking a token that is in five places means rotating it.

The tempting defensive move is a ReplaceAttr hook that scrubs keys named token or password. Use it as a backstop, not as the mechanism: it only sees attributes passed as attributes, so a credential formatted into a message string or a wrapped error goes past it. Decide at the call what the line may contain:

{"time":"2026-10-16T19:49:29.743873Z","level":"INFO","msg":"token exchange","token":"sk_live_9f3c21ab77"}

That value is now in the log stream for as long as the retention policy says. The version that ships logs something that is useful for support and useless to an attacker:

{"time":"2026-10-16T20:00:13.964511Z","level":"INFO","msg":"token exchange","token_len":18,"token_present":true}

You can still answer what matters: did the request carry a token, and was it the wrong length, meaning a truncated paste rather than a revoked credential? You cannot answer "what was the token", which is the point. The same reflex applies to errors: a driver error often contains the DSN, and slog.Any("error", err) prints it.

Configuration without a library: read the environment, then refuse to start

For a service this size, a configuration library is a dependency you do not need. You need a rule about where each setting comes from and one place that decides a configuration is usable: environment variables for what differs between deployments, a Defaults() function returning safe values, and a Validate method called once before anything is opened.

Load takes a Lookup function instead of reading os.Getenv, so its tests never touch the real environment:

type Lookup func(key string) (string, bool)

func FromEnv(key string) (string, bool) { return os.LookupEnv(key) }

func Load(lookup Lookup) (Config, error) { ... }

The second return value from os.LookupEnv is why this signature exists. "Unset" and "set to empty" are different facts, and treating them alike is how a deployment that forgot DSN silently keeps the development default.

Validation has to cover combinations that look fine individually: the profiling address must differ from the public address, because pprof on the public port is an information leak on a schedule, and in production it must be loopback with the DSN present. None of that is visible in one value, so Validate takes the whole Config.

Report every bad value at once, because fixing them one at a time costs restarts

A startup check that returns on the first problem means a restart cycle per bad variable: fix LOG_LEVEL, restart, discover READ_TIMEOUT ninety seconds later. Join the errors and print all of them:

var errs []error
if v, ok := lookup("READ_TIMEOUT"); ok {
	d, err := time.ParseDuration(v)
	if err != nil || d <= 0 {
		errs = append(errs, fmt.Errorf("READ_TIMEOUT=%q: want a positive duration like 5s", v))
	} else {
		c.ReadTimeout = d
	}
}
// ...
return c, errors.Join(errs...)

Real output from one start with several variables wrong at once. The process exits with status 2 and never opens a socket:

$ LOG_LEVEL=verbose LOG_FORMAT=xml READ_TIMEOUT=30 WORKERS=0 ./profilesvc
configuration rejected:
LOG_LEVEL="verbose": want debug, info, warn or error
READ_TIMEOUT="30": want a positive duration like 5s
WORKERS="0": want an integer between 1 and 1024
LOG_FORMAT="xml": want json or text
$ echo $?
2

Status 2 for a configuration failure and status 1 for a runtime failure is worth keeping: a script can tell "retrying will not help" from "something broke".

A static binary in scratch is the point of the two-stage build

CGO_ENABLED=0 is not a performance flag. It produces a binary with no dynamic loader and no libc, which is what makes the final image possible. The build stage carries the toolchain and the module download; the second stage copies one file. There is no shell to exploit, no package manager to update, and no CVE feed for a base image with a hundred packages in it.

# Stage 1: build. The builder image carries the toolchain and is thrown away.
FROM golang:1.26 AS build

WORKDIR /src

# Dependencies first, so a source change does not invalidate the module layer.
COPY go.mod ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build \
      -trimpath \
      -ldflags="-s -w" \
      -o /out/profilesvc ./cmd/profilesvc

# Stage 2: run. An empty filesystem, no shell, no package manager, no libc.
FROM scratch

COPY --from=build /out/profilesvc /profilesvc

# Nothing in the image needs to write, so run as a user that cannot.
USER 65534:65534

EXPOSE 8080
ENTRYPOINT ["/profilesvc"]

Every line is load-bearing. COPY go.mod before COPY . . means editing a Go file does not re-download the module cache. -trimpath keeps this machine's home directory out of the binary. -s -w drops the symbol table and DWARF data: the same package builds to 9,380,178 bytes plain and 6,420,466 bytes with those flags. USER 65534:65534 means the process cannot write anything, because the image has no directory it owns.

The Linux cross-build confirms the static part without a container runtime. It produces an ELF executable with no interpreter, which is what scratch needs:

$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o deploy/profilesvc-linux-amd64 ./cmd/profilesvc
$ file deploy/profilesvc-linux-amd64
deploy/profilesvc-linux-amd64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=..., stripped

This machine has no running Docker daemon, so the image was not built or run while writing this lesson: docker version fails to reach /var/run/docker.sock. The Dockerfile is verified by its parts (the static Linux binary, the copy ordering, the flags) and not by a docker build. Run docker build -t profilesvc . and confirm the second stage runs and docker run --rm profilesvc prints its listening line.

If PID 1 is a shell, SIGTERM never reaches your shutdown code

A runtime sends SIGTERM to PID 1 and nothing else. If PID 1 is your binary, that reaches the signal.NotifyContext handler module 4 wrote. If PID 1 is a shell that started your binary in the background, the shell takes the signal and your program never learns.

This is measurable. The harness starts the same service three ways, sends SIGTERM after 1.2 seconds, and records whether the service logged its own shutting down line:

A. binary is PID 1                           exited in 1ms        graceful_shutdown_logged=true
B. sh -c exec (exec replaces sh)             exited in 2ms        graceful_shutdown_logged=true
C. sh -c 'binary & wait'  (sh stays PID 1)   exited in 0s         graceful_shutdown_logged=false

Cases A and B are correct, and B is the one to remember: sh -c 'exec /profilesvc' replaces the shell with the binary, so the program is PID 1. Case C is what a wrapper script that starts the service and sleeps does:

before SIGTERM: service alive = true (sh pid 21800 is PID 1)
sh exited; service still listening = true
  t+1s: service alive = true
  t+2s: service alive = true
  t+3s: service alive = true
runtime gives up and SIGKILLs the container:

The drain never runs, in-flight requests are cut when the grace period expires, and a background worker keeps going for as long as the runtime allows. In Kubernetes the pod sits in Terminating until terminationGracePeriodSeconds expires and the kubelet sends SIGKILL, turning a half-second deploy into ten seconds, every deploy. Three fixes exist: the exec form of ENTRYPOINT, exec in the script, or docker run --init so a tiny init process forwards signals. Prefer the first: it costs nothing and cannot be forgotten in another image.

Lab 45 minutes

Make three changes, checking each with real output.

First, add a /orders/{id} handler that calls loadOrder(ctx, id) in another package. Log a line from each and confirm both carry the same request_id without either function taking the ID as an argument.

Second, add a CACHE_DSN setting that must start with redis://. Start the service with it missing, empty, and set to a plausible wrong value, recording the exit code and output each time.

Third, produce the Linux binary by hand and write a Dockerfile for it. Answer: what does the final image contain, and what happens to a SIGTERM sent to a container whose ENTRYPOINT is ["sh", "-c", "/profilesvc"]?

Show the solution and the reasoning

The middle step exposes the design. A first draft fails the empty case for the wrong reason:

if v := os.Getenv("CACHE_DSN"); v != "" {
	c.CacheDSN = v
}

A deployment that sets CACHE_DSN= by accident gets the development default back and starts against the wrong cache. It is the earlier DSN bug in another variable, which is why the fix belongs in Lookup and not in each check. Missing means use the default; present and empty means the operator wrote something wrong:

if v, ok := lookup("CACHE_DSN"); ok {
	switch {
	case v == "":
		errs = append(errs, errors.New("CACHE_DSN is set but empty: unset it to use the default"))
	case !strings.HasPrefix(v, "redis://"):
		errs = append(errs, fmt.Errorf("CACHE_DSN=%q: want a redis:// URL", v))
	default:
		c.CacheDSN = v
	}
}

The exit codes are 0 for the missing case, since the default is redis://127.0.0.1:6379/0, and 2 for the other two with a message naming the variable and the shape it wanted. Printing a scheme and a host is safe; the moment the value contains a password, print the name and not the value. redis://:hunter2@host in a log is the mistake this lesson started with.

For the context part, loadOrder calls reqlog.From(ctx) and not slog.Default(); the ID was attached once, in middleware. If the two lines show different IDs, something rebuilt the context with context.Background().

The final image contains one file, /profilesvc, plus the directory entries scratch starts with. With ENTRYPOINT ["sh", "-c", "/profilesvc"] the runtime sends SIGTERM to the shell, the shell dies without forwarding it, and the Go process becomes an orphan holding its listener open until the runtime sends SIGKILL. Nothing is logged, because the service never saw a signal. The fixes are ENTRYPOINT ["/profilesvc"], sh -c 'exec /profilesvc', or --init.

Which is the thread running through all six modules. Every choice in this course is about where a value lives and who owns it. A type owns its invariants, so a Message the parser alone can build cannot carry a negative byte count. An interface is owned by the code that calls it, which is why it is declared next to the consumer. An error is a value the caller decides about, not a jump the runtime decides about. A goroutine's lifetime is owned by the context it started with, which is the only reason cancellation propagates. A transaction owns the rows it touches, a migration owns the schema's shape, a request owns its logger and its ID, a container owns one process, and a test owns the input it claims to exercise. None of that is a Go feature. It is one question asked at six levels, and the services that stay calm are the ones where the answer is the same at every level.

Official reference

The Go standard library documentation on pkg.go.dev for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of Go for Backend Services. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.