Serhii Mazurok - founder of Segla; CKA/CKAD
2026-03-16 · 8 min read
Your users should never know you deployed. Here's how to make that happen on Kubernetes.
Zero-downtime deployments on Kubernetes come down to five settings on one Deployment: a readiness probe that only passes when the pod can serve, a liveness probe that is cheaper than the readiness probe and never checks a dependency, maxUnavailable: 0 so the old pod stays until the new one is ready, a preStop pause so the load balancer stops sending traffic before the process is told to quit, and a shutdown handler that finishes in-flight requests inside terminationGracePeriodSeconds. Add a PodDisruptionBudget so node drains obey the same rules. The complete manifest is at the end; the sections in between are why each line is there.
A basic Kubernetes Deployment with RollingUpdate strategy will update pods one at a time. But without proper configuration, you'll still drop requests during rollouts. Here's why:
Let's fix each of these.
The readiness probe tells Kubernetes when a pod is ready to receive traffic. Without it, traffic is sent to pods that haven't finished starting.
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
successThreshold: 1
Rules:
/readyz should return 200 only when the app can actually serve (DB connected, caches warmed)initialDelaySeconds should cover your app's startup timeperiodSecondsReadiness asks can this pod take traffic right now? Liveness asks is this process alive at all? They need different endpoints, and the liveness one must be cheap and dependency-free. If liveness checks the database and the database goes away, the kubelet restarts every pod in the deployment - healthy processes, killed in a loop, during the one incident where you need them standing still. The outage gets a second outage on top.
livenessProbe:
httpGet:
path: /livez # returns 200 if the process can answer HTTP. Nothing else.
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
With the two split, a database outage makes every pod not ready - pulled from the Service, still running, back the moment the database is - and restarts nothing.
When Kubernetes sends SIGTERM, your app should:
// Go example
srv := &http.Server{Addr: ":8080"}
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
Set terminationGracePeriodSeconds to match:
spec:
terminationGracePeriodSeconds: 30
There's a race condition: Kubernetes sends SIGTERM and removes the pod from endpoints simultaneously. The kube-proxy / ingress controller may not have updated yet, so traffic can still arrive at a terminating pod.
The fix: add a short delay before your app starts shutting down.
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
This gives the network 5 seconds to drain the pod from all load balancer pools before the app starts shutting down.
exec: ["sleep", "5"] needs a sleep binary in the image. Distroless and scratch images do not have one, and the hook fails silently - the pod goes straight to SIGTERM and you are back to dropping requests, with nothing in the logs to say why. Since Kubernetes 1.30 (on by default; stable since 1.34) the kubelet can do the pause itself, with no binary involved:
lifecycle:
preStop:
sleep:
seconds: 5
On an older cluster, either copy a static sleep into the image or have the application itself wait a few seconds after SIGTERM before it closes the listener. The complete manifest below uses the native form.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # never reduce below desired count
maxSurge: 1 # add 1 extra pod during rollout
maxUnavailable: 0 is critical - it ensures the old pod stays running until the new pod passes its readiness probe.
Protect against voluntary disruptions (node drains, cluster upgrades):
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: app-pdb
spec:
minAvailable: 1 # at least 1 pod must be running at all times
selector:
matchLabels:
app: my-app
For larger deployments, use maxUnavailable: 25% instead of minAvailable to allow faster rollouts while maintaining capacity.
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: app:v1.2.3
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /livez
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
lifecycle:
preStop:
sleep:
seconds: 5
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: app-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: app
| Check | Why |
|---|---|
Readiness probe on /readyz | Traffic only goes to pods that can serve |
Liveness probe on /livez, no dependencies | A database outage does not restart healthy pods |
maxUnavailable: 0 | Old pod stays up until new one is ready |
preStop sleep of 5 s (native, or a real sleep binary) | Drains pod from LB before shutdown |
| Graceful shutdown handles SIGTERM | In-flight requests complete |
terminationGracePeriodSeconds ≥ shutdown time | K8s waits long enough |
| PodDisruptionBudget set | Node drains don't kill all pods |
Count the manifest above: forty-five lines of YAML per service, and every one of them has to be right on every service, forever. That is the job. On Segla it is the default shape of a release - the probes, the surge, the drain and the budget are already there when a repository becomes an app, and the rollout is a version you can return to.
Connect a repository and the first release ships this way, without the YAML.