Deploying stateful services and long-lived connections

What it is

Two deployment problems that share one root cause: the unit being replaced is not interchangeable.

Stateless HTTP           a replica is fungible. Kill it, the load
                         balancer routes elsewhere, the request
                         retries. Nothing is lost.

Long-lived connections   the replica holds thousands of sockets that
(WebSocket, SSE, gRPC    represent user sessions. Killing it does not
streams, MQTT)           lose a request, it loses N sessions, and all
                         N clients reconnect at once.

Stateful services        the replica holds DATA and a ROLE. It may be
(Kafka, etcd, Cassandra, the leader, or one of three quorum members.
Postgres, Elasticsearch, Killing the wrong one at the wrong time
Redis)                   loses availability or, worse, writes.

What this is confused with: PodDisruptionBudgets protecting rollouts. A PDB constrains the Eviction API, which is what kubectl drain, the cluster autoscaler and the descheduler use. A Deployment or StatefulSet rolling update deletes pods directly and is governed by maxUnavailable in the workload spec, not by the PDB. This is the single most common misconception in this area and it produces a false sense of safety.

Also confused: graceful shutdown and connection draining. Graceful shutdown means the process finishes in-flight work before exiting. Draining means the load balancer stops sending new work before the process is told to stop. You need both, and they are configured in different places.

The problem it solves

The connection case: a rolling restart is a synchronised reconnect storm.

100,000 WebSocket clients, 20 pods -> 5,000 connections per pod.
Rolling update with maxUnavailable: 25% -> 5 pods replaced at once.

25,000 clients disconnect within the same second.
If clients reconnect immediately with no jitter, the remaining 15
pods receive 25,000 connection attempts in ~1 second, which is
1,667/s per pod against a normal rate of maybe 20/s.

Each reconnect costs a TLS handshake, an auth check, and a session
restore. The surviving pods saturate, drop connections, and those
clients reconnect too. The rollout has become an outage.

The stateful case: the rollout violates a quorum invariant.

A 3-node etcd cluster tolerates 1 failure.
maxUnavailable: 1 is mandatory, not a preference.

But the danger is compounded: during a rolling restart of node 3,
node 1 is cordoned by an unrelated cluster autoscaler action.
Two of three are gone. The cluster loses quorum and every write
in the platform fails.

This is exactly the case PDBs exist for, AND the case where people
believe the PDB is protecting the rollout when it is only
protecting against the drain.

And the Kubernetes race that catches everyone:

On pod deletion, two things happen CONCURRENTLY:
  A) the kubelet sends SIGTERM to the container
  B) the endpoints controller removes the pod from the Endpoints/
     EndpointSlice, which then propagates to kube-proxy on every
     node, to the ingress controller, and to the cloud load balancer

B is eventually consistent and takes hundreds of milliseconds to
several seconds. A is immediate.

So a pod routinely receives new connections AFTER it has begun
shutting down, and those requests fail.

Mechanics

Draining correctly in Kubernetes

The standard shape, and every part of it is load-bearing:

spec:
  # Must exceed preStop sleep + the app's own drain time.
  terminationGracePeriodSeconds: 120
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            # Do NOT start shutting down yet. Sleep long enough for
            # endpoint removal to propagate to every proxy and LB.
            # SIGTERM is not sent until this completes.
            command: ["/bin/sh", "-c", "sleep 15"]
      readinessProbe:
        httpGet: { path: /readyz, port: 8080 }
        periodSeconds: 2
        failureThreshold: 2
The sequence this produces:

t=0     pod marked Terminating; endpoints controller begins removal
t=0     preStop starts: sleep 15
t=0-5   endpoint removal propagates to kube-proxy / ingress / ALB
        (the pod still serves during this window, correctly)
t=15    preStop returns; kubelet sends SIGTERM
t=15+   app stops accepting NEW connections, finishes in-flight
        work, closes idle connections
t<=120  process exits, or SIGKILL at the grace period

Without the preStop sleep, the app receives SIGTERM at t=0 and starts refusing connections while proxies are still sending them. The sleep is not a hack; it is the documented way to wait out an eventually-consistent removal you cannot observe from inside the pod.

