Graceful Shutdown: Signals, Channels, Cleanup¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Shepherd runs several goroutines: scheduler, replication controller, service controller, node controller, API server, agent. When SIGTERM arrives, they all need to stop cleanly. Here's how to do it with a single channel.
Stop channel¶
func runServer(addr, dataDir string, logger *log.Logger) {
store, _ := shepherd.NewStore(dataDir + "/shepherd.db")
defer store.Close()
stopCh := make(chan struct{})
scheduler := shepherd.NewScheduler(store, logger)
go scheduler.Run(stopCh)
replicationCtrl := shepherd.NewReplicationController(
store, scheduler, logger)
go replicationCtrl.Run(stopCh)
serviceCtrl := shepherd.NewServiceController(store, logger)
go serviceCtrl.Run(stopCh)
nodeCtrl := shepherd.NewNodeController(store, logger)
go nodeCtrl.Run(stopCh)
api := shepherd.NewAPIServer(addr, store, scheduler, logger)
// Graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
logger.Println("shutting down...")
close(stopCh)
api.Shutdown(context.Background())
}()
api.Start()
}
graph TB
SIG["SIGTERM / SIGINT"] --> CLOSE["close(stopCh)"]
CLOSE --> S["Scheduler: return"]
CLOSE --> RC["ReplicationController: return"]
CLOSE --> SC["ServiceController: return"]
CLOSE --> NC["NodeController: return"]
CLOSE --> API["api.Shutdown()"]
A single close(stopCh) stops all goroutines. Each controller has a select on stopCh:
func (s *Scheduler) Run(stopCh <-chan struct{}) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-stopCh:
s.logger.Println("scheduler stopped")
return
case <-ticker.C:
s.reconcile()
}
}
}
When the channel is closed, <-stopCh returns the zero value without blocking. All goroutines listening on this channel wake up and terminate.
Why close() instead of send?¶
close(ch) wakes all listeners. ch <- struct{}{} wakes only one. With close, a single line stops everything.
Standalone mode¶
In standalone mode, the API Server and Agent run in the same process:
func runStandalone(addr, dataDir, nodeName string,
logger *log.Logger) {
// ... start all controllers ...
go api.Start()
go agent.Run(stopCh)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
logger.Println("shutting down...")
close(stopCh)
api.Shutdown(context.Background())
}
<-sigCh blocks the main goroutine until a signal arrives. After the signal, close(stopCh) stops everything.
Why this is better than context.Cancel¶
You could use context.WithCancel. But for simple cases, a channel is more straightforward: one close - everything stops. Context adds complexity (Done(), Err(), Value()) that isn't needed here.
Something to keep in mind¶
We don't wait for reconcile to finish. close(stopCh) signals a stop, but if a controller is in the middle of a write operation to BoltDB, the transaction may not complete cleanly. In production you'd need a graceful drain with a timeout.
Two separate traps. close(stopCh) twice panics - closing an already-closed channel crashes the process; so exactly one owner should ever close it. And api.Shutdown(context.Background()) without a deadline can hang forever if a long-lived keep-alive connection remains: a context with no timeout here means "wait forever."
💡 Fun facts¶
close(ch)as a broadcast is an idiom described back in Rob Pike's "Go Concurrency Patterns." A closed channel hands out the zero value an unlimited number of times, so all listeners wake up at once. You can't do that with a send.- SIGKILL (
kill -9) can't be caught or ignored - the kernel kills the process with no chance for cleanup. That's why graceful shutdown reacts to SIGTERM: it's a "polite" request you can still respond to. SIGSTOP is the second signal you can't trap - it freezes the process without asking. - Kubernetes gives a pod
terminationGracePeriodSeconds(30 by default): first SIGTERM, then a wait, and only then SIGKILL. Ourclose(stopCh)is that same "polite" first step, just without the timer. docker stopfollows the same script but with a 10-second default (--timechanges it): SIGTERM, wait, SIGKILL. So the timeout in yourclose(stopCh)should be smaller than the orchestrator's, or you'll always eat the SIGKILL.net/httphas had a built-inServer.Shutdown(ctx)since Go 1.8: it stops accepting new connections and waits for in-flight ones to finish. That's exactly what we call - no need to reinvent it.- PID 1 is special: the kernel doesn't apply default signal actions to it, so a process running as PID 1 in a container that doesn't explicitly handle SIGTERM simply ignores it, and
docker stopwaits out the full grace period before SIGKILL. That's the whole reasontiniand--initexist. - Since Go 1.16 there's
signal.NotifyContext: it folds the signal handler and acontext.Contextinto one object, so the arrival of SIGTERM cancels the context and the manualsigChdisappears entirely. - A
chan struct{}is a zero-byte type: the value carries no data, only the event (the close) matters. It's the canonical "signal-only" channel, and the compiler even avoids allocating for its elements.
What I figured out while digging into this¶
For a long time I was puzzled why everyone recommends a chan struct{} rather than a chan bool - until I wrote this shutdown myself. struct{} takes zero bytes and says clearly: the value carries no meaning, only the fact of the event matters (here, the close). But the bigger realization is more down-to-earth: I was used to thinking of stopping as "send a stop command." It turns out the cleaner model is to send nothing at all - just close the channel and let each goroutine notice it in its own select. Less code, and no message for anyone to lose.
What could be improved¶
- Add a
sync.WaitGroup: the shutdown goroutine should wait until all controllers have actually exited before closing the store and the process. - Give
api.Shutdowna context with a timeout (context.WithTimeout), so a keep-alive hang doesn't block the exit forever. - Implement a real drain: a controller in the middle of a reconcile should finish the current iteration (or roll back the transaction) rather than getting cut off mid-write.
- Move signal handling to
signal.NotifyContext(Go 1.16+) - it ties the signal and the context into one object and removes the manualsigCh. - Consider
golang.org/x/sync/errgroupwhen goroutines can fail: it combines aWaitGroup, error propagation, and a shared context cancel in one primitive.
Try it yourself¶
# Start shepherd and stop with Ctrl+C:
sudo ./shepherd --mode standalone
# You'll see in the logs:
# shutting down...
# scheduler stopped
# replication controller stopped
Next up - Two-Mode Architecture: one binary for server, agent, and standalone.
Resources¶
- os/signal:
Notify,Stop, andNotifyContext - signal(7): SIGTERM/SIGKILL semantics
- http.Server.Shutdown: graceful connection draining in the standard library
- Go concurrency patterns: Rob Pike's classic talk on
closeas broadcast - sync.WaitGroup and errgroup - waiting for goroutines to finish
- Pod termination: Kubernetes grace period
- PodDisruptionBudget: controlled voluntary disruptions
- docker stop: SIGTERM, grace period, then SIGKILL
- tini: why PID 1 needs a real init to forward signals
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Async Scheduling | Next: Two-Mode Architecture
