How connection draining prevents dropped requests
How load balancers and orchestrators use connection draining to gracefully remove backend servers by completing in-flight requests before deregistering, preventing 502 errors during deployments.
Why connection draining is necessary
During a deployment or scale-down, a backend can be stopping while a load balancer still believes it is healthy. New requests routed during that window fail with connection resets or 502 Bad Gateway, while requests already in flight may be abandoned. A health check alone only detects the problem after a polling interval; it does not coordinate a planned shutdown.
Scope and assumptions
This article covers HTTP services behind a load balancer, including Kubernetes ingress or service routing, and then extends the model to HTTP/2, WebSockets, gRPC streams, and SSE. The timing values are illustrative. Set drain and termination timeouts from measured request and stream lifetimes, health-check behavior, and the deployment controller's parallelism.
30-second mental model
Connection draining is a three-state handoff:
- Deregister: mark the old backend unready and remove it from new-request routing.
- Drain: stop accepting new work, but let existing requests or streams finish.
- Terminate: after the work completes or a bounded deadline expires, close remaining connections and exit.
The critical ordering is deregister β drain β terminate. Readiness, load-balancer deregistration, application signal handling, and client reconnect behavior must agree on that ordering.
The design has four layers:
- The race condition: Why naive server shutdown causes dropped requests
- The draining lifecycle: Stop accepting new connections, finish in-flight requests, timeout, force close
- Load balancer draining: How ALB deregistration delay, Nginx upstream, and HAProxy drain mode work
- Kubernetes graceful shutdown: preStop hooks, SIGTERM handling, terminationGracePeriodSeconds, and readiness probe coordination
The Architecture
Five-minute end-to-end flow
Here is what happens during a healthy rolling update with connection draining:
- The orchestrator decides to terminate Server v1 (OLD). Before killing it, the server is removed from the load balancer's rotation.
- The load balancer stops sending new requests to Server v1. But the 12 requests that are already being processed continue to completion.
- Meanwhile, Server v2 (NEW) starts up, passes health checks, and joins the LB rotation.
- Once Server v1 finishes all in-flight requests (or the drain timeout expires), the process shuts down cleanly.
The problem happens when step 1 and step 4 are collapsed into a single "kill the process" command. The server dies while those 12 requests are mid-flight, and each one gets a 502.
The Race Condition That Causes 502s
This is the core of the problem: the load balancer's view can be stale while the process is already exiting.
The failure sequence is:
- T=0s: The orchestrator sends SIGTERM to the old server process.
- T=0.01s: The process exits immediately (no signal handler, default behavior).
- T=0.5s: A new client request arrives at the load balancer.
- T=0.5s: The LB's health check has not run yet (it runs every 5-10s), so the LB still thinks the old server is healthy.
- T=0.5s: The LB forwards the request to a dead process. Connection refused. 502.
The root cause is a timing gap: the server is dead, but the load balancer does not know yet. Health checks are periodic, not instant. There is always a window where the LB's view of the world is stale.
The 502 window is not the health check interval. It is the health check interval plus the number of consecutive failures required before marking unhealthy. An ALB with a 10-second interval and 2 required failures has a 20-second window of potential 502s.
The Draining Lifecycle
Connection draining solves this by inverting the sequence. Instead of "kill then deregister," you "deregister then drain then kill."
The four phases of connection draining:
Phase 1: Signal and deregister (0-2 seconds) The server receives a shutdown signal. It immediately starts failing health checks (returns 503 on the health endpoint). The load balancer detects the failure and removes the server from rotation. No more new requests arrive.
Phase 2: Drain in-flight requests (2-30 seconds) The server continues processing all requests that are already in progress. A typical HTTP request takes 50-200ms, so most requests finish within 1 second. But some requests are slow: large file uploads, long database queries, or SSE streams.
Phase 3: Timeout enforcement (at drain timeout) If requests are still running after the drain timeout (typically 30 seconds), the server force-closes them. This is a safety net. You do not want a single stuck request to block deployments indefinitely.
Phase 4: Process termination The server process exits with code 0. The orchestrator confirms the instance is gone and proceeds with the next server in the rolling update.
AWS ALB calls this "deregistration delay" and defaults to 300 seconds (5 minutes). That may be longer than needed for a typical short-request service, but the correct value depends on request and stream lifetimes. Set it from P99 duration plus a safety margin.
Deep Dive 1: Load Balancer Draining Mechanisms
Different load balancers implement draining differently, and the details matter.
How ALB deregistration delay actually works:
When you deregister a target from an ALB target group, the ALB enters a draining state for that target:
- The ALB stops sending new requests to the target.
- Existing connections continue until they complete naturally or the deregistration delay expires.
- If the target has sticky sessions enabled, the ALB redirects sticky session requests to other healthy targets.
- After the delay, any remaining connections are force-closed.
The key insight: the ALB tracks connection count at the target level. This is not a guess or a fixed sleep. It is active monitoring of real connections.
Deep Dive 2: Kubernetes Graceful Shutdown
Kubernetes has the most complex draining mechanism because it coordinates multiple systems: the kubelet, the API server, kube-proxy/iptables, and the application itself.
There are two critical race conditions in Kubernetes pod termination:
Race condition 1: Endpoint removal vs SIGTERM
When a pod is deleted, the API server sends two signals in parallel:
- It tells the kubelet to terminate the pod (which sends SIGTERM).
- It removes the pod from the Endpoints object (which tells kube-proxy to update iptables rules).
These happen asynchronously. The SIGTERM might arrive before kube-proxy has updated the iptables rules. If the app shuts down immediately on SIGTERM, traffic is still being routed to it via the old iptables rules.
The fix: add a preStop hook with a small sleep.
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
This 5-second sleep gives kube-proxy enough time to update iptables rules before the app starts its shutdown sequence.
Race condition 2: Readiness probe timing
Even with a preStop hook, external load balancers (like an ALB using target groups with pod IPs) have their own health check interval. If the ALB's health check runs every 10 seconds, it might keep routing traffic to the pod for up to 10 seconds after the pod stops accepting connections.
The fix: set the readiness probe to fail as soon as the pod starts shutting down.
terminationGracePeriodSeconds: 45
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 2
failureThreshold: 1
The terminationGracePeriodSeconds must be longer than your preStop sleep plus your application's drain timeout. If preStop sleeps 5s and your app needs 30s to drain, set terminationGracePeriodSeconds to at least 40. If the process is still alive after this period, Kubernetes sends SIGKILL.
A properly instrumented application handles SIGTERM like this:
import signal
import sys
shutting_down = False
def handle_sigterm(signum, frame):
global shutting_down
shutting_down = True
server.stop_accepting()
server.wait_for_drain(timeout=30)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
@app.route('/healthz')
def health():
if shutting_down:
return '', 503
return '', 200
Deep Dive 3: Long-Lived Connection Draining
HTTP request/response cycles are the easy case. Each request takes 50-500ms, and draining a server with only HTTP traffic is straightforward. The hard case is long-lived connections.
gRPC streaming connections have similar challenges. A gRPC server can send a GOAWAY frame to signal that clients should open new connections. The gRPC client library handles this automatically.
// gRPC graceful shutdown in Go
server.GracefulStop() // sends GOAWAY, waits for streams to finish
// If GracefulStop takes too long, force it:
time.AfterFunc(30*time.Second, func() { server.Stop() })
Server-Sent Events (SSE) are the simplest long-lived case. The server can send a custom event telling the client to reconnect, and the EventSource API has built-in reconnection logic with configurable retry intervals.
Bottlenecks and failure modes
-
Health check propagation delay: Even after your server starts failing health checks, the LB needs at least one check cycle to detect it. With a 10-second interval and 2 failure threshold, that is 20 seconds of stale routing. Some teams lower the health check interval during deployments, but that adds load to the health check system.
-
Connection reuse and keep-alive: HTTP/2 multiplexes many requests over one TCP connection. "Draining connections" does not mean "draining requests." A single HTTP/2 connection might have 100 concurrent streams. The server needs to stop accepting new streams on existing connections, not just stop accepting new connections.
-
Sticky sessions break draining: If the LB uses cookie-based session stickiness, it might keep routing requests to a draining server because the cookie says so. The LB must override stickiness for draining targets, which ALB does natively but Nginx requires explicit configuration.
-
Database connection pools in the app: When your app shuts down, it also needs to drain its connection pool to the database. Closing a database connection while a transaction is in progress causes that transaction to roll back. The app must wait for all active transactions to commit or roll back before closing the pool.
-
Cascading drain during scale-down: If you are scaling from 10 servers to 5, and you start draining all 5 at once, the remaining 5 servers suddenly get double the load. Always drain one at a time, or limit parallel drain to a fraction of the fleet (Kubernetes maxUnavailable controls this).
Common mistakes
| Mistake | What they say | Why it is wrong | What to say instead |
|---|---|---|---|
| Confusing health checks with draining | "Just add a health check and the LB will stop routing" | Health checks detect dead servers after the fact. Draining prevents requests from reaching dying servers proactively. | "Health checks detect failure. Draining prevents it. You need both." |
| Ignoring the preStop hook | "SIGTERM triggers graceful shutdown in Kubernetes" | SIGTERM can race with endpoint removal. Without a propagation window, traffic may arrive after shutdown starts. | "Add a preStop delay or equivalent coordination so kube-proxy can update routing before SIGTERM handling begins." |
| Setting drain timeout too high | "Set the drain timeout to 5 minutes to be safe" | Long drain timeouts slow down deployments and hold capacity unnecessarily. | "Set the drain timeout from P99 request latency plus a safety margin, then validate it against deployment time." |
| Forgetting about WebSockets | "Connection draining handles all connection types" | HTTP draining does not help long-lived connections. WebSocket connections can stay open for hours. | "For long-lived connections, implement application-level drain signaling so clients reconnect gracefully." |
| Draining too many servers at once | "Start draining all old servers simultaneously" | If half the fleet drains at once, the remaining servers get double the load and may crash. | "Drain one server at a time, or cap parallel drain to a safe fraction of the fleet." |
Practical checklist
- Mark the backend unready before terminating the process, and verify that the load balancer and service-discovery layer honor the state.
- Stop new requests and new HTTP/2 streams while allowing in-flight work to finish.
- Install a real
SIGTERMor equivalent shutdown handler; do not assume the runtime will drain application work automatically. - Set the deregistration and termination deadlines from measured P99 request duration plus a safety margin, with a hard upper bound.
- Ensure
preStop, readiness propagation, load-balancer deregistration, andterminationGracePeriodSecondsleave enough overlap for the handoff. - Drain database pools, queues, background workers, and other downstream resources after request admission stops.
- Give WebSocket, gRPC, and SSE clients an application-level reconnect or resumption protocol with jittered backoff.
- Limit the number of targets draining at once and monitor 502s, connection resets, in-flight count, drain duration, forced closes, and capacity on the remaining fleet.
Test Your Understanding
Quick Recap
- 502 errors during deployment happen because the load balancer routes requests to servers that have already shut down, before health checks detect the failure.
- Connection draining inverts the shutdown sequence: deregister from LB, finish in-flight requests, enforce timeout, then terminate.
- AWS ALB uses "deregistration delay" (default 300 seconds, lower it to 30-60 for typical services).
- Kubernetes requires a preStop hook (sleep 5 seconds) to handle the race condition between SIGTERM and endpoint removal via kube-proxy.
- Long-lived connections (WebSockets, gRPC streams) need application-level drain signaling because HTTP-level draining does not help.
- Drain timeout should be P99 request duration plus a safety margin, not an arbitrary large number.
- Rolling updates should drain one server at a time (maxUnavailable=1) to avoid overloading remaining servers.
- Connection draining is proactive (planned shutdown), while circuit breaking is reactive (server misbehaving).
Related Concepts
- Rolling updates and blue-green deployments use connection draining as the mechanism that prevents errors during the transition between old and new versions.
- Circuit breakers complement draining by handling the case where a server becomes unhealthy unexpectedly, rather than being intentionally removed.
- Health checks and readiness probes are the signaling mechanism that load balancers use to detect draining servers.
- Kubernetes pod lifecycle (preStop hooks, SIGTERM, terminationGracePeriodSeconds) is the orchestration layer that coordinates draining across containers, sidecars, and the infrastructure.
- Service mesh drain semantics in Envoy/Istio use the same principles but implement them at the sidecar proxy level, draining the Envoy connection pool separately from the application.