Graceful shutdown is critical for production readiness. It ensures that in-flight requests complete or are properly handled, and that dependent services can cleanly release resources during rolling updates or maintenance windows.
In a distributed, multi-instance system, instances go up and down continuously. Starting up is straightforward: configure health probes and optimize startup time. Shutting down gracefully is more complex.
At scale, when your system processes multiple concurrent requests, you must ensure work either completes or is properly delegated before shutdown.
Go
The following examples use Go and its concurrency-first design. However, these principles apply equally to Spring Boot, Node.js, and other platforms.
Waiting
The idea is simple. You have a process running and you need to leverage the underlying operating system to coordinate signaling to actual stopping.
Your flow looks like this:
- Event triggers stopping sequence against supervisor (we’ll get to that later)
- Operating system emits signal to process
- Your process delays stopping until confirmation
Signals
On Linux systems, two signal types exist:
- Catchable:
SIGTERM,SIGINT,SIGHUP - Non-catchable:
SIGKILL,SIGSTOP
Shutdown coordination
Your application typically subscribes to signals, wires them with shared, concurrency-friendly global data (usually context.Context), and defines cleanup routines.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func(){
// ... your logic
}()
<-ctx.Done()
// cleanup work
server.Shutdown()
dependencyA.Cleanup()
You’ll want to wire up your cleanup either in your caller (like main) or accept context.Context as a function argument.
Delaying
Server handlers
With http.Server, you must call server.Shutdown() manually after receiving the signal.
server := &http.Server{
Addr: port,
Handler: mux,
}
go func() {
if err := server.ListenAndServe(); err != nil {
// ... handle error
done()
}
}()
<-ctx.Done()
server.Shutdown()
Worker pools
For worker pools, whether custom or from a library, a well-designed library needs only ctx passed. It should regularly check ctx.Done() and handle cleanup accordingly.
Here’s an example of such a library:
// caller
go worker.Start(ctx)
// worker
func Start(ctx context.Context) {
select {
// ...
case <-ctx.Done():
// or other Shutdown methods
close(jobs)
return
}
}
Design considerations
Notice the worker pool both stops receiving work and orchestrates cancellation of ongoing tasks.
This comes at a cost. You might want to split it into two phases since they’re different, and you might distinguish between waiting for a signal versus setting a deadline to stop work.
Here’s a better, more idiomatic Go design:
func (wp *WorkerPool) Start(ctx context.Context) {
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go func(ctx context.Context) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
}
}
}(ctx)
}
}
// Called after you know you want to stop to handle timeouts
func (wp *WorkerPool) Shutdown(ctx context.Context) error {
close(p)
}
This achieves the following:
- Context is used for cancellation of in-flight work
- Explicit methods handle lifecycle
You see the http.Server example above implementing this exact pattern.
Container orchestration
This behavior heavily depends on your deployment environment. In ephemeral container systems like Kubernetes, the control plane may not wait for you to emit an exit code. Kubernetes escalates from catchable signal SIGTERM to non-catchable SIGKILL. You can only influence the grace period.
To do so, use:
> kubectl explain pod.spec.terminationGracePeriodSeconds
KIND: Pod
VERSION: v1
FIELD: terminationGracePeriodSeconds <integer>
DESCRIPTION:
Optional duration in seconds the pod needs to terminate gracefully. May be
decreased in delete request. Value must be non-negative integer. The value
zero indicates stop immediately via the kill signal (no opportunity to shut
down). If this value is nil, the default grace period will be used instead.
The grace period is the duration in seconds after the processes running in
the pod are sent a termination signal and the time when the processes are
forcibly halted with a kill signal. Set this value longer than the expected
cleanup time for your process. Defaults to 30 seconds.
Conclusion
Graceful shutdown involves tradeoffs that depend heavily on your deployment environment. In some cases, you might prefer to simply close all channels instead. Weighing these options is essential before wiring up your application correctly.