Skip to main content
DevOps · lesson 4 of 8
DevOps intermediate Lesson 4 of 8

Also part of: Kubernetes

Deployment Strategies Compared: Rolling, Blue-Green, and Canary

Measure the real request failure rate of a rolling deployment, prove blue-green's zero-downtime claim, and show canary's blast-radius containment against a real bug.

By SkillByExample · Published

devops deployment reliability

Three deployment strategies, one small fleet of real local HTTP servers, and three very different failure counts.

A fleet you can actually measure

Each “instance” is a real local HTTP server that reports its own version on every request, and can be told to misbehave:

# .scratch/service.py
import os, sys, http.server

VERSION = os.environ.get("VERSION", "v1")
BUGGY = os.environ.get("BUGGY") == "1"
PORT = int(sys.argv[1])
count = [0]

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        count[0] += 1
        if BUGGY and count[0] % 3 == 0:
            self.send_response(500); self.end_headers(); return
        self.send_response(200); self.end_headers()
        self.wfile.write(VERSION.encode())
    def log_message(self, *a): pass

http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

A small helper module starts instances, polls until one is actually accepting connections, and sends a single request:

# .scratch/fleet.py
import subprocess, time, urllib.request, os

def start(port, version, buggy=False):
    env = {**os.environ, "VERSION": version, "BUGGY": "1" if buggy else "0"}
    return subprocess.Popen(["py", ".scratch/service.py", str(port)], env=env,
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def wait_healthy(port, timeout=5):
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{port}", timeout=0.2)
            return True
        except Exception:
            time.sleep(0.05)
    return False

def request(port):
    try:
        r = urllib.request.urlopen(f"http://127.0.0.1:{port}", timeout=0.3)
        return r.status, r.read().decode()
    except Exception:
        return None, None

Rolling: capacity dips, requests fail

A rolling deployment replaces one instance at a time, waiting for each replacement to become healthy before moving to the next — while a background load generator keeps sending real requests round-robin across the fleet the whole time:

# .scratch/rolling.py (excerpt — full loop starts a background thread hitting
# ports round-robin every 20ms, then does this while it runs)
for p in ports:
    procs[p].kill()
    procs[p].wait()
    procs[p] = start(p, "v2")
    wait_healthy(p)
$ py rolling.py
Rolling replace of 3 instances took 0.87s
Requests during rollout: 9 ok, 3 failed

3 of 16 requests failed — one for each instance’s restart window, when that instance wasn’t listening on its port yet. The fleet was never fully down, but whichever request landed on the instance mid-restart had nothing to answer it.

Blue-green: a second fleet, then an atomic switch

Blue-green spins up an entirely separate “green” fleet on different ports, health-checks all of it before any traffic is redirected, then switches which ports the load generator reads from in one atomic list assignment — old instances stay up until after the switch:

# .scratch/bluegreen.py (excerpt)
for p in green_ports:
    procs[p] = start(p, "v2")
    wait_healthy(p)          # green is fully healthy before it sees traffic

active[:] = green_ports      # atomic swap — every subsequent request hits green
time.sleep(0.3)
for p in blue_ports:
    procs[p].kill()          # blue only stops after green has taken over
$ py bluegreen.py
Cutover (list swap) took 0.01ms
Requests during full blue-green cycle: 30 ok, 0 failed

Zero failed requests, for a concrete reason: nothing is torn down until its replacement is already confirmed healthy and already receiving traffic. The cost is visible in the setup code, not the output — for the cutover window, both fleets are running at full size.

Canary: contained blast radius

Canary keeps the stable fleet fully in place and adds one new-version instance that gets only a small slice of real traffic — here it’s a genuinely broken build, failing every third request it serves:

# .scratch/canary.py (excerpt)
if random.random() < 0.10:               # 10% of traffic goes to the canary
    status, _ = request(canary_port)
    tally["v2_ok" if status == 200 else "v2_fail"] += 1
else:
    status, _ = request(random.choice(stable_ports))
    tally["v1_ok" if status == 200 else "v1_fail"] += 1
$ py canary.py
Total requests: 200
v1 (stable): 181 ok, 0 failed
v2 (canary): 13 ok, 6 failed
Bug exposed to 19/200 requests (9.5%) before promotion

The bug is real and it does fail requests — but only the roughly-10% slice of traffic deliberately routed to it. A rolling deployment of the same broken build would have replaced every stable instance in turn, eventually routing 100% of traffic through the bug instead of 9.5% of it, with no traffic-split step where a health dashboard could catch it first.

Comparing the three

StrategyRequests failedWhy
Rolling3 / 12Each instance is briefly absent while it restarts
Blue-green0 / 30New fleet is proven healthy before old fleet stops
Canary6 / 19 (bad build)Real traffic hits real bug, but capped to ~10%

Rolling is the cheapest (no doubled capacity) but has a real, measurable failure window. Blue-green eliminates that window at the cost of running two full fleets briefly. Canary doesn’t prevent failures from a bad release — it limits how much traffic sees them before someone notices and rolls back.

Practice

1. Change the rolling deployment to replace instances two at a time instead of one. Does the failure count change?

Modify rolling.py’s loop to kill and restart ports[0:2] together, wait for both to be healthy, then do ports[2:]. Measured result: 0 ok, 3 failed — with only one of three instances left standing during the first batch, almost every round-robin request lands on a dead port instead of the roughly 1-in-3 chance a single-instance rolling replace has. Bigger batches finish faster but widen the failure window per batch.

2. What happens to blue-green's failure count if you don't wait for green to be healthy before swapping?

Remove the wait_healthy(p) calls in the green startup loop and re-run bluegreen.py. Measured result: 1 ok, 1 failed (reproducible across repeated runs) — the load generator starts hitting green ports before those servers have finished binding, so a real request fails with a connection error. This proves blue-green’s zero-downtime property comes specifically from the health-check ordering, not from anything inherent to having two fleets.

3. Raise the canary's traffic share from 10% to 50%. How does the bug exposure change?

Change 0.10 to 0.50 in canary.py and re-run. Measured result: 101/200 (50.5%) of traffic now hits the canary, with 34 of those requests failing — roughly half of total traffic now exposed to the bug instead of ~9.5%. Canary’s blast-radius protection is a direct function of the traffic percentage you choose, not a fixed property of the pattern.

4. Fix the canary's bug (remove `BUGGY=True`) and add a promotion step that replaces the stable fleet with v2 once the canary shows zero failures over 50 requests.

After the canary loop, check tally["v2_fail"] == 0; if so, run the same instance-by-instance replacement loop from rolling.py against stable_ports, starting each with version="v2". This is the actual production pattern: canary as a gate, rolling (or blue-green) as the mechanism that completes the rollout once the gate passes.

5. Add a fourth stable instance to the rolling deployment. Does the total downtime window get longer or shorter?

Add a fourth port to rolling.py’s ports list. The failure count rises roughly proportionally (one more restart window contributes its own failed requests), but each individual instance’s downtime window stays the same length — more instances means more total windows, not longer ones, since they’re still replaced one at a time.

Frequently Asked Questions

Which deployment strategy has zero downtime — rolling, blue-green, or canary?
Blue-green is the only one that can guarantee zero request failures during the switch, because the new fleet is fully health-checked before any traffic reaches it and the old fleet stays up until the switch completes. Rolling deployments briefly reduce capacity while each instance restarts, and canary deliberately sends some real traffic to unproven code.
Why does a rolling deployment fail some requests if it never takes the whole fleet down?
Because whichever instance is mid-restart isn't there to answer the request routed to it. A load balancer with retry logic can hide this from users, but the request to that specific instance still fails at the transport level — the fleet's total capacity is temporarily reduced, one instance at a time.
What does 'blast radius' mean in the context of canary deployments?
It's the fraction of total traffic exposed to a bug before the deployment is caught and rolled back. A canary sending 10% of traffic to a new version limits a bad release to roughly 10% of requests failing, versus a rolling deployment that would eventually route all traffic through the same broken build.
Does blue-green deployment cost more than rolling or canary?
Yes — for the duration of the cutover, both the old and new fleets run at full size, roughly doubling compute cost. Rolling and canary avoid this by never running more than a small overlap of old and new capacity at once.
Can I combine canary and blue-green?
Yes, and many real deployment platforms do: stand up a fully separate new fleet (blue-green's isolation), but ramp traffic to it gradually with health checks at each step (canary's blast-radius control) instead of an instant all-or-nothing cutover.