Health Checks and Graceful Shutdown
In modern cloud-native architectures, service instances are dynamically created, destroyed, and rolled out continuously. Maintaining high availability across these volatile environments requires a robust synergy between health checks and graceful shutdowns. Without proper implementation, rolling updates or auto-scaling events can easily trigger frustrating 502 Bad Gateway errors, connection resets, and dropped user requests.
Within Kubernetes, health checks are typically handled through three distinct types of probes designed to monitor container lifecycles:
livenessProbe: Evaluates whether the container is operating normally. If failure thresholds are repeatedly breached, thekubeletwill proactively restart the container. Crucially, liveness probes should avoid checking external dependencies like databases, as a transient downstream outage could otherwise cascade into a cluster-wide restart loop.readinessProbe: Determines if an instance is fully prepared to accept inbound traffic. When this probe fails, the Pod is automatically removed from the associated Service'sEndpoints, cutting off new request routing.startupProbe: Tailored for legacy or resource-intensive applications with prolonged initialization phases. While the startup probe is running, both liveness and readiness probes are suppressed, preventing premature container termination before the application has fully initialized.
Probes can be executed via various mechanisms, including HTTP GET requests, TCP sockets, command execution, or gRPC. To prevent resource exhaustion, health check endpoints should always remain lightweight and avoid complex queries or blocking operations.
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo
spec:
replicas: 2
selector:
matchLabels:
app: demo
template:
metadata:
labels:
app: demo
spec:
terminationGracePeriodSeconds: 40
containers:
- name: app
image: demo:1.0
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 2
failureThreshold: 1
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
In the configuration above, the /readyz endpoint triggers an immediate removal from the load balancer upon a single failure, while /healthz requires three consecutive failures before initiating a container restart. The sleep 5 command inside the preStop hook introduces a deliberate buffer, giving the routing layer sufficient time to update its endpoint lists and minimizing the chance of routing new traffic to a terminating Pod.
The Core Workflow of Graceful Shutdowns
The primary objective of a graceful shutdown is not an immediate exit, but a controlled exit sequence: first halting incoming traffic, then finishing active requests, and finally releasing system resources. A standard shutdown lifecycle follows these steps:
- The application receives a
SIGTERMsignal or a termination notification. - The readiness state is explicitly toggled to failure, signaling the load balancer or service mesh to deregister the instance.
- The application pauses briefly to allow routers, Ingress controllers, and clients to synchronize their routing tables.
- The server stops accepting new connections while keeping existing persistent connections open.
- The process waits for ongoing requests to complete or until a predefined shutdown timeout is reached.
- Underlying resources—such as database connection pools, message queue consumers, and file descriptors—are safely closed.
- The process exits normally. If the shutdown exceeds the allotted timeout, the orchestration platform forcefully terminates it.
When deleting a Pod in Kubernetes, the kubelet first executes the configured preStop hook, dispatches a SIGTERM signal to the container, and starts the terminationGracePeriodSeconds countdown before resorting to a forced SIGKILL.
Code Example: Graceful Shutdown in a Go Service
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
)
func main() {
var ready atomic.Bool
ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("listen error:", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig
ready.Store(false) // Mark readiness as false to allow traffic deregistration
time.Sleep(5 * time.Second) // Buffer for load balancers to update endpoints
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Println("server shutdown error:", err)
}
fmt.Println("server gracefully stopped")
}