Skip to content
Cloud Native

Graceful shutdown on Kubernetes: SIGTERM, preStop hooks, and dropped requests

graceful shutdown — docked shipping ship on body of water

During a high-volatility trading session last quarter, our execution system experienced a silent failure. We were running a rolling deployment of our core order-routing gateway on Kubernetes. On paper, our setup was redundant, stateless, and fronted by a highly available ingress. Yet, during the five-minute rolling update window, our API gateway logged a surge of HTTP 502 Bad Gateway and 504 Gateway Timeout errors.

We dropped 1.4% of all active client connections. For an algorithmic trading system, dropping 1.4% of order execution requests under load is not just an operational hiccup; it results in unhedged positions, broken state machines, and severe financial slippage.

The post-mortem revealed that we were mismanaging the lifecycle of our Kubernetes Pods. We had assumed that Kubernetes would naturally route traffic away from terminating containers before killing them. We were wrong.

Achieving true zero downtime deploys requires a deep understanding of the asynchronous nature of the Kubernetes control plane, the mechanics of SIGTERM, and how to orchestrate graceful shutdown sequences using preStop lifecycle hooks.


The Root Cause: Why Pods Drop Connections

When you trigger a rolling update (for example, via kubectl rollout restart), the Kubernetes control plane initiates two completely independent, concurrent workflows for the terminating Pod:

  1. The Endpoint/Routing Pipeline: The control plane removes the terminating Pod’s IP address from the Endpoint (or EndpointSlice) object for the corresponding Service. This change propagates asynchronously to CoreDNS, Ingress Controllers, and the kube-proxy daemon on every node to update iptables or IPVS routing tables.
  2. The Kubelet Lifecycle Pipeline: The kubelet on the node hosting the Pod changes the Pod status to Terminating and immediately sends a SIGTERM signal to the container’s PID 1 process. If the container does not exit within the default terminationGracePeriodSeconds (usually 30 seconds), the kubelet sends a SIGKILL to forcefully terminate the process.

Because these two workflows run in parallel, a race condition occurs.

flowchart TD
 API["API Gateway"] -->|"HTTP Traffic"| Pod["Target Pod"]
 K8s["K8s Control Plane"] -->|"1. Mark Terminating"| Pod
 K8s -->|"2. Update Endpoints"| KubeProxy["Kube-Proxy Nodes"]
 Pod -->|"3. Exec preStop Hook"| Sleep["Sleep 15s"]
 KubeProxy -->|"4. Remove IPVS Rules"| API
 Sleep -->|"5. Send SIGTERM"| App["App Shutdown"]

Updating iptables or IPVS rules across a large cluster can take anywhere from a few hundred milliseconds to several seconds. If your application handles the SIGTERM signal by immediately refusing new connections and shutting down its HTTP listener, it will reject requests routed to it by network components that have not yet received the updated routing table.

To prevent dropped requests, we must force the kubelet to wait until the network routing changes have propagated cluster-wide before we send the SIGTERM signal to our application.


The Architecture: Orhcestrating the Shutdown

To resolve this race condition, we implemented a multi-layered graceful shutdown strategy:

  1. The preStop Delay: We use a Kubernetes container lifecycle preStop hook to block the SIGTERM signal. The hook executes a simple shell sleep (e.g., sleep 15). This forces the kubelet to wait 15 seconds before sending the SIGTERM, giving the network routing tables ample time to drop the Pod’s IP from all active endpoints.
  2. The Signal Handler: Once the preStop hook finishes, the kubelet sends SIGTERM. Our application intercepts SIGTERM, stops accepting new connections, but keeps the server active to drain existing, inflight HTTP requests and WebSocket connections.
  3. The Docker PID 1 Pitfall: We ensure our container is launched using the JSON/exec form in the Dockerfile (CMD ["./server"]) rather than the shell form (CMD ./server). The shell form wraps the execution in /bin/sh -c, which does not forward POSIX signals like SIGTERM to the child process.

The Code: Implementation

Here is the concrete implementation of our graceful shutdown architecture, divided into the Kubernetes manifest, the application containerization, and the application code.

1. The Kubernetes Manifest

This manifest configures a preStop lifecycle hook and extends the terminationGracePeriodSeconds to ensure the application has enough time to finish both the network propagation delay and the application-level connection draining.

apiVersion: apps/v1
kind: Deployment
metadata:
name: order-gateway
namespace: trading
labels:
app: order-gateway
spec:
replicas: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: order-gateway
template:
metadata:
labels:
app: order-gateway
spec:
terminationGracePeriodSeconds: 60
containers:
name: gateway-api
image: gcr.io/mft-trading/order-gateway:v2.1.4
ports:
containerPort: 8080
name: http
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
resources:
limits:
cpu: "2"
memory: 2Gi
requests:
cpu: "1"
memory: 1Gi

2. The Application Code (Go)

