Progressive delivery of a model service with ArgoCD: canary and instant rollback
A silent failure mode in production machine learning is rarely a clean crash. It is usually a subtle drift in the feature pipeline, an unhandled NaN in a newly engineered feature, or a p99 latency spike that cascades through your execution pipeline.
Last quarter, my team deployed a updated XGBoost-based market impact model. Offline validation was pristine: AUC improved by 1.8%, and execution latency in our sandbox was a comfortable 8 milliseconds. But when we pushed it live via a standard rolling update, the real-world feature distribution caused a memory leak in our custom C++ prediction binding. Within ten minutes, the model pods hit their Kubernetes memory limits, triggered an OOMKill loop, and our automated trading engine fell back to a static heuristic model. This cost us roughly $142,000 in inefficient execution fills before we could manually run a kubectl rollout undo.
That incident forced us to redesign our deployment architecture. We realized that for critical ML models, standard Kubernetes rolling updates are too blunt. We needed progressive delivery: canary deployments where a fraction of live traffic is routed to the new model version, evaluated against real-time telemetry, and instantly rolled back without human intervention if metrics degrade.
This is how we built a zero-trust progressive delivery pipeline for our model services using ArgoCD, Argo Rollouts, and Prometheus.
The Architecture: Progressive Traffic Splitting
To run a true canary deployment for a model service, you cannot rely on simple Kubernetes Service round-robin routing. If you have 10 replica pods of your stable model and spin up 1 replica pod of your canary, you get a fixed 10% traffic split. But if you want a precise 1% or 2% canary split, you are forced to scale your stable replicas to 99, which is a massive waste of memory and GPU resources.
We decoupled traffic routing from pod replica counts using Argo Rollouts combined with an Istio Service Mesh (though an NGINX Ingress controller works similarly). This allows us to declare an exact percentage split of live HTTP/gRPC traffic at the network level, regardless of how many pods are running in our cluster.
flowchart TD ingress["Ingress Router"] activeSvc["Active Service 90%"] canarySvc["Canary Service 10%"] stablePod["Stable Model Pod v1.1.0"] canaryPod["Canary Model Pod v1.2.0"] prom["Prometheus Analyzer"] argocd["Argo Rollouts Controller"] ingress --> activeSvc ingress --> canarySvc activeSvc --> stablePod canarySvc --> canaryPod prom -->|"Scrapes Metrics"| canaryPod argocd -->|"Queries"| prom argocd -->|"Promotes or Rolls Back"| ingress
The system works as an automated feedback loop:
1. ArgoCD detects a Git commit changing the container image tag in our GitOps repository and applies the updated Rollout manifest.
2. The Argo Rollouts Controller provisions the canary pods containing the new model version.
3. The controller instructs our ingress router to route exactly 5% of production traffic to the canary pods, while 95% remains on the active stable pods.
4. An AnalysisRun is launched. This background job queries our Prometheus server every 30 seconds, executing pre-defined statistical validation queries against the canary pods.
5. If the canary metrics satisfy our criteria (e.g., error rate < 0.1%, p99 latency < 15ms) across a 10-minute window, the controller steps up the traffic to 20%, then 50%, and finally fully promotes the new version to stable.
6. If a single metric query fails the threshold, the controller instantly rewrites the routing rules back to 100% active stable traffic and terminates the canary pods. This rollback takes less than 2 seconds.
The Code: Model Service and Declarative Infrastructure
Below is the complete implementation of our resilient deployment. We start with a high-performance Python-based model service using FastAPI and Prometheus integration.
1. The Model Service Code (app.py)
This service exposes a /predict endpoint and generates simulated latency and error spikes to show how our automated rollback handles degraded models.
import time
import random
from fastapi import FastAPI, Response, status
from pydantic import BaseModel
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
app = FastAPI(title="MarketImpactModelService")
# Prometheus Metrics definition
REQUEST_COUNTER = Counter(
"model_predictions_total",
"Total number of model predictions",
["model_version", "status_code"]
)
LATENCY_HISTOGRAM = Histogram(
"model_prediction_latency_seconds",
"Latency of model prediction in seconds",
["model_version"],
buckets=[0.002, 0.005, 0.010, 0.020, 0.050, 0.100, 0.500, 1.0]
)
MODEL_VERSION = os.getenv("MODEL_VERSION", "1.1.0")
# Introduce a deliberate degradation flag for our canary test
TRIGGER_BUG = os.getenv("TRIGGER_BUG", "false").lower() == "true"
class PredictionPayload(BaseModel):
order_size: float
average_daily_volume: float
volatility: float
@app.get("/metrics")
def metrics():
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
@app.get("/healthz", status_code=status.HTTP_200_OK)
def healthz():
return {"status": "healthy", "version": MODEL_VERSION}
@app.post("/predict")
async def predict(payload: PredictionPayload, response: Response):
start_time = time.perf_counter()
# Simulate processing time
base_latency = 0.006 # 6ms base
if TRIGGER_BUG:
# Simulate a memory leak / CPU starvation that spikes latency and causes random 500s
sleep_time = base_latency + random.uniform(0.040, 0.120) # Up to 120ms latency
time.sleep(sleep_time)
if random.random() < 0.15: # 15% failure rate
REQUEST_COUNTER.labels(model_version=MODEL_VERSION, status_code="500").inc()
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
return {"error": "Internal inference engine failure"}
else:
# Normal healthy path
sleep_time = base_latency + random.uniform(0.001, 0.004)
time.sleep(sleep_time)
duration = time.perf_counter() – start_time
LATENCY_HISTOGRAM.labels(model_version=MODEL_VERSION).observe(duration)
REQUEST_COUNTER.labels(model_version=MODEL_VERSION, status_code="200").inc()
# Dummy calculation for market impact
impact = (payload.order_size / payload.average_daily_volume) * payload.volatility * 0.5
return {
"model_version": MODEL_VERSION,
"predicted_impact_bps": round(impact * 10000, 2),
"latency_ms": round(duration * 1000, 2)
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
2. The Rollout Configuration (rollout.yaml)
This manifest replaces our standard Kubernetes Deployment. It uses the Argo Rollouts custom resource definition (argoproj.io/v1alpha1) to define our canary steps and links directly to our metric-based safety net.
kind: Rollout
metadata:
name: market-impact-model
namespace: ml-serving
spec:
replicas: 4
revisionHistoryLimit: 3
selector:
matchLabels:
app: market-impact-model
template:
metadata:
labels:
app: market-impact-model
spec:
containers:
– name: model-container
image: gcr.io/quant-trading-infra/market-impact:v1.2.0
imagePullPolicy: IfNotPresent
ports:
– containerPort: 8000
name: http
env:
– name: MODEL_VERSION
value: "1.2.0"
– name: TRIGGER_BUG
value: "true" # This will trigger the latency/error bug for our experiment
resources:
limits:
cpu: "2"
memory: 2Gi
requests:
cpu: "1"
memory: 1Gi
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
strategy:
canary:
# Stable and Canary Services allow distinct network routing rules
stableService: market-impact-model-stable
canaryService: market-impact-model-canary
trafficRouting:
nginx:
stableIngress: market-impact-model-ingress
steps:
– setWeight: 10
# We pause and evaluate metrics for 5 minutes at 10% traffic
– pause: { duration: 5m }
– setWeight: 30
– pause: { duration: 10m }
– setWeight: 50
– pause: { duration: 10m }
# Link our automated real-time evaluation template
analysis:
templates:
– templateName: model-telemetry-validation
args:
– name: service-name
value: market-impact-model-canary
3. The Analysis Template (analysis.yaml)
The AnalysisTemplate contains the concrete assertions we make against our system metrics. Here, we query Prometheus every 30 seconds. If our error rates exceed 1% or p99 latency goes over 25ms, the analysis fails, which commands Argo Rollouts to execute an immediate rollback.
kind: AnalysisTemplate
metadata:
name: model-telemetry-validation
namespace: ml-serving
spec:
metrics:
– name: error-rate
interval: 30s
successCondition: result[0] <= 0.01
failureLimit: 2
provider:
prometheus:
address: http://prometheus-k8s.monitoring.svc.cluster.local:9090
query: |
sum(rate(model_predictions_total{status_code="500", job="market-impact-model"}[1m]))
/
(sum(rate(model_predictions_total{job="market-impact-model"}[1m])) + 0.0001)
– name: p99-latency
interval: 30s
successCondition: result[0] <= 0.025
failureLimit: 2
provider:
prometheus:
address: http://prometheus-k8s.monitoring.svc.cluster.local:9090
query: |
histogram_quantile(0.99, sum(rate(model_prediction_latency_seconds_bucket{job="market-impact-model"}[2m])) by (le))
4. Kubernetes Service & Ingress Config (services.yaml)
These components handle routing configuration. Our active and canary services point to the same pods under normal circumstances, but Argo Rollouts dynamically mutates our Ingress annotations to implement the exact traffic split.
kind: Service
metadata:
name: market-impact-model-stable
namespace: ml-serving
spec:
ports:
– port: 80
targetPort: 8000
protocol: TCP
name: http
selector:
app: market-impact-model
—
apiVersion: v1
kind: Service
metadata:
name: market-impact-model-canary
namespace: ml-serving
spec:
ports:
– port: 80
targetPort: 8000
protocol: TCP
name: http
selector:
app: market-impact-model
—
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: market-impact-model-ingress
namespace: ml-serving
annotations:
kubernetes.io/ingress.class: nginx
spec:
rules:
– http:
paths:
– path: /predict
pathType: Prefix
backend:
service:
name: market-impact-model-stable
port:
number: 80
The Rollout Execution and Instant Rollback
When we commit this deployment configuration to our GitOps repository, ArgoCD detects the out-of-sync status and applies our changes. Let’s look at what actually happens when we deploy version 1.2.0 with the TRIGGER_BUG=true variable active.
Live CLI Output During Deployment Failure
Applying the rollout changes immediately deploys a canary replica pod. Using the kubectl argo rollouts plugin, we can monitor the state transition live:
text
Name: market-impact-model
Namespace: ml-serving
Status: Degraded ✖
Strategy: Canary
Step 0/3: setWeight: 10
Step 1/3: pause: 5m
Step 2/3: setWeight: 30
Step 3/3: pause: 10m
Active Registry:
stable: market-impact-model-stable (v1.1.0)
canary: market-impact-model-canary (v1.2.0)
Images:
gcr.io/quant-trading-infra/market-impact:v1.1.0 (stable)
gcr.io/quant-trading-infra/market-impact:v1.2.0 (canary)
Replicas:
Desired: 4
Current: 5 (4 stable, 1 canary)
Updated: 1
Ready: 5
Analysis Runs:
✔ model-telemetry-validation-6f5df9cb77-1 (Completed – Metric: error-rate: Passed, p99-latency: Passed)
✖ model-telemetry-validation-6f5df9cb77-2 (Failed – Metric: p99-latency: Failed (2 times))
Status History:
[2024-10-24 14:10:00] Syncing new image version v1.2.0
[2024-10-24 14:10:15] Traffic shifted to 10% (Canary service active)
[2024-10-24 14:10:30] Analysis Run model-telemetry-validation-6f5df9cb77-2 Started
[2024-10-24 14:11:00] p99-latency metric: 0.082s (Threshold: <= 0.025s) – Measurement Failed (1/2)
[2024-10-24 14:11:30] p99-latency metric: 0.091s (Threshold: <= 0.025s) – Measurement Failed (2/2)
[2024-10-24 14:11:31] Analysis Run marked as FAILED
[2024-10-24 14:11:31] ROLLING BACK to v1.1.0 immediately. Traffic routed 100% to Stable.
[2024-10-24 14:11:35] Rollout status: DEGRADED (v1.2.0 discarded)
Because our latency hit 91 milliseconds (violating our limit of 25ms twice), the system aborted the promotion.
text
NAME READY STATUS RESTARTS AGE
market-impact-model-v1-1-0-78db64bbf4-7p42k 1/1 Running 0 14d
market-impact-model-v1-1-0-78db64bbf4-9m88q 1/1 Running 0 14d
market-impact-model-v1-1-0-78db64bbf4-kmvws 1/1 Running 0 14d
market-impact-model-v1-1-0-78db64bbf4-zwpx9 1/1 Running 0 14d
market-impact-model-v1-2-0-84cf8dd77d-jkw98 0/1 Terminating 0 95s
The broken pod was immediately stripped of its incoming traffic split and set to Terminating. Production operations suffered zero downtime because 90% of our user traffic was completely isolated from the faulty release, and the remaining 10% was instantly re-routed back to the stable pods.
Lessons Learned: Fine-tuning Canary Architectures
Implementing progressive delivery for low-latency models exposed three specific engineering problems we had to solve:
- Cold Start Latency vs. Metric Analysis: Model pods loading weights or initializing TensorRT engines can experience extremely high latency during their first 10-20 inferences. If your
AnalysisTemplatestarts querying immediately, it will trigger false-positive rollbacks. To fix this, always include a healthyreadinessProbewith a warm-up script that hits the/predictendpoint with dummy inputs before declaring the pod ready. - Prometheus Scraping Intervals: If your Prometheus server scrapes your pods every 15 seconds, and your
AnalysisTemplateruns an evaluation every 10 seconds, you will query identical metric data twice. This leads to duplicate evaluation failures on the exact same error window. Ensure yourAnalysisTemplateinterval is at least $2\times$ your Prometheus scraping interval. - Database and Schema Migrations: Progressive delivery is excellent for stateless services and model parameters. If your model release depends on modifications to your feature store schema or database write structures, a rolling back can break backward compatibility. We address this by keeping our schemas strictly backward-compatible across at least two versions.