Skip to main content

🎢 Rolling Updates

Blue-green needs a whole second environment. Canary needs weighted routing and metric analysis. The rolling update needs almost nothing extra — it just upgrades the instances you already have, a few at a time, keeping the rest serving traffic. It's the default zero-downtime strategy baked into Kubernetes, and the one you'll reach for most often.

Week 13 · Day 2 (Tuesday: Deployment Strategies) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how a rolling update replaces instances batch by batch with zero downtime
  • Configure maxSurge and maxUnavailable to trade deployment speed against spare capacity
  • Write readiness and liveness probes that gate traffic until a new instance is truly ready
  • Drive a Kubernetes Deployment rollout, pause it, and roll it back with kubectl
  • Keep two versions compatible while they coexist during the rollout
  • Use connection draining and graceful shutdown so in-flight requests aren't dropped

Estimated Time: 70 minutes

Practice: Write a Deployment with a tuned rolling strategy and probes, then drive and roll back a release.

In This Lesson

What Is a Rolling Update?

A rolling update upgrades a fleet of identical instances by replacing them incrementally — one small batch at a time — within a single environment. At any moment only a fraction of instances are being swapped; the rest keep handling requests, so the service never goes fully offline.

sequenceDiagram participant LB as Load Balancer participant I1 as Instance 1 participant I2 as Instance 2 participant I3 as Instance 3 Note over I1,I3: All instances start on v1 LB->>I1: Route a request I1->>LB: Response from v1 Note over I1: Take instance 1 out and upgrade it to v2 Note over I1: Wait for its readiness probe to pass LB->>I1: Route a request I1->>LB: Response from v2 Note over I2: Now upgrade instance 2 the same way LB->>I3: Route a request I3->>LB: Response still from v1 Note over I1,I3: Continue until every instance runs v2

🔧 The tire-rotation analogy

A mechanic changing all four tires doesn't lift the whole car onto a crane. They jack up one corner, swap that tire, lower it, and move to the next. The car stays mostly on the ground the entire time. A rolling update jacks up one "corner" of your fleet at a time — the service keeps running at slightly reduced capacity, never fully down.

Compared with its cousins, the rolling update's appeal is efficiency: no duplicate environment (blue-green) and no weighted-routing machinery (canary). The cost is that two versions run at once during the rollout, so they must be compatible — a theme we'll return to.

maxSurge & maxUnavailable

Two knobs control the pace and safety of a Kubernetes rolling update. Together they answer: "during the rollout, how many extra pods may I create, and how many may be missing?"

maxSurge adds temporary extra pods above desired count while maxUnavailable allows some to be missing Desired = 4 pods maxSurge: 1 up to 5 pods may exist (4 desired + 1 extra) faster, needs headroom maxUnavailable: 1 at least 3 pods stay up (4 desired − 1 missing) protects capacity
maxSurge lets the rollout add temporary pods so new ones warm up before old ones leave; maxUnavailable caps how much serving capacity you're willing to lose mid-rollout.
SettingMeaningHigher value
maxSurgeExtra pods allowed above the desired count during the updateFaster rollout, needs spare capacity/quota
maxUnavailablePods allowed to be missing below the desired countFaster rollout, but less serving capacity mid-update

💡 Both accept counts or percentages

You can write maxSurge: 1 or maxSurge: 25%. Percentages scale automatically as your replica count grows. A safe, common default is maxSurge: 25% with maxUnavailable: 0 — new pods come up before old ones go down, so you never dip below full capacity. Setting both to 0 is illegal; the update could never make progress.

# A capacity-preserving rolling strategy: surge up, never drop below desired.
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # create at most 1 extra pod at a time
      maxUnavailable: 0    # never let serving capacity fall below 4

Readiness Probes Gate Traffic

Here's the single most important safety mechanism in a rolling update: Kubernetes will not send traffic to a new pod until its readiness probe passes. Without a good probe, the load balancer would route users to a pod that has started but isn't actually ready — no database connection, caches cold — and they'd get errors.

ProbeQuestion it answersOn failure
ReadinessShould this pod receive traffic right now?Pod is removed from the Service until it passes again
LivenessIs this pod still healthy, or stuck?Pod is restarted
StartupHas a slow-booting app finished starting?Holds off the other probes until it passes
# Probes turn a rolling update from "hope it works" into "verified each step".
spec:
  containers:
    - name: my-app
      image: my-app:v2
      ports:
        - containerPort: 8080
      readinessProbe:            # GATE: no traffic until this returns 200
        httpGet: { path: /health/ready, port: 8080 }
        initialDelaySeconds: 5
        periodSeconds: 5
      livenessProbe:             # restart the pod if it wedges
        httpGet: { path: /health/live, port: 8080 }
        initialDelaySeconds: 15
        periodSeconds: 20

A meaningful readiness endpoint checks the dependencies real requests need, not just that the process is alive:

