Preface#
You can define what healthy means (SLOs). You can see if you're healthy (observability).
Now: how do you ship changes without breaking that health?
To improve a system, you must change it. To keep a system stable, you must not change it.
Every deployment is a risk
- New code might have bugs
- New config might be wrong
- New dependencies might fail
- New scale might break assumptions
- Humans might make mistakes
Yet we MUST deploy
- Fix bugs
- Add features
- Patch security
- Improve performance
- Stay competitive
The solution
Deploy frequently, but deploy SAFELY.
Deployment philosophy
| Old way | Modern way |
|---|---|
| Deploy monthly (or quarterly!) | Deploy daily (or more!) |
| Big bang releases | Small, incremental changes |
| "Change freeze" before releases | Automated pipelines |
| All-hands-on-deck deployment days | Gradual rollouts with monitoring |
| Hope nothing breaks | Automatic rollback if problems detected |
The safest deployment is a small one. The safest rollback is a fast one.
Today, we learn to ship changes without fear.
To improve a system you must change it, so deploy frequently but safely. Big bang means downtime and every user on the new version at once; rolling replaces instances gradually but runs two versions together with a slow rollback; blue-green switches all traffic to an idle environment and back instantly, for twice the infrastructure; canary moves from 1% to 10%, 50% and 100%, compared with the stable version at each stage. Feature flags decouple deployment from release and must be removed once at 100%, a column rename runs as expand, migrate and contract so old and new code both keep working, and rollback is instant for a flag, fast for code and config, and slow and risky for data.
Part I: Foundations#
Chapter 1: Deployment Strategies Overview#
1.1 The Deployment Strategy Spectrum#
From RISK → SAFETY
- Big Bang — All at once · High risk · Fast
- Rolling — Gradual replacement · Medium risk
- Blue-Green — Instant Switchover · Fast rollback
- Canary — Gradual % rollout · Safest
- Feature Flags — Code live, not active · Decoupled
Strategy comparison
| Strategy | Downtime | Rollback | Resource Cost | Complexity |
|---|---|---|---|---|
| Big Bang | Yes | Full redeploy | 1x | Low |
| Rolling | No | Slow | 1x + buffer | Medium |
| Blue-Green | No | Instant | 2x | Medium |
| Canary | No | Fast | 1x + small | High |
| Feature Flag | No | Instant | 1x | High |
1.2 Big Bang Deployment (Don't Do This)#
What it is
- Stop old version
- Deploy new version
- Start new version
- Hope it works
Timeline
v1.0 Running → Stop v1 → DOWNTIME → Start v1.1 → v1.1 Running
Problems
- Downtime during deployment
- All users hit new version at once
- If broken, ALL users affected
- Rollback requires another deployment
- High stress, high risk
When it's acceptable
- Development environment
- Non-critical internal tools
- Scheduled maintenance windows
- When other options truly impossible
For production services: AVOID.
1.3 Rolling Deployment#
What it is
- Gradually replace old instances with new
- One (or few) at a time
- Health checks before proceeding
- No downtime
Timeline (v1.0 → v1.1, one instance after another)
- Instance 1 — switched to v1.1 first
- Instance 2 — switched next
- Instance 3 — switched next
- Instance 4 — switched next
- Instance 5 — still on v1.0 until the end of the rollout
Traffic distribution during rollout
| Stage | v1.0 | v1.1 |
|---|---|---|
| Start | 100% | 0% |
| 1 of 5 done (20%) | 80% | 20% |
| 2 of 5 done (40%) | 60% | 40% |
| 3 of 5 done (60%) | 40% | 60% |
| 4 of 5 done (80%) | 20% | 80% |
| Complete | 0% | 100% |
Advantages
- No downtime
- Gradual risk exposure
- Can pause if issues detected
- Natural load balancing
Disadvantages
- Two versions running simultaneously
- Rollback is slow (reverse the process)
- Database must support both versions
- API must be backward compatible
Chapter 2: Blue-Green Deployment#
Trade-off: instant rollback, at what price?
Your team wants to undo a bad release in seconds, but finance won't fund twice the servers all year. Compare a rolling deployment and a blue-green deployment on rollback speed and cost. Which would you pick, and is there a third option?
One strong answer
- Rolling: about 1× the servers plus a small buffer, but rollback means reversing the rollout instance by instance, which is slow.
- Blue-green: rollback is one traffic switch back to the old environment, which is still running. The price is 2× the infrastructure.
- Third option: a canary costs about 1× plus a little, and backing out is fast because only a small slice of traffic ever reached the new version.
2.1 How Blue-Green Works#
%% title: Blue-green deployment architecture
flowchart TD
LB@{ icon: "sd:load-balancer", label: "Load Balancer" }
subgraph BLUE["BLUE (Current)"]
B1@{ icon: "sd:server", label: "App 1" }
B2@{ icon: "sd:server", label: "App 2" }
B3@{ icon: "sd:server", label: "App 3" }
end
subgraph GREEN["GREEN (New)"]
G1@{ icon: "sd:server", label: "App 1" }
G2@{ icon: "sd:server", label: "App 2" }
G3@{ icon: "sd:server", label: "App 3" }
end
LB -->|"switches between"| BLUE
LB -.->|"switches between"| GREEN
BLUE --> DB@{ icon: "sd:database", label: "Shared Database" }
GREEN --> DB
class LB,B1,B2,B3 edge
class G1,G2,G3 ok
class DB store- CURRENT STATE
- Blue: Running v1.0, receiving traffic
- Green: Idle (or running old version)
- DEPLOY NEW VERSION
- Blue: Still running v1.0, receiving traffic
- Green: Deploy v1.1, run smoke tests
- SWITCH TRAFFIC
- Blue: Running v1.0, NO traffic
- Green: Running v1.1, receiving ALL traffic
- VERIFY
- Monitor metrics, logs, errors
- If problems: Switch back to Blue (instant rollback)
- CLEANUP
- Blue becomes the standby for next deployment
2.2 Blue-Green Implementation#
# deployment/blue_green.py
"""
Blue-Green deployment orchestration.
This manages the switch between environments
and provides instant rollback capability.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from datetime import datetime, timezone
import asyncio
import logging
import uuid
logger = logging.getLogger(__name__)
class Environment(Enum):
BLUE = "blue"
GREEN = "green"
class DeploymentState(Enum):
IDLE = "idle"
DEPLOYING = "deploying"
TESTING = "testing"
SWITCHING = "switching"
VERIFYING = "verifying"
COMPLETE = "complete"
ROLLED_BACK = "rolled_back"
@dataclass
class DeploymentRecord:
"""Record of a deployment."""
id: str
version: str
started_at: datetime
completed_at: Optional[datetime]
state: DeploymentState
active_environment: Environment
previous_environment: Environment
deployed_by: str
rollback_reason: Optional[str] = None
class BlueGreenDeployer:
"""
Orchestrates blue-green deployments.
"""
def __init__(
self,
load_balancer,
blue_cluster,
green_cluster,
health_checker,
metrics_client
):
self.lb = load_balancer
self.blue = blue_cluster
self.green = green_cluster
self.health = health_checker
self.metrics = metrics_client
self.current_environment = None
self.current_deployment = None
async def deploy(
self,
version: str,
deployed_by: str,
skip_tests: bool = False
) -> DeploymentRecord:
"""
Execute a blue-green deployment.
"""
# Determine which environment to deploy to
if self.current_environment == Environment.BLUE:
target = Environment.GREEN
target_cluster = self.green
else:
target = Environment.BLUE
target_cluster = self.blue
deployment = DeploymentRecord(
id=str(uuid.uuid4()),
version=version,
started_at=datetime.now(timezone.utc),
completed_at=None,
state=DeploymentState.DEPLOYING,
active_environment=self.current_environment,
previous_environment=self.current_environment,
deployed_by=deployed_by
)
try:
# Step 1: Deploy to inactive environment
logger.info(f"Deploying {version} to {target.value}")
deployment.state = DeploymentState.DEPLOYING
await target_cluster.deploy(version)
# Step 2: Run health checks and tests
logger.info(f"Running health checks on {target.value}")
deployment.state = DeploymentState.TESTING
if not skip_tests:
health_result = await self.health.check_cluster(target_cluster)
if not health_result.healthy:
raise DeploymentError(f"Health check failed: {health_result.reason}")
# Run smoke tests
test_result = await self._run_smoke_tests(target_cluster)
if not test_result.passed:
raise DeploymentError(f"Smoke tests failed: {test_result.failures}")
# Step 3: Switch traffic
logger.info(f"Switching traffic to {target.value}")
deployment.state = DeploymentState.SWITCHING
await self.lb.switch_to(target)
# Step 4: Verify in production
logger.info("Verifying deployment")
deployment.state = DeploymentState.VERIFYING
# Wait and check metrics
await asyncio.sleep(30) # Give it 30 seconds
verification = await self._verify_deployment(version)
if not verification.success:
logger.warning(f"Verification failed: {verification.reason}")
await self.rollback(deployment, verification.reason)
return deployment
# Step 5: Complete
deployment.state = DeploymentState.COMPLETE
deployment.completed_at = datetime.now(timezone.utc)
deployment.active_environment = target
self.current_environment = target
self.current_deployment = deployment
logger.info(f"Deployment {deployment.id} complete")
return deployment
except Exception as e:
logger.error(f"Deployment failed: {e}")
await self.rollback(deployment, str(e))
raise
async def rollback(
self,
deployment: DeploymentRecord,
reason: str
):
"""
Rollback to previous environment.
This is INSTANT because the old environment is still running.
"""
logger.warning(f"Rolling back deployment {deployment.id}: {reason}")
deployment.state = DeploymentState.ROLLED_BACK
deployment.rollback_reason = reason
deployment.completed_at = datetime.now(timezone.utc)
# Switch traffic back to previous environment
await self.lb.switch_to(deployment.previous_environment)
# Record rollback metric
self.metrics.increment(
"deployments_rolled_back_total",
labels={"version": deployment.version, "reason": reason}
)
logger.info(f"Rollback complete. Traffic restored to {deployment.previous_environment.value}")
async def _run_smoke_tests(self, cluster) -> TestResult:
"""Run smoke tests against a cluster."""
tests = [
self._test_health_endpoint(cluster),
self._test_api_endpoint(cluster),
self._test_database_connectivity(cluster),
]
results = await asyncio.gather(*tests, return_exceptions=True)
failures = [r for r in results if isinstance(r, Exception) or not r.passed]
return TestResult(
passed=len(failures) == 0,
failures=[str(f) for f in failures]
)
async def _verify_deployment(self, version: str) -> VerificationResult:
"""Verify deployment is healthy in production."""
# Check error rate
error_rate = await self.metrics.query(
'sum(rate(http_requests_total{status=~"5.."}[1m])) / '
'sum(rate(http_requests_total[1m]))'
)
if error_rate > 0.01: # > 1% error rate
return VerificationResult(
success=False,
reason=f"Error rate too high: {error_rate:.2%}"
)
# Check latency
p99_latency = await self.metrics.query(
'histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[1m])) by (le))'
)
if p99_latency > 1.0: # > 1 second
return VerificationResult(
success=False,
reason=f"Latency too high: {p99_latency:.2f}s"
)
return VerificationResult(success=True, reason=None)2.3 Blue-Green Pros and Cons#
Advantages
- Instant rollback (just switch back)
- Full testing before switch
- Zero downtime
- Clean separation of environments
- Easy to understand
Disadvantages
- Double infrastructure cost
- Database must support both versions
- Long-running connections may be disrupted
- Not gradual (all-or-nothing switch)
- Requires careful state management
Best for
- Applications where instant rollback is critical
- Teams with mature CI/CD pipelines
- Services with clear version boundaries
- When you can afford 2x infrastructure
Not ideal for
- Services with lots of persistent connections
- Applications with complex database migrations
- Extremely high-traffic services (state issues)
- Teams without automation
Rolling and blue-green deployments
Q1/3 A rolling deployment of v1.1 is halfway done. Which requirement becomes non-negotiable at this point?
Answer & explanation
Answer: B
Mid-rollout, half the instances run v1.0 and half run v1.1, so the database and APIs must support both at once. Double infrastructure is the cost of blue-green, not rolling, which only needs a small buffer.
Q2/3 Ten minutes after switching traffic to green, errors spike. Why can blue-green roll back almost instantly?
Answer & explanation
Answer: C
The old environment is left running untouched, so rollback is just another traffic switch with no deploy at all. Rolling back a rolling deployment, by contrast, means reversing the process instance by instance.
Q3/3 Which service is the weakest fit for blue-green?
Answer & explanation
Answer: A
The switch is all-or-nothing, so long-running connections on the old side get disrupted when traffic flips. The other three are exactly the cases the lesson lists as best fits for blue-green.
Chapter 3: Canary Deployment#
3.1 How Canary Works#
Named after "canary in a coal mine" — test with a small group first.
%% title: Canary deployment
%% steps:
%% LB1 --> ST1, LB1 --> CA1: Stage 1: 1% of traffic goes to a single canary pod running v1.1
%% S1 --> S2: Watch it for 15 minutes before expanding
%% LB2 --> ST2, LB2 --> CA2: Stage 2: 10% of traffic to the canary
%% S2 --> S3, LB3 --> CA3: Stage 3: half the traffic, 5 pods of each version
%% S3 --> S4, LB4 --> ST4: Stage 4: v1.1 takes 100% and becomes the new stable version
flowchart TD
subgraph S1["STAGE 1: CANARY (1% traffic)"]
LB1@{ icon: "sd:load-balancer", label: "Load Balancer" } -->|"99%"| ST1@{ icon: "sd:servers", label: "STABLE v1.0<br/>10 pods" }
LB1 -->|"1%"| CA1["CANARY v1.1<br/>1 pod"]:::core
end
subgraph S2["STAGE 2: EXPAND (10% traffic)"]
LB2@{ icon: "sd:load-balancer", label: "Load Balancer" } -->|"90%"| ST2@{ icon: "sd:servers", label: "STABLE v1.0<br/>9 pods" }
LB2 -->|"10%"| CA2["CANARY v1.1<br/>1 pod"]:::core
end
subgraph S3["STAGE 3: EXPAND (50% traffic)"]
LB3@{ icon: "sd:load-balancer", label: "Load Balancer" } -->|"50%"| ST3@{ icon: "sd:servers", label: "STABLE v1.0<br/>5 pods" }
LB3 -->|"50%"| CA3["CANARY v1.1<br/>5 pods"]:::core
end
subgraph S4["STAGE 4: COMPLETE (100% traffic)"]
LB4@{ icon: "sd:load-balancer", label: "Load Balancer" } -->|"100%"| ST4@{ icon: "sd:servers", label: "STABLE v1.1<br/>10 pods" }
end
S1 -->|"Monitor for 15 minutes"| S2 --> S3 --> S4
class LB1,LB2,LB3,LB4 edge
class ST1,ST2,ST3 service
class ST4 ok- Stage 1: 1% of traffic goes to a single canary pod running v1.1
- Watch it for 15 minutes before expanding
- Stage 2: 10% of traffic to the canary
- Stage 3: half the traffic, 5 pods of each version
- Stage 4: v1.1 takes 100% and becomes the new stable version
The 1% stage needs weighted routing (a service mesh or a weighted load balancer). With plain round-robin, 1 canary pod among 10 stable pods would take 1 of 11 requests, about 9%.
Stage 1 — monitor for 15 minutes:
- Compare error rate: canary vs stable
- Compare latency: canary vs stable
- Check business metrics
v1.1 gets 1%, then 10%, 50% and 100% of traffic, 15 minutes per stage, while its error rate and p99 latency are compared with stable (at most 1 point more errors, 10% more latency). Passing every stage promotes it; a failed analysis sets the canary weight to 0 and deletes the canary pods.
Estimate: how much does a bad canary hurt?
Assume the service takes 2,000 requests a second. The canary gets 1% of traffic for a 15-minute stage, and the new version fails 10% of its requests. How many requests fail before the stage ends, and how many would fail if the same release went to everyone for 15 minutes?
One strong answer
- Canary: 1% of 2,000 = 20 requests a second × 900 s = 18,000 requests, of which ~1,800 fail.
- Everyone: 2,000 × 900 = 1.8 million requests, of which ~180,000 fail: 100 times more.
- The catch: across the whole fleet the error rate rises by only 0.1 percentage points, which a global alert might miss. That's why the canary is compared side by side with stable.
3.2 Canary Implementation#
# deployment/canary.py
"""
Canary deployment with automated analysis.
Gradually rolls out new version while comparing
metrics against the stable version.
"""
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime, timedelta, timezone
from enum import Enum
import asyncio
import logging
logger = logging.getLogger(__name__)
@dataclass
class CanaryStage:
"""A stage in the canary rollout."""
name: str
traffic_percentage: int
duration_minutes: int
auto_promote: bool = True # Auto-promote if metrics pass
@dataclass
class CanaryConfig:
"""Configuration for canary deployment."""
stages: List[CanaryStage] = field(default_factory=lambda: [
CanaryStage("canary-1pct", 1, 15),
CanaryStage("canary-10pct", 10, 15),
CanaryStage("canary-50pct", 50, 15),
CanaryStage("full-rollout", 100, 0),
])
# Thresholds for automated analysis
max_error_rate_increase: float = 0.01 # 1% absolute increase
max_latency_increase_pct: float = 0.10 # 10% relative increase
min_request_count: int = 100 # Minimum requests before analysis
@dataclass
class CanaryAnalysis:
"""Result of canary analysis."""
passed: bool
stable_error_rate: float
canary_error_rate: float
stable_p99_latency: float
canary_p99_latency: float
request_count: int
reason: Optional[str] = None
class CanaryDeployer:
"""
Orchestrates canary deployments with automated analysis.
"""
def __init__(
self,
kubernetes_client,
metrics_client,
traffic_manager,
config: CanaryConfig = None
):
self.k8s = kubernetes_client
self.metrics = metrics_client
self.traffic = traffic_manager
self.config = config or CanaryConfig()
async def deploy(
self,
version: str,
deployment_name: str,
namespace: str = "default"
) -> CanaryResult:
"""
Execute a canary deployment.
"""
logger.info(f"Starting canary deployment of {version}")
result = CanaryResult(
version=version,
started_at=datetime.now(timezone.utc),
stages_completed=[]
)
try:
# Deploy canary pods (initially with 0% traffic)
await self._deploy_canary_pods(version, deployment_name, namespace)
# Progress through stages
for stage in self.config.stages:
logger.info(f"Entering stage: {stage.name} ({stage.traffic_percentage}%)")
# Shift traffic
await self.traffic.set_canary_weight(
deployment_name,
stage.traffic_percentage
)
# Wait for stage duration
if stage.duration_minutes > 0:
await asyncio.sleep(stage.duration_minutes * 60)
# Analyze metrics
analysis = await self._analyze_canary(deployment_name)
if not analysis.passed:
logger.warning(f"Canary failed at {stage.name}: {analysis.reason}")
await self._rollback(deployment_name, namespace)
result.failed = True
result.failure_reason = analysis.reason
result.failure_stage = stage.name
return result
logger.info(f"Stage {stage.name} passed analysis")
result.stages_completed.append(stage.name)
# Promote canary to stable
await self._promote_canary(deployment_name, namespace)
result.completed_at = datetime.now(timezone.utc)
result.success = True
logger.info(f"Canary deployment complete: {version}")
return result
except Exception as e:
logger.error(f"Canary deployment failed: {e}")
await self._rollback(deployment_name, namespace)
result.failed = True
result.failure_reason = str(e)
return result
async def _analyze_canary(self, deployment_name: str) -> CanaryAnalysis:
"""
Compare canary metrics against stable.
"""
# Get error rates
stable_errors = await self.metrics.query(f'''
sum(rate(http_requests_total{{
deployment="{deployment_name}",
version="stable",
status=~"5.."
}}[5m])) /
sum(rate(http_requests_total{{
deployment="{deployment_name}",
version="stable"
}}[5m]))
''')
canary_errors = await self.metrics.query(f'''
sum(rate(http_requests_total{{
deployment="{deployment_name}",
version="canary",
status=~"5.."
}}[5m])) /
sum(rate(http_requests_total{{
deployment="{deployment_name}",
version="canary"
}}[5m]))
''')
# Get latencies
stable_latency = await self.metrics.query(f'''
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{{
deployment="{deployment_name}",
version="stable"
}}[5m])) by (le)
)
''')
canary_latency = await self.metrics.query(f'''
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{{
deployment="{deployment_name}",
version="canary"
}}[5m])) by (le)
)
''')
# Get request count
request_count = await self.metrics.query(f'''
sum(increase(http_requests_total{{
deployment="{deployment_name}",
version="canary"
}}[5m]))
''')
# Check thresholds
analysis = CanaryAnalysis(
passed=True,
stable_error_rate=stable_errors,
canary_error_rate=canary_errors,
stable_p99_latency=stable_latency,
canary_p99_latency=canary_latency,
request_count=int(request_count)
)
# Not enough traffic to analyze: fail closed, never promote on no
# evidence (alternatively, extend the stage and re-check)
if analysis.request_count < self.config.min_request_count:
logger.warning(f"Not enough requests for analysis: {analysis.request_count}")
analysis.passed = False
analysis.reason = (
f"Insufficient canary traffic ({analysis.request_count} < "
f"{self.config.min_request_count} requests)"
)
return analysis
# Check error rate
error_increase = canary_errors - stable_errors
if error_increase > self.config.max_error_rate_increase:
analysis.passed = False
analysis.reason = (
f"Error rate increased by {error_increase:.2%} "
f"(threshold: {self.config.max_error_rate_increase:.2%})"
)
return analysis
# Check latency
if stable_latency > 0:
latency_increase = (canary_latency - stable_latency) / stable_latency
if latency_increase > self.config.max_latency_increase_pct:
analysis.passed = False
analysis.reason = (
f"Latency increased by {latency_increase:.2%} "
f"(threshold: {self.config.max_latency_increase_pct:.2%})"
)
return analysis
return analysis
async def _rollback(self, deployment_name: str, namespace: str):
"""Rollback canary deployment."""
logger.info(f"Rolling back canary for {deployment_name}")
# Remove canary traffic
await self.traffic.set_canary_weight(deployment_name, 0)
# Delete canary pods
await self.k8s.delete_canary_deployment(deployment_name, namespace)
logger.info("Rollback complete")
async def _promote_canary(self, deployment_name: str, namespace: str):
"""Promote canary to stable."""
logger.info(f"Promoting canary to stable for {deployment_name}")
# Update stable deployment with canary version
await self.k8s.promote_canary(deployment_name, namespace)
# Remove canary traffic split
await self.traffic.remove_canary_route(deployment_name)
logger.info("Promotion complete")| Value | |
|---|---|
| Total traffic (assumed) | 2,000 requests/s |
| Stage 1 canary share | 1% → 20 requests/s |
| Requests in the 5-minute analysis window | 20 × 300 = 6,000, well above the 100-request minimum |
| Requests the canary serves in its 15-minute stage | 20 × 900 = 18,000 |
| Same release to everyone for 15 minutes | 2,000 × 900 = 1.8 million |
| A bug failing 10% of requests (assumed) | ~1,800 failures on the canary vs ~180,000 fleet-wide |
| Fleet-wide error rate during stage 1 | 10% × 1% = +0.1 points |
| Time to reach 100% if every stage passes | 3 stages × 15 minutes = 45 minutes |
Conclusion: the 1% stage cuts the damage from a bad release 100-fold and still has plenty of traffic to judge. But the same bug barely moves the fleet's overall error rate, so the analysis must compare canary against stable rather than wait for a global alert.
3.3 Canary vs Blue-Green#
| Aspect | Blue-Green | Canary |
|---|---|---|
| Traffic switch | All at once (100%) | Gradual (1% → 100%) |
| Risk exposure | All users at once | Limited users first |
| Rollback speed | Instant | Fast (seconds) |
| Infrastructure cost | 2x (the idle environment stays up for instant rollback) | 1x + small overhead |
| Complexity | Medium | High |
| Comparison testing | Before switch only | Side-by-side continuous |
| Best for | Quick full cutover | Gradual risk reduction |
When to use blue-green
- Need instant rollback capability
- Changes are well-tested
- Want clean environment separation
- Can afford 2x infrastructure
When to use canary
- Want to minimize blast radius
- Need production comparison metrics
- High-traffic services (can't risk all users)
- Automated analysis/rollback desired
Advanced: combine them
- Blue-Green for environments
- Canary for traffic within an environment
- Best of both worlds
Design it: ship today, release next week
The new checkout flow is finished, but product wants it live for 10% of users next Tuesday, and you want to turn it off in seconds if it misbehaves. Each user should see the same checkout every time they visit. How would you ship it?
One strong answer
- Deploy the code today behind a flag that's off. Deployment and release become separate steps.
- On Tuesday, enable it for 10% of users by hashing the flag key and user ID into a fixed bucket from 0 to 99, so each user always lands on the same side.
- If it misbehaves, turn the flag off. No deploy is needed, and the old path is still in the code. Remove the flag once it reaches 100%.
Chapter 4: Feature Flags#
4.1 Feature Flags Concept#
Decouple DEPLOYMENT from RELEASE.
| Flow | Outcome | |
|---|---|---|
| Traditional | Deploy code → Feature is live | Problem: Deploy is risky because features go live immediately |
| With Feature Flags | Deploy code → Feature is OFF; Turn on flag → Feature is live | Benefit: Deploy anytime, release when ready |
Feature flag types
- RELEASE FLAGS — Control feature rollout
- New checkout flow: OFF
- Enable for 10% of users
- Enable for beta users
- Enable for everyone
- EXPERIMENT FLAGS — A/B testing
- Button color: "blue" vs "green"
- Pricing display: "monthly" vs "annual"
- Measure conversion rates
- OPS FLAGS — Operational controls
- Enable/disable expensive feature under load
- Circuit breaker for external services
- Kill switch for problematic code
- PERMISSION FLAGS — User-specific features
- Premium features for paid users
- Beta access for selected users
- Admin features
4.2 Feature Flag Implementation#
# deployment/feature_flags.py
"""
Feature flag system for controlled rollouts.
Supports:
- Boolean flags (on/off)
- Percentage rollouts
- User targeting
- Environment targeting
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Callable
from enum import Enum
import hashlib
import logging
logger = logging.getLogger(__name__)
class RolloutStrategy(Enum):
ALL = "all" # Flag applies to everyone
NONE = "none" # Flag applies to no one
PERCENTAGE = "percentage" # Random percentage of users
USER_LIST = "user_list" # Specific users
ATTRIBUTE = "attribute" # Based on user attributes
@dataclass
class FeatureFlag:
"""A feature flag definition."""
key: str
name: str
description: str
# Targeting
strategy: RolloutStrategy
percentage: int = 0 # For PERCENTAGE strategy
user_list: List[str] = field(default_factory=list) # For USER_LIST
attribute_rules: List[Dict] = field(default_factory=list) # For ATTRIBUTE
# Metadata
owner: str = ""
created_at: datetime = None
expires_at: Optional[datetime] = None
# Default value
default_value: bool = False
# Variants (for A/B tests)
variants: Dict[str, Any] = field(default_factory=dict)
@dataclass
class EvaluationContext:
"""Context for evaluating a feature flag."""
user_id: Optional[str] = None
tenant_id: Optional[str] = None
environment: str = "production"
attributes: Dict[str, Any] = field(default_factory=dict)
class FeatureFlagService:
"""
Feature flag evaluation service.
"""
def __init__(self, flag_store, metrics_client):
self.store = flag_store
self.metrics = metrics_client
self._cache = {}
def is_enabled(
self,
flag_key: str,
context: EvaluationContext = None
) -> bool:
"""
Check if a feature flag is enabled.
Usage:
if feature_flags.is_enabled("new_checkout", context):
return new_checkout_flow()
else:
return old_checkout_flow()
"""
context = context or EvaluationContext()
try:
flag = self._get_flag(flag_key)
if flag is None:
logger.warning(f"Unknown flag: {flag_key}")
return False
# Check if expired
if flag.expires_at and datetime.now(timezone.utc) > flag.expires_at:
return flag.default_value
# Evaluate based on strategy
result = self._evaluate(flag, context)
# Record metric
self.metrics.increment(
"feature_flag_evaluation_total",
labels={
"flag": flag_key,
"result": str(result),
"strategy": flag.strategy.value
}
)
return result
except Exception as e:
logger.error(f"Error evaluating flag {flag_key}: {e}")
return False
def get_variant(
self,
flag_key: str,
context: EvaluationContext = None
) -> Optional[str]:
"""
Get variant for A/B test flags.
Usage:
variant = feature_flags.get_variant("button_color", context)
# Returns "blue" or "green" based on user bucketing
"""
context = context or EvaluationContext()
flag = self._get_flag(flag_key)
if flag is None or not flag.variants:
return None
# Consistent hashing to assign user to variant
bucket = self._get_bucket(flag_key, context.user_id)
# Distribute buckets across variants
cumulative = 0
for variant_name, percentage in flag.variants.items():
cumulative += percentage
if bucket < cumulative:
return variant_name
return list(flag.variants.keys())[0] # Default to first variant
def _evaluate(self, flag: FeatureFlag, context: EvaluationContext) -> bool:
"""Evaluate flag based on its strategy."""
if flag.strategy == RolloutStrategy.ALL:
return True
if flag.strategy == RolloutStrategy.NONE:
return False
if flag.strategy == RolloutStrategy.PERCENTAGE:
return self._evaluate_percentage(flag, context)
if flag.strategy == RolloutStrategy.USER_LIST:
return context.user_id in flag.user_list
if flag.strategy == RolloutStrategy.ATTRIBUTE:
return self._evaluate_attributes(flag, context)
return flag.default_value
def _evaluate_percentage(
self,
flag: FeatureFlag,
context: EvaluationContext
) -> bool:
"""
Percentage-based evaluation with consistent hashing.
Same user always gets same result for same flag.
"""
bucket = self._get_bucket(flag.key, context.user_id)
return bucket < flag.percentage
def _get_bucket(self, flag_key: str, user_id: str) -> int:
"""
Get consistent bucket (0-99) for user and flag.
Same user + flag = same bucket, always.
"""
if not user_id:
# Random for anonymous users (NOT sticky: pass a session or device ID for a stable assignment)
import random
return random.randint(0, 99)
# Hash user_id + flag_key for consistent bucketing
hash_input = f"{flag_key}:{user_id}"
hash_value = hashlib.md5(hash_input.encode()).hexdigest()
return int(hash_value[:8], 16) % 100
def _evaluate_attributes(
self,
flag: FeatureFlag,
context: EvaluationContext
) -> bool:
"""Evaluate attribute-based rules."""
for rule in flag.attribute_rules:
attribute = rule.get("attribute")
operator = rule.get("operator")
value = rule.get("value")
user_value = context.attributes.get(attribute)
if operator == "equals" and user_value == value:
return True
if operator == "in" and user_value in value:
return True
if operator == "greater_than" and user_value > value:
return True
return flag.default_value
def _get_flag(self, flag_key: str) -> Optional[FeatureFlag]:
"""Get flag from store with caching.
Note: this cache has no TTL, so flag changes (including kill switches)
would not propagate until restart. Real systems expire or refresh entries.
"""
if flag_key in self._cache:
return self._cache[flag_key]
flag = self.store.get(flag_key)
if flag:
self._cache[flag_key] = flag
return flag
# =============================================================================
# USAGE EXAMPLES
# =============================================================================
# Initialize
feature_flags = FeatureFlagService(flag_store, metrics)
# Simple boolean check
if feature_flags.is_enabled("new_checkout"):
process_with_new_checkout()
else:
process_with_old_checkout()
# With user context
context = EvaluationContext(
user_id="user-123",
tenant_id="tenant-456",
attributes={
"plan": "enterprise",
"country": "US"
}
)
if feature_flags.is_enabled("advanced_analytics", context):
show_advanced_analytics()
# A/B test
button_color = feature_flags.get_variant("button_color_test", context)
render_button(color=button_color)4.3 Feature Flag Best Practices#
1. Have a lifecycle
Flags should be temporary!
Lifecycle:
- Create flag (with owner and expiration)
- Roll out gradually
- Enable for 100%
- REMOVE FLAG from code
- Archive flag
Stale flags are tech debt.
2. Keep flags simple
Flags should gate features, not business logic.
- GOOD:
feature_flags.is_enabled("new_search") - BAD:
feature_flags.get_value("search_algorithm_config")
Complex configuration should be in config files, not flags.
3. Test both paths
Every flag creates a code branch. Test both.
def test_feature_enabled():
with flag_enabled("new_checkout"):
assert checkout() == expected_new
def test_feature_disabled():
with flag_disabled("new_checkout"):
assert checkout() == expected_old4. Have kill switches
Some flags should always exist for operational control.
Examples:
disable_expensive_queriesenable_read_only_modedisable_external_integrationsenable_maintenance_mode
These are not temporary — they're operational controls.
5. Monitor flag usage
Track:
- How often each flag is evaluated
- Which flags are always true/false (candidates for removal)
- Which flags are never evaluated (dead code)
- Errors in flag evaluation
Canary releases and feature flags
Q1/3 A canary at 1% has served 5,000 requests. Error rate is 0.4% vs 0.3% on stable, and canary p99 is 25% higher than stable. With the lesson's thresholds (1% absolute error increase, 10% relative latency increase), what happens?
Answer & explanation
Answer: D
Analysis checks error rate and latency separately; the error increase passes, but a 25% relative p99 increase breaks the 10% limit. 5,000 requests is well above the 100-request minimum, so there is enough data to act on.
Q2/3 You roll out
new_checkoutto 10% of users. A user refreshes ten times. How does the lesson's flag service make sure they see the same experience every time?Answer & explanation
Answer: C
Hashing
flag_key:user_idgives the same bucket for the same user every time, so the experience is stable with no stored state. A per-request random draw would flip users between old and new checkout on every refresh.Q3/3 Which flag should stay in the code permanently rather than being removed after rollout?
Answer & explanation
Answer: A
Kill switches are operational controls and are meant to exist permanently. Release and experiment flags that are fully rolled out or finished are stale tech debt and should be removed.
Part II: Advanced Topics#
Chapter 5: Database Migrations#
5.1 The Database Migration Problem#
Code deployments are (relatively) easy
- Deploy new code
- If broken, rollback to old code
- Done
Database changes are HARD
- Can't just "rollback" a migration
- Schema changes affect running code
- Data migrations can take hours
- Both old and new code must work with DB
- Mistakes can cause data loss
The key insight
During deployment, BOTH versions of code are running. Your database must support BOTH versions.
What breaks if you just rename the column?
You need users.name to become users.full_name. The plan: run ALTER TABLE users RENAME COLUMN name TO full_name, then do a rolling deploy of code that uses full_name. What breaks during the rollout, and what order of steps would avoid it?
One strong answer
- During the rollout both versions are live. Right after the rename, every old instance still queries
nameand fails; rename later and the new instances fail instead. - Avoid it by never breaking either version: add
full_nameand write to both; backfill it and switch reads to it; dropnameonly once no running code uses it. - Each step works with both old and new code, so each can be rolled back on its own.
5.2 Expand-Contract Pattern#
The safe way to make breaking schema changes.
Example: Rename column name to full_name
WRONG: Direct rename
- Deploy migration:
ALTER TABLE users RENAME COLUMN name TO full_name; - Deploy code that uses
full_name
Problem: During deployment, old code (using name) and new code (using full_name) are both running. One of them will break.
RIGHT: Expand-Contract (3-phase migration)
PHASE 1: EXPAND
- Add new column
- Write to both columns
- Read from old column
Migration:
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);Code:
# Write to both
UPDATE users SET name = $1, full_name = $1 WHERE id = $2
# Read from old
SELECT name FROM users WHERE id = $1Run the migration first (the column must exist before any code writes to it), then deploy the code. Both old and new code work.
PHASE 2: MIGRATE
- Backfill data
- Switch reads to new column
- Continue writing to both
Migration:
UPDATE users SET full_name = name WHERE full_name IS NULL;Code:
# Write to both (for safety)
UPDATE users SET name = $1, full_name = $1 WHERE id = $2
# Read from new
SELECT full_name FROM users WHERE id = $1Run the backfill first, then deploy the code that reads the new column (otherwise un-backfilled rows read as NULL).
PHASE 3: CONTRACT
- Stop writing to old column
- Remove old column
Code:
# Write to new only
UPDATE users SET full_name = $1 WHERE id = $2
# Read from new
SELECT full_name FROM users WHERE id = $1Migration (after all old code is gone):
ALTER TABLE users DROP COLUMN name;Timeline
| Original | Expand | Migrate | Contract |
|---|---|---|---|
name only |
name + full_name (write both) |
name + full_name (read new) |
full_name only |
5.3 Migration Implementation#
Note: the samples call fetch_one, as in the databases library (or a thin wrapper with that method). With asyncpg the equivalent is fetchrow, which returns a Record.
# deployment/migrations.py
"""
Safe database migration patterns.
Implements expand-contract for breaking changes.
"""
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
class MigrationPhase(Enum):
EXPAND = "expand"
MIGRATE = "migrate"
CONTRACT = "contract"
@dataclass
class Migration:
"""A database migration."""
version: str
name: str
phase: MigrationPhase
# SQL to run
up_sql: str
down_sql: str
# Validation
pre_check_sql: Optional[str] = None # Must pass before running
post_check_sql: Optional[str] = None # Must pass after running
# Safety
requires_lock: bool = False # Requires table lock
estimated_duration: str = "" # e.g., "5 minutes", "2 hours"
affects_writes: bool = False # Will block writes
class SafeMigrationRunner:
"""
Runs migrations safely with validation and rollback support.
"""
def __init__(self, db_connection, lock_manager):
self.db = db_connection
self.locks = lock_manager
async def run_migration(
self,
migration: Migration,
dry_run: bool = False
) -> MigrationResult:
"""
Run a migration with safety checks.
"""
logger.info(f"Starting migration: {migration.name}")
result = MigrationResult(
migration=migration,
started_at=datetime.now(timezone.utc)
)
try:
# Pre-check
if migration.pre_check_sql:
check_result = await self.db.fetch_one(migration.pre_check_sql)
if not check_result or not check_result[0]:
raise MigrationError("Pre-check failed")
# Acquire lock if needed
if migration.requires_lock:
await self.locks.acquire("migration_lock")
# Run migration
if not dry_run:
await self.db.execute(migration.up_sql)
else:
logger.info(f"DRY RUN: Would execute: {migration.up_sql}")
# Post-check
if migration.post_check_sql:
check_result = await self.db.fetch_one(migration.post_check_sql)
if not check_result or not check_result[0]:
raise MigrationError("Post-check failed, rolling back")
# Record success
await self._record_migration(migration)
result.success = True
result.completed_at = datetime.now(timezone.utc)
logger.info(f"Migration complete: {migration.name}")
return result
except Exception as e:
logger.error(f"Migration failed: {e}")
# Attempt rollback
if not dry_run:
try:
await self.db.execute(migration.down_sql)
logger.info("Rollback successful")
except Exception as rollback_error:
logger.error(f"Rollback failed: {rollback_error}")
result.success = False
result.error = str(e)
return result
finally:
if migration.requires_lock:
await self.locks.release("migration_lock")
# =============================================================================
# EXAMPLE: RENAME COLUMN MIGRATION SET
# =============================================================================
RENAME_COLUMN_MIGRATIONS = [
# Phase 1: Expand - Add new column
Migration(
version="2024_01_15_001",
name="add_full_name_column",
phase=MigrationPhase.EXPAND,
up_sql="""
ALTER TABLE users
ADD COLUMN full_name VARCHAR(255);
-- Trigger to keep columns in sync during transition
CREATE OR REPLACE FUNCTION sync_name_columns()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
-- Old code inserts only name; new code may set both
NEW.full_name := COALESCE(NEW.full_name, NEW.name);
NEW.name := COALESCE(NEW.name, NEW.full_name);
ELSIF NEW.name IS DISTINCT FROM OLD.name THEN
NEW.full_name := NEW.name;
ELSIF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.name := NEW.full_name;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_names
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_name_columns();
""",
down_sql="""
DROP TRIGGER IF EXISTS sync_names ON users;
DROP FUNCTION IF EXISTS sync_name_columns();
ALTER TABLE users DROP COLUMN IF EXISTS full_name;
""",
post_check_sql="""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'full_name'
);
"""
),
# Phase 2: Migrate - Backfill data
Migration(
version="2024_01_16_001",
name="backfill_full_name",
phase=MigrationPhase.MIGRATE,
up_sql="""
-- Backfill in batches to avoid locking
DO $$
DECLARE
batch_size INT := 10000;
updated INT;
BEGIN
LOOP
UPDATE users
SET full_name = name
WHERE full_name IS NULL
AND id IN (
SELECT id FROM users
WHERE full_name IS NULL
LIMIT batch_size
);
GET DIAGNOSTICS updated = ROW_COUNT;
EXIT WHEN updated = 0;
-- Commit each batch so its row locks are released
-- (PostgreSQL 11+; the DO block must not run inside
-- an explicit transaction, or it is all one transaction)
COMMIT;
-- Small pause between batches
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
""",
down_sql="-- No rollback needed for backfill",
post_check_sql="""
SELECT COUNT(*) = 0
FROM users
WHERE full_name IS NULL AND name IS NOT NULL;
""",
estimated_duration="30 minutes for 1M rows"
),
# Phase 3: Contract - Remove old column (after code fully migrated)
Migration(
version="2024_01_20_001",
name="remove_name_column",
phase=MigrationPhase.CONTRACT,
up_sql="""
DROP TRIGGER IF EXISTS sync_names ON users;
DROP FUNCTION IF EXISTS sync_name_columns();
ALTER TABLE users DROP COLUMN name;
""",
down_sql="""
ALTER TABLE users ADD COLUMN name VARCHAR(255);
UPDATE users SET name = full_name;
""",
pre_check_sql="""
-- Ensure no code is reading from 'name' column
-- This should be checked via application logs/metrics
SELECT true;
""",
requires_lock=True
),
]Chapter 6: Rollback Strategies#
6.1 Types of Rollback#
| Type | Speed | Risk |
|---|---|---|
| 1. Code rollback | Fast (seconds to minutes) | Low if code is backward compatible |
| 2. Feature rollback | Instant (seconds) | Very low |
| 3. Config rollback | Minutes | Low to medium |
| 4. Data rollback | Slow (minutes to hours) | High (potential data loss) |
1. Code rollback
Roll back to previous code version.
Methods:
kubectl rollout undo deployment/myapp- Deploy previous version tag
- Blue-green: switch back to previous environment
- Revert git commit, redeploy
2. Feature rollback
Turn off feature flag without code change.
Methods:
- Flip flag in feature flag service
- No deployment needed
- Instant effect
3. Config rollback
Revert configuration change.
Methods:
- Update ConfigMap/environment variables
- Rolling restart to pick up new config
- Some systems support hot reload
4. Data rollback
Revert data changes. THE HARDEST TYPE.
Methods:
- Restore from backup (slow, nuclear option)
- Run compensating transactions
- Point-in-time recovery
- Manual data fixes
BEST APPROACH: Avoid needing data rollback
- Test migrations thoroughly
- Use expand-contract pattern
- Make changes reversible
- Take backups before risky changes
6.2 Automated Rollback#
# deployment/auto_rollback.py
"""
Automated rollback based on metrics.
If deployment causes problems, automatically roll back.
"""
from dataclasses import dataclass
from typing import Optional
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@dataclass
class RollbackConfig:
"""Configuration for automated rollback."""
# Metric thresholds
max_error_rate: float = 0.05 # 5%
max_latency_p99_ms: int = 1000 # 1 second
min_success_rate: float = 0.95 # 95%
# Timing
observation_window_seconds: int = 300 # 5 minutes
check_interval_seconds: int = 30
# Behavior
auto_rollback_enabled: bool = True
require_confirmation: bool = False # For critical services
class AutomatedRollbackMonitor:
"""
Monitors deployments and triggers rollback if metrics degrade.
"""
def __init__(
self,
metrics_client,
deployer,
alerter,
config: RollbackConfig = None
):
self.metrics = metrics_client
self.deployer = deployer
self.alerter = alerter
self.config = config or RollbackConfig()
async def monitor_deployment(
self,
deployment_id: str,
baseline_metrics: dict
):
"""
Monitor a deployment and rollback if needed.
"""
logger.info(f"Starting rollback monitor for deployment {deployment_id}")
start_time = datetime.now(timezone.utc)
end_time = start_time + timedelta(seconds=self.config.observation_window_seconds)
while datetime.now(timezone.utc) < end_time:
await asyncio.sleep(self.config.check_interval_seconds)
# Check metrics
current_metrics = await self._get_current_metrics()
# Compare to baseline and thresholds
should_rollback, reason = self._should_rollback(
baseline_metrics,
current_metrics
)
if should_rollback:
logger.warning(f"Rollback triggered: {reason}")
if self.config.require_confirmation:
# Alert and wait for confirmation
await self.alerter.send_critical(
f"Deployment {deployment_id} may need rollback: {reason}. "
f"Reply 'ROLLBACK' to confirm."
)
# In a real system, this would wait for response
elif self.config.auto_rollback_enabled:
await self._execute_rollback(deployment_id, reason)
return RollbackResult(
rolled_back=True,
reason=reason,
metrics=current_metrics
)
logger.info(f"Deployment {deployment_id} passed monitoring period")
return RollbackResult(rolled_back=False, metrics=current_metrics)
def _should_rollback(
self,
baseline: dict,
current: dict
) -> tuple[bool, Optional[str]]:
"""
Determine if rollback is needed.
"""
# Check absolute thresholds
if current['error_rate'] > self.config.max_error_rate:
return True, f"Error rate {current['error_rate']:.2%} exceeds threshold"
if current['latency_p99_ms'] > self.config.max_latency_p99_ms:
return True, f"Latency {current['latency_p99_ms']}ms exceeds threshold"
if current['success_rate'] < self.config.min_success_rate:
return True, f"Success rate {current['success_rate']:.2%} below threshold"
# Check relative degradation vs baseline
if baseline:
error_increase = current['error_rate'] - baseline['error_rate']
if error_increase > 0.02: # 2% absolute increase
return True, f"Error rate increased by {error_increase:.2%}"
latency_increase_pct = (
(current['latency_p99_ms'] - baseline['latency_p99_ms']) /
baseline['latency_p99_ms']
) if baseline['latency_p99_ms'] > 0 else 0
if latency_increase_pct > 0.5: # 50% increase
return True, f"Latency increased by {latency_increase_pct:.0%}"
return False, None
async def _execute_rollback(self, deployment_id: str, reason: str):
"""Execute the rollback."""
logger.warning(f"Executing rollback for {deployment_id}")
# Send alert
await self.alerter.send_critical(
f"AUTO-ROLLBACK executed for deployment {deployment_id}. "
f"Reason: {reason}"
)
# Execute rollback
await self.deployer.rollback(deployment_id)
logger.info(f"Rollback complete for {deployment_id}")Migrations and rollback
Q1/3 Why does a direct
ALTER TABLE users RENAME COLUMN name TO full_namebreak a zero-downtime deploy?Answer & explanation
Answer: B
During a deploy both versions are live, so the database has to support both; after a rename, whichever version uses the other name fails. The data survives a rename; the break comes from the two code versions disagreeing on the schema.
Q2/3 In the expand-contract rename, when is it safe to run
ALTER TABLE users DROP COLUMN name?Answer & explanation
Answer: D
The contract phase drops the old column only after all old code is gone. In the migrate phase, code still writes both columns and older instances may still read
name, so dropping it then breaks them.Q3/3 A new recommendation widget, shipped behind a feature flag in today's release, is throwing errors. What is the fastest, lowest-risk way to stop the impact?
Answer & explanation
Answer: A
Feature rollback takes effect in seconds with no deploy, and it leaves the rest of the release in place. A code rollback also works, but it takes longer and reverts every other change that shipped with it.
Part III: Real-World Application#
Chapter 7: Case Studies#
7.1 How Amazon Deploys#
Scale
- Thousands of deployments per day
- Thousands of services
- Millions of servers
- Zero-downtime expectation
Key practices
- ONE-BOX DEPLOYMENT
- Deploy to single instance first
- Run tests and monitor
- If healthy, proceed to wider rollout
- If not, automatic rollback
- WAVE DEPLOYMENTS
- Deploy in waves of growing size (the exact sizes are each team's choice)
- Bake time between waves
- Automated health checks between waves
- Automatic rollback if metrics degrade
- REGION-BY-REGION
- Deploy to one region first
- Verify in production
- Then roll out to other regions
- Can quickly isolate issues
- FEATURE FLAGS
- Deploy code dark
- Enable features gradually
- A/B testing for new features
- Kill switches for risky features
- APOLLO + DYNAMIC CONFIG
- Apollo is Amazon's internal deployment engine (pipelines, version tracking, rollback)
- Config is separated from code and changed without a deploy
- Config changes are validated and rolled out gradually (the approach AWS later offered as AppConfig)
- Versioned config with rollback
Lessons
- Small changes are safer than big changes
- Automate everything
- Monitor everything
- Roll back fast
- Deploy often to reduce batch size
7.2 How Google Does It#
Philosophy: make rollbacks easy and deployments boring.
Key systems
- BORG/KUBERNETES
- Declarative deployment
- Automated rollout/rollback
- Health checks built-in
- Self-healing
- STAGED ROLLOUTS
- Canary first
- Automated analysis
- Progressive percentage increase
- Cross-cluster, cross-region
- RELEASE TRAINS
- Regular release cadence
- Features either make the train or wait
- Predictable release schedule
- Reduces urgency and risk
- BINARY VS CONFIG
- Binary releases: on a regular cadence (varies by team, from hourly builds to scheduled releases)
- Config changes: anytime, through their own rollout
- Many behaviour changes ship as config
- Reduces code deployment risk
- CANARYING EVERYTHING
- Code changes: Canary
- Config changes: Canary
- Capacity changes: Canary
- Everything that can break should go through a canary
SRE integration
- Release engineers build the release tooling (such as Rapid); SREs use it and build rollout automation for complex deployments
- Error budget gates deployments
- If SLO violated, deployment paused
- Reliability is a gating criterion
Chapter 8: Common Mistakes#
Mistake 1: Big Bang Deployments
Wrong:
- Save up changes for months
- Deploy everything at once
- Hope for the best
Problems:
- Huge blast radius if something breaks
- Hard to identify which change caused issues
- Rollback affects all changes
Right:
- Deploy frequently (daily or more)
- Small, incremental changes
- Easy to identify and rollback specific changes
Mistake 2: No Rollback Plan
Wrong: "We'll figure out rollback if we need it"
Problems:
- Panic when things break
- Unclear process under pressure
- Longer outages
Right:
- Test rollback BEFORE you need it
- Document rollback procedure
- Automate if possible
- Practice regularly
Mistake 3: Database Changes with Code
Wrong:
- Code and schema change in same deployment
- Migration runs during deployment
- If either fails, both rollback needed
Problems:
- Complex rollback
- Data migration might not be reversible
- Extended downtime
Right:
- Separate database changes from code changes
- Use expand-contract pattern
- Database changes first, then code
- Each can roll back independently
Mistake 4: Deploy on Friday
Wrong: "Let's ship this before the weekend!"
Problems:
- Reduced staffing if issues
- Tired people making decisions
- Customer impact over weekend
Right:
- Deploy early in the week
- Deploy early in the day
- Have full team available for monitoring
- Friday deployments only for emergencies
Mistake 5: No Monitoring During Deploy
Wrong:
- Deploy and walk away
- Assume success
- Find out about problems from customers
Right:
- Watch dashboards during deploy
- Have alerts set for deployment metrics
- Bake time with active monitoring
- Only declare success after observation period
Deployment practice at scale
Q1/3 A change passes every pre-production test. What does Amazon's one-box-then-waves approach give you that a single fleet-wide push does not?
Answer & explanation
Answer: C
Bake time and health checks at each wave limit the blast radius: problems show up on one box or a small percentage first. Waves are slower than one push, not faster, and they depend on automated checks rather than replacing them.
Q2/3 Your service has burned through its error budget for the month. Under the SRE practice described for Google, what happens to the next code release?
Answer & explanation
Answer: B
The error budget gates deployments: if the SLO is violated, deployment is paused, which makes reliability a gating criterion. A longer canary still spends budget you no longer have.
Q3/3 Two minutes after a deploy, every dashboard is green. When should you declare it a success?
Answer & explanation
Answer: A
Some failures only show up under real load over time, so success is declared only after the observation period. Waiting for customer tickets means finding out from customers, which is the anti-pattern itself.
Part IV: Interview Preparation#
Chapter 9: Interview Tips#
9.1 Deployment Discussion Framework#
When asked "How would you deploy changes to this system?":
- ASSESS RISK — "First, I'd assess the risk of this change. Is it a small bug fix or a major feature? Does it touch critical paths like payments? Does it require database changes?"
- CHOOSE STRATEGY — "Based on risk, I'd choose a deployment strategy:"
- Low risk: Rolling deployment with monitoring
- Medium risk: Canary with automated analysis
- High risk: Blue-green with feature flag
- EXPLAIN SAFETY MECHANISMS — "I'd ensure safety through:"
- Health checks before declaring success
- Metrics comparison (canary vs stable)
- Automated rollback if thresholds exceeded
- ADDRESS DATABASE CHANGES — "For database changes, I'd use expand-contract:"
- Phase 1 (expand): Add new column/table; write to both
- Phase 2 (migrate): Backfill data, then switch reads to the new schema
- Phase 3 (contract): Remove old schema once no code uses it
- DISCUSS ROLLBACK — "If something goes wrong:"
- Blue-green: Instant switch back
- Canary: Remove canary traffic
- Feature flag: Turn off flag
- Code: Rollback to previous version
9.2 Key Phrases#
On strategy selection
"I match deployment strategy to risk. For a new payment feature, I'd use canary with aggressive monitoring. For a UI tweak, a standard rolling deployment is fine."
On canary
"Canary lets me compare the new version against the stable version in production with real traffic. If error rate increases or latency degrades, I roll back before most users are affected."
On feature flags
"Feature flags decouple deployment from release. I can deploy code anytime, then enable the feature gradually. If something's wrong, turning off the flag is instant — no deployment needed."
On database migrations
"I never make breaking schema changes directly. I use expand-contract: add the new structure, migrate data, switch the code, then remove the old structure. This way, both old and new code work at every step."
On rollback
"The first thing I consider with any change is: how do I undo this? If I can't answer that, I need to rethink the approach. Rollback should be fast and safe."
Chapter 10: Practice Problems#
Practice · problem 1
Problem 1: E-commerce Checkout Deployment#
Scenario: You're deploying a new checkout flow. How would you do it safely?
Questions:
- What deployment strategy would you use?
- What metrics would you monitor?
- What's your rollback plan?
Click for approach
-
Strategy: Canary with feature flag
- Deploy code with feature flag OFF
- Enable for 1% of users, monitor
- Gradually increase to 100%
-
Metrics:
- Checkout completion rate
- Error rate
- Payment success rate
- Latency p99
- Cart abandonment rate
-
Rollback:
- Turn off feature flag (instant)
- If persistent issue, roll back code deployment
Practice · problem 2
Problem 2: Database Column Rename#
Scenario:
You need to rename a column from email to primary_email. The table has 10 million rows.
Questions:
- What's the safest approach?
- What are the migration phases?
- How do you handle the transition period?
Click for approach
-
Approach: Expand-Contract
- Never directly rename a column
- Three-phase migration
-
Phases:
- Expand: Add
primary_emailcolumn - Migrate: Backfill data, switch code to use new column
- Contract: Remove
emailcolumn
- Expand: Add
-
Transition:
- Code writes to BOTH columns
- Code reads from new column (with fallback to old)
- Once all code updated, remove old column
Chapter 11: Sample Interview Dialogue#
"You have a new feature that changes how we calculate shipping costs. How would you deploy it?"
"Let me think through this systematically. First, let me ask a few questions:
- How critical is shipping cost accuracy? Are mistakes costly?
- Is there a database component to this change?
- How much traffic does this code path see?"
"It's a high-volume checkout flow. Mistakes in shipping costs directly impact revenue. There's a new table for shipping zones."
"Given the high risk, I'd use a careful approach:
Phase 1: Database changes first"
- Create new
shipping_zonestable - Populate with data
- No code changes yet
- Verify data is correct
"Phase 2: Deploy code behind feature flag"
- Deploy new calculation code with flag OFF
- Both old and new code paths exist
- No user impact yet
"Phase 3: Shadow testing"
- Run new calculation alongside old
- Log both results, compare
- Look for discrepancies
- Fix any issues found
"Phase 4: Gradual rollout"
- Enable for internal users first
- Then 1% of customers
- Monitor:
- Shipping cost accuracy
- Checkout completion rate
- Customer complaints
- Revenue metrics
- Gradually increase to 100%
"What if you find a bug at 10% rollout?"
"I'd turn off the feature flag immediately. Users would get the old calculation. Since the old code is still there, this is instant.
Then I'd analyze the bug, fix it, and start the rollout again from shadow testing.
The beauty of feature flags is that rollback is just a configuration change — no deployment needed."
"Good. How would you handle a bug in the database changes?"
"That's trickier. Database changes are harder to roll back.
For the shipping_zones table:
- It's additive (new table), so it doesn't break existing code
- If the data is wrong, I can update it without schema changes
- If the schema itself is wrong, I'd create a new table with the correct schema, migrate data, then drop the old one
The key is making database changes backward compatible. I never put code that depends on new schema in the same release as the schema change."
Summary#
Deployment strategies
- Rolling: Gradual replacement, no downtime
- Blue-Green: Instant switch, instant rollback
- Canary: Gradual traffic shift with comparison
- Feature Flags: Decouple deployment from release
Safety mechanisms
- Health checks before proceeding
- Metric comparison (canary vs stable)
- Automated rollback on threshold breach
- Observation periods between stages
Database migrations
- Use expand-contract pattern
- Never make breaking changes directly
- Separate schema changes from code changes
- Both old and new code must work during transition
Rollback
- Code: Deploy previous version (fast)
- Feature: Turn off flag (instant)
- Config: Revert configuration (fast)
- Data: Restore from backup (slow, avoid)
Best practices
- Deploy frequently (reduces batch size)
- Deploy early in the week (more support)
- Monitor during deployment
- Have a rollback plan before deploying
- Automate everything possible
Key insight
The safest deployment is a small, incremental one with automated monitoring and fast rollback.
Further Reading#
Books:
- Book
"Continuous Delivery" by Jez Humble & David Farley
- Book
"Accelerate" by Nicole Forsgren, Jez Humble, Gene Kim
Articles:
- Read
Martin Fowler: "Blue Green Deployment"
- Book
Google SRE Book: Chapter on Release Engineering
- Read
AWS: "Blue/Green Deployments on AWS"
Tools:
- Read
Kubernetes: Rolling updates, readiness probes
- Read
Istio/Linkerd: Traffic shifting for canary
- Read
LaunchDarkly/Split: Feature flag management
- Read
Argo Rollouts: Progressive delivery for Kubernetes
You now have the trifecta: Define health (SLOs). See health (observability). Maintain health through change (deployments). Tomorrow, we ensure health under load.
this lesson in shorts · 2
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
Automated Canary Analysis at Netflix with Kayenta
Michael Graff, Chris Sanden · 2018- What
- Netflix's open-source system for judging canary releases automatically.
- Key idea
- Spinnaker deploys a new baseline cluster and a canary side by side, and Kayenta compares their metrics statistically to pass or fail the release.
- Why here
- The canary chapter of this lesson made rigorous: compare against a fresh baseline, not the old fleet, and let statistics decide.
Deployment At Scale: Story Behind Dream11's In-House Blue-Green Deployment Platform ‘OneClick’.
2022- What
- Dream11's automated blue-green deployment platform.
- Key idea
- OneClick creates the green stack sized from a predicted-concurrency model, then shifts traffic to it gradually instead of all at once.
- Why here
- Blue-green from this lesson with a twist: size the new stack for upcoming traffic and move traffic in steps.
Online migrations at scale
Jacqueline Xu · 2017- What
- How Stripe changes data models under a live payments API.
- Key idea
- Four steps: dual-write to old and new, change reads to the new path, change writes to the new path, then remove the old data.
- Why here
- The expand-and-contract database migration of this lesson, done with no downtime on critical data.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

