The pipeline from the previous lesson leaves bookings-api in production with a verified, signed and tested artefact. And yet, the moment Argo CD syncs the new digest, something happens that no amount of prior testing can entirely prevent: every user moves to the new version. If something slipped through — a query that degrades under real load, a race condition that only appears at a thousand requests per second, an integration with the payment gateway that fails with real data — everyone discovers it at once.
This lesson is about eliminating that leap. We will look at four ways to reduce the risk of releasing a version: the rolling update we already know, blue-green deployment, the canary and feature flags. And at the end we will apply what we have learned to the concrete case facing the Rutas Norte team: releasing the new version of bookings-api the week before the May bank-holiday weekend, when traffic multiplies tenfold for three days and a failure costs real sales.
Contents
- Why
RollingUpdateis not always enough - Blue-green: two versions, one switch
- Database migrations compatible with both versions
- Canary: releasing to a percentage of users
- Argo Rollouts: automating the canary with analysis
- Flagger as an alternative
- Feature flags: separating deployment from activation
- The Rutas Norte plan for the May bank-holiday weekend
- A comparison of the four strategies
- Why
RollingUpdate is not always enough
RollingUpdate is not always enoughIn 02-04 we saw RollingUpdate and with the configuration from 11-01 (maxSurge: 25%, maxUnavailable: 0) it works well: no downtime, no loss of capacity. Its limits are not about availability, but about control over risk.
| Limitation | Practical consequence at Rutas Norte |
|---|---|
| It mixes versions during the deployment | With 40 replicas and an 8-minute deployment, a user can hit the old version on one request and the new one on the next. If the API contract changed, the SPA breaks halfway through a purchase |
| It does not allow validation before committing | By the time you notice the problem, the new version is already serving a large share of users |
| The rollback is not instantaneous | rollout undo walks through 40 pods again: another 6-8 minutes with users affected |
| The criterion for progressing is the readiness probe | A pod that returns 200 on /ready but 500 on 30 % of purchases progresses just the same |
| There is no automatic analysis at all | Nobody is watching Grafana during a deployment at eleven o'clock on a Tuesday morning |
The fourth point is the central one. The readiness probe answers "can the process serve?", not "is this version working well?". Those are very different questions, and only the second matters when deciding whether to carry on.
graph LR
subgraph RU["RollingUpdate"]
direction TB
A1[100% v1] --> A2[75% v1 · 25% v2] --> A3[50/50] --> A4[100% v2]
A5[Criterion for progressing:<br/>readinessProbe] -.-> A3
end
subgraph BG["Blue-green"]
direction TB
B1[100% blue] --> B2[green deployed<br/>no real traffic] --> B3[100% green<br/>instant switch]
B4[Criterion: manual or<br/>automatic validation] -.-> B3
end
subgraph CN["Canary"]
direction TB
C1[100% v1] --> C2[95% v1 · 5% v2] --> C3[75/25] --> C4[100% v2]
C5[Criterion: analysis of<br/>real metrics] -.-> C3
end
- Blue-green: two versions, one switch
The idea is simple: two complete environments coexisting, one receiving traffic and one not. The Service points to one of the two via a label, and releasing means changing that label.
2.1. The manifests
# BLUE Deployment: the version serving today.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api-blue
namespace: rutas-norte-pro
spec:
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
rutasnorte.example/colour: blue
template:
metadata:
labels:
app.kubernetes.io/name: bookings-api
rutasnorte.example/colour: blue # ← the label that decides everything
rutasnorte.example/version: "2.7.0"
spec:
# ... identical to the Deployment in 11-01
containers:
- name: api
image: registry.rutasnorte.example/rutasnorte/bookings-api@sha256:3f9c1b7e5a04c2d8f61b93ae7c05d2419e8f6ab3c4d5e6f708192a3b4c5d6e7f
---
# GREEN Deployment: the candidate version.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api-green
namespace: rutas-norte-pro
spec:
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
rutasnorte.example/colour: green
template:
metadata:
labels:
app.kubernetes.io/name: bookings-api
rutasnorte.example/colour: green
rutasnorte.example/version: "2.8.0"
spec:
containers:
- name: api
image: registry.rutasnorte.example/rutasnorte/bookings-api@sha256:9a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f3029
---
# PRODUCTION Service: the one the Ingress uses. Its selector is the switch.
apiVersion: v1
kind: Service
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
selector:
app.kubernetes.io/name: bookings-api
rutasnorte.example/colour: blue # ← change here = release
ports:
- { name: http, port: 80, targetPort: http }
---
# PREVIEW Service: always points at green, with no user traffic.
apiVersion: v1
kind: Service
metadata:
name: bookings-api-preview
namespace: rutas-norte-pro
spec:
selector:
app.kubernetes.io/name: bookings-api
rutasnorte.example/colour: green
ports:
- { name: http, port: 80, targetPort: http }
---
# Preview Ingress: internal URL for validating the candidate.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bookings-api-preview
namespace: rutas-norte-pro
annotations:
# Corporate network only: this is not a public URL.
nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.0/24"
cert-manager.io/cluster-issuer: letsencrypt-production
spec:
ingressClassName: nginx
tls:
- hosts: ["api-preview.rutasnorte.example"]
secretName: api-preview-tls
rules:
- host: api-preview.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: bookings-api-preview, port: { name: http } }Each Deployment carries its own HPA, its PDB and its ServiceMonitor with the colour label, so that both versions can be observed separately in Grafana.
2.2. The complete procedure
# --- 1. Deploy the candidate to green (no user traffic) ---
kubectl -n rutas-norte-pro set image deploy/bookings-api-green \
api=registry.rutasnorte.example/rutasnorte/bookings-api@sha256:9a8b7c6d...
kubectl -n rutas-norte-pro rollout status deploy/bookings-api-green --timeout=10m
kubectl -n rutas-norte-pro scale deploy/bookings-api-green --replicas=8
# --- 2. Validate with no real traffic ---
# Smoke tests against the preview URL
npm run test:smoke -- --base-url https://api-preview.rutasnorte.example
# A gentle load test to rule out obvious performance regressions
k6 run --vus 40 --duration 5m tests/preview-load.js
# Manual check of the full purchase flow by product
# --- 3. Switch over (this is the release: it takes milliseconds) ---
kubectl -n rutas-norte-pro patch svc bookings-api \
-p '{"spec":{"selector":{"rutasnorte.example/colour":"green"}}}'
# --- 4. Watch ---
watch -n5 'curl -sG http://prometheus.rutas-norte.example/api/v1/query \
--data-urlencode "query=sum(rate(rutasnorte_requests_total{code=~\"5..\"}[2m]))
/ sum(rate(rutasnorte_requests_total[2m]))" | jq -r ".data.result[0].value[1]"'
# --- 5. Roll back, if needed (seconds) ---
kubectl -n rutas-norte-pro patch svc bookings-api \
-p '{"spec":{"selector":{"rutasnorte.example/colour":"blue"}}}'Step 5 is blue-green's selling point: rolling back is a label change. The previous version is still alive, warm, with its pods ready and its database connections open. You go back in under five seconds, rather than the eight minutes of a rollout undo.
2.3. What it costs
| Cost | Detail at Rutas Norte |
|---|---|
| Resources | During the validation window there is double the capacity deployed: 8 blue replicas + 8 green. At the May bank-holiday peak that would be 40 + 40, which is unaffordable |
| In-flight connections | On switching, connections already open against blue pods stay there until they close. It is not an outage, but it is a transition lasting a few seconds |
| Shared state | Both versions talk to the same database and the same cache. This is the serious problem, and it gets the next section |
| Manifest complexity | Everything duplicated: Deployment, HPA, PDB, ServiceMonitor. With Kustomize or Helm it is manageable, but it is more surface area |
| All or nothing | You switch to 100 %. If the failure only shows up under real load, every user suffers it for however many seconds it takes you to roll back |
The Rutas Norte rule: blue-green for large, risky changes outside peak season; never during the May bank-holiday weekend, because doubling 40 replicas is not viable.
- Database migrations compatible with both versions
Here is blue-green's real problem, and the canary's and RollingUpdate's as well. You can have two versions of the code at once. You cannot have two versions of the database schema.
If bookings-api 2.8.0 needs the seat column to be called seat_number, and the migration is applied at the moment of switching, the blue version stops working at that instant and rolling back becomes impossible.
The solution is the expand and contract pattern: every migration is split into steps, each one compatible with the previous version and the next.
graph LR V1[v2.7.0<br/>uses 'seat'] --> E[Deployment 1: EXPAND<br/>add column 'seat_number'<br/>+ trigger that syncs] E --> V2[Deployment 2<br/>v2.8.0 writes to both<br/>reads from 'seat_number'] V2 --> V3[Deployment 3<br/>v2.9.0 uses only 'seat_number'] V3 --> C[Deployment 4: CONTRACT<br/>drop 'seat'<br/>and the trigger]
3.1. The complete example
-- ===== Migration 1: EXPAND (compatible with 2.7.0) =====
-- Add, never rename or drop.
ALTER TABLE bookings ADD COLUMN seat_number integer;
-- Copy the history in batches, without locking the table.
UPDATE bookings SET seat_number = seat
WHERE seat_number IS NULL AND id IN (SELECT id FROM bookings WHERE seat_number IS NULL LIMIT 5000);
-- (repeated until exhausted; in production, via a batch Job)
-- Trigger that keeps both columns in sync while versions that
-- write to one or the other coexist.
CREATE OR REPLACE FUNCTION sync_seat_number() RETURNS trigger AS $$
BEGIN
IF NEW.seat_number IS NULL THEN NEW.seat_number := NEW.seat; END IF;
IF NEW.seat IS NULL THEN NEW.seat := NEW.seat_number; END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_seat_number
BEFORE INSERT OR UPDATE ON bookings
FOR EACH ROW EXECUTE FUNCTION sync_seat_number();-- ===== Migration 2: CONTRACT (only when NOBODY uses 'seat') =====
-- Weeks later, with the old version fully retired.
DROP TRIGGER trg_sync_seat_number ON bookings;
DROP FUNCTION sync_seat_number();
ALTER TABLE bookings DROP COLUMN seat;
ALTER TABLE bookings ALTER COLUMN seat_number SET NOT NULL;3.2. Practical rules
| Desired change | How to make it compatible |
|---|---|
| Rename a column | Add the new one, sync with a trigger, migrate the code, drop the old one afterwards |
| Drop a column | Stop using it in the code, wait two releases, drop it |
| Add a mandatory column | Add it nullable with a default, backfill, set NOT NULL in a later step |
| Change a column's type | New column with the new type, dual writes, migrate reads, drop the old one |
| Add an index | CREATE INDEX CONCURRENTLY so as not to lock the table |
| Change the meaning of a value | A distinct new value; never reinterpret an existing one |
The rule that sums it all up: the schema must always be one step ahead of the code and must never break the previous version. Each migration is applied in its own deployment, before the code that takes advantage of it. That is what, in 11-03, made the promotion pull request declare whether the migration was backwards compatible.
- Canary: releasing to a percentage of users
The canary solves what blue-green does not: instead of switching to 100 %, the new version is exposed to a small fraction of users, the real metrics are observed and the share is increased progressively.
4.1. The poor version: by replica count
With nothing more than a Service and two Deployments, traffic is split in proportion to the number of pods.
# 19 stable replicas + 1 canary ≈ 5 % of the traffic
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api-canary
namespace: rutas-norte-pro
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
rutasnorte.example/track: canary
template:
metadata:
labels:
app.kubernetes.io/name: bookings-api # the Service selects on this one
rutasnorte.example/track: canaryThe production Service selects only on app.kubernetes.io/name, so it sends traffic to both Deployments spread across the 20 pods.
It works, and on a cluster without an Ingress controller capable of weighted splitting it is a reasonable option. Its limits:
- The granularity depends on the replica count. With 4 replicas, the minimum is 20 %. Getting down to 5 % would need 19 stable replicas.
- The HPA interferes. If it scales the stable Deployment, the canary's percentage changes on its own, with nobody deciding it.
- There is no stickiness. A user alternates between versions request by request, which during a ticket purchase is a problem.
- You cannot segment. You cannot say "only Rutas Norte employees" or "only whoever has this header".
4.2. The good version: weights in ingress-nginx
ingress-nginx lets you declare a canary Ingress that shares a host with the main one and captures a percentage of the traffic.
# Main Ingress: unchanged from 11-01.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bookings-api
namespace: rutas-norte-pro
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
spec:
ingressClassName: nginx
tls:
- hosts: ["api.rutasnorte.example"]
secretName: api-rutasnorte-tls
rules:
- host: api.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: bookings-api-stable, port: { name: http } }
---
# CANARY Ingress: same host, different backend, with a weight.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bookings-api-canary
namespace: rutas-norte-pro
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
# Enables canary mode for this Ingress.
nginx.ingress.kubernetes.io/canary: "true"
# Percentage of traffic that goes to the canary.
nginx.ingress.kubernetes.io/canary-weight: "5"
# Escape header: forces the canary for internal testing,
# regardless of the weight.
nginx.ingress.kubernetes.io/canary-by-header: "X-Rutasnorte-Canary"
nginx.ingress.kubernetes.io/canary-by-header-value: "always"
# Cookie: once assigned, the user stays on their version.
nginx.ingress.kubernetes.io/canary-by-cookie: "rutasnorte_canary"
spec:
ingressClassName: nginx
tls:
- hosts: ["api.rutasnorte.example"]
secretName: api-rutasnorte-tls
rules:
- host: api.rutasnorte.example # ← SAME host as the main one
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: bookings-api-canary, port: { name: http } }The order of precedence of the annotations matters and is not always obvious:
canary-by-headerwith the exact value → always to the canary (ornever→ never).canary-by-cookie→ according to the cookie's value.canary-weight→ a random decision based on the stated percentage.
The header is what makes it possible to validate in production without exposing anybody:
# Internal validation aimed at the canary, with 0 % weight
curl -H 'X-Rutasnorte-Canary: always' https://api.rutasnorte.example/schedules?line=BIL-SANManual weight progression:
for WEIGHT in 5 10 25 50 100; do
kubectl -n rutas-norte-pro annotate ingress bookings-api-canary \
nginx.ingress.kubernetes.io/canary-weight="$WEIGHT" --overwrite
echo "Weight at $WEIGHT %. Watching for 10 minutes..."
sleep 600
# here somebody looks at Grafana and decides whether to carry on
doneAnd this loop, with its sleep 600 and its "here somebody looks at Grafana", is exactly what needs automating.
- Argo Rollouts: automating the canary with analysis
Argo Rollouts replaces the Deployment with a Rollout resource that knows how to progress in steps, query metrics and decide for itself whether to continue or abort.
sequenceDiagram participant G as Git (new digest) participant R as Rollout controller participant N as ingress-nginx participant P as Prometheus G->>R: Changes the Rollout's image R->>R: Creates a canary ReplicaSet R->>N: setWeight 5 % R->>P: AnalysisRun (error rate, p95) P-->>R: 0.04 % errors · p95 210 ms → pass R->>N: setWeight 25 % R->>P: AnalysisRun P-->>R: 0.9 % errors → FAIL R->>N: setWeight 0 % (aborts) R->>R: Scales the canary to zero, the stable one stays intact
5.1. The Rollout resource
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
# replicas is still governed by the HPA, which now points at the Rollout.
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: bookings-api
# 'template' is identical to the Deployment's in 11-01: same probes,
# same securityContext, same resources, same topologySpread.
template:
metadata:
labels:
app.kubernetes.io/name: bookings-api
spec:
serviceAccountName: bookings-api
containers:
- name: api
image: registry.rutasnorte.example/rutasnorte/bookings-api@sha256:9a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f3029
# ... the rest identical to 11-01
strategy:
canary:
canaryService: bookings-api-canary # Services that the controller
stableService: bookings-api-stable # relabels automatically
trafficRouting:
nginx:
stableIngress: bookings-api # creates and manages the canary Ingress
# Analysis that runs in parallel with the WHOLE progression.
analysis:
templates:
- templateName: bookings-api-analysis
startingStep: 1 # starts once the first weight is reached
args:
- name: canary-service
value: bookings-api-canary
steps:
- setWeight: 5
- pause: { duration: 10m } # 10 min with 5 % of the traffic
- setWeight: 25
- pause: { duration: 15m }
- setWeight: 50
- pause: { duration: 20m }
- setWeight: 75
- pause: { duration: 10m }
# No duration: waits for explicit human approval before 100 %.
- pause: {}
# Safety windows
scaleDownDelaySeconds: 600 # the previous version stays alive 10 min after promoting
abortScaleDownDelaySeconds: 30
maxSurge: "25%"
maxUnavailable: 0That final pause: {} with no duration is a deliberate Rutas Norte decision: the automatic analysis can take the canary up to 75 %, but the final jump to 100 % is confirmed by a person. It is a balance between automating the watching and keeping a human control point.
5.2. The AnalysisTemplate against Prometheus
This is where the real decision is made. The queries are PromQL over the metrics that bookings-api has exposed since 07-03.
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: bookings-api-analysis
namespace: rutas-norte-pro
spec:
args:
- name: canary-service
metrics:
# --- 1. 5xx error rate ---
- name: error-rate
interval: 1m
# Tolerates 2 isolated bad readings; 3 in a row abort.
failureLimit: 2
consecutiveErrorLimit: 3
# Does not judge during the first 2 min: the pods are warming up.
initialDelay: 2m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_requests_total{
service="{{args.canary-service}}", code=~"5.."
}[2m]))
/
sum(rate(rutasnorte_requests_total{
service="{{args.canary-service}}"
}[2m]))
# Passes if the result is < 0.5 %. NaN (no traffic) is accepted too:
# with no requests there is no evidence of failure.
successCondition: result[0] < 0.005 || isNaN(result[0])
# --- 2. 95th percentile of latency ---
- name: latency-p95
interval: 1m
failureLimit: 2
initialDelay: 2m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
histogram_quantile(0.95,
sum by (le) (rate(rutasnorte_request_duration_seconds_bucket{
service="{{args.canary-service}}"
}[2m]))
)
successCondition: result[0] < 0.35 || isNaN(result[0]) # 350 ms
# --- 3. Relative comparison against the stable version ---
# An absolute threshold can miss a regression if the system was
# already slow. This metric requires the canary to be no more than
# 20 % worse than the stable version.
- name: relative-latency
interval: 2m
failureLimit: 1
initialDelay: 5m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
(
histogram_quantile(0.95, sum by (le) (rate(
rutasnorte_request_duration_seconds_bucket{service="bookings-api-canary"}[3m])))
/
histogram_quantile(0.95, sum by (le) (rate(
rutasnorte_request_duration_seconds_bucket{service="bookings-api-stable"}[3m])))
)
successCondition: result[0] < 1.2 || isNaN(result[0])
# --- 4. Business metric: the one that really matters ---
# A version can have 0 errors and perfect latency, and have broken
# the "confirm booking" button. This catches that.
- name: conversion-rate
interval: 5m
failureLimit: 1
initialDelay: 10m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_bookings_confirmed_total{
service="bookings-api-canary"}[5m]))
/
sum(rate(rutasnorte_bookings_started_total{
service="bookings-api-canary"}[5m]))
successCondition: result[0] > 0.55 || isNaN(result[0])The fourth metric is the most valuable and the one most teams forget. A deployment can be flawless on every technical indicator and be losing half the sales.
5.3. Automatic promotion and abort
Automatic promotion: if every metric meets its condition throughout all the steps, the Rollout climbs the weight ladder on its own up to the final pause: {}.
Automatic abort: as soon as one metric exceeds its failureLimit, the controller sets the canary weight to 0 immediately, scales the canary ReplicaSet to zero and leaves the stable one untouched. There is nothing to roll back, because the stable version never stopped serving the bulk of the traffic.
Name: bookings-api
Namespace: rutas-norte-pro
Status: ✖ Degraded
Message: RolloutAborted: metric "error-rate" assessed Failed
Strategy: Canary
Step: 2/9
SetWeight: 0
ActualWeight: 0
Images: bookings-api@sha256:3f9c1b7e (stable)
bookings-api@sha256:9a8b7c6d (canary)
Replicas:
Desired: 8
Current: 8
Updated: 0
Ready: 8
Available: 8
NAME KIND STATUS AGE INFO
⟳ bookings-api Rollout ✖ Degraded 41d
├──# revision:18
│ └──⧉ bookings-api-6d9f7b4c8 ReplicaSet • ScaledDown 14m canary
│ └──⊞ bookings-api-analysis-18 AnalysisRun ✖ Failed 12m
│ ├──📊 error-rate Measurement ✖ Failed 0.021
│ └──📊 latency-p95 Measurement ✔ Successful 0.198
└──# revision:17
└──⧉ bookings-api-7d4b8c9f5 ReplicaSet ✔ Healthy 14d stableThat output is a complete diagnosis in itself: it aborted at step 2, because of error-rate at 2.1 % against a threshold of 0.5 %, with latency perfectly fine. The next place to look is the logs of the canary pods before they disappear (scaleDownDelaySeconds keeps them around for a while precisely for that).
Manual control commands:
kubectl argo rollouts promote bookings-api -n rutas-norte-pro # advance one step
kubectl argo rollouts promote bookings-api -n rutas-norte-pro --full # jump to 100 %
kubectl argo rollouts abort bookings-api -n rutas-norte-pro # abort now
kubectl argo rollouts undo bookings-api -n rutas-norte-pro --to-revision=175.4. The monitoring interface
This brings up a web interface on localhost:3100 showing the ladder of steps, the current weight, the result of each measurement and the promote and abort buttons. At Rutas Norte it is projected on a screen during peak-season deployments: it is not essential, but it turns an opaque operation into something the whole team understands at a glance.
5.5. Living alongside Argo CD
A Rollout is a CRD, so Argo CD syncs it like any other resource. Two necessary adjustments:
# In the Argo CD Application
spec:
ignoreDifferences:
- group: argoproj.io
kind: Rollout
jsonPointers:
- /spec/replicas # governed by the HPA
syncPolicy:
automated:
selfHeal: true
# The Rollouts controller modifies the Services during the
# progression; without this, selfHeal would fight against it.
syncOptions:
- RespectIgnoreDifferences=trueAnd the HPA must point at the Rollout, not at a Deployment:
- Flagger as an alternative
Flagger solves the same problem with a different philosophy: instead of replacing the Deployment, it wraps it. You keep the Deployment you always had and add a Canary resource that manages it.
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment # ← the original Deployment, untouched
name: bookings-api
autoscalerRef:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
name: bookings-api
provider: nginx
service:
port: 80
targetPort: 8080
analysis:
interval: 1m
threshold: 5 # failed measurements before aborting
maxWeight: 50
stepWeight: 10
metrics:
- name: request-success-rate
thresholdRange: { min: 99.5 }
interval: 2m
- name: request-duration
thresholdRange: { max: 350 }
interval: 2m
webhooks:
- name: load-tests
type: rollout
url: http://flagger-loadtester.platform/
metadata:
cmd: "hey -z 2m -q 10 -c 2 http://bookings-api-canary.rutas-norte-pro/schedules"| Aspect | Argo Rollouts | Flagger |
|---|---|---|
| Workload resource | Replaces the Deployment with a Rollout |
Keeps the Deployment and wraps it |
| Migrating from a Deployment | Change kind and adjust the HPA and Argo CD |
Add a Canary, without touching what exists |
| Graphical interface | Yes, its own and very good | None of its own; you use Grafana |
| Dedicated CLI | kubectl argo rollouts, very complete |
kubectl describe canary |
| Strategies | Canary, blue-green, with or without a mesh | Canary, blue-green, A/B, traffic mirroring |
| Metrics | Completely free-form AnalysisTemplate |
Predefined metrics + custom ones |
| Load generation | External | Built in (loadtester) |
| Fits better with | The Argo ecosystem (Argo CD, Workflows) | The Flux ecosystem |
| Fine manual control | Very good (promote, abort, steps) |
More automatic, less intervention |
There is no universal answer. Rutas Norte chose Argo Rollouts for consistency with Argo CD (10-05), for the CLI and the interface, and for the complete freedom of the AnalysisTemplate, which was needed for the conversion metric. A team using Flux would choose Flagger by the same logic.
- Feature flags: separating deployment from activation
Everything above controls which version of the code is running. Feature flags control something different: what behaviour the code that is already running has.
// bookings-api: the feature ships deployed but switched off.
app.post('/bookings', async (req, res) => {
const useNewEngine = await flags.enabled('pricing-engine-v2', {
userId: req.user.id,
percentage: 5, // progressive rollout by user
allowList: ['rutasnorte-employees']
});
const price = useNewEngine
? await pricingEngineV2.calculate(req.body)
: await pricingEngineV1.calculate(req.body);
// Metric labelled by flag: lets you compare both paths
// in Grafana without deploying anything different.
metrics.priceCalculated.inc({ engine: useNewEngine ? 'v2' : 'v1' });
...
});| Aspect | Canary | Feature flag |
|---|---|---|
| Unit of control | The complete artefact version | One specific feature |
| Audience granularity | Percentage of requests, by header or cookie | By user, plan, region, or any attribute |
| Activation speed | Minutes (weight progression) | Seconds (a configuration change) |
| Deactivation speed | Seconds (abort) | Seconds, and without touching the cluster |
| Who can operate it | Platform or development | Product, support, the business |
| Cost | Temporary double capacity | Complexity in the code |
| Debt it creates | None | Dead branches if they are not cleaned up |
When the flag beats the canary:
- The feature is a business one, not a technical one: the person deciding to switch it on is product, not engineering.
- It has to be switched on for a specific segment (corporate customers, one region, internal staff), not for a random percentage.
- The change must be switchable off in seconds by somebody in support at three in the morning, without deploying.
- You want to run an A/B test and measure conversion over weeks.
- The change is large and you want to merge it into
mainin pieces without releasing anything.
When the canary is better:
- The change is cross-cutting: a new version of a library, a change of base image, an internal refactoring, a runtime upgrade. There is no "if" to put anywhere.
- The risk is about performance or resource consumption, not functional behaviour.
- You do not want dead code living alongside everything else in production.
They combine very well: canary to release the artefact safely, flags to switch the feature on afterwards, for whatever audience product decides.
A warning about the debt: every flag duplicates a code path and therefore duplicates what has to be tested. The Rutas Norte rule is that every flag is born with an expiry date noted in the code, and there is a quarterly review that removes the ones that have served their purpose.
- The Rutas Norte plan for the May bank-holiday weekend
The real situation: bookings-api 2.8.0 includes the new dynamic pricing engine, which is the feature the company is counting on to increase revenue during peak season. It has to be released the week before the May bank-holiday weekend, the worst possible time to get it wrong.
8.1. Constraints
| Constraint | Implication |
|---|---|
| The long weekend starts on Friday 1 May | Change freeze from Wednesday the 29th at 18:00 |
| Traffic multiplies tenfold for three days | Capacity cannot be doubled: blue-green is ruled out |
| The pricing engine affects conversion | A business metric is needed, not just a technical one |
There is a schema migration (pricing_rules table) |
It must be expand and contract, and compatible with 2.7.0 |
| A rollback must be immediate | Any irreversible change is ruled out |
8.2. The calendar
| Moment | Action | Criterion for continuing |
|---|---|---|
| Monday 20th, 10:00 | Deployment 1: expansion migration (new tables, unused) | Migration applied, bookings-api 2.7.0 with no change in its metrics |
| Monday 20th, 16:00 | Deployment 2: Rollout of 2.8.0 with the v2 engine switched off by flag |
Canary analysis passing at every step |
| Tuesday 21st | 2.8.0 at 100 %, v2 engine off | 24 h with no alerts and no latency regression |
| Wednesday 22nd, 10:00 | Flag enabled for employees (allow list) | Functional validation by product: prices correct |
| Wednesday 22nd, 16:00 | Flag at 1 % of real users | Segment conversion ≥ 55 %, no complaints to support |
| Thursday 23rd | Flag at 10 % | Conversion and average basket comparable or better; no attributable 5xx errors |
| Friday 24th | Flag at 50 % | Same criteria, at greater volume |
| Monday 27th, 10:00 | Flag at 100 % | Joint decision by product and platform |
| Wednesday 29th, 18:00 | Freeze: no changes until Tuesday 5 May | — |
The important thing about this plan is that it separates two different risks: the technical risk of the new version (canary, Monday and Tuesday) and the business risk of the pricing engine (flag, Wednesday to Monday). Mixing them would have made it impossible to know which of the two was causing a problem.
8.3. Success criteria and abort thresholds
# AnalysisTemplate specific to this release, with tighter thresholds
# than usual because peak season is so close.
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: may-bank-holiday-analysis
namespace: rutas-norte-pro
spec:
metrics:
- name: error-rate
interval: 30s
failureLimit: 1 # minimal tolerance: one bad reading aborts
initialDelay: 2m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_requests_total{service="bookings-api-canary",code=~"5.."}[2m]))
/ sum(rate(rutasnorte_requests_total{service="bookings-api-canary"}[2m]))
successCondition: result[0] < 0.003 || isNaN(result[0]) # 0.3 %
- name: latency-p95
interval: 30s
failureLimit: 1
initialDelay: 2m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
histogram_quantile(0.95, sum by (le) (rate(
rutasnorte_request_duration_seconds_bucket{service="bookings-api-canary"}[2m])))
successCondition: result[0] < 0.30 || isNaN(result[0]) # 300 ms
- name: payment-gateway-errors
interval: 1m
failureLimit: 1
initialDelay: 5m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_payments_failed_total{service="bookings-api-canary"}[5m]))
/ sum(rate(rutasnorte_payment_attempts_total{service="bookings-api-canary"}[5m]))
successCondition: result[0] < 0.02 || isNaN(result[0]) # 2 %
- name: conversion
interval: 5m
failureLimit: 1
initialDelay: 15m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_bookings_confirmed_total{service="bookings-api-canary"}[10m]))
/ sum(rate(rutasnorte_bookings_started_total{service="bookings-api-canary"}[10m]))
successCondition: result[0] > 0.55 || isNaN(result[0])| Success criterion | Threshold | Abort threshold |
|---|---|---|
| 5xx errors | < 0.1 % | ≥ 0.3 % on one reading |
| p95 latency | < 250 ms | ≥ 300 ms on one reading |
| Payment gateway failures | < 1 % | ≥ 2 % |
| Booking conversion | ≥ 60 % | < 55 % |
| Canary pod restarts | 0 | ≥ 1 |
| New alerts in Alertmanager | 0 | Any of high severity |
8.4. The rollback plan
Three levels, from fastest to slowest:
- Flag switched off (5 seconds, no deployment): disables the v2 pricing engine. It covers 80 % of the risks of this release. Support can do it without calling anyone.
- Abort the
Rollout(10 seconds):kubectl argo rollouts abort bookings-api. It returns all traffic to the stable version, which never stopped serving. - Revert in Git (4 minutes):
git revertof the promotion commit inoverlays/proand an Argo CD sync. This is what leaves the system consistent and is always done after level 1 or 2.
The expansion migration is not reverted: the new tables stay there, unused, causing no trouble. Their contraction is scheduled for June, once peak season is over.
8.5. What actually happened
At the canary's second step (25 % weight), the analysis aborted on latency-p95 at 340 ms. The cause: the v2 pricing engine was querying pricing_rules with no index on (line, time_slot). It was caught in eleven minutes, affected 25 % of the traffic for under two minutes and produced no customer-facing incident. The index was added with CREATE INDEX CONCURRENTLY, the canary was repeated that same afternoon and it passed cleanly.
That is exactly the value of everything above: a problem that with RollingUpdate would have been a severity-2 incident in the middle of the May bank-holiday weekend was resolved as an afternoon's work.
- A comparison of the four strategies
| Criterion | Rolling | Blue-green | Canary | Flags |
|---|---|---|---|---|
| Exposure risk | High: reaches 100 % unvalidated | Medium: all or nothing on switching | Low: a small percentage first | Very low: a chosen segment |
| Resource cost | Low (+25 % temporarily) | High (×2 during the window) | Medium (+10-25 %) | None |
| Implementation complexity | None, it comes as standard | Low: two Deployments and a selector | Medium-high: a controller and analysis | Medium: a flag service and discipline |
| Rollback speed | Slow: minutes | Fast: seconds | Immediate: abort | Immediate: switch off |
| Validation with real traffic before committing | No | Synthetic only | Yes | Yes, segmented |
| Automating the decision | No | Difficult | Yes, with metrics | Manual, or by experiment |
| Versions coexisting | Yes, uncontrolled | No (a clean jump) | Yes, controlled | Yes, in the same process |
| Demands on migrations | Compatible | Compatible | Compatible | Compatible |
| Debt it leaves | None | Duplicated manifests | Analysis configuration | Dead code if not cleaned up |
| When to use it | Small, routine changes | Large changes outside peak season | Risky releases with reliable metrics | Business features and A/B tests |
The practical choice at Rutas Norte:
- Rolling for
web-storeand for configuration changes: low risk, the complexity does not pay off. - Canary with Argo Rollouts for every
bookings-apirelease: it is the critical component and it has quality metrics. - Blue-green for large infrastructure migrations (such as the major PostgreSQL version change in 11-02), and only outside peak season.
- Flags for every user-visible feature, always, on top of any of the above.
Common Mistakes and Tips
- Doing blue-green with an incompatible schema migration. The rollback, which was the entire argument for the strategy, stops being possible the instant the migration is applied. Expand and contract, always.
- Absolute analysis thresholds and nothing else. If the system is already slow, a p95 of 340 ms can pass a 350 ms threshold while being a 70 % regression. Always add a relative metric against the stable version.
- Forgetting
isNaN(result[0])in the conditions. At 5 % weight and low traffic, a query can returnNaNand the analysis fails without there being any real problem. - A canary with no business metric. Every technical indicator perfect and conversion on the floor is a real and frequent scenario. The business metric is what justifies the canary.
- Analysing from the very first second. Freshly started pods have a cold cache and an unwarmed JIT. Without
initialDelay, you abort on false positives systematically. - Weights by replica count with an active HPA. Autoscaling changes the percentage with nobody deciding it. If there is an HPA, use weighted splitting at the Ingress.
- Eternal flags. Every flag with no expiry duplicates code paths forever. An expiry date when it is created and a quarterly review.
- Tip: rehearse the canary with a version identical to the current one. Deploying the same digest as a canary validates the whole machinery with no risk at all, and uncovers badly calibrated thresholds before they matter.
- Tip: leave the previous version alive for a while after promoting (
scaleDownDelaySeconds). Problems that appear after five minutes are more common than those in the first five seconds.
Exercises
Exercise 1: choosing a strategy and justifying it
For each of these changes at Rutas Norte, choose the most suitable strategy and justify it in two or three sentences:
- Updating the
bookings-apibase image from Node 22.4 to 22.9 for a security patch, with no functional changes. - Changing the design of the seat-selection page in
web-store, on which product wants to measure conversion over three weeks. - Replacing the route search engine with a new one, with the same API contract but a completely different internal algorithm.
- Fixing a typo in some text in the SPA.
Exercise 2: spotting the flaw in an AnalysisTemplate
metrics:
- name: error-rate
interval: 10s
failureLimit: 0
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_requests_total{code=~"5.."}[30s]))
/ sum(rate(rutasnorte_requests_total[30s]))
successCondition: result[0] < 0.001This analysis aborts practically every canary, even the good ones. Identify four problems and propose the corrected manifest.
Exercise 3: designing the complete release
bookings-api is going to add the ability to cancel a booking from the web. It requires: a new status column in the bookings table (values active and cancelled), a new DELETE /bookings/{id} endpoint, a change in web-store to show the button, and an email notification via notifications-worker. The release is in October, outside peak season. Design the complete sequence: how many deployments, which strategy for each one, in what order, and what makes a rollback possible at each point.
Solutions
Solution 1.
- Canary. There is no feature to switch on and no conditional to write, so a flag does not apply. The risk is technical (performance, native dependency compatibility) and that is precisely what metric analysis catches. Blue-green would be needlessly expensive.
- A feature flag, complemented by the usual rolling deployment of
web-store. Product needs to control the segment and measure conversion over weeks: that is an A/B experiment, not a release. A canary lasts minutes, not three weeks. - A canary with reinforced analysis, including business metrics (search result quality measured by clicks on the first option) and relative latency against the stable version. Since the API contract does not change, neither the SPA nor the schema needs touching; the risk is about behaviour and performance, exactly what the canary validates. As a complement, a flag would allow going back to the old engine without deploying.
- Rolling. Practically zero risk. Setting up a canary for a typo is over-engineering that also delays the fix.
Solution 2. Problems:
| # | Problem | Effect |
|---|---|---|
| 1 | No initialDelay |
It judges the first seconds, with pods warming up and a cold cache: systematic false positives |
| 2 | failureLimit: 0 |
A single bad reading, even a one-second transient spike, aborts |
| 3 | No filter on service="...-canary" |
It measures the error rate of the whole application, stable included: if the stable version has errors, the canary can never pass |
| 4 | A [30s] window with interval: 10s |
Window far too short: very little sample, a lot of noise, and overlapping readings |
| 5 | No isNaN |
With 5 % of the traffic there may be no requests in the window and it returns NaN, which fails the condition |
Correction:
metrics:
- name: error-rate
interval: 1m
failureLimit: 2
consecutiveErrorLimit: 3
initialDelay: 2m
provider:
prometheus:
address: http://prometheus-operated.monitoring:9090
query: |
sum(rate(rutasnorte_requests_total{service="bookings-api-canary",code=~"5.."}[2m]))
/ sum(rate(rutasnorte_requests_total{service="bookings-api-canary"}[2m]))
successCondition: result[0] < 0.005 || isNaN(result[0])Solution 3. Five deployments:
- Expansion migration.
ALTER TABLE bookings ADD COLUMN status text DEFAULT 'active'(nullable, with a default). No version of the code uses it yet. Rollback: unnecessary, the column is harmless to the current 2.x. bookings-apiwith the new endpoint, protected by a flag that is off. Canary with Argo Rollouts. TheDELETE /bookings/{id}endpoint exists but returns 404 while the flag is off. Rollback: abort the canary, or switch off the flag (it is already off).notifications-workerwith the cancellation email template. Rolling: it is a queue consumer, with no user traffic. It must be deployed before cancellation is possible, so that no cancellation goes without a notification. Rollback:rollout undo, no impact.web-storewith the button, gated by the same flag. Rolling. The button does not appear while the flag is off. Rollback: a rolling deployment in reverse, or simply leaving the button hidden.- Progressive activation of the flag: employees → 5 % → 25 % → 100 %, watching the cancellation rate (an abnormally high rate would suggest the button is badly placed or people are cancelling by mistake) and the endpoint's errors. Rollback: switch the flag off, five seconds, with nothing deployed.
- Contraction, in November:
ALTER COLUMN status SET NOT NULLonce it is confirmed that every row has a value and that no old version is still alive.
The key ordering is that the capability (endpoint and worker) is deployed before the interface (button), and that both stay inert until the flag is switched on. That way, at every moment before step 5 the system is functionally identical to the current one, and any deployment can be rolled back without consequences.
Conclusion
We have seen four ways to reduce the risk of releasing. RollingUpdate is convenient and sufficient for routine work, but it progresses guided by the readiness probe, which only knows whether the process responds, not whether the version works well. Blue-green buys an instantaneous rollback in exchange for doubling the capacity and switching everything at once. The canary exposes the new version to a small fraction of users and lets you decide on real evidence; with Argo Rollouts that decision is automated by querying Prometheus, with automatic promotion and abort, and with Flagger the same thing is achieved with a different philosophy. Feature flags operate in another dimension: they separate deploying the code from activating the behaviour, and put the switch in the hands of product and support.
And underneath all of them there is a condition that cannot be skipped: database migrations must be compatible with the previous version and the next. Without expand and contract, none of the four strategies can truly roll back.
The May bank-holiday case sums the lesson up: by separating the technical risk (canary) from the business risk (flag), a performance problem caused by a missing index was caught in eleven minutes, affected a fraction of the traffic for under two, and was resolved as an afternoon's work rather than as an incident on the biggest sales weekend of the year.
So far we have always worked on a single cluster. In the next lesson, Multi-Cluster Management, we will see what changes when the platform no longer fits into just one: why companies end up with several, how you work day to day without ever applying to the wrong one, how Argo CD deploys across an entire fleet, and the problem with no easy solution when you have to recover from a disaster, which is the data.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