For long-lived connections, the application must also drain them deliberately:

// On SIGTERM: stop accepting, then close existing connections over a
// window with jitter, so 5,000 clients do not reconnect in the same
// second.
func (s *Server) Drain(ctx context.Context, window time.Duration) {
    s.acceptingNew.Store(false)          // readiness probe now fails
    conns := s.snapshotConnections()      // e.g. 5,000

    // Spread closes across the window. With window=60s and 5,000
    // connections that is ~83 closes/second, and each client's own
    // jittered backoff spreads the reconnects further.
    step := window / time.Duration(len(conns))
    for _, c := range conns {
        // Tell the client to go away politely and when to come back.
        // For WebSocket: a close frame with a reconnect hint.
        // For HTTP/2 and gRPC: GOAWAY, which lets the client finish
        // in-flight streams and open a new connection elsewhere.
        c.CloseWithHint(reconnectAfter(2*time.Second, 30*time.Second))
        select {
        case <-time.After(step):
        case <-ctx.Done():
            return
        }
    }
}

Three client-side requirements that the server cannot provide:

1. JITTERED EXPONENTIAL BACKOFF. Never a fixed retry interval.
     delay = min(cap, base * 2^attempt) * random(0.5, 1.5)
   Without jitter, the herd re-synchronises on every retry round.

2. HONOUR THE RECONNECT HINT if the server sends one.

3. RESUMABLE SESSIONS. A reconnect that replays state from scratch
   turns a connection storm into a data storm. Last-event-id for
   SSE, a session token plus a cursor for WebSocket.

A fourth control removes the problem's cause: bounded connection lifetime.

Set a max connection age of, say, 30 to 60 minutes with jitter, so
clients reconnect continuously at a low rate rather than all at once
at deploy time.

100,000 connections / 45 min mean lifetime = ~37 reconnects/second,
continuously. The system is always handling reconnects, so a deploy
is not a special event, and the capacity to absorb them is proven
every minute rather than assumed.

That inversion, making the exceptional case continuous so it is always tested, is the strongest form of the fix, and it is the same reasoning as chaos engineering applied to a specific mechanism.

Rolling stateful services

Start from the invariant, not from the config.

Quorum systems (etcd, ZooKeeper, Consul, Kafka's KRaft controllers,
Raft-based databases):
    N nodes tolerate floor((N-1)/2) failures.
    3 -> 1,   5 -> 2,   7 -> 3
    So maxUnavailable is 1 for a 3-node cluster. Always.

Replication systems (Cassandra, Elasticsearch):
    the constraint is per-shard/per-token-range replica count and
    the consistency level, not a cluster-wide number. With RF=3 and
    QUORUM reads, you can lose 1 replica per range. Restarting two
    nodes that happen to share a range breaks it even if the
    cluster-wide count looks safe.
    -> use rack/zone awareness so one "rack" can be restarted as a
       unit, which is what Cassandra's rack concept is for.

Primary/replica (Postgres, MySQL, Redis):
    the primary is special. Restarting it means a failover, which
    has a cost measured in seconds of write unavailability and, if
    replication is asynchronous, possible data loss.
    -> restart replicas first, then fail over deliberately, then
       restart the old primary.

StatefulSet mechanics:

apiVersion: apps/v1
kind: StatefulSet
spec:
  podManagementPolicy: OrderedReady    # start/stop one at a time
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      # Only pods with ordinal >= partition are updated. This is the
      # canary mechanism for stateful sets: set partition to N-1,
      # observe the single updated pod, then lower it.
      partition: 2
With replicas: 3 and partition: 2
  -> only pod-2 is updated. pod-0 and pod-1 stay on the old version.
  Observe pod-2 for as long as you want (it holds real data and
  serves real traffic), then set partition: 1, then 0.

This is a genuine canary for a stateful service, and it is the
feature most teams do not know exists.

The PodDisruptionBudget, and what it actually covers:

apiVersion: policy/v1
kind: PodDisruptionBudget
spec:
  maxUnavailable: 1
  selector:
    matchLabels: { app: etcd }
