Graceful Shutdown and Health Checks in Go

Khimananda Oli 8 min read Programming and Languages
Graceful Shutdown and Health Checks in Go

By Khimananda Oli | Last reviewed: August 2026

Dropped requests during rolling updates remain a primary source of user-facing errors in microservices, even when infrastructure is correctly configured. Implementing graceful shutdown and health checks in Go requires coordinating OS signal handling, HTTP server lifecycle management, and distinct readiness versus liveness semantics. Without this coordination, load balancers continue routing traffic to terminating pods, causing intermittent 502 errors that are difficult to trace. This guide provides the exact implementation pattern I use in production to ensure zero-downtime deployments on Kubernetes.

SIGTERM SignalReadiness ProbeReturns 503 ImmediatelyServer.Shutdown(ctx)Drain Active RequestsIn-flight CompleteClose DB / CacheExit 0Load balancer stops sending new traffic after first failed readiness check
Signal flow for graceful shutdown and health checks in Go: SIGTERM triggers immediate readiness failure while active requests drain

How do you implement graceful shutdown and health checks in Go with signal handling?

The foundation of reliable Go services is proper signal propagation using context. Before implementing probes or server logic, you must establish a root context that cancels when the operating system sends a termination signal. In Go 1.21+, signal.NotifyContext simplifies this significantly compared to older channel-based patterns.

Setting up the cancellation root

Your main function should create a context that listens specifically for SIGTERM and SIGINT. Kubernetes sends SIGTERM during pod termination, while SIGINT covers local development (Ctrl+C). Never listen for SIGKILL; it cannot be caught by design.

package main

import (
    "context"
    "log/slog"
    "os"
    "os/signal"
    "syscall"
)

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

    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // Pass ctx to all long-lived components
    if err := run(ctx, logger); err != nil {
        logger.Error("application error", "error", err)
        os.Exit(1)
    }
}

This pattern ensures every goroutine, database connection, and HTTP handler receives the same cancellation signal. For teams managing complex dependencies, understanding Kubernetes resource limits and requests is essential because insufficient CPU can delay signal processing, causing hard kills before graceful shutdown completes.

Why context matters more than channels

Older tutorials often show raw channel selects for signal handling. Avoid this in 2026. Context propagation integrates naturally with Go's standard library servers, database drivers, and gRPC clients. When the context cancels, pending operations abort automatically without manual teardown logic scattered across your codebase.

What is the difference between liveness and readiness probes in Go?

Confusing these two probes is the most common cause of deployment failures I see during audits. They serve fundamentally different purposes, and conflating them creates cascading failures during restarts.

CharacteristicLiveness ProbeReadiness Probe
PurposeDetect deadlocks or unrecoverable stateDetermine if pod should receive traffic
Failure ActionKubelet restarts the containerEndpoints controller removes pod from Service
During ShutdownMust continue returning 200Must return 503 immediately on SIGTERM
Dependency ChecksNever check external dependenciesOptionally verify critical dependencies
Path Convention/health/live/health/ready

The shutdown-critical distinction

During graceful shutdown, your liveness probe must remain healthy until the process actually exits. If liveness fails during drain, Kubernetes kills the pod mid-request. Conversely, readiness must fail the moment SIGTERM arrives so the load balancer stops routing new traffic while existing connections complete. This asymmetry is non-negotiable for graceful shutdown and health checks in Go.

For deeper observability integration beyond basic probes, refer to the four golden signals of monitoring to understand which metrics correlate with probe failures in production dashboards.

Probe Request ArrivesWhich endpoint was hit?/health/live/health/readyReturn 200 AlwaysCheck Shutdown FlagFlag Set? → 503Check DependenciesLiveness never checks dependencies; readiness fails fast on shutdown signal
Decision tree separating liveness and readiness logic for graceful shutdown and health checks in Go

How do you configure Kubernetes probes for Go applications?

