KServe Canary Deployment on Kubernetes with kind
Run a KServe canary on kind: split traffic 90/10 between model versions, verify it with real requests, roll back a broken one, promote.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project runs a KServe canary deployment on a local kind Kubernetes cluster: model v2 receives 10 percent of live traffic while v1 keeps 90 percent, you prove the split with real requests instead of trusting a status field, promote v2 when its error rate and latency hold up, then roll out a broken v3, watch the canary fail, and roll back to v2 with one field. Everything runs free on a laptop; an optional Terraform appendix builds the same thing on Amazon EKS and costs money.
The problem
The acme-fraud team ships a new model by replacing the old one for every user at once. The last release renamed a feature during training (amount became amount_usd) while the payment service still sent amount. Every offline check passed, because offline data had the new column. In production every request failed until someone noticed. A canary would have shown the failure on 10 percent of requests, and a rollback would have taken one field change instead of a hotfix.
What you build
Host header] G --> R{Knative route
90 / 10} R -->|90%| A[Revision 1
MODEL_VERSION=v1] R -->|10%| B[Revision 2
MODEL_VERSION=v2] A --> D[[canary_decide.py]] B --> D classDef client fill:#DBEAFE,color:#1E293B,stroke:#326CE5 classDef mesh fill:#8B5CF6,color:#FFFFFF,stroke:#8B5CF6 classDef old fill:#475569,color:#FFFFFF,stroke:#475569 classDef new fill:#F97316,color:#FFFFFF,stroke:#F97316 classDef tool fill:#D1FAE5,color:#1E293B,stroke:#10B981 class C client class G,R mesh class A old class B new class D tool
One container image holds all three model versions; an environment variable picks one. Changing it creates a new revision, and canaryTrafficPercent tells KServe how much traffic the newest revision gets. Every response, errors included, carries x-model-version and x-revision headers, so you can count who actually answered.
The rollout you will run
remove the field V2 --> Canary_v3: v3 at 10% Canary_v3 --> V2_pinned: decide ROLLBACK
canaryTrafficPercent 0
Repository layout
acme-fraud-kserve/├── predictor/│ ├── app.py # FastAPI, KServe V1 protocol, version headers│ ├── Dockerfile│ ├── requirements.txt│ └── models/v1 v2 v3/ # written by scripts/train_models.py├── k8s/│ ├── kind-config.yaml # Kubernetes 1.33, pinned by digest│ ├── isvc-v1.yaml isvc-v2-canary.yaml isvc-v2-promote.yaml│ ├── isvc-v3-canary.yaml isvc-v3-rollback.yaml isvc-v9-never-ready.yaml├── scripts/│ ├── train_models.py # v1, v2, and the broken v3│ ├── cluster_up.sh build_and_load.sh ingress.sh split_status.sh cluster_down.sh│ ├── traffic_check.py # real requests, counted per version│ └── canary_decide.py # PROMOTE / ROLLBACK / WAIT├── tests/test_canary.py└── terraform/main.tf # OPTIONAL, costs moneyPrerequisites (by module title)
- Model Serving on Kubernetes with KServe (custom predictors, V1 protocol, Knative versus Standard mode).
- Kubernetes, Docker, and Model Serving with FastAPI and BentoML.
- Optional appendix only: Terraform and AWS.
- Tools: Docker with at least 4 CPUs and 8 GB of memory for the VM, kind v0.32.0, kubectl 1.33, Python 3.12,
curl,sha256sum. The KServe installer fetches Helm and its other helpers itself (read it first, Milestone 1).
Versions used
| Component | Version | Checked |
|---|---|---|
| KServe (Knative mode) | v0.21.0 | GitHub tag and installer, 5 Nov 2026 |
| Istio / Knative Serving / cert-manager | 1.27.1 / 1.21.1 / v1.17.0 | pinned inside the v0.21.0 installer |
| kind / Kubernetes node | v0.32.0 / v1.33.12 by digest | kind release notes, 5 Nov 2026 |
| scikit-learn / FastAPI / Python | 1.9.1 / 0.162.0 / 3.12 | PyPI, 5 Nov 2026 |
TipIf you did the KServe module on v0.16, three things changed by v0.21. "Serverless" mode is now called Knative mode (the old value still works but is deprecated). The default mode is now Standard, which does not support
canaryTrafficPercent, so every manifest here setsserving.kserve.io/deploymentMode: Knativeexplicitly. And the oldhack/quick_install.shwas replaced by scripts underhack/setup/quick-install/.
TipWhat was run for this guide: model training, the predictor (readiness, prediction, a 422 for bad input, the v3 feature-mismatch 500),
traffic_check.pyandcanary_decide.pyagainst a sandbox stand-in for Knative's weighted router (90/10, 100/0, a broken 10 percent canary, and a deliberately wrong 70/30 split), and the 4 tests. The kind cluster, the KServe install, the image build and everykubectlstep were not run: the sandbox cannot pull container images. Those steps show the expected output shape, from the KServe and Knative documentation, and are marked "not run". The Terraform appendix was not run or validated.
Milestone 1: Create the kind Cluster and Install KServe
This milestone creates a one-node Kubernetes 1.33 cluster with kind and installs KServe v0.21.0 in Knative mode with its pinned dependencies, after you have read the installer.
Why Knative mode for a canary
KServe has two ways to run a model. Standard mode creates a plain Deployment: simple, but one version at a time. Knative mode runs the predictor as a Knative Service, where every spec change becomes an immutable revision and the route can split traffic between revisions by percentage. canaryTrafficPercent is a Knative-mode feature.
Step 1: Write the kind config
k8s/kind-config.yaml:
# One-node kind cluster for KServe in Knative mode. Give Docker at least 4 CPUs and 8 GB.kind: ClusterapiVersion: kind.x-k8s.io/v1alpha4name: acme-kservenodes: - role: control-plane # Kubernetes 1.33, pinned by digest (kind v0.32.0 release notes) image: kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4Step 2: Create the cluster and install KServe
scripts/cluster_up.sh:
#!/usr/bin/env bash# Create the kind cluster and install KServe v0.21.0 in Knative mode.# Installs: Gateway API CRDs, cert-manager v1.17.0, Istio 1.27.1, Knative Serving 1.21.1, KServe v0.21.0.set -euo pipefailKSERVE_VERSION=v0.21.0SCRIPT=kserve-knative-mode-full-install-helm.shURL="https://raw.githubusercontent.com/kserve/kserve/${KSERVE_VERSION}/hack/setup/quick-install/${SCRIPT}" kind create cluster --config k8s/kind-config.yamlkubectl cluster-info --context kind-acme-kserve # Download the installer pinned to the release tag, keep a copy, and READ IT before running.mkdir -p .installcurl -fsSL "$URL" -o ".install/${SCRIPT}"sha256sum ".install/${SCRIPT}" | tee .install/installer.sha256if [ "${I_HAVE_READ_THE_INSTALLER:-no}" != "yes" ]; then echo "Review .install/${SCRIPT}, then rerun with I_HAVE_READ_THE_INSTALLER=yes"; exit 3fibash ".install/${SCRIPT}" kubectl wait --for=condition=Available deployment --all -n kserve --timeout=600skubectl create namespace acme-fraud --dry-run=client -o yaml | kubectl apply -f -echo "cluster ready"./scripts/cluster_up.sh # stops after downloading: read the installerless .install/kserve-knative-mode-full-install-helm.shI_HAVE_READ_THE_INSTALLER=yes ./scripts/cluster_up.shSecurityNever pipe a remote script straight into
bash. The script is pinned to thev0.21.0tag, saved to disk, hashed, and only run after you read it. Keep.install/installer.sha256so you can tell later if a rerun fetched something different.
Step 3: Check the platform
kubectl get pods -n kservekubectl get pods -n knative-servingkubectl get pods -n istio-systemExpected output shape (not run for this guide): every pod Running and READY 1/1 (or 2/2), including kserve-controller-manager, activator, autoscaler, controller, webhook and istio-ingressgateway.
Acceptance check
-
kubectl get crd inferenceservices.serving.kserve.ioexists. - All pods in
kserve,knative-servingandistio-systemare ready.
Common MistakeGiving Docker Desktop 2 CPUs and 4 GB. Istio, Knative and KServe together leave pods
PendingwithInsufficient cpu. Raise the VM to at least 4 CPUs and 8 GB before creating the cluster.
Milestone 2: Build the Predictor and Load It into kind
This milestone trains three model versions, packages them into one predictor image that speaks KServe's V1 protocol, and loads the image into kind without a registry.
Why one image with three versions
Here the image is fixed and only MODEL_VERSION changes, so every revision differs in exactly one thing: the model. That keeps the experiment clean. In production you would pull the model from a registry or storage URI instead, but the canary mechanics are identical.
Step 1: Train the versions
scripts/train_models.py:
"""Train the three model versions this project rolls out. v1: the current champion (max_leaf_nodes=31)v2: the candidate (max_leaf_nodes=7, picked on validation)v3: a BROKEN candidate: trained after a feature was renamed (amount -> amount_usd), while clients still send "amount". It passes offline checks and fails live."""import hashlibimport jsonfrom pathlib import Path import joblibimport numpy as npimport pandas as pdfrom sklearn.compose import ColumnTransformerfrom sklearn.ensemble import HistGradientBoostingClassifierfrom sklearn.metrics import average_precision_scorefrom sklearn.model_selection import train_test_splitfrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import OneHotEncoder CATEGORIES = ["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] def make_payments(rows=20000, fraud_rate=0.03, seed=42) -> pd.DataFrame: """Same synthetic payments as the earlier projects.""" rng = np.random.default_rng(seed) df = pd.DataFrame({ "amount": np.round(rng.lognormal(3.5, 1.0, rows), 2), "hour": rng.integers(0, 24, rows), "merchant_category": rng.choice(CATEGORIES, rows), "country_mismatch": rng.binomial(1, 0.08, rows), "card_age_days": rng.integers(1, 3650, rows), "txns_last_24h": rng.poisson(2.0, rows), }) score = (0.9 * np.log1p(df["amount"]) / 5 + 1.2 * df["country_mismatch"] + 0.6 * df["merchant_category"].isin(["electronics", "gaming"]) + 0.5 * ((df["hour"] <= 4) | (df["hour"] >= 23)) + 0.25 * np.minimum(df["txns_last_24h"], 8) / 2 - 0.6 * (df["card_age_days"] > 365) + rng.normal(0, 1.0, rows)) df["is_fraud"] = (score >= np.quantile(score, 1 - fraud_rate)).astype(int) return df def train(df: pd.DataFrame, max_leaf_nodes: int): features = [c for c in df.columns if c != "is_fraud"] tr, va = train_test_split(df, test_size=0.2, stratify=df["is_fraud"], random_state=42) model = Pipeline([ ("pre", ColumnTransformer([("cat", OneHotEncoder(handle_unknown="ignore"), ["merchant_category"])], remainder="passthrough")), ("clf", HistGradientBoostingClassifier(max_leaf_nodes=max_leaf_nodes, class_weight="balanced", random_state=42)), ]).fit(tr[features], tr["is_fraud"]) return model, average_precision_score(va["is_fraud"], model.predict_proba(va[features])[:, 1]) def save(version: str, model, pr_auc: float) -> None: out = Path("predictor/models") / version out.mkdir(parents=True, exist_ok=True) joblib.dump(model, out / "model.joblib") meta = {"version": version, "val_pr_auc": round(pr_auc, 4), "threshold": 0.5, "model_sha256": hashlib.sha256((out / "model.joblib").read_bytes()).hexdigest()} (out / "metadata.json").write_text(json.dumps(meta, indent=2)) print(f"{version}: val_pr_auc={pr_auc:.4f} sha={meta['model_sha256'][:12]}") if __name__ == "__main__": df = make_payments() save("v1", *train(df, 31)) save("v2", *train(df, 7)) save("v3", *train(df.rename(columns={"amount": "amount_usd"}), 7)) # the bugpython3.12 -m venv .venv && source .venv/bin/activatepip install -r predictor/requirements.txt pytestpython scripts/train_models.pyExpected output (real run):
v1: val_pr_auc=0.2159 sha=0759b53f1f5bv2: val_pr_auc=0.2335 sha=81412ae5fa05v3: val_pr_auc=0.2335 sha=276fe81ba5ddv2's smaller trees were picked on validation data. v3 is the same model trained after the column rename: identical offline score, broken online. That is the whole point of a canary.
Step 2: Write the predictor
predictor/app.py:
"""acme-fraud predictor for KServe: a custom container speaking the V1 protocol. GET /v1/models/acme-fraud -> 200 when the model is loaded (readiness)POST /v1/models/acme-fraud:predict -> {"predictions": [...], "model_version", "revision"} Which model it serves comes from the MODEL_VERSION env var. Knative sets K_REVISION,which we echo back so every response says exactly which revision answered."""import hashlibimport jsonimport osfrom contextlib import asynccontextmanagerfrom pathlib import Pathfrom typing import Literal import joblibimport pandas as pdfrom fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, Field MODEL_NAME = "acme-fraud"VERSION = os.environ.get("MODEL_VERSION", "v1")REVISION = os.environ.get("K_REVISION", "local") # set by Knative inside the clusterMODEL_DIR = Path(os.environ.get("MODEL_ROOT", "/app/models")) / VERSIONSTATE: dict = {} class Payment(BaseModel): amount: float = Field(gt=0, le=1_000_000) hour: int = Field(ge=0, le=23) merchant_category: Literal["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] country_mismatch: Literal[0, 1] card_age_days: int = Field(ge=0, le=36_500) txns_last_24h: int = Field(ge=0, le=1_000) class PredictRequest(BaseModel): # V1 protocol: {"instances": [...]} instances: list[Payment] = Field(min_length=1, max_length=100) @asynccontextmanagerasync def lifespan(app: FastAPI): meta = json.loads((MODEL_DIR / "metadata.json").read_text()) blob = (MODEL_DIR / "model.joblib").read_bytes() if hashlib.sha256(blob).hexdigest() != meta["model_sha256"]: raise RuntimeError("model file does not match its metadata") # pod never becomes ready STATE["model"], STATE["meta"] = joblib.load(MODEL_DIR / "model.joblib"), meta yield app = FastAPI(title="acme-fraud-predictor", lifespan=lifespan) @app.middleware("http")async def tag_response(request, call_next): """Stamp EVERY response, errors included, with the version and revision that produced it.""" response = await call_next(request) response.headers["x-model-version"] = VERSION response.headers["x-revision"] = REVISION return response @app.get(f"/v1/models/{MODEL_NAME}")def model_ready() -> dict: return {"name": MODEL_NAME, "ready": "model" in STATE, "model_version": VERSION} @app.post(f"/v1/models/{MODEL_NAME}:predict")def predict(req: PredictRequest) -> dict: rows = pd.DataFrame([p.model_dump() for p in req.instances]) model = STATE["model"] try: # select columns by the names the model was TRAINED with proba = model.predict_proba(rows[list(model.feature_names_in_)])[:, 1] except KeyError as err: # training-serving skew: the model expects a feature the request does not have raise HTTPException(status_code=500, detail=f"feature mismatch: {err}") from err t = STATE["meta"]["threshold"] return { "predictions": [{"fraud_probability": round(float(p), 4), "is_fraud": bool(p >= t)} for p in proba], "model_version": VERSION, "revision": REVISION, }predictor/requirements.txt:
fastapi==0.162.0uvicorn==0.54.0pydantic==2.14.2scikit-learn==1.9.1pandas==3.1.1numpy==2.5.4joblib==1.6.0predictor/Dockerfile:
FROM python:3.12-slimENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 OMP_NUM_THREADS=1 MODEL_ROOT=/app/modelsWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app.py .# all three versions are baked in; MODEL_VERSION picks one at runtimeCOPY models/ models/RUN useradd --create-home --uid 10001 appUSER 10001EXPOSE 8080# KServe sends traffic to port 8080 by default for custom containersCMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1"]Step 3: Smoke test it locally before Kubernetes
cd predictorMODEL_ROOT=$PWD/models MODEL_VERSION=v1 K_REVISION=acme-fraud-predictor-00001 uvicorn app:app --port 8081 &MODEL_ROOT=$PWD/models MODEL_VERSION=v3 K_REVISION=acme-fraud-predictor-00003 uvicorn app:app --port 8083 &cd ..BODY='{"instances":[{"amount":129.99,"hour":2,"merchant_category":"electronics","country_mismatch":1,"card_age_days":40,"txns_last_24h":6}]}'curl -s localhost:8081/v1/models/acme-fraud; echocurl -s -i -X POST localhost:8081/v1/models/acme-fraud:predict -H 'Content-Type: application/json' -d "$BODY" | grep -iE "^x-|predictions"curl -s -i -X POST localhost:8083/v1/models/acme-fraud:predict -H 'Content-Type: application/json' -d "$BODY" | grep -iE "^HTTP|^x-|detail"kill %1 %2Expected output (real run; revision names were set by hand here, Knative sets them in the cluster):
{"name":"acme-fraud","ready":true,"model_version":"v1"}x-model-version: v1x-revision: acme-fraud-predictor-00001{"predictions":[{"fraud_probability":0.861,"is_fraud":true}],"model_version":"v1","revision":"acme-fraud-predictor-00001"}HTTP/1.1 500 Internal Server Errorx-model-version: v3x-revision: acme-fraud-predictor-00003{"detail":"feature mismatch: \"['amount_usd'] not in index\""}v3 is ready, healthy by every probe, and fails every prediction. Readiness probes cannot catch this; only real traffic can.
Step 4: Build and load into kind
scripts/build_and_load.sh:
#!/usr/bin/env bash# Train the three versions, build one image holding them, and load it into kind (no registry needed).set -euo pipefailIMAGE=dev.local/acme-fraud-predictor:0.1.0 # dev.local: Knative will not try to resolve a digestpython scripts/train_models.pydocker build -t "$IMAGE" predictor/kind load docker-image "$IMAGE" --name acme-kserveecho "loaded $IMAGE"./scripts/build_and_load.shdocker exec acme-kserve-control-plane crictl images | grep acme-fraudExpected output shape (not run): one line showing dev.local/acme-fraud-predictor with tag 0.1.0.
Common MistakeNaming the image
acme-fraud-predictor:0.1.0without a registry prefix. Knative resolves every image tag to a digest by asking the registry, and a kind-loaded image is in no registry, so the revision fails with "failed to resolve image to digest". Knative skips that lookup forkind.local,ko.localanddev.local(theregistries-skipping-tag-resolvingdefault in Knative 1.21), which is why the image is calleddev.local/....
Milestone 3: Deploy v1 and Verify It Two Ways
This milestone deploys v1 as an InferenceService and checks the traffic in two independent ways: what KServe reports, and what real requests through the ingress actually hit.
Why two checks
READY=True and the traffic field describe what the control plane intends. They do not prove that the ingress, the Host header and the routes deliver requests where you think. traffic_check.py sends real requests and counts the x-model-version header on each answer.
Step 1: Write the manifests
k8s/isvc-v1.yaml:
# Step 1: v1 takes 100% of traffic.apiVersion: serving.kserve.io/v1beta1kind: InferenceServicemetadata: name: acme-fraud namespace: acme-fraud annotations: serving.kserve.io/deploymentMode: Knative # canary needs Knative mode; v0.21 default is Standard serving.kserve.io/enable-tag-routing: "true" # adds latest- and prev- hostnames for debuggingspec: predictor: minReplicas: 1 # keep one pod warm, so the canary is not a cold start maxReplicas: 2 containers: - name: kserve-container image: dev.local/acme-fraud-predictor:0.1.0 # dev.local: Knative skips digest lookup for kind-loaded images imagePullPolicy: IfNotPresent env: - name: MODEL_VERSION value: "v1" ports: - containerPort: 8080 protocol: TCP readinessProbe: httpGet: path: /v1/models/acme-fraud resources: requests: {cpu: 100m, memory: 256Mi} limits: {cpu: "1", memory: 512Mi}The other five manifests differ only in MODEL_VERSION and the canaryTrafficPercent line (shown in later milestones).
Step 2: Write the verification tools
scripts/split_status.sh:
#!/usr/bin/env bash# What KServe says the split is (check 1 of 2; traffic_check.py is check 2).set -euo pipefailkubectl get isvc acme-fraud -n acme-fraud \ -o jsonpath='{range .status.components.predictor.traffic[*]}{.revisionName}{" "}{.percent}{"% tag="}{.tag}{"\n"}{end}'kubectl get isvc acme-fraud -n acme-fraud \ -o jsonpath='latestReady={.status.components.predictor.latestReadyRevision} latestRolledOut={.status.components.predictor.latestRolledoutRevision} previousRolledOut={.status.components.predictor.previousRolledoutRevision}{"\n"}'scripts/ingress.sh:
#!/usr/bin/env bash# Open the Istio ingress on localhost:8080 and print the Host header to use.# Usage: source scripts/ingress.sh (exports ISVC_HOST and PF_PID)kubectl port-forward -n istio-system svc/istio-ingressgateway 8080:80 >/dev/null 2>&1 &export PF_PID=$!sleep 3ISVC_HOST=$(kubectl get isvc acme-fraud -n acme-fraud -o jsonpath='{.status.url}' | sed 's|https\?://||')export ISVC_HOSTecho "ingress on localhost:8080 (pid ${PF_PID}), Host: ${ISVC_HOST}"scripts/traffic_check.py:
"""Send real requests through the ingress and count who answered. Do not trust READY or the traffic field alone: this measures the split users actually get.Exit codes: 0 = observed canary share within the expected band, 1 = outside it, 4 = bad input."""import argparseimport jsonimport mathimport randomimport sysimport timeimport urllib.errorimport urllib.requestfrom collections import defaultdictfrom pathlib import Path CATS = ["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] def one_request(url: str, host: str | None) -> tuple[str, int, float]: body = json.dumps({"instances": [{ "amount": round(random.lognormvariate(3.5, 1.0), 2), "hour": random.randint(0, 23), "merchant_category": random.choice(CATS), "country_mismatch": random.choice([0, 0, 0, 1]), "card_age_days": random.randint(1, 3650), "txns_last_24h": random.randint(0, 10)}]}).encode() req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}) if host: req.add_header("Host", host) # Istio routes on the Host header start = time.perf_counter() try: with urllib.request.urlopen(req, timeout=10) as r: status, version = r.status, r.headers.get("x-model-version", "?") except urllib.error.HTTPError as err: # 4xx/5xx still carry our headers status, version = err.code, err.headers.get("x-model-version", "?") except (urllib.error.URLError, TimeoutError): status, version = 0, "no-response" return version, status, (time.perf_counter() - start) * 1000 def p95(values: list[float]) -> float: s = sorted(values) return round(s[max(0, math.ceil(0.95 * len(s)) - 1)], 1) if s else 0.0 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--url", default="http://localhost:8080/v1/models/acme-fraud:predict") ap.add_argument("--host", help="Host header, e.g. acme-fraud.acme-fraud.example.com") ap.add_argument("--requests", type=int, default=500) ap.add_argument("--canary-version", required=True) ap.add_argument("--expect-canary-percent", type=float, required=True) ap.add_argument("--out", required=True) a = ap.parse_args() stats = defaultdict(lambda: {"count": 0, "errors": 0, "lat": []}) for _ in range(a.requests): version, status, ms = one_request(a.url, a.host) s = stats[version] s["count"] += 1 s["errors"] += status != 200 s["lat"].append(ms) n = a.requests report = {v: {"count": s["count"], "share": round(s["count"] / n, 4), "errors": s["errors"], "error_rate": round(s["errors"] / s["count"], 4), "p95_ms": p95(s["lat"])} for v, s in sorted(stats.items())} for v, r in report.items(): print(f"{v:<12} {r['count']:>5} reqs share {100*r['share']:5.1f}% " f"errors {r['errors']:>4} ({100*r['error_rate']:.1f}%) p95 {r['p95_ms']} ms") # 3-sigma band for a binomial share: wide enough not to flap, tight enough to catch a wrong split p = a.expect_canary_percent / 100 sigma = math.sqrt(p * (1 - p) / n) if 0 < p < 1 else 0.0 lo, hi = max(0.0, p - 3 * sigma), min(1.0, p + 3 * sigma) seen = report.get(a.canary_version, {"share": 0.0})["share"] ok = lo <= seen <= hi print(f"canary {a.canary_version}: observed {100*seen:.1f}%, expected {100*p:.0f}% " f"(band {100*lo:.1f}% to {100*hi:.1f}%) -> {'SPLIT OK' if ok else 'SPLIT WRONG'}") Path(a.out).parent.mkdir(parents=True, exist_ok=True) Path(a.out).write_text(json.dumps({"requests": n, "expected_canary": p, "canary_version": a.canary_version, "split_ok": ok, "by_version": report}, indent=2)) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())The pass band is three standard deviations of a binomial share: for 500 requests and an expected 10 percent, anything from 6.0 to 14.0 percent is consistent with a true 10 percent split. Narrower bands flap; wider ones miss real misrouting.
Step 3: Deploy and check
kubectl apply -f k8s/isvc-v1.yamlkubectl wait --for=condition=Ready isvc/acme-fraud -n acme-fraud --timeout=300s./scripts/split_status.shsource scripts/ingress.shpython scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v2 --expect-canary-percent 0 --out results/v1-only.jsonExpected output shape (not run): split_status.sh prints one revision, acme-fraud-predictor-00001 100% tag=, and traffic_check.py prints v1 500 reqs share 100.0% errors 0, then SPLIT OK.
Acceptance check
-
kubectl get isvc -n acme-fraudshowsREADY Trueand a URL. - 500 out of 500 responses carry
x-model-version: v1.
Milestone 4: Canary v2 at 10 Percent and Decide
This milestone sends 10 percent of traffic to v2, verifies the split with real requests, and lets a rule written in advance decide whether to promote.
Step 1: Write the decision rules
scripts/canary_decide.py:
"""Decide promote, rollback or wait from a traffic_check report. Rules were written before any canary ran. Illustrative, not production SLOs: * need at least 30 canary responses, otherwise WAIT * canary error rate <= baseline error rate + 1 percentage point * canary p95 latency <= 1.5 x baseline p95 + 20 msExit codes: 0 = PROMOTE, 1 = ROLLBACK, 2 = WAIT (not enough data), 4 = bad input."""import argparseimport jsonimport sysfrom pathlib import Path MIN_SAMPLES, MAX_EXTRA_ERRORS, P95_FACTOR, P95_SLACK_MS = 30, 0.01, 1.5, 20 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--report", required=True) ap.add_argument("--baseline-version", required=True) ap.add_argument("--canary-version", required=True) a = ap.parse_args() r = json.loads(Path(a.report).read_text())["by_version"] if a.baseline_version not in r: print(f"ERROR: no responses from baseline {a.baseline_version}"); return 4 base, can = r[a.baseline_version], r.get(a.canary_version) if can is None or can["count"] < MIN_SAMPLES: print(f"WAIT: {0 if can is None else can['count']} canary responses, need {MIN_SAMPLES}"); return 2 err_ok = can["error_rate"] <= base["error_rate"] + MAX_EXTRA_ERRORS lat_limit = P95_FACTOR * base["p95_ms"] + P95_SLACK_MS lat_ok = can["p95_ms"] <= lat_limit print(f"{'PASS' if err_ok else 'FAIL'} error rate: canary {100*can['error_rate']:.1f}% " f"vs limit {100*(base['error_rate'] + MAX_EXTRA_ERRORS):.1f}%") print(f"{'PASS' if lat_ok else 'FAIL'} p95: canary {can['p95_ms']} ms vs limit {lat_limit:.1f} ms") if err_ok and lat_ok: print("DECISION: PROMOTE"); return 0 print("DECISION: ROLLBACK"); return 1 if __name__ == "__main__": sys.exit(main())tests/test_canary.py:
"""Decision rules and the split band, tested without a cluster."""import jsonimport subprocessimport sys def decide(tmp_path, base: dict, canary: dict | None) -> int: by = {"v1": {"count": 450, **base}} if canary: by["v2"] = canary (tmp_path / "r.json").write_text(json.dumps({"by_version": by})) return subprocess.run([sys.executable, "scripts/canary_decide.py", "--report", str(tmp_path / "r.json"), "--baseline-version", "v1", "--canary-version", "v2"], capture_output=True).returncode OK = {"error_rate": 0.0, "p95_ms": 10.0} def test_healthy_canary_is_promoted(tmp_path): assert decide(tmp_path, OK, {"count": 50, "error_rate": 0.0, "p95_ms": 12.0}) == 0 def test_erroring_canary_is_rolled_back(tmp_path): assert decide(tmp_path, OK, {"count": 50, "error_rate": 0.04, "p95_ms": 9.0}) == 1 def test_slow_canary_is_rolled_back(tmp_path): assert decide(tmp_path, OK, {"count": 50, "error_rate": 0.0, "p95_ms": 60.0}) == 1 def test_too_few_canary_responses_means_wait(tmp_path): assert decide(tmp_path, OK, {"count": 12, "error_rate": 0.0, "p95_ms": 9.0}) == 2 assert decide(tmp_path, OK, None) == 2pytest -q testsExpected output (real run):
.... [100%]4 passed in 0.19sStep 2: Start the canary
k8s/isvc-v2-canary.yaml:
# Step 2: canary v2 at 10%.apiVersion: serving.kserve.io/v1beta1kind: InferenceServicemetadata: name: acme-fraud namespace: acme-fraud annotations: serving.kserve.io/deploymentMode: Knative # canary needs Knative mode; v0.21 default is Standard serving.kserve.io/enable-tag-routing: "true" # adds latest- and prev- hostnames for debuggingspec: predictor: minReplicas: 1 # keep one pod warm, so the canary is not a cold start maxReplicas: 2 canaryTrafficPercent: 10 # new revision gets 10%, last rolled-out keeps 90% containers: - name: kserve-container image: dev.local/acme-fraud-predictor:0.1.0 # dev.local: Knative skips digest lookup for kind-loaded images imagePullPolicy: IfNotPresent env: - name: MODEL_VERSION value: "v2" ports: - containerPort: 8080 protocol: TCP readinessProbe: httpGet: path: /v1/models/acme-fraud resources: requests: {cpu: 100m, memory: 256Mi} limits: {cpu: "1", memory: 512Mi}kubectl apply -f k8s/isvc-v2-canary.yamlkubectl wait --for=condition=Ready isvc/acme-fraud -n acme-fraud --timeout=300s./scripts/split_status.shExpected output shape (not run; from the KServe rollout docs):
acme-fraud-predictor-00002 10% tag=latestacme-fraud-predictor-00001 90% tag=prevlatestReady=acme-fraud-predictor-00002 latestRolledOut=acme-fraud-predictor-00001 previousRolledOut=acme-fraud-predictor-00001Step 3: Verify with traffic, then decide
python scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v2 --expect-canary-percent 10 --out results/canary-v2.jsonpython scripts/canary_decide.py --report results/canary-v2.json --baseline-version v1 --canary-version v2Expected output (real run against the sandbox's stand-in router, not Knative; your counts will vary around 10 percent):
v1 451 reqs share 90.2% errors 0 (0.0%) p95 8.3 msv2 49 reqs share 9.8% errors 0 (0.0%) p95 10.0 mscanary v2: observed 9.8%, expected 10% (band 6.0% to 14.0%) -> SPLIT OKPASS error rate: canary 0.0% vs limit 1.0%PASS p95: canary 10.0 ms vs limit 32.5 msDECISION: PROMOTE49 canary responses is enough for this rule (minimum 30) but thin evidence for rare errors: one error in 49 is already 2 percent. Real canaries run longer, or at a higher percentage once the first minutes look clean.
TipWith tag routing on, you can hit each revision directly to debug it:
curl -H "Host: latest-$ISVC_HOST" ...for the canary andprev-$ISVC_HOSTfor the stable revision. Exact tag hostnames are inkubectl get isvc acme-fraud -n acme-fraud -o jsonpath='{.status.components.predictor.traffic}'.
Acceptance check
- Both checks agree: the status says 10/90, and the observed canary share is inside the band.
-
canary_decide.pyprintedPROMOTEand exited 0.
Milestone 5: Promote v2
This milestone promotes v2 by removing canaryTrafficPercent, which sends 100 percent to the latest ready revision, and proves no request still reaches v1.
Step 1: Promote
k8s/isvc-v2-promote.yaml is isvc-v2-canary.yaml without the canaryTrafficPercent line.
kubectl apply -f k8s/isvc-v2-promote.yamlkubectl wait --for=condition=Ready isvc/acme-fraud -n acme-fraud --timeout=300s./scripts/split_status.shpython scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v1 --expect-canary-percent 0 --out results/promoted-v2.jsonExpected output (traffic part real, against the stand-in router):
v2 500 reqs share 100.0% errors 0 (0.0%) p95 6.3 mscanary v1: observed 0.0%, expected 0% (band 0.0% to 0.0%) -> SPLIT OKStatus shape (not run): one revision, acme-fraud-predictor-00002 100%, and latestRolledOut=acme-fraud-predictor-00002. KServe now treats revision 2 as the last good rollout, which is what a later rollback returns to.
Acceptance check
-
latestRolledoutRevisionis revision 2. - 0 of 500 responses came from v1.
Milestone 6: Canary the Broken v3 and Roll Back
This milestone rolls out v3 at 10 percent, lets the error rate condemn it, and rolls back by setting canaryTrafficPercent: 0, which pins all traffic to the last rolled-out revision.
Step 1: Start the v3 canary and measure
kubectl apply -f k8s/isvc-v3-canary.yamlkubectl wait --for=condition=Ready isvc/acme-fraud -n acme-fraud --timeout=300spython scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v3 --expect-canary-percent 10 --out results/canary-v3.jsonpython scripts/canary_decide.py --report results/canary-v3.json --baseline-version v2 --canary-version v3; echo "exit=$?"Expected output (real run, stand-in router):
v2 448 reqs share 89.6% errors 0 (0.0%) p95 6.5 msv3 52 reqs share 10.4% errors 52 (100.0%) p95 5.2 mscanary v3: observed 10.4%, expected 10% (band 6.0% to 14.0%) -> SPLIT OKFAIL error rate: canary 100.0% vs limit 1.0%PASS p95: canary 5.2 ms vs limit 29.8 msDECISION: ROLLBACKexit=1Notice the latency check passed: failing fast is fast. A canary judged on latency alone would have promoted this model.
Step 2: Roll back
k8s/isvc-v3-rollback.yaml:
# Step 5: roll back. Same spec as v3, canary set to 0%.apiVersion: serving.kserve.io/v1beta1kind: InferenceServicemetadata: name: acme-fraud namespace: acme-fraud annotations: serving.kserve.io/deploymentMode: Knative # canary needs Knative mode; v0.21 default is Standard serving.kserve.io/enable-tag-routing: "true" # adds latest- and prev- hostnames for debuggingspec: predictor: minReplicas: 1 # keep one pod warm, so the canary is not a cold start maxReplicas: 2 canaryTrafficPercent: 0 # pin: 100% back to the last rolled-out revision (v2) containers: - name: kserve-container image: dev.local/acme-fraud-predictor:0.1.0 # dev.local: Knative skips digest lookup for kind-loaded images imagePullPolicy: IfNotPresent env: - name: MODEL_VERSION value: "v3" ports: - containerPort: 8080 protocol: TCP readinessProbe: httpGet: path: /v1/models/acme-fraud resources: requests: {cpu: 100m, memory: 256Mi} limits: {cpu: "1", memory: 512Mi}kubectl apply -f k8s/isvc-v3-rollback.yaml./scripts/split_status.shpython scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v3 --expect-canary-percent 0 --out results/rolled-back.jsonExpected output (traffic part real, stand-in router):
v2 500 reqs share 100.0% errors 0 (0.0%) p95 7.0 mscanary v3: observed 0.0%, expected 0% (band 0.0% to 0.0%) -> SPLIT OKStatus shape (not run): revision 3 at 0% with tag=latest, revision 2 at 100% with tag=prev.
Step 3: Leave a clean state
The rollback pins traffic but leaves the broken spec as the latest. Re-apply the good spec so the next change starts from v2:
kubectl apply -f k8s/isvc-v2-promote.yamlkubectl wait --for=condition=Ready isvc/acme-fraud -n acme-fraud --timeout=300s./scripts/split_status.shKnative creates a new revision (4) for this spec change even though it matches revision 2, and sends it 100 percent.
Acceptance check
-
canary_decide.pyexited 1 for v3, and the reason was the error rate. - After rollback, 0 of 500 responses came from v3.
Common MistakeRolling back by deleting the InferenceService and re-creating it with v2. That drops all traffic during the re-create and throws away revision history. Setting
canaryTrafficPercent: 0moves traffic in one route update while both revisions stay up.
Break It on Purpose
The broken model in Milestone 6 is the main exercise. These two break the platform instead.
Step 1: A revision that never becomes ready
k8s/isvc-v9-never-ready.yaml asks for MODEL_VERSION=v9, which does not exist in the image, at 10 percent:
kubectl apply -f k8s/isvc-v9-never-ready.yamlkubectl get isvc acme-fraud -n acme-fraudkubectl get pods -n acme-fraudpython scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v9 --expect-canary-percent 0 --out results/v9.jsonExpected behaviour (not run): the new pod crashes on startup (FileNotFoundError for /app/models/v9/metadata.json, status CrashLoopBackOff), the InferenceService shows READY False, and traffic stays 100 percent on the previous revision. Knative never routes to a revision that is not ready, so a crash-on-start bug cannot reach users; a bug like v3's, which starts fine and fails on requests, can. After Knative's progress-deadline (600 seconds by default) the revision is marked failed. Re-apply k8s/isvc-v2-promote.yaml to recover.
Step 2: An image Knative cannot resolve
Change the image in a copy of isvc-v2-promote.yaml to acme-fraud-predictor:0.1.0 (no dev.local/), tag it the same way locally, load it into kind, and apply it.
Expected behaviour (not run): the revision fails with a message about resolving the image to a digest, even though the image is on the node. Put dev.local/ back.
Step 3: Clean up after breaking
kubectl apply -f k8s/isvc-v2-promote.yamlkubectl wait --for=condition=Ready isvc/acme-fraud -n acme-fraud --timeout=300s./scripts/split_status.sh # one revision at 100%Validation & Testing
Run these to prove the project end to end, then tear down. The local route costs nothing.
Step 1: Final checks
pytest -q tests # 4 passedkubectl get revisions -n acme-fraud # history of every rollout stepls results/ # one JSON per traffic checkpython scripts/traffic_check.py --host "$ISVC_HOST" --canary-version v2 --expect-canary-percent 10 \ --out results/sanity.json; echo "exit=$?" # on a 100/0 route this must FAILThe last command is a sanity check of the checker itself: against a route that is 100 percent on one version it must print SPLIT WRONG and exit 1. In the sandbox, a deliberately wrong 70/30 split printed (real run):
canary v2: observed 28.0%, expected 10% (band 6.0% to 14.0%) -> SPLIT WRONGDefinition of done
- KServe v0.21.0 runs in Knative mode on kind, installed from a pinned installer you read.
- Every manifest sets
deploymentMode: Knativeand uses adev.local/image. - A 90/10 split was verified by status and by real requests, inside the 3-sigma band.
- v2 was promoted by rule (
PROMOTE), v3 was rolled back by rule (ROLLBACK), and both steps were re-verified with traffic. - The README shows the
results/*.jsonfiles and states what ran on a real cluster.
Step 2: Tear down
scripts/cluster_down.sh:
#!/usr/bin/env bash# Delete the kind cluster and the local image. Nothing else was created.set -euo pipefail[ -n "${PF_PID:-}" ] && kill "$PF_PID" 2>/dev/null || truekind delete cluster --name acme-kservedocker image rm dev.local/acme-fraud-predictor:0.1.0 2>/dev/null || truekind get clustersecho "teardown done"./scripts/cluster_down.shExpected output: kind get clusters prints No kind clusters found., then teardown done.
Optional: Build the Cluster on EKS with Terraform
This appendix creates the same platform on Amazon EKS with Terraform. It costs money from the moment apply finishes until destroy completes, and it was not run or validated for this guide.
SecurityBillable resources: the EKS control plane (USD 0.10 per hour on standard support), two
t3.largenodes, one NAT gateway, EBS volumes and data transfer. Check the AWS Pricing Calculator for your region before you start, set a Budgets alert, and destroy the same day.
terraform/main.tf:
# OPTIONAL and NOT FREE: an EKS cluster for the same project. Not run for this guide.# Costs while it exists: EKS control plane, two EC2 nodes, a NAT gateway, EBS volumes.# Destroy the same day: terraform destroyterraform { required_version = ">= 1.6" required_providers { aws = { source = "hashicorp/aws", version = "~> 6.72" } }} provider "aws" { region = var.region default_tags { tags = { project = "acme-fraud-kserve", owner = var.owner } } # find every resource later} variable "region" { default = "ap-south-1" }variable "owner" { type = string } # your name or email, required module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "6.7.3" name = "acme-kserve" cidr = "10.42.0.0/16" azs = ["${var.region}a", "${var.region}b"] private_subnets = ["10.42.1.0/24", "10.42.2.0/24"] public_subnets = ["10.42.101.0/24", "10.42.102.0/24"] enable_nat_gateway = true single_nat_gateway = true # one NAT, not one per zone: cheaper, fine for a lab} module "eks" { source = "terraform-aws-modules/eks/aws" version = "21.29.0" name = "acme-kserve" kubernetes_version = "1.33" endpoint_public_access = true enable_cluster_creator_admin_permissions = true vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnets addons = { coredns = {} eks-pod-identity-agent = { before_compute = true } kube-proxy = {} vpc-cni = { before_compute = true } } eks_managed_node_groups = { default = { ami_type = "AL2023_x86_64_STANDARD" instance_types = ["t3.large"] # 2 vCPU, 8 GiB each: enough for Istio + Knative + KServe min_size = 2 max_size = 2 desired_size = 2 } }} output "kubeconfig_command" { value = "aws eks update-kubeconfig --region ${var.region} --name ${module.eks.cluster_name}"}cd terraformterraform init && terraform apply -var owner="$USER"$(terraform output -raw kubeconfig_command)Then two changes from the kind path. Run the KServe installer as in Milestone 1 (skip kind create cluster). And push the image to ECR instead of kind load, use the ECR image URI in the manifests (no dev.local/), and let Knative resolve digests normally. When done:
kubectl delete isvc acme-fraud -n acme-fraud # remove load balancers the cluster createdterraform destroy -var owner="$USER"Common MistakeRunning
terraform destroywhile Istio's LoadBalancer Service still exists. AWS created that load balancer, not Terraform, so it can block VPC deletion. Delete Kubernetes Services of type LoadBalancer (or uninstall Istio) first.
Present This Project in an Interview
The 60-second version
"I ran a KServe canary on a local kind cluster in Knative mode. One image holds three model versions and an env var picks one, so each revision differs only in the model. I sent 10 percent to v2 and did not trust the status: a script sent 500 real requests through the Istio ingress and counted which version answered, using response headers, against a 3-sigma band. A rule written in advance promoted v2 on error rate and p95. Then I rolled out v3, which had a renamed training feature: same offline score, healthy probes, and a 100 percent error rate on its 10 percent. The rule said rollback, I set canaryTrafficPercent to 0, and real traffic confirmed 0 requests on v3."
Questions to expect
- "Why did the readiness probe not catch v3?" The model loads fine; it fails only when a real request lacks the renamed column. Probes check startup, not the feature contract. Canary traffic or a contract test in CI catches it.
- "Why count responses instead of reading the traffic field?" The field is intent. Counting real responses proves the ingress, Host routing and revisions all agree.
- "Is 49 canary requests enough?" For a 100 percent error rate, yes. For a 0.5 percent regression, no: I would run longer or raise the canary percentage after a clean first window.
- "Why Knative mode when Standard is the v0.21 default?"
canaryTrafficPercentneeds revisions and a weighted route, which Knative provides. Standard mode would need a separate rollout tool. - "How would you automate this?" A pipeline applies the canary, runs
traffic_check.pyon a schedule, and applies the promote or rollback manifest based oncanary_decide.py's exit code, the same pattern as the CI/CD project's gate.
What to show on screen
split_status.shnext totraffic_check.pyoutput for the 90/10 step.- The v3
ROLLBACKdecision with the latency check passing and the error check failing. kubectl get revisions -n acme-fraudshowing the full rollout history.