// A readiness check that actually reflects "can serve traffic".
app.get('/health/ready', async (req, res) => {
  try {
    await db.query('SELECT 1');            // database reachable?
    await redis.ping();                    // cache reachable?
    if (!global.appInitialized) throw new Error('warming up');
    res.status(200).json({ status: 'UP', version: process.env.APP_VERSION });
  } catch (err) {
    // 503 keeps this pod OUT of the load balancer until it recovers.
    res.status(503).json({ status: 'DOWN', reason: err.message });
  }
});

// Liveness is deliberately shallow: is the process responsive at all?
app.get('/health/live', (req, res) => res.status(200).json({ status: 'UP' }));

⚠️ Don't make liveness deep

A tempting mistake is to check the database in the liveness probe too. But liveness failure restarts the pod — so a brief database hiccup would trigger a restart storm across your whole fleet, turning a small blip into an outage. Keep liveness shallow (is the process responsive?) and put dependency checks in readiness.

Driving & Rolling Back

With a Deployment in place, triggering a rolling update is one command: change the image. Kubernetes handles the batch-by-batch replacement, honoring your surge/unavailable settings and readiness gates.

# Trigger a rolling update by pointing at the new image.
kubectl set image deployment/my-app my-app=my-app:v2

# Watch it progress batch by batch (blocks until done or failed).
kubectl rollout status deployment/my-app

# See something wrong mid-rollout? Pause it where it stands.
kubectl rollout pause deployment/my-app

# ...investigate, then resume when you're satisfied.
kubectl rollout resume deployment/my-app

# Roll back to the previous revision — fast and built in.
kubectl rollout undo deployment/my-app

# Inspect the history of revisions.
kubectl rollout history deployment/my-app

✅ Rollback is a first-class operation

Kubernetes keeps a history of previous ReplicaSets, so kubectl rollout undo is itself just another rolling update — back to the old image. You don't rebuild anything. Combine it with automated monitoring so a spike in errors triggers the undo without a human in the loop.

That automation is straightforward: watch a metric after the rollout and call undo if it breaches a threshold.

#!/usr/bin/env bash
set -euo pipefail
DEPLOY="my-app"; NS="production"
ERROR_MAX=5          # percent
WINDOW=600           # watch for 10 minutes
PROM="http://prometheus:9090"

deadline=$(( $(date +%s) + WINDOW ))
while [ "$(date +%s)" -lt "$deadline" ]; do
  err=$(curl -s "$PROM/api/v1/query" --data-urlencode \
    'query=sum(rate(http_requests_total{status=~"5.."}[1m]))/sum(rate(http_requests_total[1m]))*100' \
    | jq -r '.data.result[0].value[1] // "0"')
  echo "error rate: ${err}%"

  if (( $(echo "$err > $ERROR_MAX" | bc -l) )); then
    echo "Error rate over threshold — rolling back."
    kubectl rollout undo "deployment/$DEPLOY" -n "$NS"
    exit 1
  fi
  sleep 15
done
echo "Rollout stable."

Version Compatibility

Because old and new instances run simultaneously during a rolling update, they must get along. Two versions of your code talk to the same database, and possibly to each other, at the same time.

