Kubernetes advertises rolling updates out of the box, yet many teams still see a burst of 502s on every deploy. The defaults are only part of the story: zero downtime needs correct probes, a sensible rollout strategy, graceful shutdown, and protection from voluntary disruptions like node drains. This post walks through each piece with manifests you can apply today.
Why requests get dropped
During a rollout, three things can go wrong:
- New pods receive traffic before they are ready. The container process is running, but caches, DB pools, or JIT warm-up are not done.
-
Old pods are killed while they still have in-flight requests. Endpoint removal and
SIGTERMhappen concurrently, so a pod may receive new connections after it starts shutting down. - Too many pods disappear at once. A rollout combined with a node drain or autoscaler scale-down can drop capacity below what your traffic needs.
Each problem has a dedicated Kubernetes feature.
Step 1: Readiness, liveness, and startup probes
- Readiness decides whether a pod is in the Service's endpoints. Fail it and traffic stops; the pod is not restarted.
- Liveness decides whether the container should be restarted. Use it only for "this process is wedged" conditions.
- Startup holds off liveness checks until a slow-starting app has booted.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 4
revisionHistoryLimit: 5
selector:
matchLabels:
app: web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
minReadySeconds: 10
template:
metadata:
labels:
app: web
spec:
terminationGracePeriodSeconds: 45
containers:
- name: web
image: ghcr.io/example/web:1.4.2
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
memory: 512Mi
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 24 # up to 120s to boot
readinessProbe:
httpGet:
path: /ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
Keep /healthz cheap and local: it should answer "is my process alive?", not "is the database up?". If liveness checks a shared dependency, a database blip restarts every pod at once. /ready can check dependencies the pod truly can't serve without.
Step 2: A rollout strategy that never reduces capacity
The two knobs that matter:
-
maxUnavailable: 0means Kubernetes never removes an old pod until a new one is Ready. -
maxSurge: 25%lets it create extra pods (here, 1 of 4) above the desired count during the rollout.
minReadySeconds: 10 requires a new pod to stay Ready for 10 seconds before it counts as available. That catches apps that pass their first readiness check and then crash.
The trade-off is that you need spare cluster capacity for the surge pods. If your cluster is tightly packed, the rollout stalls in Pending instead of dropping traffic, which is the failure mode you want.
Step 3: Graceful shutdown
When a pod is deleted, Kubernetes sends SIGTERM and, in parallel, removes the pod from Service endpoints. kube-proxy, ingress controllers, and cloud load balancers take a few seconds to notice. The preStop sleep above keeps the pod serving during that window; only then does SIGTERM reach your process.
Your application must also handle SIGTERM by finishing in-flight requests. A minimal Python example:
import signal
import sys
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import threading
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = b"ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
def shutdown(signum, frame):
# shutdown() blocks until serve_forever exits, so run it in a thread
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, shutdown)
server.serve_forever()
server.server_close()
sys.exit(0)
Make sure terminationGracePeriodSeconds exceeds the preStop sleep plus your longest request. Otherwise the kubelet sends SIGKILL mid-request.
Step 4: PodDisruptionBudgets for voluntary disruptions
Rollouts aren't the only time pods go away. kubectl drain, cluster upgrades, and the cluster autoscaler all evict pods. A PodDisruptionBudget (PDB) tells the eviction API how many pods must stay up:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 3
selector:
matchLabels:
app: web
With 4 replicas and minAvailable: 3, a drain evicts at most one web pod at a time and waits for its replacement to become Ready. Spread the replicas across nodes so a single drain doesn't touch most of them:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: web
(Add that under spec.template.spec in the Deployment.)
Step 5: Deploy and verify with kubectl
#!/usr/bin/env bash
set -euo pipefail
NS="${NS:-default}"
IMAGE="${1:?usage: deploy.sh <image>}"
kubectl -n "$NS" set image deployment/web web="$IMAGE"
kubectl -n "$NS" annotate deployment/web \
kubernetes.io/change-cause="deploy $IMAGE" --overwrite
if ! kubectl -n "$NS" rollout status deployment/web --timeout=300s; then
echo "Rollout failed, rolling back" >&2
kubectl -n "$NS" rollout undo deployment/web
kubectl -n "$NS" rollout status deployment/web --timeout=300s
exit 1
fi
kubectl -n "$NS" get pdb web-pdb
Set spec.progressDeadlineSeconds (default 600) so a stuck rollout is reported as failed and rollout status exits non-zero.
To prove zero downtime, run a load loop against the Service while deploying:
while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' http://web.default.svc.cluster.local/)
[ "$code" != "200" ] && echo "$(date +%T) got $code"
sleep 0.1
done
Any line printed during a rollout is a dropped request worth investigating.
Common pitfalls
- Liveness probe identical to readiness, checking the database. One dependency outage becomes a cluster-wide restart storm.
-
No
preStopdelay. Traffic still routes to terminating pods for a few seconds, causing connection resets. -
minAvailableequal toreplicas. The PDB blocks every drain forever, and node upgrades hang. - Single replica. No probe or PDB can make one pod zero-downtime. Run at least two.
-
Shells that swallow signals.
command: ["sh", "-c", "python app.py"]may leaveshas PID 1, and it won't forwardSIGTERM. Useexecor an init liketini. - Probe timeouts too tight. A 1s timeout on a GC-heavy JVM causes flapping under load.
Wrap-up
Zero-downtime deploys come from layers that work together: probes gate traffic, maxUnavailable: 0 preserves capacity, preStop plus SIGTERM handling drains connections, and PDBs guard against drains. Add the curl loop to your staging pipeline and you'll catch regressions before users do.
Written by the ByteBridge Talent LLC engineering team.
Top comments (0)