Lesson 21 / 25

Graceful Shutdown

Stop accepting work, finish what is in flight, then exit.

Signals, contexts and Shutdown

A graceful shutdown has three steps: notice the request to stop, stop accepting new work, and drain in-flight work within a deadline. signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) returns a context that is cancelled when one of those signals arrives (call its stop function to restore default signal handling). For HTTP, srv.Shutdown(ctx) closes the listeners, closes idle connections and waits for active requests to complete, or returns the context's error if the deadline passes first; at that point ListenAndServe returns http.ErrServerClosed, which is not a real failure. Background workers should watch the same root context and be tracked with a WaitGroup or errgroup so that main waits for them. Shutdown does not wait for hijacked connections such as WebSockets; RegisterOnShutdown can notify them.

An HTTP server and a worker that stop together

SIGINT or SIGTERM triggers a bounded, orderly exit.

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"golang.org/x/sync/errgroup"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
	g, gctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			return err
		}
		return nil
	})

	g.Go(func() error { // background worker
		t := time.NewTicker(time.Second)
		defer t.Stop()
		for {
			select {
			case <-gctx.Done():
				return nil
			case <-t.C:
				// periodic job
			}
		}
	})

	g.Go(func() error { // shutdown coordinator
		<-gctx.Done()
		shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		return srv.Shutdown(shutCtx) // drain in-flight requests
	})

	if err := g.Wait(); err != nil {
		log.Printf("exit with error: %v", err)
	}
}

Use a fresh context for the drain

The signal context is already cancelled when shutdown starts, so passing it to Shutdown would end the drain immediately. Derive the drain deadline from context.Background() and keep it shorter than your orchestrator's kill timeout.

Quick check: What does http.Server.ListenAndServe return after Shutdown is called?

  • nil
  • http.ErrServerClosed
  • context.Canceled
  • It never returns
Answer

http.ErrServerClosed — Treat ErrServerClosed as a normal exit, not a failure.