Alex Verhoevenbackend & data · amsterdam

· go · kubernetes

What a Go service owes its readiness probe

Readiness and liveness get conflated, and the conflation is why services get restarted for having a slow database.

Liveness asks "is this process wedged? if so, kill it." Readiness asks "should this pod get traffic right now?" A failed liveness check restarts the pod. A failed readiness check just removes it from the Service's endpoints until it passes again. Only one of those helps when Postgres is slow, and it isn't the restart.

So, the rules I've settled on:

var ready atomic.Bool

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
        if !ready.Load() || !depsOK() {
            http.Error(w, "not ready", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })

    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() { _ = srv.ListenAndServe() }()
    ready.Store(true)

    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
    defer stop()
    <-ctx.Done()

    ready.Store(false)           // 1. fail readiness
    time.Sleep(5 * time.Second)  // 2. let endpoints catch up
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    _ = srv.Shutdown(shutdownCtx) // 3. drain in-flight requests
}

The time.Sleep is not a smell here; it's the endpoint-propagation delay made explicit, and I'd rather it were a named constant than a comment. Make sure terminationGracePeriodSeconds is larger than the sleep plus the shutdown timeout, or the kubelet will send SIGKILL in the middle of the drain and undo the point of all this. atomic.Bool is Go 1.19+; before that, an int32 and atomic.LoadInt32 did the same job less legibly.