PROTECTS AGAINST (voluntary disruptions, via the Eviction API):
  kubectl drain
  cluster autoscaler scaling down a node
  descheduler rebalancing
  node upgrades performed by a managed control plane

DOES NOT PROTECT AGAINST:
  a StatefulSet or Deployment rolling update (the controller
    deletes pods directly; use maxUnavailable / OrderedReady)
  a node crashing or a kernel panic
  a kubelet losing contact
  someone running `kubectl delete pod`

So: PDB *and* an update strategy. They cover different things and
neither substitutes for the other.

Readiness must mean "caught up", not "process started".

readinessProbe:
  # For a replica rejoining a cluster, this endpoint must return
  # 200 only when replication lag is within tolerance. A probe that
  # checks the port routes reads to a replica that is minutes behind.
  httpGet: { path: /readyz, port: 8080 }
  periodSeconds: 5
startupProbe:
  # Stateful nodes can take many minutes to load or catch up. The
  # startup probe grants that time WITHOUT making the liveness
  # probe's timeout absurdly long for steady state.
  httpGet: { path: /healthz, port: 8080 }
  failureThreshold: 60
  periodSeconds: 10          # allows up to 10 minutes to start

Without a startupProbe, teams lengthen the liveness probe's failureThreshold to survive startup, which means a genuinely wedged process is not restarted for ten minutes in steady state. The two probes exist to decouple those.

Graceful role transfer beats election timeout.

Kafka: controlled.shutdown.enable=true (the default) makes the
  broker move partition leadership to other replicas BEFORE it
  stops. Without it, every partition it led becomes leaderless and
  waits for the controller to elect a new leader, which is a burst
  of unavailability proportional to the number of partitions.

etcd/Raft: `etcdctl move-leader <id>` transfers leadership
  explicitly. Otherwise followers wait out the election timeout
  (default around 1s, with heartbeats at 100ms) before starting an
  election, and writes stall for that period plus the election.

