Signals and graceful shutdown¶
When an orchestrator stops your service it sends SIGTERM and starts a
clock. Handle it and in-flight work finishes; ignore it and you are
killed mid-request. The whole mechanism is one function plus the
concurrency patterns from
long-running goroutines.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
fmt.Println("signal received:", ctx.Err())
// output: signal received: context canceled
signal.NotifyContext¶
This is the modern form and the one to reach for. It returns a context
cancelled when any listed signal arrives, which plugs straight into
every ctx.Done() you have already written — no separate channel to
plumb through.
Note that ctx.Err() is context.Canceled, not something signal-shaped.
A shutdown looks like any other cancellation to the code being shut down,
which is the point.
Call the returned stop when you are done. It restores default signal
behaviour, so a second Ctrl-C during a slow shutdown kills the process
instead of being swallowed.
The older signal.Notify with a channel still works and still appears
in existing code:
The channel must be buffered. Signal delivery never blocks, so an unbuffered channel with nobody receiving at that instant drops the signal silently.
Which signals¶
| Signal | Meaning | Catchable |
|---|---|---|
SIGTERM |
orchestrators' "please stop" | yes — handle this one |
SIGINT |
Ctrl-C |
yes |
SIGHUP |
terminal closed; often "reload config" | yes |
SIGKILL |
forced kill | no |
SIGKILL cannot be caught, blocked, or deferred. That is precisely why
the grace period matters: a container runtime sends SIGTERM, waits,
and then sends SIGKILL. Whatever you have not finished by then is lost.
The shape of a shutdown¶
Three steps, in order:
- Stop accepting new work. A server stops listening; a consumer stops pulling.
- Drain what is in flight, with a deadline.
- Release resources — flush buffers, close pools.
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := startServer()
<-ctx.Done()
stop() // a second signal now kills us, as it should
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
fmt.Fprintln(os.Stderr, "shutdown:", err)
}
}
http.Server and its Shutdown method belong to the next topic; what
matters here is the shape — something that stops accepting work and
drains, given a deadline.
The shutdown context is built from context.Background(), not from
ctx. Deriving it from the already-cancelled ctx would give you a
context that is dead on arrival, and nothing would drain.
Draining with a deadline¶
wg.Wait() has no timeout. Convert it to a channel and race it, the
trick from the concurrency topic:
func drain(wg *sync.WaitGroup, budget time.Duration) string {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return "drained cleanly"
case <-time.After(budget):
return "budget expired"
}
}
// two workers, 30ms and 40ms, budget 500ms
// output:
// worker 1 finished
// worker 2 finished
// drained cleanly
When the budget runs out, cancel the workers' own context so they stop early rather than being killed mid-write:
That ordering is the whole design: ask nicely, wait a bounded time, then insist.
Budget arithmetic¶
Split the grace period between phases and make sure the total fits inside it. If the orchestrator allows 30 seconds, a 25-second request drain plus a 10-second background drain overruns and the remainder is killed. Name the budgets as constants so the sum is visible:
Things that quietly break it¶
os.Exitskipsdefer. Any cleanup in a deferred function does not run, as flags and environment showed. Return frommaininstead.- A deferred
stop()alone is not enough. Callstop()explicitly after<-ctx.Done()so a second signal is honoured. - PID 1 in a container has no default signal handlers. If your
process is PID 1 and does not handle
SIGTERM, the default action does not apply and the signal is ignored — the container then takes the full grace period and dies toSIGKILLevery time. - Detached work loses its context. Anything started from a request but outliving it needs its own lifetime, or it is cancelled the moment the request ends.
Distinguishing why a context ended uses errors.Is:
fmt.Println(errors.Is(ctx.Err(), context.DeadlineExceeded)) // output: true
fmt.Println(errors.Is(ctx.Err(), context.Canceled)) // output: false
From Python:
signal.NotifyContextreplacessignal.signal(SIGTERM, handler), and it is better behaved — there is no separate handler running at an arbitrary point, just a context that becomes cancelled, observed wherever you already select on one.
Quick reference¶
| Task | Call |
|---|---|
| catch signals as a context | signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) |
| restore default behaviour | the returned stop() — call it explicitly too |
| older channel form | signal.Notify(ch, ...) with a buffered channel |
| shutdown deadline | context.WithTimeout(context.Background(), d) — not from the dead ctx |
| stop a server | srv.Shutdown(shutdownCtx) |
| wait with a deadline | wg.Wait() in a goroutine, close(done), select |
| after the budget | cancel the workers' context |
| why it ended | errors.Is(ctx.Err(), context.DeadlineExceeded) |