Graceful Shutdown
Every lesson so far has ended the same way: main calls
http.ListenAndServe and the process runs until something kills it —
Ctrl-C in a terminal, a container orchestrator sending a signal, a deploy
script replacing the binary. What happens at that moment matters. A server
that dies mid-request drops whatever that request was doing: a task never
gets created, a client gets a connection reset instead of a response, and
nobody — not the caller, not the log — necessarily knows the write didn't
land. "It shut down" and "it shut down correctly" are different claims.
The problem with just stopping
If the process receives SIGTERM and does nothing special, the Go runtime's
default behavior is to exit. Any request being handled at that instant is
abandoned wherever it happened to be — mid-decode, mid-store-write,
mid-response. os.Exit (or an unhandled signal that has the same effect)
doesn't run deferred cleanup, doesn't flush anything, doesn't wait. For a
read this is often harmless. For a write — creating or updating a task —
it's the difference between "the client's request succeeded" and "the
client doesn't know if it succeeded," which is worse than a clean failure
because now someone has to go check.
The fix isn't complicated, but it does require holding onto something that
http.ListenAndServe throws away: the server itself.
Holding the server value
Every prior lesson called the package-level http.ListenAndServe(addr, handler), which builds an http.Server internally and never gives it back.
That's fine when the only lifecycle event is "run forever." Shutting down
gracefully means calling Shutdown on that server, so the program needs to
construct one explicitly:
srv := &http.Server{
Addr: ":8080",
Handler: (&server{store: NewTaskStore()}).routes(),
}
routes() here is the same *http.ServeMux built in Part 2 — nothing
about the handler changes. What changes is who owns the http.Server and
therefore who can tell it to stop.
Listening for the signal
signal.NotifyContext (from os/signal) returns a context.Context that
gets cancelled when the process receives one of the given signals — SIGINT
(Ctrl-C) or SIGTERM (what most orchestrators send before killing a
container):
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ListenAndServe blocks, so it needs to run in a goroutine while the main
goroutine waits on that context. When the context's Done() channel closes
— because a signal arrived — it's time to shut down:
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("listen: %v", err)
}
}()
<-ctx.Done()
stop() // restore default signal behavior for a second Ctrl-C
Draining, not dropping
srv.Shutdown(ctx) stops accepting new connections immediately, but lets
requests already in flight finish — up to a deadline set by the context
passed in. Without a deadline, a stuck request could hang shutdown forever,
so that context should carry its own bounded timeout:
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
Ten seconds is a starting point, not a rule — it should be comfortably
longer than the slowest request the service normally handles, but short
enough that an orchestrator's own kill timeout (often 30 seconds) doesn't
arrive first and force a hard kill anyway. If Shutdown returns
context.DeadlineExceeded, some requests were still in flight when the
clock ran out; that's worth logging so it's visible, not silent.
Putting it together
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: (&server{store: NewTaskStore()}).routes(),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("listen: %v", err)
}
}()
log.Println("serving on :8080")
<-ctx.Done()
stop()
log.Println("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
This is illustrative rather than a graded exercise: a real listener binding
a real port and receiving a real OS signal isn't something the offline
exercise sandbox can exercise. Read it, understand why each piece is there,
and reuse the shape directly in a real deployment — it doesn't need much
adaptation beyond wiring in the actual routes().