Postgres: a planned switchover (Patroni's `switchover`) is a
  coordinated handoff. A failover is the unplanned version and
  costs more.

Rule: restart followers first, transfer leadership deliberately,
restart the old leader last.

Anti-affinity and topology spread, so the topology does not undo the arithmetic:

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels: { app: etcd }

A 3-node quorum with two nodes in the same availability zone tolerates zero zone failures, which makes the maxUnavailable: 1 reasoning meaningless. Spread is a precondition for the quorum arithmetic to hold.

Local persistent volumes pin a pod to a node, so a node drain cannot reschedule it. That is a deliberate trade (local NVMe performance) and it means node maintenance requires an explicit data movement step rather than an eviction.

A worked example: a chat platform's deploy that took the platform down

A messaging product. 180,000 concurrent WebSocket connections across 30 gateway pods, backed by a 5-node Redis cluster for presence and a 3-node etcd for coordination.

The incident:

14:02  routine gateway deploy begins. Deployment defaults:
       maxUnavailable 25%, maxSurge 25%, no preStop, grace period 30s.
14:02  8 pods enter Terminating. SIGTERM immediately. The app closes
       the listener and exits in ~2 seconds.
       48,000 connections dropped in under 3 seconds.
14:02  clients reconnect immediately, fixed 1-second retry, no jitter.
       ~48,000 connection attempts hit the remaining 22 pods within
       2 seconds: ~1,090/s per pod against a baseline of ~25/s.
14:03  each reconnect does a TLS handshake + auth + presence restore
       (3 Redis round trips). The 22 pods saturate CPU; the Redis
       cluster's ops/s goes from 40k to 310k.
14:03  Redis latency p99: 0.8ms -> 340ms. Presence restore times out.
       Gateways drop the new connections.
14:03  clients retry again, still synchronised. The herd re-forms.
14:11  full outage of the messaging product. 9 minutes.

Every element of the cascade came from a default.

The five fixes, and what each was worth:

1. preStop sleep 15 + terminationGracePeriodSeconds 180
   Endpoint removal now propagates before SIGTERM, so no new
   connections arrive at a shutting-down pod.
   -> removed the "connection refused during rollout" errors
      entirely (previously ~4,000 per deploy).

2. Application drain over a 90-second window with per-connection
   jitter, sending a close frame carrying a reconnect hint.
   6,000 connections per pod / 90s = ~67 closes/s per pod.
   8 pods draining concurrently = ~530 reconnects/s platform-wide,
   against a previous 48,000-in-3-seconds.
   -> peak reconnect rate fell by ~99%.

3. Client backoff with full jitter, and honouring the hint:
     delay = random(0, min(30s, 0.5s * 2^attempt))
   -> retry rounds stop re-synchronising. This was the fix that
      prevented the herd RE-FORMING after the first failure, which
      is what turned a 3-second event into a 9-minute one.

4. Resumable sessions: a session token plus a last-received cursor,
   so a reconnect restores presence with 1 Redis round trip
   instead of 3, and does not re-send history.
   -> Redis ops per reconnect: 3 -> 1.

5. Bounded connection age: 40 minutes, +/- 25% jitter.
   180,000 / 40 min = ~75 reconnects/s, continuously.
   -> a deploy is no longer a special event. The system's capacity
      to absorb reconnects is exercised every minute.

Fix 5 is the one that changed the character of the problem. After it, the deploy-time reconnect rate (~530/s) was only about seven times the continuous baseline (~75/s), rather than a 2,000x spike against a system that had never handled one.

Measured after:

                            before        after
connections dropped
  per deploy                48,000        drained over 90s
peak reconnect rate       ~24,000/s       ~530/s
Redis peak ops/s            310,000       58,000
errors per deploy            ~4,000       0
deploy duration              4 min        11 min
deploys per day                  1        8

Deploys got slower and became routine, which was the correct trade and needed to be stated explicitly, because "the deploy takes three times as long" was raised as an objection.

The stateful half, found during the same review.

The 3-node etcd cluster had:
  - a PDB with maxUnavailable: 1  (correct)
  - a StatefulSet with no partition strategy
  - no topology spread constraints
  - all three pods, as scheduled, on nodes in TWO availability
    zones: two in zone a, one in zone b.

Consequences:
  a) The PDB did NOT protect the rolling update, because the
     StatefulSet controller deletes pods directly. The team
     believed it did.
  b) Losing zone a would have taken 2 of 3 nodes and lost quorum,
     so the cluster tolerated zero zone failures despite being a
     3-node quorum.

Fixes:
  - topologySpreadConstraints with maxSkew 1 over zone, and
    DoNotSchedule, forcing one node per zone across three zones.
  - podManagementPolicy: OrderedReady, plus updateStrategy
    partition used as a canary: update ordinal 2, observe for 10
    minutes, then 1, then 0.
  - readiness reflecting raft membership and applied-index lag
    rather than the port being open.
  - `etcdctl move-leader` before restarting the pod that held
    leadership, so writes stalled for the transfer rather than for
    the election timeout.

Measured: write unavailability during an etcd rollout fell from
~3.2s per node restart (election timeout plus reconnection) to
~180ms (leadership transfer only).

The PDB misconception is the finding worth carrying, because the configuration looked correct in review and protected against the wrong thing.

Production evidence

Kubernetes documentation states that PodDisruptionBudgets apply to voluntary disruptions via the Eviction API and explicitly notes they do not apply to deletions performed by controllers during updates, which is the documented basis for the distinction this page draws.

The preStop sleep pattern is the standard mitigation for the endpoint-propagation race, and Kubernetes' own documentation on pod termination describes the concurrency between SIGTERM delivery and endpoint removal that makes it necessary.

Kafka's controlled.shutdown.enable (enabled by default) moves partition leadership off a broker before it stops, and Kafka's documentation describes the alternative as leaving partitions leaderless until the controller elects replacements.

etcd's move-leader command exists specifically so a planned restart transfers leadership rather than triggering an election, and etcd's tuning documentation gives the default heartbeat (100ms) and election timeout (1000ms) that set the cost of not using it.

Cassandra's rack awareness places replicas in distinct racks so that a whole rack can be restarted without losing a quorum for any token range, which is the replication-system analogue of topology spread.

