Graceful shutdown on Kubernetes: SIGTERM, preStop hooks, and dropped requests
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:
- 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-proxydaemon on every node to updateiptablesorIPVSrouting tables. - The Kubelet Lifecycle Pipeline: The
kubeleton the node hosting the Pod changes the Pod status toTerminatingand immediately sends aSIGTERMsignal to the container’s PID 1 process. If the container does not exit within the defaultterminationGracePeriodSeconds(usually 30 seconds), thekubeletsends aSIGKILLto 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:
- The
preStopDelay: We use a Kubernetes container lifecyclepreStophook to block theSIGTERMsignal. The hook executes a simple shell sleep (e.g.,sleep 15). This forces thekubeletto wait 15 seconds before sending theSIGTERM, giving the network routing tables ample time to drop the Pod’s IP from all active endpoints. - The Signal Handler: Once the
preStophook finishes, thekubeletsendsSIGTERM. Our application interceptsSIGTERM, stops accepting new connections, but keeps the server active to drain existing, inflight HTTP requests and WebSocket connections. - 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 likeSIGTERMto 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.
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.
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:
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)
# 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)
# 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.pyorENTRYPOINT ./start.sh. Always use the exec JSON list syntax:ENTRYPOINT ["/app/binary"]. If you must use a startup shell script, ensure you useexecinside 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 yourpreStophook sleeps for 15 seconds, and your Go server takes up to 30 seconds to drain active connections, yourterminationGracePeriodSecondsmust 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
iptablesrules. Ensure yourpreStopsleep length matches your target platform’s endpoint synchronization latency.