- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Kubernetes rolls a new version of your Go service. The old pod gets a
SIGTERM and, 30 seconds later, a SIGKILL. Your process caught the
SIGTERM, closed the HTTP listener, and exited in 40 milliseconds.
Clean, right?
Except the background worker that was halfway through writing a batch
to Postgres got SIGKILLed mid-write. The Kafka consumer that had
pulled 200 messages but committed none of them just lost them back to
the group. The HTTP request that was three seconds into a payment call
returned a connection reset to the client. The deploy dashboard is
green. The support queue is not.
Catching the signal is the easy 20%. Coordinating every goroutine to
stop in the right order, drain what it already started, and still
guarantee the process exits even if one of them wedges — that is the
part people skip. Here is how to build it in Go, one piece at a time.
The done signal: one context, threaded everywhere
Start with a single cancellation source derived from the OS signals.
signal.NotifyContext (Go 1.16+) gives you a context that cancels on
SIGINT or SIGTERM. Every goroutine in the process reads from it.
func main() {
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT, syscall.SIGTERM,
)
defer stop()
if err := run(ctx); err != nil {
log.Fatalf("shutdown: %v", err)
}
}
ctx.Done() closes the moment the first signal lands. That is your
one broadcast: "everyone start winding down." No shared bool, no
sync.Once, no channel you have to remember to close. One context,
passed into every component, is the whole coordination primitive.
The mistake here is treating the signal context as the thing that
also bounds shutdown. It doesn't. Cancelling ctx tells goroutines
to stop accepting new work. It says nothing about how long they get to
finish the work they already have. That is a separate clock, and it
comes later.
Draining in-flight work
Stopping means two different things, and conflating them is where
requests get dropped. There is "stop taking new work" and there is
"finish the work you already accepted." A graceful stop does the first
immediately and the second with patience.
The standard library models this well. http.Server.Shutdown closes
the listener so no new connections arrive, then blocks until every
in-flight handler returns:
func serveHTTP(ctx context.Context, srv *http.Server) error {
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
shutCtx, cancel := context.WithTimeout(
context.Background(), 15*time.Second,
)
defer cancel()
return srv.Shutdown(shutCtx)
}
Two details matter. The errCh is buffered so the goroutine can
publish a startup error and exit even if nobody is selecting yet.
And Shutdown gets a fresh context with its own timeout, not the
already-cancelled signal context — pass the cancelled one and
Shutdown returns instantly without draining anything.
For your own worker pools, drain means the same shape: close the input
so no new jobs enter, then wait for the in-flight ones to land.
func (w *Worker) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
// stop pulling; finish what's buffered
return w.drain()
case job := <-w.jobs:
w.handle(job)
}
}
}
func (w *Worker) drain() error {
for {
select {
case job := <-w.jobs:
w.handle(job)
default:
return nil
}
}
}
The default arm is what makes drain terminate: it processes
everything already buffered in the channel, then falls through the
moment the buffer is empty. Whether you drain at all is a policy
choice. A payment worker drains. A metrics-scraper that runs again in
10 seconds can drop its buffer and exit.
Ordering dependent shutdowns
Here is the part a single cancelled context can't express on its own.
Your components depend on each other, and they have to stop in
dependency order. The HTTP server accepts requests that write to a
job queue. The job queue is drained by workers that write to the
database. If you cancel all three at once, the workers close their DB
pool while a late request is still trying to enqueue, and you get
writes to a closed connection.
The order is the reverse of the request flow. Stop the front door
first, then the middle, then the back:
- HTTP server — stop accepting requests, drain in-flight handlers.
- Workers — stop pulling jobs, drain the queue.
- Database pool and other clients — close last, once nothing writes.
You can express this with a small ordered list of shutdown steps, each
run in sequence:
type Component struct {
name string
close func(context.Context) error
}
func shutdownAll(
ctx context.Context, comps []Component,
) error {
var errs []error
for _, c := range comps {
if err := c.close(ctx); err != nil {
errs = append(errs,
fmt.Errorf("%s: %w", c.name, err))
}
}
return errors.Join(errs...)
}
errors.Join (Go 1.20+) collects a failure from any step without
letting it abort the rest. A failed HTTP drain should not stop you
from closing the database. You still want to attempt every close and
report all of them.
Components that have no dependency on each other can stop in parallel —
a sync.WaitGroup fans them out. But anything with an ordering
constraint goes in sequence. Getting this wrong is subtle: the process
still exits, the logs look fine, and you only notice the truncated
writes days later when the numbers don't reconcile.
The deadline that always exits
Every draining step above can hang. A handler stuck on a wedged
upstream. A worker in a retry loop. A database close waiting on a
connection that will never return. Graceful shutdown that waits
forever is just a hang with good intentions, and Kubernetes will
SIGKILL you anyway — turning your careful drain into the abrupt
stop you were trying to avoid.
So the whole shutdown sequence runs under a hard deadline. Whatever
hasn't finished when the clock runs out gets abandoned, and the
process exits on its own terms.
func run(ctx context.Context) error {
// ... start components ...
<-ctx.Done()
log.Println("signal received, shutting down")
shutCtx, cancel := context.WithTimeout(
context.Background(), 25*time.Second,
)
defer cancel()
done := make(chan error, 1)
go func() {
done <- shutdownAll(shutCtx, components)
}()
select {
case err := <-done:
return err
case <-shutCtx.Done():
return errors.New("shutdown timed out, forcing exit")
}
}
Pick the budget just under the platform's kill window. Kubernetes
defaults terminationGracePeriodSeconds to 30, so 25 seconds of app
budget leaves room for the runtime to flush and exit before the
SIGKILL. The point is that this function always returns. Either
every component closed cleanly, or the deadline fired and you exit on
purpose with a logged reason. You never hand the decision to SIGKILL.
Putting it together
The four pieces stack:
- One
signal.NotifyContextis the broadcast. Its cancellation means "start winding down," nothing more. - Each component reads
ctx.Done(), stops accepting new work, and drains what it already holds — if draining is the right policy for it. - Components close in dependency order, front door to back, so nothing writes to something that's already closed.
- The entire sequence runs under a
WithTimeoutthat guarantees the process exits even when a drain wedges.
None of these need a framework. context, select, a channel, and
errors.Join are the whole toolkit. What turns them into a correct
shutdown is thinking about the two clocks (cancel now, finish by a
deadline) as separate concerns, and about your components as an
ordered graph rather than a flat set that all stops at once.
If this was useful
Graceful shutdown is one of those Go problems where the language gives
you exactly the primitives and no opinions, so the design is on you.
The Complete Guide to Go Programming goes deep on context, the
scheduler, and how channels and select actually behave under
cancellation. Hexagonal Architecture in Go is the one to reach for
when you want each adapter (HTTP, queue, database) to own its own
Close at the right boundary, so the shutdown order falls out of the
architecture instead of a hand-maintained list.

Top comments (0)