DevOps · lesson 4 of 8
Intermediate
7- 2 Choosing a CI/CD Toolchain: The Same Weighted Framework as Choosing a Cloud
- 3 CI/CD Pipeline Patterns: Sequential, Parallel, and Staged
- 4 Deployment Strategies Compared: Rolling, Blue-Green, and Canary
- 5 GitOps in Practice: Git as the Source of Truth
- 6 Computing DORA Metrics From Your Own Git History
- 7 Terraform vs. Ansible: A Weighted Decision Framework
- 8 Observability Synthesis: Logs, Metrics, Traces, and Alerts as One Signal
Also part of DevOps
12- + PR Workflow: Rebasing, Squash Merge, and Merge Commits from Git & GitHub
- + GitHub Actions: Workflows, Jobs, and a Real Failing Step from GitHub Actions
- + Jenkins CI: Pipeline Basics from Jenkins
- + Bash DevOps Scripts from Bash
- + Introduction to Docker from Docker
- + Deployments and Rollouts from Kubernetes
- + Terraform Fundamentals (IaC) from Terraform
- + Terraform CI/CD with GitHub Actions (plan/apply) from Terraform
- + Ansible Idempotency, Verified: Run It Twice and Watch changed=0 from Ansible
- + Prometheus Scrapes You — It's Pull, Not Push from Monitoring & Observability
- + A Firing Alert Doesn't Mean Anyone Was Notified from Monitoring & Observability
- + A CI Security Gate: Gitleaks, Bandit and pip-audit in One Workflow from DevSecOps (AppSec in the pipeline)
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
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
| Strategy | Requests failed | Why |
|---|---|---|
| Rolling | 3 / 12 | Each instance is briefly absent while it restarts |
| Blue-green | 0 / 30 | New fleet is proven healthy before old fleet stops |
| Canary | 6 / 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.