graph TD A["Keep versions compatible"] --> B["Additive schema changes only
add columns, never rename or drop"] A --> C["Backward-compatible APIs
add fields, keep old ones working"] A --> D["Graceful handling
ignore unknown fields, use defaults"]

The rules mirror the expand-contract pattern from the blue-green lesson, and they matter here for the same reason: the two versions overlap.

  • Deploy schema changes first, additively. Add the new column before the code that uses it. The old version simply ignores it.
  • Never remove a column, table, or API field until every instance is on the new version and any data migration is done.
  • Provide defaults for new columns so old code that inserts a row without them still succeeds.
// New code must still read rows written by the OLD version.
function getCustomerPhone(customer) {
  // New schema populated? Use it. Otherwise fall back to the legacy field.
  return customer.phone_number ?? customer.legacy_phone ?? null;
}

// And old code must survive rows/fields the NEW version adds:
function handleRequest(body) {
  const name = body.name;
  // Unknown future fields are simply ignored — forward compatibility.
  const settings = body.settings ?? getDefaultSettings();
  return process(name, settings);
}

⚠️ The classic rolling-update outage

Someone ships a migration that renames a column in the same release as the code change. The instant the migration runs, every not-yet-upgraded instance queries a column that no longer exists — and half your fleet starts throwing 500s. Always split it: add the new column (expand), migrate all instances, backfill, then drop the old column in a later release (contract).

Connection Draining

When a pod is about to be replaced, it may still be in the middle of serving requests. Connection draining (graceful shutdown) means: stop accepting new requests, finish the ones already in flight, then exit. Skip this and users get dropped mid-request during every deploy.

sequenceDiagram participant LB as Load Balancer participant Pod as Instance being replaced participant User as User mid-request Note over LB,Pod: Kubernetes sends SIGTERM to the pod LB->>Pod: Stop routing new requests here User->>Pod: Finish the in-flight request Pod->>User: Return the final response Note over Pod: Close connections and exit cleanly Note over LB,Pod: Now the pod is safe to terminate

In Node, handle SIGTERM: stop the server from taking new connections, let existing ones complete, and close your resource pools before exiting.

const server = app.listen(8080);

// Kubernetes sends SIGTERM before removing the pod. Drain gracefully.
process.on('SIGTERM', async () => {
  console.log('SIGTERM received — draining connections.');

  // 1. Stop accepting new connections; finish in-flight requests.
  server.close(async () => {
    // 2. Release shared resources once requests are done.
    await redisClient.quit();
    await db.end();
    console.log('Drain complete — exiting.');
    process.exit(0);
  });

  // 3. Safety net: force-exit if draining takes too long.
  setTimeout(() => process.exit(1), 30_000).unref();
});

💡 Give the pod time to drain

Kubernetes waits terminationGracePeriodSeconds (default 30) between SIGTERM and a forceful kill. Set it comfortably longer than your slowest normal request so draining actually finishes. Pair this with a readiness probe that starts failing on shutdown, so the pod leaves the load balancer before it stops accepting work.

Practice & Quiz

🏋️ Exercise 1: Tune the strategy for zero capacity loss

Goal: You run 8 replicas of a latency-sensitive API and cannot afford to drop below full capacity during a deploy, but you have quota for a couple of extra pods. Write the strategy block and explain the trade-off.

💡 Hint

To never lose capacity, new pods must come up before old ones leave. Which knob allows extra pods, and which must be zero?

✅ Solution
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 2          # spin up 2 new pods at a time (uses spare quota)
    maxUnavailable: 0    # never drop below the 8 desired

Trade-off: you temporarily run up to 10 pods, consuming extra resources, in exchange for holding full serving capacity throughout the rollout.

🏋️ Exercise 2: Diagnose the 500s

Goal: During a rolling update, error rates spike briefly every time a new pod appears, then settle. Users hit "connection refused" for a second or two. What's the likely cause and fix?

✅ Solution

Traffic is reaching pods before they're ready — either there's no readiness probe, or its initialDelaySeconds is too short so it passes before the app can actually serve. Add or tighten the readiness probe to check real dependencies, so Kubernetes only adds the pod to the Service once it can genuinely handle requests.

🎯 Quick Quiz

Question 1: What does a rolling update do?

Question 2: With maxUnavailable: 0 and maxSurge: 1, what happens during the update?

Question 3: Why should the liveness probe stay shallow instead of checking the database?

Best Practices & Pitfalls

✅ Do

  • Always define a readiness probe that checks real dependencies
  • Start conservative: small maxSurge, maxUnavailable: 0 for capacity-sensitive services
  • Keep API and schema changes backward-compatible (additive first)
  • Implement graceful shutdown on SIGTERM and set a generous grace period
  • Monitor error rate and latency during the rollout, and automate rollback
  • Use kubectl rollout pause to freeze a suspicious deploy while you investigate

❌ Don't

  • Don't ship without a readiness probe — traffic will hit half-ready pods
  • Don't put dependency checks in the liveness probe (restart storms)
  • Don't rename or drop columns in the same release as the code change
  • Don't set both maxSurge and maxUnavailable to 0 — the rollout can't progress
  • Don't ignore in-flight requests; skipping drain drops users mid-request

📖 Choosing among the three strategies

Reach for a rolling update as your everyday default — cheap, built-in, zero-downtime. Choose blue-green when you need an atomic cutover and instant, total rollback. Choose canary when a bug would be costly and you want real-user validation on a small slice before committing. They also combine — a canary step ahead of a fuller rollout is common.

Summary

🎉 Key Takeaways

  • A rolling update replaces instances batch by batch in one environment — zero downtime, minimal extra capacity
  • maxSurge allows temporary extra pods; maxUnavailable caps how much capacity you'll lose mid-rollout
  • Readiness probes gate traffic — a pod joins the load balancer only once it can truly serve
  • Keep liveness shallow; deep dependency checks belong in readiness to avoid restart storms
  • Two versions coexist, so keep schema and APIs backward-compatible (additive first, drop later)
  • Handle SIGTERM for graceful connection draining, and lean on kubectl rollout undo for fast rollback

📚 Additional Resources

🚀 What's Next?

You've now seen the three core deployment strategies. All of them assume infrastructure already exists to deploy onto. The next lesson steps back to ask how that infrastructure itself is created and versioned, reproducibly, from code: Infrastructure as Code (IaC) concepts.

🎉 That's the deployment trilogy!

Blue-green, canary, and rolling updates are now in your toolkit. Next we define the infrastructure they all run on — as code.