Segla

Back to Blog
Kubernetes
Deployments
DevOps
Reliability

Zero-Downtime Deployments on Kubernetes: A Practical Guide

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.


The Default Isn't Zero-Downtime

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:

  1. Pod receives SIGTERM but is still in the Service endpoint list
  2. Readiness probe hasn't been configured or is too slow
  3. Application doesn't handle graceful shutdown - connections are cut mid-request
  4. Load balancer hasn't removed the pod from its pool yet

Let's fix each of these.


Step 1: Readiness Probes

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 time
  • Don't make the probe too expensive - it runs every periodSeconds

Liveness is a different question, and must not check the database

Readiness 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.


Step 2: Graceful Shutdown

When Kubernetes sends SIGTERM, your app should:

  1. Stop accepting new connections
  2. Finish processing in-flight requests
  3. Close database connections cleanly
  4. Exit
// 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

Step 3: Pre-Stop Hook

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.


Step 4: Rolling Update Strategy

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.


Step 5: PodDisruptionBudget

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.


The Complete Configuration

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

Checklist

CheckWhy
Readiness probe on /readyzTraffic only goes to pods that can serve
Liveness probe on /livez, no dependenciesA database outage does not restart healthy pods
maxUnavailable: 0Old 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 SIGTERMIn-flight requests complete
terminationGracePeriodSeconds ≥ shutdown timeK8s waits long enough
PodDisruptionBudget setNode 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.

The modern platform for cloud-native application delivery. From code to production in minutes.

Connect

© 2026 Segla. All rights reserved.

Made with ♥ in Ukraine 🇺🇦