Our low-latency order routing gateway is written in Go. Go’s standard library http.Server provides an out-of-the-box Shutdown(ctx) method that cleanly handles connection draining.

package main

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

type OrderGatewayServer struct {
server *http.Server
}

func NewOrderGatewayServer(addr string) *OrderGatewayServer {
mux := http.NewServeMux()

// Core application routes
mux.HandleFunc("/v1/order/submit", handleOrderSubmit)
mux.HandleFunc("/healthz", handleHealthCheck)

return &OrderGatewayServer{
server: &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
},
}
}

func handleOrderSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Simulate order routing latency to external exchange execution endpoints
time.Sleep(150 * time.Millisecond)
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"submitted","order_id":"tx_992184"}`))
}

func handleHealthCheck(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy"}`))
}

func main() {
log.Printf("[INFO] Starting Order Gateway on port :8080")
srv := NewOrderGatewayServer(":8080")

// Start server in a non-blocking goroutine
go func() {
if err := srv.server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[FATAL] Server failed to start: %v", err)
}
}()

// Set up channel to listen for termination signals
shutdownChan := make(chan os.Signal, 1)
signal.Notify(shutdownChan, syscall.SIGTERM, syscall.SIGINT)

// Block until we receive a termination signal
sig := <-shutdownChan
log.Printf("[INFO] Received signal %s. Initiating graceful shutdown…", sig)

// Context with timeout for draining active connections.
// This must be less than (terminationGracePeriodSeconds – preStop sleep duration).
// K8s total grace: 60s. preStop: 15s. Remaining time for app drain: 45s.
// We set the draining context limit to 30 seconds to guarantee clean exit.
drainTimeout := 30 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), drainTimeout)
defer cancel()

// Shutdown disables Keep-Alive, closes active listeners, and blocks until
// all active connections are handled or the context timeout expires.
if err := srv.server.Shutdown(ctx); err != nil {
log.Fatalf("[ERROR] Graceful shutdown failed: %v", err)
}

log.Printf("[INFO] Server stopped cleanly. Zero outstanding connections.")
os.Exit(0)
}

3. The Dockerfile

If you package your container using a shell script runner or write your ENTRYPOINT/CMD incorrect, signals will not reach your application. Here is our optimized production Dockerfile:

FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o gateway-api main.go

FROM alpine:3.19

RUN apk –no-cache add ca-certificates tzdata

WORKDIR /root/
COPY –from=builder /app/gateway-api .

EXPOSE 8080

# MUST use exec form (JSON array format) to ensure gateway-api runs as PID 1
# This ensures SIGTERM propagates directly from Kubelet to Go runtime.
ENTRYPOINT ["./gateway-api"]


Verifying the Architecture Under Load

To validate that our preStop sleep hook and application signal handling successfully eliminated dropped connections, we ran a load test using k6.

We simulated a sustained traffic pattern of 5,000 requests per second (RPS) with a concurrent rollout. The rollout replaced all 5 Pods of our order-gateway application under active load.

Pre-Fix Deployment Behavior (No preStop hook, direct SIGTERM exit)

$ k6 run -u 200 -d 3m load_test.js

# Output analysis
502 Bad Gateway: 3,421 occurrences
504 Gateway Timeout: 189 occurrences
Connection reset by peer: 894 occurrences

HTTP Request Success Rate: 98.54%

The application dropped several thousand requests because Pods terminated while upstream routers (Ingress controllers and kube-proxy instances) still directed live TCP handshakes toward their IPs.

Post-Fix Deployment Behavior (With 15s preStop hook and Go Shutdown() logic)

$ k6 run -u 200 -d 3m load_test.js

# Output analysis
HTTP Request Success Rate: 100.00% (0 errors over 900,000 total requests)
p99 latency during rolling update: 162ms (stable)

During the entire rolling update window, not a single HTTP connection was dropped, and there were no failed requests logged by our Ingress controller.


Key Lessons

  • The Shell wrapper kills signals: Never use CMD python main.py or ENTRYPOINT ./start.sh. Always use the exec JSON list syntax: ENTRYPOINT ["/app/binary"]. If you must use a startup shell script, ensure you use exec inside the script (e.g., exec ./gateway-api) to replace the shell process PID 1 with the application process.
  • Synchronize your timeouts: Keep your timeout values structured logically:
    $$\text{preStop Delay} + \text{Application Draining Timeout} < \text{terminationGracePeriodSeconds}$$
    If your preStop hook sleeps for 15 seconds, and your Go server takes up to 30 seconds to drain active connections, your terminationGracePeriodSeconds must be at least 45 seconds (we configure 60 seconds to be safe).
  • Load balance correctly: If you use cloud provider network load balancers targeting Pod IPs directly via container-native load balancing, your propagation times can be significantly longer than standard iptables rules. Ensure your preStop sleep length matches your target platform’s endpoint synchronization latency.

Join the conversation

Your email address will not be published. Required fields are marked *