Part V · Chapter 8 · Running It · lesson 6
Every deploy drops requests for eight seconds
14 min
Builds on How many requests can be inside your service at once and Under overload you refuse work, or you lose all of it
Part V · Chapter 8 · Running It · lesson 6
14 min
Builds on How many requests can be inside your service at once and Under overload you refuse work, or you lose all of it
Every deploy, the error-rate graph shows a spike lasting about eight seconds. Connection resets and gateway errors, a few hundred of them, then clean. The rolling update is configured, the readiness probe is green, the application logs show nothing — no exception, no shutdown message, nothing at all. The team has decided this is normal and deploys at night.
It is four settings, and none of them is the readiness probe.
Predict
A pod is deleted — by a rolling update, a scale-down, an eviction, a node drain. From that instant:
preStop hook, waits
for it to finish, and then sends SIGTERM to process one.SIGKILL.Nothing in that list makes step 3 wait for step 2. If the container stops accepting connections the moment it gets the signal, every connection routed to it by a node whose rules have not caught up is refused — and refused by the proxy, which is why the application logs are empty.
One: a preStop hook that does nothing but wait. The signal is not sent
until the hook returns, so a hook that sleeps is the only mechanism that makes
the container outlive the routing update. It looks absurd and it is the single
highest-value line in this lesson.
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
Pick the duration from the propagation delay you measure, not from a blog post. The honest way to measure it: delete a pod while offering steady load, and record the time between the deletion timestamp and the last request the pod received. Double it.
Two: terminationGracePeriodSeconds longer than the hook plus the longest
request. The grace period is the budget for everything in steps 3 and 4,
hook included. A hook that sleeps for ten seconds inside a default grace period
of thirty leaves twenty for the application to finish in-flight work, and if
your slowest request takes longer than that the kill arrives mid-request.
spec:
terminationGracePeriodSeconds: 45
containers:
- name: api
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
Three: an application that actually handles the signal. This is the setting that is not a setting, and the one most often missing. On the signal the application should stop accepting new connections, finish what it holds, close idle keep-alive connections, and then exit — and the keep-alive clause is the one that gets forgotten, because a client holding an idle connection to a departing pod will send its next request down it.
The lab's own service is a worked example of getting this wrong. It installs no handler at all, so the default disposition applies and the process terminates at once, abandoning every connection on its queue. You can watch it happen:
docker compose run -d --rm load --rate 40 --seconds 60
docker compose stop service # SIGTERM, then SIGKILL after the timeout
docker compose logs load
Every request in flight and every request queued becomes an error in the generator's output. Nothing appears in the service's log, because there is no code that runs on the way out.
What the real frameworks need, named: gunicorn finishes current requests on
SIGTERM and bounds that with --graceful-timeout; a Starlette or FastAPI
application needs its shutdown work in the lifespan handler, which runs on the
signal; a Node HTTP server needs server.close() plus closeIdleConnections
or the keep-alive sockets stay open until their own timeout; Spring Boot needs
server.shutdown=graceful set explicitly, because the default is immediate,
and spring.lifecycle.timeout-per-shutdown-phase to bound it.
Four: a rolling update that never dips below capacity, and probes that do different jobs. The default update strategy allows a quarter of the fleet to be unavailable at once, which is a capacity decision disguised as a deployment setting. And the readiness probe decides whether a pod receives traffic while the liveness probe decides whether it is restarted — pointing both at the same endpoint with the same thresholds means that a pod which is merely overloaded gets killed, which is the most reliable way to turn a latency incident into an outage.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet: { path: /alive, port: 8080 }
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 5
startupProbe:
httpGet: { path: /alive, port: 8080 }
periodSeconds: 5
failureThreshold: 30
/ready should answer for the dependencies the pod needs to serve. /alive
should answer for the process being able to run at all, and nothing else — a
liveness probe that checks the database restarts every pod in the fleet the
moment the database has a bad minute.
This is the part the setting names hide, and it is why the defaults are what they are.
| setting | what it buys | what it costs |
|---|---|---|
preStop sleep | the routing race closes | every pod replacement takes that much longer |
| longer grace period | in-flight requests survive | a node drain, an eviction and a rollback all take longer |
| graceful shutdown in the app | in-flight requests survive | code that must be written and tested once per service |
maxUnavailable: 0 with maxSurge: 1 | capacity never dips | one extra pod's resources for the length of the rollout |
The arithmetic worth doing before you set these: the sleep plus the grace
period multiplied by the number of pods, divided by the surge, is how long your
rollout now takes. On a fleet of a few pods that is a minute. On a fleet of a
few hundred with a one-pod surge it is hours, and the setting that made deploys
safe has made a rollback slower than the incident it is meant to end. The
balance is maxSurge — a larger surge is the thing you buy with capacity to
keep the rollout short — and the reason it is a cost question rather than a
correctness one.
What would you do
/health, which returns a constant. The error spike on each deploy lasts roughly eight seconds per pod replacement and the deploy takes a few minutes in total.Check
All four settings exist to keep traffic flowing to an instance that is on its way out, which assumes the deploy is incremental and some of the fleet is always serving.
The crossing to watch for: a deploy that replaces the whole fleet at once on purpose — a recreate strategy, a stop-the-world migration, a cutover between two clusters — has no instance to drain traffic to, so draining is not the mechanism and none of this applies. There the window is the full startup time of the new version, the honest answers are a maintenance window or a blue-green switch with both versions running, and the thing to get right is the order of the database migration rather than the shutdown signal.
The same is true at one replica. A single pod being replaced has nowhere to send
the traffic during the gap, so a surge is not optional, it is the only fix —
and at one replica maxUnavailable: 0 with maxSurge: 1 is the difference
between a drop and no drop rather than a tuning choice.