AWS's guidance on jittered exponential backoff (the "Exponential Backoff and Jitter" article from the AWS Architecture Blog) is the canonical treatment of why unjittered retries re-synchronise a herd, and full jitter is the variant it recommends.

Slack's published writing on its WebSocket infrastructure describes the reconnect-storm problem directly, including that a mass disconnect is far more expensive than the connections themselves because each reconnect carries session restoration work.

The debate

Should long-lived connections be drained slowly or dropped fast? Drained, over a window proportional to the connection count, with a reconnect hint. The counter-argument is deploy duration, and it is legitimate: a 90-second drain per batch multiplies rollout time. The resolution is bounded connection age, which makes reconnects continuous, proves the capacity every minute, and makes the drain window less critical.

Is a preStop sleep a hack? It is the documented answer to an eventually-consistent removal you cannot observe from inside the pod. The alternative, having the application keep serving after SIGTERM until it observes no traffic for N seconds, is more precise and more complex, and it fails when traffic is naturally bursty. Prefer the sleep; length it from measured propagation time rather than folklore.

Should stateful services run on Kubernetes at all? Increasingly yes for well-supported systems with mature operators, and the honest caveat is that the operator is doing the work described here on your behalf and you should know what it does. For a small team running one Postgres, a managed service is usually the better trade, because the failure modes above are the operator's core competence and not yours.

Is maxUnavailable: 1 sufficient for a quorum system? Only in combination with topology spread and a PDB. The workload's maxUnavailable governs your rollout; the PDB governs someone else's node drain; topology spread governs the cloud provider's zone failure. All three are needed, and any one of them alone gives a false sense of safety, which is exactly what a correct-looking PDB provides.

Should you use the StatefulSet partition canary? Yes, for anything holding data. It is the only mechanism that lets a stateful node run the new version against real data and real traffic for an arbitrary period before its peers follow. The reason it is rarely used is discoverability rather than any drawback.

Is bounded connection age wasteful? It spends a continuous low rate of reconnect work to remove a large periodic spike. For any system where a mass reconnect is expensive, it is a good trade, and its real value is that it converts an untested emergency path into an exercised steady-state path.

Follow-up Q&A

"Why does a pod receive requests after it has started shutting down?"

Because SIGTERM delivery and endpoint removal happen concurrently, and endpoint removal is eventually consistent: the endpoints controller updates the EndpointSlice, which then propagates to kube-proxy on every node, to the ingress controller and to any cloud load balancer, taking hundreds of milliseconds to several seconds. The pod is told to stop immediately. The standard fix is a preStop hook that sleeps for longer than the measured propagation time, since SIGTERM is not sent until preStop returns, and a terminationGracePeriodSeconds that exceeds the sleep plus the application's own drain time.

"How do you deploy a service holding 100,000 WebSocket connections?"

Four things. Drain rather than drop: on SIGTERM stop accepting, then close existing connections spread across a window with a reconnect hint, so 5,000 per pod become roughly 67 closes per second rather than 5,000 at once. Require jittered exponential backoff on the client, because without jitter every retry round re-synchronises the herd and one spike becomes a sustained outage. Make sessions resumable so a reconnect restores state with one round trip rather than replaying from scratch. And bound connection age with jitter, so reconnects happen continuously at a low rate and a deploy stops being a special event.

"What does a PodDisruptionBudget actually protect?"

Voluntary disruptions that go through the Eviction API: kubectl drain, the cluster autoscaler, the descheduler, and managed node upgrades. It does not constrain a Deployment or StatefulSet rolling update, because those controllers delete pods directly, and it does not help with node crashes or a manual kubectl delete pod. So a quorum system needs a PDB and a workload maxUnavailable and topology spread, covering three different threats, and having only the PDB is the common correct-looking mistake.

"How do you roll a 3-node etcd or ZooKeeper cluster safely?"

