Initial commit: Go SRE interview exercises
Includes solved 02-worker-pool and 09-safe-counter (mutex, atomic, sharded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# 08 — Graceful Shutdown
|
||||
|
||||
Pure SRE bread and butter: every rolling deploy, node drain, and spot
|
||||
eviction ends with this code path. Interviewers love it because it chains
|
||||
signals, contexts, and HTTP internals into one small program.
|
||||
|
||||
## The fresh-context trick (the whole exercise in one line)
|
||||
|
||||
```go
|
||||
<-ctx.Done() // SIGTERM arrived; ctx is DEAD
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), drain)
|
||||
srv.Shutdown(shutCtx)
|
||||
```
|
||||
|
||||
`Shutdown`'s context bounds *how long draining may take*. Pass the
|
||||
already-cancelled signal ctx and Shutdown gives up instantly — you built
|
||||
"graceful" shutdown that guillotines every in-flight request. It has to derive
|
||||
from `Background()`. This inverted-context moment is the most commonly botched
|
||||
line in the whole genre; the `TestDrainDeadlineExceeded`/`TestInFlight` pair
|
||||
exists to catch both failure modes.
|
||||
|
||||
## `http.ErrServerClosed` is a success
|
||||
|
||||
`ListenAndServe` *always* returns non-nil. After `Shutdown` it returns
|
||||
`http.ErrServerClosed`, which means "you asked me to stop and I did." Filter
|
||||
it with `errors.Is`. Logging it as fatal is the "error: success" log line
|
||||
every fleet has somewhere.
|
||||
|
||||
Also: send it through a **buffered** channel (`make(chan error, 1)`) — if you
|
||||
exit `Run` via the ctx path first, an unbuffered send would strand the
|
||||
ListenAndServe goroutine forever. Tiny buffer, whole leak class gone.
|
||||
|
||||
## What Shutdown actually does
|
||||
|
||||
1. Closes listeners → new connects get refused (RST).
|
||||
2. Closes *idle* keep-alive connections.
|
||||
3. Waits for active requests to finish, polling, until its ctx expires.
|
||||
4. Returns `ctx.Err()` if the drain deadline passed with requests still going.
|
||||
|
||||
Note what it does **not** do: notify your load balancer. Which brings us to…
|
||||
|
||||
## The Kubernetes choreography (senior talking points)
|
||||
|
||||
SIGTERM and "stop receiving traffic" are **not ordered** the way you'd hope.
|
||||
Kubelet sends SIGTERM *and* starts removing the pod from Endpoints
|
||||
concurrently — traffic can arrive *after* SIGTERM for however long it takes
|
||||
every kube-proxy and LB in the fleet to converge. Hence the cargo-cult that
|
||||
is actually correct:
|
||||
|
||||
- **`preStop: sleep 5-15s`** — not superstition; it's buying time for
|
||||
endpoint propagation *before* the process even sees SIGTERM.
|
||||
- Fail readiness on SIGTERM, keep serving while draining.
|
||||
- `terminationGracePeriodSeconds` (default 30s) must exceed
|
||||
preStop + drainTimeout, or SIGKILL wins and no code of yours runs at all.
|
||||
- If you're PID 1 in a container without an init, you get no default signal
|
||||
handlers — `signal.NotifyContext` is doing real work there. (Related deep
|
||||
cut: PID 1 doesn't reap zombies unless you make it — `tini` exists for a
|
||||
reason.)
|
||||
|
||||
## `signal.NotifyContext` fine print
|
||||
|
||||
Second signal = default action (instant death). That's a *feature*: hung
|
||||
drain + impatient operator's second Ctrl+C = immediate exit. Calling the
|
||||
returned `stop()` after `ctx.Done()` restores default handling explicitly for
|
||||
the same effect. Mentioning "first SIGTERM drains, second kills" is a nice
|
||||
operational garnish.
|
||||
@@ -0,0 +1,69 @@
|
||||
// Exercise 08 — Graceful HTTP Server Shutdown
|
||||
//
|
||||
// ASSIGNMENT
|
||||
// ----------
|
||||
// Implement NewServer and Run:
|
||||
//
|
||||
// 1. NewServer(addr, handler) returns an *http.Server with EXPLICIT
|
||||
// ReadTimeout, WriteTimeout and IdleTimeout (pick sane values; the
|
||||
// zero-value server has none, which is the bug).
|
||||
// 2. Run(ctx, srv, drainTimeout):
|
||||
// a. Starts the server (ListenAndServe) in a goroutine.
|
||||
// b. Blocks until ctx is cancelled (that's your SIGTERM signal).
|
||||
// c. Then calls srv.Shutdown with a FRESH context carrying
|
||||
// drainTimeout — NOT the already-cancelled ctx (why? see README).
|
||||
// d. In-flight requests must be allowed to complete (up to drainTimeout);
|
||||
// new connections are refused during drain.
|
||||
// e. Returns nil on clean drain; returns an error if the drain deadline
|
||||
// was exceeded or ListenAndServe failed for a real reason.
|
||||
// (http.ErrServerClosed is NOT a real reason.)
|
||||
//
|
||||
// Run the tests: go test -race ./08-graceful-shutdown/
|
||||
// Run the demo: go run ./08-graceful-shutdown/ (then Ctrl+C)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewServer builds an http.Server with production timeouts.
|
||||
// TODO: implement me.
|
||||
func NewServer(addr string, h http.Handler) *http.Server {
|
||||
return &http.Server{}
|
||||
}
|
||||
|
||||
// Run serves until ctx is cancelled, then drains gracefully.
|
||||
// TODO: implement me.
|
||||
func Run(ctx context.Context, srv *http.Server, drainTimeout time.Duration) error {
|
||||
// TODO:
|
||||
// - errCh := make(chan error, 1); go srv.ListenAndServe() into it
|
||||
// (why buffered? so the goroutine never leaks if we exit via ctx)
|
||||
// - select on ctx.Done() vs errCh
|
||||
// - on ctx.Done(): context.WithTimeout(context.Background(), drainTimeout)
|
||||
// and srv.Shutdown(shutCtx)
|
||||
// - filter http.ErrServerClosed with errors.Is
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer stop()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(3 * time.Second)
|
||||
fmt.Fprintln(w, "done")
|
||||
})
|
||||
|
||||
srv := NewServer(":8080", mux)
|
||||
fmt.Println("serving on :8080 — curl localhost:8080/slow, then Ctrl+C mid-request")
|
||||
if err := Run(ctx, srv, 10*time.Second); err != nil {
|
||||
fmt.Println("shutdown error:", err)
|
||||
}
|
||||
fmt.Println("bye")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testClient disables keep-alives: the default transport may speculatively
|
||||
// dial a second connection and park it in StateNew without ever sending a
|
||||
// request — and Server.Shutdown waits for StateNew conns, deadlocking the
|
||||
// drain. A fun wart, but not the one this exercise is about.
|
||||
var testClient = &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
|
||||
|
||||
func freeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
func waitReady(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
if resp, err := testClient.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("server never became ready")
|
||||
}
|
||||
|
||||
func TestServerHasTimeouts(t *testing.T) {
|
||||
srv := NewServer(":0", http.NewServeMux())
|
||||
if srv.ReadTimeout == 0 || srv.WriteTimeout == 0 || srv.IdleTimeout == 0 {
|
||||
t.Fatalf("server must set Read/Write/Idle timeouts explicitly (got %v/%v/%v) — zero means unlimited",
|
||||
srv.ReadTimeout, srv.WriteTimeout, srv.IdleTimeout)
|
||||
}
|
||||
if srv.Addr != ":0" || srv.Handler == nil {
|
||||
t.Fatal("Addr/Handler not wired through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesAndStopsOnCancel(t *testing.T) {
|
||||
addr := freeAddr(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Run(ctx, NewServer(addr, mux), 5*time.Second) }()
|
||||
|
||||
waitReady(t, "http://"+addr+"/ok")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("clean shutdown should return nil, got %v (leaking http.ErrServerClosed?)", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not return after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInFlightRequestCompletes(t *testing.T) {
|
||||
addr := freeAddr(t)
|
||||
started := make(chan struct{})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
||||
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
io.WriteString(w, "slow-done")
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runDone := make(chan error, 1)
|
||||
go func() { runDone <- Run(ctx, NewServer(addr, mux), 5*time.Second) }()
|
||||
waitReady(t, "http://"+addr+"/ok")
|
||||
|
||||
reqDone := make(chan string, 1)
|
||||
go func() {
|
||||
resp, err := testClient.Get("http://" + addr + "/slow")
|
||||
if err != nil {
|
||||
reqDone <- "error: " + err.Error()
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
reqDone <- string(b)
|
||||
}()
|
||||
|
||||
<-started // request is in flight
|
||||
cancel() // SIGTERM arrives mid-request
|
||||
|
||||
if body := <-reqDone; body != "slow-done" {
|
||||
t.Fatalf("in-flight request was killed instead of drained: got %q", body)
|
||||
}
|
||||
if err := <-runDone; err != nil {
|
||||
t.Fatalf("drain within deadline should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainDeadlineExceeded(t *testing.T) {
|
||||
addr := freeAddr(t)
|
||||
started := make(chan struct{})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
||||
mux.HandleFunc("/hang", func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
time.Sleep(5 * time.Second)
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runDone := make(chan error, 1)
|
||||
go func() { runDone <- Run(ctx, NewServer(addr, mux), 200*time.Millisecond) }()
|
||||
waitReady(t, "http://"+addr+"/ok")
|
||||
|
||||
go func() { testClient.Get("http://" + addr + "/hang") }()
|
||||
<-started
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err == nil {
|
||||
t.Fatal("want error when drain deadline exceeded, got nil")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run hung past the drain deadline — Shutdown ctx wired up wrong?")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user