Application code alone does not guarantee zero-downtime. Your Kubernetes manifest must align probe timing with Go's shutdown behavior. Misconfigured timeouts cause pods to be killed before in-flight requests complete, negating all application-level effort.

  • readinessProbe.initialDelaySeconds: 2–5 (Go starts fast; don't wait 30s)
  • readinessProbe.periodSeconds: 5 (detect unready state quickly during rollout)
  • readinessProbe.failureThreshold: 1 (remove from LB on first failure during shutdown)
  • livenessProbe.initialDelaySeconds: 10 (allow startup without premature restart)
  • livenessProbe.periodSeconds: 15 (avoid excessive polling)
  • livenessProbe.failureThreshold: 3 (tolerate transient GC pauses)
  • terminationGracePeriodSeconds: Match your max request duration + buffer (default 30s is often too short)

The preStop hook safety net

Even with perfect readiness logic, there is a race condition between the kubelet sending SIGTERM and the endpoints controller updating iptables/IPVS rules. Adding a preStop hook with a 3–5 second sleep gives the network layer time to propagate the endpoint removal before your app stops accepting connections:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"]

This is especially critical in high-throughput Nepali e-commerce platforms where even brief windows of misrouted traffic during flash sales cause visible customer impact. Combine this with blue-green and canary deploys on Kubernetes to further reduce blast radius during releases.

How do you handle in-flight requests during Go server shutdown?

http.Server.Shutdown() stops accepting new connections and waits for active requests to complete, but it does not manage downstream resources. You must coordinate database pools, message queue consumers, and cache connections to close only after HTTP draining finishes.

Complete server implementation

func run(ctx context.Context, logger *slog.Logger) error {
    mux := http.NewServeMux()
    
    // Readiness state managed via atomic or channel
    ready := make(chan struct{})
    close(ready) // Initially ready
    
    mux.HandleFunc("/health/live", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
    })
    
    mux.HandleFunc("/health/ready", func(w http.ResponseWriter, r *http.Request) {
        select {
        case <-ready:
            w.WriteHeader(http.StatusServiceUnavailable)
            w.Write([]byte("shutting down"))
        default:
            w.WriteHeader(http.StatusOK)
            w.Write([]byte("ok"))
        }
    })
    
    srv := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }
    
    errCh := make(chan error, 1)
    go func() {
        logger.Info("server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            errCh <- err
        }
        close(errCh)
    }()
    
    // Block until signal or server error
    select {
    case <-ctx.Done():
        logger.Info("shutdown signal received")
        // Fail readiness immediately
        ready = nil 
    case err := <-errCh:
        return err
    }
    
    // Drain with timeout independent of parent ctx
    shutdownCtx, cancel := context.WithTimeout(
        context.Background(), 25*time.Second,
    )
    defer cancel()
    
    if err := srv.Shutdown(shutdownCtx); err != nil {
        return fmt.Errorf("server shutdown: %w", err)
    }
    
    logger.Info("server stopped gracefully")
    return nil
}

Closing downstream resources safely

After srv.Shutdown() returns, all HTTP handlers have completed. Only then should you close database pools, Redis connections, and Kafka consumers. Closing these before shutdown completes causes panics in handlers still executing queries. Structure your cleanup as deferred functions registered after server creation, or use an explicit resource manager that respects ordering.

SIGTERMProcess ExitReadiness Returns 503HTTP Server Draining In-Flight RequestsClose DB PoolClose Redis / MQFlush Metrics / LogsDownstream resources close ONLY after HTTP drain completes to prevent handler panics
Resource cleanup ordering during graceful shutdown and health checks in Go prevents use-after-close errors

Why does my Go service still drop requests during deployment?

If you have implemented everything above and still see 502/504 errors, the issue usually lies outside your Go code. Check these common failure points:

  1. terminationGracePeriodSeconds too short: If your longest request takes 20s but grace period is 15s, Kubernetes sends SIGKILL. Measure p99 latency and add buffer.
  2. Missing preStop sleep: Endpoint propagation lag means new requests arrive after SIGTERM. The 3–5s sleep absorbs this.
  3. Readiness not failing atomically: Using a mutex instead of atomic/channel introduces a window where readiness returns 200 after signal receipt.
  4. Load balancer health check interval too long: Cloud LBs may cache healthy status for 30s+. Align cloud LB health check frequency with Kubernetes probe timing.
  5. Connection reuse without notification: HTTP/2 persistent connections may not respect GOAWAY frames properly in older clients. Consider disabling keep-alive during shutdown for sensitive workloads.

