When Kubernetes decides to kill a pod — it sends the process a SIGTERM signal. If the server simply closes at that moment, the clients that have already sent a request get a connection reset. The browser shows an error, the mobile app fails with 502 Bad Gateway.
Graceful shutdown is an orderly termination: first stop accepting new requests, wait for the current ones to finish, then close. In Go this is not a single function from the standard library, but an explicit sequence of steps that the developer assembles themselves in main.
How to correctly stop an HTTP server
In Go, http.Server has two termination methods, and they behave in fundamentally different ways.
srv.Close() immediately closes all connections — including those where a request is still being processed. The client will get a reset, the server will return 502. This is the wrong choice for a normal shutdown.
srv.Shutdown(ctx) works differently: it stops accepting new connections but waits until all current requests reach their response. It returns nil when they all finish, or an error — if the passed context expired.
// The correct shutdown path
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
slog.ErrorContext(ctx, "server shutdown", "error", err)
}
A context with a timeout is mandatory here. Without it, Shutdown will wait forever — and Kubernetes will send SIGKILL after 30 seconds and kill the process forcibly. A good timeout value is 20–25 seconds: enough for most requests, but not exceeding terminationGracePeriodSeconds.
How to catch SIGTERM
To react to an operating-system signal, you need to create a channel and subscribe via signal.Notify:
sigC := make(chan os.Signal, 1)
signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigC)
The channel's buffer of 1 matters: if a goroutine did not manage to read the signal instantly, it will not be lost. Without a buffer, there is a possibility that the signal arrived but no one was reading the channel — and it was simply ignored.
Next, the server is started in a goroutine, and main waits for either the signal or a startup error:
errC := make(chan error, 1)
go func() { errC <- srv.ListenAndServe() }()
select {
case sig := <-sigC:
slog.InfoContext(ctx, "received SIGTERM, starting graceful shutdown",
"signal", sig.String())
case err := <-errC:
return err
}
Readiness — so that Kubernetes does not send traffic to a dying pod
When Kubernetes receives SIGTERM, it simultaneously starts removing the pod from the list of endpoints. But this is not instant: the DNS cache, the load balancer — all of this takes several seconds. New requests may keep arriving for another 5–10 seconds after the process has already started shutting down.
The solution is to switch the readiness state immediately upon receiving the signal, before calling Shutdown. Then /health/ready will start returning 503, Kubernetes will remove the pod from load balancing, and new requests will stop arriving.
// internal/health/state.go
package health
import "sync/atomic"
type State struct {
ready atomic.Bool
}
func NewState() *State {
s := &State{}
s.ready.Store(true)
return s
}
func (s *State) SetNotReady() { s.ready.Store(false) }
func (s *State) IsReady() bool { return s.ready.Load() }
atomic.Bool is a thread-safe primitive without a mutex. Since readiness is read on every HTTP request, a lock here would be excessive.
The order in the shutdown path:
appState.SetNotReady() // first — so that k8s removes the pod before the drain begins
// ... then the server shutdown
Two health endpoints instead of one
A common mistake is to combine liveness and readiness into a single /health endpoint. At first glance it is convenient, but on shutdown it leads to a problem.
Kubernetes distinguishes two types of checks:
- Readiness probe — "is the pod ready to accept traffic?" On
503the pod is removed from load balancing. - Liveness probe — "is the pod alive?" On
503Kubernetes restarts the pod.
If they are combined and you switch both to 503 on shutdown — Kubernetes will see a broken liveness and start a new pod. Instead of a correct shutdown, you get a restart.
Correct: on shutdown, readiness returns 503, while liveness stays 200 until the end.
// internal/server/routes.go
func RegisterHealthRoutes(r chi.Router, appState *health.State) {
r.Get("/health/live", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) // always 200 while the process is alive
})
r.Get("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
if !appState.IsReady() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
}
The full sequence in main
Here is how all the elements come together. Order matters:
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.ErrorContext(ctx, "pgxpool init", "error", err)
os.Exit(1)
}
appState := health.NewState()
// Start background tasks
consumerCtx, cancelConsumer := context.WithCancel(ctx)
var consumerWg sync.WaitGroup
consumerWg.Add(1)
go func() {
defer consumerWg.Done()
productConsumer.Run(consumerCtx)
}()
schedulerCtx, cancelScheduler := context.WithCancel(ctx)
var schedulerWg sync.WaitGroup
schedulerWg.Add(1)
go outboxRelay.Run(schedulerCtx, &schedulerWg)
// HTTP server
r := chi.NewRouter()
health.RegisterHealthRoutes(r, appState)
srv := &http.Server{Addr: cfg.Addr, Handler: r}
sigC := make(chan os.Signal, 1)
signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigC)
errC := make(chan error, 1)
go func() { errC <- srv.ListenAndServe() }()
select {
case sig := <-sigC:
slog.InfoContext(ctx, "received SIGTERM, starting graceful shutdown",
"signal", sig.String())
case err := <-errC:
slog.ErrorContext(ctx, "server error", "error", err)
os.Exit(1)
}
start := time.Now()
appState.SetNotReady() // 1. remove from load balancing
cancelConsumer() // 2. stop the Kafka consumer
consumerWg.Wait()
cancelScheduler() // 3. stop the background tasks
schedulerWg.Wait()
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil { // 4. drain HTTP
slog.ErrorContext(ctx, "server shutdown", "error", err)
}
pool.Close() // 5. DB pool — last
slog.InfoContext(ctx, "graceful shutdown complete",
"duration_s", time.Since(start).Seconds())
}
The database pool is closed last — by this point all tasks and HTTP requests have already finished and no longer access the DB.
In short
srv.Shutdown(ctx)waits for the current requests to finish;srv.Close()tears them off immediately — use onlyShutdown.context.WithTimeoutfor 20–25 seconds — not an unbounded context: otherwiseShutdownwill wait forever and Kubernetes will kill the process forcibly.atomic.Boolinhealth.Stateis the single source of the readiness state; thread-safe without a mutex.SetNotReady()is called beforesrv.Shutdown— it gives Kubernetes time to remove the pod from load balancing before the drain begins./health/liveand/health/readyare different endpoints: on503, liveness restarts the pod, readiness only removes it from traffic.- An
os.Signalchannel with a buffer of1— the signal will not be lost even if the goroutine does not read it instantly. - The shutdown order: readiness off → consumers → background tasks → HTTP drain → DB pool.
What to read next
- HTTP drain and long requests — preStop sleep,
202 Acceptedfor slow endpoints. - DB and persistence — the order of closing
pgxpooland transactions on shutdown. - Kafka shutdown — stopping the consumer via context and committing the last messages.
- Kubernetes configuration —
terminationGracePeriodSeconds, probe configuration,maxUnavailable: 0.