One node at a time, because a 3-node quorum tolerates exactly one failure. Use OrderedReady pod management and the StatefulSet partition field to update the highest ordinal first and observe it against real traffic before lowering the partition. Make readiness reflect raft membership and applied-index lag rather than the port being open, and use a startupProbe so a slow catch-up does not force an absurd liveness timeout in steady state. Transfer leadership explicitly before restarting the leader, since otherwise followers wait out the election timeout, which took write unavailability per node restart from about 3.2 seconds to about 180 milliseconds in one case. And spread across three zones, because two of three nodes in one zone means the cluster tolerates no zone failure at all.

"Why is readiness different for stateful services?"

Because the process being up does not mean the node can serve. A replica rejoining after a restart may be minutes behind, and a readiness probe that checks the port will route reads to it and serve stale data. Readiness must encode the actual condition, replication lag within tolerance, raft membership established, shards recovered. And because catch-up can take many minutes, use a startupProbe to grant that time rather than lengthening the liveness probe, which would leave a genuinely wedged process unrestarted for the same duration in steady state.

"What is the argument for bounded connection lifetime?"

It converts a rare, expensive, untested event into a continuous, cheap, always-exercised one. With 180,000 connections and a 40-minute jittered maximum age, the system handles about 75 reconnects per second all the time, so the capacity to absorb reconnects is proven every minute rather than assumed at deploy time. After that change the deploy-time reconnect rate was only about seven times baseline rather than a thousandfold spike, which is the difference between a load the system routinely handles and one it has never seen.

Common misconceptions

"The PDB protects my rolling update." It constrains the Eviction API. Controllers delete pods directly during updates; use maxUnavailable and OrderedReady for that.

"Graceful shutdown is enough." Graceful shutdown finishes in-flight work. It does not stop the load balancer from sending new work, which is what the preStop delay and readiness failure handle.

"A 3-node quorum tolerates one node failure." Only if the three nodes are in three failure domains. Two in one zone means it tolerates zero zone failures.

"Dropping connections is fine, clients reconnect." They reconnect simultaneously. Without jitter and resumable sessions, the reconnect is more expensive than the traffic it replaces, and the herd re-forms on every retry round.

"Readiness means the process started." For a stateful node it must mean caught up, or you route reads to a replica minutes behind.

"Restarting the leader is the same as restarting a follower." It costs an election unless you transfer leadership deliberately, and for a primary/replica database it may cost data if replication is asynchronous.

Interview delivery note

Say this verbatim: "A PodDisruptionBudget constrains the Eviction API, so it protects against node drains and the autoscaler, not against your own rolling update, which deletes pods directly and is governed by maxUnavailable. A quorum service needs all three: maxUnavailable for your rollout, the PDB for someone else's drain, and topology spread for the cloud provider's zone failure." It is a precise correction of a belief most engineers hold.

The senior-versus-staff separator is bounded connection age as the structural fix. A senior engineer drains connections slowly at deploy time. A staff engineer points out that this leaves an expensive path exercised once per deploy, and instead bounds connection lifetime with jitter so reconnects run continuously at 75 per second, which proves the capacity every minute and reduces the deploy-time spike from roughly 24,000 per second to 530. Converting an untested emergency path into an exercised steady-state path is the general move, and it is the same reasoning as chaos engineering applied to one mechanism.

The second signal is naming the reconnect cascade rather than the disconnect. The nine-minute outage in the worked example was not caused by dropping 48,000 connections, which took three seconds; it was caused by unjittered client retries re-synchronising after the first failure. Knowing that the client's backoff policy is part of your deployment safety, and that you cannot fix this server-side alone, is the depth signal.

Further reading

  • Kubernetes documentation on pod termination and lifecycle hooks, and on PodDisruptionBudgets, including the statement that they do not apply to controller-driven deletions.
  • Kubernetes documentation on StatefulSet update strategies, particularly the partition field as a staged-rollout mechanism.
  • AWS Architecture Blog, "Exponential Backoff and Jitter," for why unjittered retries re-synchronise a herd and what full jitter does.
  • etcd's tuning documentation for heartbeat and election timeouts, and etcdctl move-leader for planned leadership transfer.
  • Kafka's documentation on controlled.shutdown.enable and what happens to partition leadership without it.