Capstone Part 2 — Progressive Delivery
This is the second of five parts building one continuous project: cert-tracker, the exam-tracking service Part 1 got running under Argo CD, self-healing and prune both proven live. Every release since Part 1 has meant one thing: edit an image tag in Git, let the reconciler apply it, and let a plain Deployment's RollingUpdate cycle through every pod at once — whether or not the new build was actually safe. Today that changes. This page swaps cert-tracker's Deployment for an Argo Rollout, wires a real weighted traffic split through the NGINX ingress controller cert-tracker already needs, and gates every step on an AnalysisTemplate reading a real metric — not a guess, not a fixed pause. By the end you will have watched a safe release ramp itself from 20% to 100% on its own, and watched a broken one get caught and reversed automatically, with nobody pressing a button either time.
Picture Mission Control certifying a redesigned heat shield for the capsule. Nobody straps a full crew onto the new shield's very first flight — they send an uncrewed test capsule through a partial re-entry first, and watch the one sensor reading that actually matters, shield temperature, not just "did the capsule power on." Good numbers for a few minutes, and the next test goes further, then further, until the shield is proven and every future crew rides it. But one bad temperature reading, even for a few seconds, and the test aborts immediately — the capsule reverts to the old, already-proven shield, and nobody waits around hoping the number recovers on its own. Mission Control isn't watching a screen at 2 a.m. for this; a sensor threshold makes that call by itself, instantly, every time.
Rollout and wires the traffic split, and Ellie makes sure the AnalysisTemplate's query is reading a real, meaningful number before anyone trusts it to gate a release.Arriving: cert-tracker running as a plain Deployment in the cert-tracker namespace, reconciled by the cert-tracker Argo CD Application Part 1 built — prune and selfHeal both on, watching cert-tracker-config at apps/cert-tracker/overlays/dev. Leaving this page: cert-tracker running as a Rollout instead, a real weighted split between a stable and a canary Service driven by the NGINX ingress controller's own built-in canary routing, a namespaced AnalysisTemplate gating every promotion on a real metric read straight from that same ingress controller, and two new ignoreDifferences entries so Argo CD's selfHeal stops fighting the Rollout controller over the one field it now owns. Part 3 picks up here and puts cert-tracker's traffic inside a real mesh; today's ingress split is deliberately the narrowest thing that could work.
What this part assumes, and what it produces
☺ Like you're 10: Just the cluster, the two repos, and the app Part 1 already got Synced and Healthy — nothing about cert-tracker's own code changes today, except two small version bumps at the very end.
This part assumes Part 1's cert-tracker Application reports Synced and Healthy — pods Running in the cert-tracker namespace, prune and selfHeal both proven with your own hands, not just configured. You need everything Part 1 needed — kubectl, Docker, git, the argocd CLI — plus the kubectl argo rollouts plugin. Nothing about the real mesh or the real observability stack gets built yet: the ingress controller and the narrow Prometheus this page installs are deliberately the smallest slice of each that a working canary actually needs, not Part 3's mesh or Part 4's dashboards.
| Thing | Changes to | Introduced |
|---|---|---|
| Workload kind | Deployment → Rollout | Part 1 → Part 2 — this page |
| Traffic routing | none (single Service) → NGINX ingress canary split, cert-tracker-stable / cert-tracker-canary | Part 2 — this page |
| Promotion gate | readiness probe only → AnalysisTemplate cert-tracker-success-rate | Part 2 — this page |
| Metrics source | none → a narrow, single-scrape Prometheus reading the ingress controller's own request metrics | Part 2 — this page (Part 4 replaces this with real app-level OpenTelemetry instrumentation) |
| Mesh & policy | not yet installed | still Part 3 |
| Full observability | not yet wired | still Part 4 |
Part 1 called applying project.yaml and application.yaml "the one and only manual apply in this whole capstone" — and that claim was about cert-tracker's own desired state, the thing cert-tracker-config's apps/cert-tracker path governs. Installing the Argo Rollouts controller, the NGINX ingress controller, and a narrow Prometheus sits in a different category: platform infrastructure, applied by hand once, exactly like Argo CD's own install in Part 1. Later, adding an ignoreDifferences entry means editing the Application object's own spec — and nothing reconciles bootstrap/ the way the Application reconciles apps/cert-tracker, so that's a second by-hand moment in the same bootstrap category, not a new one. Everything about cert-tracker's actual desired state — the Rollout, both Services, the Ingress, the AnalysisTemplate — still only ever moves through a git push to cert-tracker-config, exactly as promised.
Platform bootstrap: the ingress controller, Argo Rollouts, and a narrow Prometheus
☺ Like you're 10: Three pieces of building infrastructure go in once, by hand, before the app itself ever notices anything changed.
Install the NGINX ingress controller first — cert-tracker's canary split rides entirely on its built-in canary annotations, and it is not a service mesh, so installing it doesn't front-run Part 3. The kind-flavored manifest below matches Part 1's assumed local cluster; swap it for minikube addons enable ingress if that's what you're running instead:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml
kubectl -n argo-rollouts rollout status deploy/argo-rollouts --timeout=180s
curl -fSL -o kubectl-argo-rollouts \
https://github.com/argoproj/argo-rollouts/releases/latest/download/kubectl-argo-rollouts-linux-amd64
chmod +x kubectl-argo-rollouts && sudo mv kubectl-argo-rollouts /usr/local/bin/Then a Prometheus deliberately narrow enough to answer one query: it scrapes only the ingress controller's own :10254/metrics endpoint, nothing app-level yet.
# platform/prometheus.yaml — applied by hand, not tracked in cert-tracker-config
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: monitoring
data:
prometheus.yml: |
scrape_configs:
- job_name: ingress-nginx
static_configs:
- targets: ["ingress-nginx-controller-metrics.ingress-nginx.svc.cluster.local:10254"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: monitoring
spec:
replicas: 1
selector: { matchLabels: { app: prometheus } }
template:
metadata: { labels: { app: prometheus } }
spec:
containers:
- name: prometheus
image: prom/prometheus:v2.54.1
args: ["--config.file=/etc/prometheus/prometheus.yml"]
volumeMounts: [{ name: config, mountPath: /etc/prometheus }]
volumes:
- name: config
configMap: { name: prometheus-config }
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: monitoring
spec:
selector: { app: prometheus }
ports: [{ port: 9090, targetPort: 9090 }]kubectl create namespace monitoring
kubectl apply -f platform/prometheus.yaml
kubectl -n monitoring rollout status deploy/prometheus --timeout=120sThe Rollout: swapping the workload kind without touching the pod template
☺ Like you're 10: Swapping the label on the box from Deployment to Rollout — everything already inside stays exactly the same, only what happens on the next release changes.
In cert-tracker-config, replace apps/cert-tracker/base/deployment.yaml with rollout.yaml. The pod template is copied verbatim from Part 1 — same image, same probes — and everything new lives in spec.strategy:
# apps/cert-tracker/base/rollout.yaml — replaces deployment.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: cert-tracker
labels: { app: cert-tracker }
spec:
replicas: 2
selector:
matchLabels: { app: cert-tracker }
template: # identical to Part 1's Deployment — nothing here changes
metadata:
labels: { app: cert-tracker }
spec:
containers:
- name: cert-tracker
image: ghcr.io/kubestronaut/cert-tracker:v0.1.0
ports: [{ containerPort: 8080 }]
readinessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 3 }
livenessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 10 }
strategy:
canary:
canaryService: cert-tracker-canary # selectors Argo Rollouts manages for you — see below
stableService: cert-tracker-stable
trafficRouting:
nginx:
stableIngress: cert-tracker # the Ingress it clones into a canary variant
analysis:
templates:
- templateName: cert-tracker-success-rate # namespaced — see why, below
startingStep: 1
args:
- name: canary-service
value: cert-tracker-canary
steps:
- setWeight: 20
- pause: { duration: 2m }
- setWeight: 50
- pause: { duration: 2m }
- setWeight: 100Argo Rollouts' own reference page shows a ClusterAnalysisTemplate — the cluster-scoped variant, published once and referenced from any namespace. This page uses the namespaced AnalysisTemplate kind instead, deliberately: Part 1's kubestronaut AppProject set clusterResourceWhitelist: [], which blocks the project from creating any cluster-scoped resource at all. A ClusterAnalysisTemplate would trip that whitelist the moment Argo CD tried to apply it. There's no reason cert-tracker's own analysis needs to be cluster-wide yet — a plain, namespaced AnalysisTemplate living in cert-tracker alongside everything else does the identical job without asking for a permission this app doesn't need.
The rest of the base directory grows by four files — two Services Argo Rollouts will manage the selectors of, the stable Ingress its NGINX integration clones, and the AnalysisTemplate itself:
# apps/cert-tracker/base/service-stable.yaml
apiVersion: v1
kind: Service
metadata:
name: cert-tracker-stable
spec:
selector: { app: cert-tracker } # Argo Rollouts narrows this with a pod-template-hash label at sync time
ports: [{ port: 80, targetPort: 8080 }]
---
# apps/cert-tracker/base/service-canary.yaml — identical shape, different name
apiVersion: v1
kind: Service
metadata:
name: cert-tracker-canary
spec:
selector: { app: cert-tracker }
ports: [{ port: 80, targetPort: 8080 }]
---
# apps/cert-tracker/base/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cert-tracker
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: cert-tracker.kubestronaut.local
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: cert-tracker-stable, port: { number: 80 } }
---
# apps/cert-tracker/base/analysistemplate.yaml
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: cert-tracker-success-rate
spec:
args:
- name: canary-service
metrics:
- name: success-rate
interval: 30s
count: 6
initialDelay: 30s # let the canary warm up before judging it
successCondition: result[0] >= 0.98
failureLimit: 2 # tolerate 2 bad readings; the 3rd fails the run
provider:
prometheus:
address: http://prometheus.monitoring.svc.cluster.local:9090
query: |
sum(rate(nginx_ingress_controller_requests{
service="{{args.canary-service}}", status!~"5.."}[2m]))
/
sum(rate(nginx_ingress_controller_requests{
service="{{args.canary-service}}"}[2m]))Update base/kustomization.yaml's resources list to swap deployment.yaml for the five files above, and update overlays/dev/patch-replicas.yaml's target.kind from Deployment to Rollout — the patch still targets the same resource name, only the kind changed. Commit both, plus every new file, and push:
cd cert-tracker-config
git rm apps/cert-tracker/base/deployment.yaml
# add rollout.yaml, service-stable.yaml, service-canary.yaml, ingress.yaml, analysistemplate.yaml
# edit base/kustomization.yaml and overlays/dev/patch-replicas.yaml as described above
git add .
git commit -m "feat: cert-tracker as a canary Rollout, gated on a real ingress metric"
git push
argocd app sync cert-tracker # or wait for the next ~3min poll
argocd app get cert-tracker # project: kubestronaut, still — nothing cluster-scoped got addedWatch kubectl argo rollouts get rollout cert-tracker -n cert-tracker --watch right after this sync and the weight jumps straight to 100% — no 20%, no pause, no analysis run. This is expected, not broken: there is no stable version yet to canary against on a brand-new Rollout object, so the very first revision always goes straight to full traffic. The canary machinery only engages starting with the next pod-template change, which is exactly what the next section ships.
Keeping Argo CD out of the Rollout controller's own field
☺ Like you're 10: Two robots reaching for the same dial — one has to be told "not this one, that one's the other robot's job."
cert-tracker-stable and cert-tracker-canary are plain manifests under Git, applied and continuously reconciled by the same cert-tracker Application as everything else. The moment a rollout starts, the Rollout controller narrows each Service's spec.selector with its own rollouts-pod-template-hash label, live, so each one points at exactly the right ReplicaSet — a runtime edit, not a Git commit. With selfHeal: true still on from Part 1, the very next reconcile pass sees a live selector that no longer matches Git's checked-in { app: cert-tracker } and — this is external-owner drift, the exact shape that page already named, not a mistake to revert — would fight the Rollout controller for it, forever, unless told otherwise. Add the carve-out to bootstrap/application.yaml:
# cert-tracker-config/bootstrap/application.yaml — spec.ignoreDifferences added
spec:
ignoreDifferences:
- group: ''
kind: Service
name: cert-tracker-stable
jsonPointers: [/spec/selector]
- group: ''
kind: Service
name: cert-tracker-canary
jsonPointers: [/spec/selector]cd cert-tracker-config
git commit -am "fix: ignoreDifferences on the two Services Rollouts now owns"
git push # keep the change in history, same as everything else
kubectl apply -n argocd -f bootstrap/application.yaml # nothing reconciles bootstrap/ automatically — apply it once, by handThe cert-tracker-canary Ingress that NGINX-mode Rollouts creates needs no such carve-out at all — it is a resource Argo CD never applied in the first place, generated and owned entirely by the Rollout controller, and it never appears anywhere in cert-tracker-config's rendered manifests. Argo CD can only prune or revert resources it tracks; an object it never created is invisible to its diff, full stop. That's a structural difference from the Istio-based routing Argo Rollouts also documents, where the controller patches weight fields directly onto a single VirtualService Argo CD does track — a genuine two-controller conflict over one object, needing its own ignoreDifferences entry on the weight fields themselves. The NGINX router sidesteps that particular fight structurally; the Service selectors are the one field this setup still has to hand off explicitly.
Proving it: a safe release promotes itself, a broken one catches itself
☺ Like you're 10: Send the safe test capsule through first and watch it get waved further each time it reports clean numbers — then send a flawed one and watch the exact same watchdog say no.
Ship a small, safe change first — v0.1.1 adds a version field to the /healthz response, nothing that touches /exams or /status at all:
docker build -t ghcr.io/kubestronaut/cert-tracker:v0.1.1 .
docker push ghcr.io/kubestronaut/cert-tracker:v0.1.1
# edit the image tag in apps/cert-tracker/base/rollout.yaml
git commit -am "feat: report version in /healthz response (v0.1.1)"
git push
kubectl argo rollouts get rollout cert-tracker -n cert-tracker --watch# ...a couple minutes in:
# SetWeight 20 Steps 1/5
# AnalysisRun cert-tracker-abc12 Running (success-rate: measuring...)
# SetWeight 50 Steps 3/5
# AnalysisRun cert-tracker-abc12 Successful (success-rate: 1.0)
# SetWeight 100 Steps 5/5
# Rollout cert-tracker HealthyNow ship the build worth proving against — not one that crashes outright, since a crashed process fails its readiness probe and never receives a single request, canary or not. v0.2.0 adds pagination to GET /exams behind a new query-parsing helper that throws on a malformed parameter, outside Express's own error handling, and takes the whole process down with it. /healthz never touches that code path, so kubelet keeps calling the pod ready between crash-restarts — but a meaningful slice of real /exams traffic during any given window lands mid-crash and gets a connection reset, which the ingress controller's own metrics record honestly as a 5xx:
kubectl argo rollouts set image cert-tracker cert-tracker=ghcr.io/kubestronaut/cert-tracker:v0.2.0 -n cert-tracker
kubectl argo rollouts get rollout cert-tracker -n cert-tracker --watch
# SetWeight 20 Steps 1/5
# AnalysisRun cert-tracker-def45 Running (success-rate: measuring...)
# AnalysisRun cert-tracker-def45 Failed (success-rate: 0.91, want >= 0.98)
# Rollout cert-tracker Degraded
kubectl get analysisrun -n cert-tracker
kubectl describe analysisrun cert-tracker-def45 -n cert-tracker
# Message: Metric "success-rate" assessed Failed due to failed (2) > failureLimit (2)
argocd app get cert-tracker
# Sync Status: Synced -- still. The spec Argo CD applied still matches Git; the abort lives in the Rollout's status.That last command is the entire point of this section, not a side note. cert-tracker's traffic never left the 20% ceiling this test held it to, the abort happened with nobody watching a dashboard, and the Argo CD Application shows exactly the same Synced it always does — proof that Rollout health and Application sync status are answering two different questions, exactly as Argo Rollouts already warned. Once a real fix ships as v0.2.1, kubectl argo rollouts retry rollout cert-tracker -n cert-tracker clears the abort and re-runs the same steps against it.
cert-tracker-success-rate is declared at strategy.canary.analysis, outside steps — a background analysis, not an inline one. It starts the moment the canary receives any traffic at all and keeps sampling for its full count of measurements independent of which step the rollout is currently on. That cuts both ways: a regression that only shows up once the weight reaches 50% still gets caught, not just whatever the first step happened to check — but it also means an analysis can still be running, and can still trigger an abort, even after the rollout has already reached setWeight: 100 and the steps list is exhausted. "Fully promoted" and "fully judged" are not the same moment.
What "done" looks like for Part 2, and where Part 3 picks up
☺ Like you're 10: A capsule design that tests itself in small, growing steps and pulls itself back the instant a real reading looks wrong — proven twice, not assumed once.
At the end of this part: cert-tracker runs as a Rollout, gated by a namespaced AnalysisTemplate reading a real metric off the NGINX ingress controller; a weighted split between cert-tracker-stable and cert-tracker-canary moves real traffic in steps; the Application carries the two ignoreDifferences entries that keep selfHeal from fighting the Rollout controller; and you've watched, with your own commands, both a safe release promote itself and a broken one get caught and reversed. Nothing here gets thrown away:
| Part | What it does with Part 2's artifacts |
|---|---|
| 3 — Mesh & Policy | Puts this same Rollout's traffic inside a real mesh, and adds Kyverno policies gating what cert-tracker-config is even allowed to declare |
| 4 — Observability | Replaces today's one narrow, ingress-only Prometheus with real OpenTelemetry traces and app-level metrics across every route |
| 5 — The Portal | Catalogs cert-tracker's Rollout status in Backstage, right alongside the repo pair Part 1 built |
Milestones
☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."
Work these in order — each depends on the cluster and repo state from the one before. Progress saves in this browser.
cert-tracker Synced and Healthyargocd app get cert-tracker — both prune and selfHeal already proven with your own hands.Synced / Healthy before you change anything.kubectl apply sequences above, by hand — same category as installing Argo CD itself.ingress-nginx-controller, argo-rollouts, and prometheus all report Ready.rollout.yaml, replacing deployment.yamlstrategy.canary block is what's new.service-stable.yaml, service-canary.yaml, ingress.yamlkubectl apply --dry-run=client -f.AnalysisTemplate cert-tracker-success-rateClusterAnalysisTemplate, given Part 1's empty clusterResourceWhitelist.provider.prometheus.address matches the Prometheus Service from step 2.kustomization.yaml and the overlay's patch targetdeployment.yaml for the five new files in base/kustomization.yaml; change patch-replicas.yaml's target.kind to Rollout.kustomize build apps/cert-tracker/overlays/dev renders clean YAML with no leftover Deployment.git push, then argocd app sync cert-tracker, then kubectl argo rollouts get rollout cert-tracker -n cert-tracker --watch.ignoreDifferences entries to bootstrap/application.yamlkubectl apply -n argocd -f bootstrap/application.yaml by hand.argocd app get cert-tracker -o yaml shows both entries under ignoreDifferences.v0.1.1 and watch a real canary promote itself: 20 → 50 → 100rollout.yaml, commit, push, then watch it.Healthy at setWeight: 100 with no manual promote.v0.2.0 and watch the analysis catch it and abortkubectl argo rollouts set image, then watch the AnalysisRun fail and confirm argocd app get cert-tracker still shows Synced.Degraded, traffic never exceeded 20%, and the Application sync status never moved.cert-tracker is a Rollout, the ingress split and analysis both proven live, both ignoreDifferences entries in place.Benny the Beaver: Rollout's live. First deploy went straight to 100%, no canary — a little anticlimactic.
Ellie the Elephant: Because there was nothing to compare it against yet. Ship the next version and watch the steps actually run — I want to see the AnalysisRun read a real number, not just exist.
Foxy: And if the number's bad?
Ellie: Then it aborts, on its own, before more than 20% of real traffic ever touched the broken build. I don't page anyone for that — the query already knows.
Gizmo: Or — hot take — just skip straight to setWeight: 100 every time. It passed /healthz, right? 😈
Benny: /healthz means the process is breathing, Gizmo. It says nothing about whether /exams actually works under real load. That's the whole reason the query watches real requests, not a heartbeat.
Recon the Robot: BEEP. And don't come looking to my sync status for the alarm either. I'll show Synced the entire time the Rollout's Degraded — that word's his, not mine.
1. Why does the very first Rollout apply on this page skip the canary steps entirely, even though strategy.canary is already fully wired? 2. Why does this page use the namespaced AnalysisTemplate kind instead of the ClusterAnalysisTemplate the Argo Rollouts reference page shows? 3. Why doesn't the Rollout-owned canary Ingress need an ignoreDifferences entry, while the two split Services do? 4. Why does v0.2.0 still pass its readiness probe even while the analysis is aborting the rollout? 5. After the abort, what does argocd app get cert-tracker show, and why doesn't that contradict the Rollout sitting Degraded? 6. Name the platform-level things this page installed by hand, and why that doesn't break Part 1's "one and only manual apply" claim.
Check your answers
- Because there is no existing stable version to canary against on a brand-new
Rolloutobject — the very first revision always ships straight to 100%. The steps only engage starting with the next change to the pod template. - Part 1's
kubestronautAppProjectsetclusterResourceWhitelist: [], which blocks the project from creating any cluster-scoped resource — andClusterAnalysisTemplateis cluster-scoped. A namespacedAnalysisTemplateliving incert-trackerdoes the identical job without needing a permission this app was never granted. - Argo CD can only prune or revert resources it actually applied and tracks. The canary
Ingressis created and owned entirely by the Rollout controller and never appears incert-tracker-config's rendered manifests, so it's invisible to Argo CD's diff by construction — there's no live/Git mismatch to fight over in the first place. The two Services, by contrast, are Git-tracked and get theirselectorpatched live, which is exactly the kind of field two controllers can fight over without a carve-out. - The bug lives in a new query-parsing helper on
GET /examsthat throws outside Express's own error handling — a code path/healthznever touches. A readiness probe only proves the process is alive and listening; it says nothing about whether a specific, newer request path actually works. Sync Status: Synced— unchanged. The spec Argo CD applied still matches Git exactly; the abort is recorded in the Rollout's ownstatus, not its spec, so sync status (does live match Git?) and Rollout health (is it actually working?) are simply answering two different questions.- The NGINX ingress controller, the Argo Rollouts controller, and the narrow Prometheus — plus, later, the one
kubectl applyneeded to update the Application's ownignoreDifferences. All of these are platform infrastructure or edits to the Application object itself, the same category Part 1 already put its own two bootstrap manifests in — not part ofcert-tracker's desired state, which still only ever moves throughcert-tracker-config.
Part 2 turned a plain Deployment into a release that promotes itself on good numbers and reverses itself on bad ones, with nobody watching a dashboard either way. Continue to Capstone Part 3 — Mesh & Policy, where this same Rollout's traffic moves inside a real mesh. Or step back to Build Your Cert Tracker — Start Here to see how this capstone's five parts fit together, and revisit Argo Rollouts and GitOps Philosophy for the concepts behind what you just built.