Debugging these issues requires correlating application logs with infrastructure events. Structured logging with request IDs, as covered in structured logging best practices, makes it possible to trace exactly which requests were in-flight during termination and whether they completed or were severed.

Implementing Graceful Shutdown and Health Checks in Go Reliably

Production-grade graceful shutdown and health checks in Go demand treating application code and Kubernetes configuration as a single unit. Implement signal-aware contexts, separate liveness from readiness with correct shutdown semantics, configure probe timing to match your workload characteristics, and always close downstream resources after HTTP draining completes. Test this end-to-end in a staging cluster with realistic traffic before relying on it in production. If your team needs help auditing your current deployment pipeline or designing compliant infrastructure for regulated workloads, reach out to discuss your specific architecture.

Frequently Asked Questions

Use signal.NotifyContext with syscall.SIGTERM and SIGINT. Pass the resulting context to http.Server.Shutdown to stop accepting new connections while allowing active requests to complete before the process exits cleanly.

Liveness confirms the process is running, while readiness verifies dependencies like databases are accessible. Returning 200 OK on readiness too early causes traffic routing failures during startup or dependency outages in Kubernetes clusters.

Running CMD without exec form prevents signal propagation to the Go binary. Always use exec form in Dockerfiles so PID 1 receives SIGTERM directly, enabling graceful shutdown logic instead of waiting for SIGKILL after timeout.

Set timeouts based on your slowest expected request duration plus buffer. Typical values range from ten to thirty seconds. Exceeding Kubernetes terminationGracePeriodSeconds forces SIGKILL, killing in-flight requests abruptly without cleanup.

Yes, passing the shutdown context to database operations cancels them immediately. Use a separate background context for critical cleanup tasks like flushing buffers or committing partial transactions to prevent data corruption during termination.

Register dedicated endpoints like /healthz and /readyz that bypass authentication middleware. These handlers must return quickly without external dependencies for liveness, ensuring load balancers receive timely responses even under high application load.

Standard http.Server.Shutdown does not close WebSocket upgrades automatically. You must track active connections manually and send close frames before shutdown completes, otherwise clients experience abrupt disconnects without proper cleanup or reconnection signals.

Avoid checking third-party APIs in readiness probes as their downtime marks your service unhealthy unnecessarily. Only verify direct dependencies required for core functionality to prevent cascading failures across microservices during partial outages.

Never expose pprof on public health endpoints. Mount debug handlers on separate ports or behind authentication to prevent information leakage. Profiling data reveals memory layouts and goroutine stacks that aid attackers in exploitation.

WaitGroups coordinate multiple concurrent cleanup routines like closing database pools and message queue consumers. This ensures all resources release properly before main returns, preventing resource leaks and incomplete state persistence during restarts.

Yes, call grpc.Server.GracefulStop instead of HTTP shutdown methods. This stops accepting new RPCs while completing in-flight streams. Combine with context timeouts to force-stop hung unary calls that block indefinite stream processing.

Send SIGTERM via kill command while monitoring logs and active connections. Verify pending requests complete and new ones receive connection refused errors. Automate this in integration tests using os.Process.Signal for repeatable validation.

Transient dependency issues or resource exhaustion trigger readiness failures post-startup. Implement circuit breakers and connection pooling to handle intermittent database timeouts. Return 503 only when truly unable to serve traffic, not during brief hiccups.

Minimal if handlers avoid allocations and external calls. Keep responses static where possible. High-frequency polling at scale can consume CPU cycles, so cache readiness states briefly and tune probe intervals to match actual failure detection needs.

Always pass cancellable contexts to spawned goroutines and select on ctx.Done channels. Without explicit cancellation signals, background workers continue running after main exits, causing resource leaks and preventing clean process termination in containers.