MLOps & Model Deployment
Deploying, monitoring, and maintaining AI models in production.
From Notebook to Production
Getting an AI model to work in a Jupyter notebook is just the beginning. The real challenge is deploying it to production where it handles real users, real data, and real business consequences. MLOps (Machine Learning Operations) is the practice of deploying, monitoring, and maintaining ML systems reliably at scale. This lesson covers everything you need to take AI models from experimentation to production.
Production ML Systems are Software Systems
All the software engineering fundamentals apply: testing, monitoring, versioning, CI/CD, and incident response. AI doesn't exempt you from good engineering practices.
What is MLOps?
MLOps brings DevOps principles to machine learning: automation, monitoring, reliability, and continuous improvement. It's the difference between "it works on my machine" and "it works reliably for millions of users."
- Model trained once, never updated
- No monitoring, silent failures
- Manual deployment process
- Performance degradation over time
- No version control for models
- Can't reproduce results
- No rollback capability
- Automated retraining pipelines
- Comprehensive monitoring & alerts
- CI/CD for model deployment
- Detect and handle model drift
- Full model versioning & lineage
- Reproducible experiments
- Instant rollback on issues
MLOps Lifecycle
Figure 1: MLOps is a continuous loop, monitoring and maintenance feed retraining back into data collection
Model Deployment Patterns
Different use cases require different deployment approaches. Choose based on latency requirements, scale, and infrastructure constraints.
First: Create the Model Artifacts
Every example that follows loads a model from disk. Run this once to produce the artifacts and sample data they expect, so each snippet below runs as written.
# train_model.py - Creates the artifacts every example in this lesson expects.
# Run this first: python train_model.py
import joblib
import numpy as np
import pandas as pd
from pathlib import Path
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
Path("models").mkdir(exist_ok=True)
Path("data/input").mkdir(parents=True, exist_ok=True)
Path("data/output").mkdir(parents=True, exist_ok=True)
# Served by deploy_model.py and scored by batch_inference.py. Iris has four
# features, matching the [5.1, 3.5, 1.4, 0.2] request used throughout.
X, y = load_iris(return_X_y=True)
joblib.dump(RandomForestClassifier(n_estimators=10, random_state=0).fit(X, y),
"models/model_v1.joblib")
# Two 10-feature models for the A/B testing example.
rng = np.random.default_rng(0)
X_ab = rng.normal(size=(300, 10))
y_ab = (X_ab[:, 0] > 0).astype(int)
joblib.dump(RandomForestClassifier(n_estimators=10, random_state=1).fit(X_ab, y_ab),
"models/production_v1.joblib")
joblib.dump(RandomForestClassifier(n_estimators=25, random_state=2).fit(X_ab, y_ab),
"models/candidate_v2.joblib")
# Input file for batch_inference.py, four columns to match model_v1.
n = 5000
pd.DataFrame({
"age": rng.normal(35, 12, n),
"income": rng.normal(55000, 25000, n),
"clicks": rng.normal(45, 20, n),
"time_on_site": rng.normal(300, 100, n),
}).to_csv("data/input/users_20240115.csv", index=False)
print("Wrote models/ and data/input/users_20240115.csv")Expected Output:
Wrote models/ and data/input/users_20240115.csv
1. REST API Endpoint (Real-Time Inference)
Deploy model as an HTTP API that accepts requests and returns predictions instantly.
# deploy_model.py - Complete production-ready ML API
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, ConfigDict, Field
from typing import List, Optional
from contextlib import asynccontextmanager
import joblib
import numpy as np
import logging
import os
from datetime import datetime
from prometheus_client import (
CONTENT_TYPE_LATEST, REGISTRY, CollectorRegistry,
Counter, Histogram, generate_latest, multiprocess,
)
import time
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Prometheus metrics
PREDICTION_COUNT = Counter('predictions_total', 'Total predictions', ['model_version'])
PREDICTION_LATENCY = Histogram('prediction_latency_seconds', 'Prediction latency')
PREDICTION_ERRORS = Counter('prediction_errors_total', 'Total prediction errors')
@asynccontextmanager
async def lifespan(app):
"""Load model on startup."""
import os
model_path = os.getenv("MODEL_PATH", "models/model_v1.joblib")
model_version = os.getenv("MODEL_VERSION", "1.0.0")
model_service.load_model(model_path, model_version)
yield
app = FastAPI(
title="ML Model API",
description="Production ML model serving",
version="1.0.0",
lifespan=lifespan
)
# Load model at startup
class ModelService:
"""Singleton model service."""
def __init__(self):
self.model = None
self.model_version = None
self.loaded_at = None
def load_model(self, model_path: str, version: str):
"""Load model from disk."""
try:
self.model = joblib.load(model_path)
self.model_version = version
self.loaded_at = datetime.now()
logger.info(f"Loaded model version {version} from {model_path}")
except Exception as e:
logger.error(f"Failed to load model: {e}")
raise
def predict(self, features: np.ndarray) -> np.ndarray:
"""Make prediction."""
if self.model is None:
raise RuntimeError("Model not loaded")
return self.model.predict(features)
def get_info(self) -> dict:
"""Get model info."""
return {
"model_version": self.model_version,
"loaded_at": self.loaded_at.isoformat() if self.loaded_at else None,
"model_type": type(self.model).__name__
}
# Initialize service
model_service = ModelService()
# Request/Response models
class PredictionRequest(BaseModel):
"""Input features for prediction."""
features: List[float] = Field(..., min_length=1, max_length=100)
request_id: Optional[str] = Field(None, description="Optional request ID for tracking")
model_config = ConfigDict(json_schema_extra={
"examples": [{
"features": [5.1, 3.5, 1.4, 0.2],
"request_id": "req_123"
}]
})
class PredictionResponse(BaseModel):
"""Prediction result."""
prediction: float
prediction_class: Optional[str] = None
confidence: Optional[float] = None
model_version: str
request_id: Optional[str]
latency_ms: float
@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
"""
Make prediction on input features.
Returns prediction with confidence and metadata.
"""
start_time = time.time()
try:
# Convert to numpy array
features = np.array(request.features).reshape(1, -1)
# Validate features
if np.any(np.isnan(features)) or np.any(np.isinf(features)):
raise HTTPException(
status_code=400,
detail="Features contain NaN or Inf values"
)
# Make prediction
with PREDICTION_LATENCY.time():
prediction = model_service.predict(features)
# Get prediction details
pred_value = float(prediction[0])
# Calculate confidence (if model supports predict_proba)
confidence = None
pred_class = None
if hasattr(model_service.model, 'predict_proba'):
proba = model_service.model.predict_proba(features)
confidence = float(np.max(proba))
pred_class = str(int(pred_value))
# Record metrics
PREDICTION_COUNT.labels(
model_version=model_service.model_version
).inc()
# Calculate latency
latency_ms = (time.time() - start_time) * 1000
# Log prediction
confidence_str = f"{confidence:.4f}" if confidence is not None else "N/A"
logger.info(
f"Prediction: {pred_value:.4f}, "
f"confidence: {confidence_str}, "
f"latency: {latency_ms:.2f}ms, "
f"request_id: {request.request_id}"
)
return PredictionResponse(
prediction=pred_value,
prediction_class=pred_class,
confidence=confidence,
model_version=model_service.model_version,
request_id=request.request_id,
latency_ms=round(latency_ms, 2)
)
except HTTPException:
raise
except Exception as e:
PREDICTION_ERRORS.inc()
logger.error(f"Prediction error: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Prediction failed: {str(e)}"
)
@app.get("/health")
async def health_check(response: Response):
"""Health check endpoint.
Returns 503 when the model cannot serve, so container and Kubernetes
probes can rely on the status code alone.
"""
try:
# Test prediction
test_features = np.zeros((1, 4)) # Adjust size to model input
_ = model_service.predict(test_features)
return {
"status": "healthy",
"model_loaded": True,
"model_info": model_service.get_info()
}
except Exception as e:
response.status_code = 503
return {
"status": "unhealthy",
"model_loaded": False,
"error": str(e)
}
@app.get("/metrics")
async def metrics():
"""Prometheus metrics endpoint.
Two things are easy to get wrong here:
1. Return a raw text Response. Returning the bytes from generate_latest()
directly lets FastAPI JSON-encode them, which Prometheus cannot scrape.
2. With multiple uvicorn workers, each process keeps its own counters, so a
scrape would return only the worker that happened to answer. Setting
PROMETHEUS_MULTIPROC_DIR makes the client share counters across workers.
"""
if os.getenv("PROMETHEUS_MULTIPROC_DIR"):
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry)
else:
registry = REGISTRY
return Response(content=generate_latest(registry), media_type=CONTENT_TYPE_LATEST)
@app.get("/model/info")
async def model_info():
"""Get model information."""
return model_service.get_info()
# Run with: uvicorn deploy_model:app --host 0.0.0.0 --port 8000Running the Service
The model is loaded once in the lifespan handler, before the server accepts traffic. No request pays the disk-read cost, and a missing model file kills the process at startup instead of surfacing as a 500 on the first prediction.
# These are the same pins collected into requirements.txt in the
# Docker section below.
pip install fastapi==0.141.1 "uvicorn[standard]==0.52.1" pydantic==2.13.4 \
scikit-learn==1.9.0 numpy==2.5.2 pandas==3.0.5 joblib==1.5.3 \
prometheus-client==0.26.0
python train_model.py # writes models/model_v1.joblib
uvicorn deploy_model:app --host 0.0.0.0 --port 8000
# Both values are read from the environment, so serving a new model
# needs no code change:
# MODEL_PATH=models/model_v2.joblib MODEL_VERSION=2.0.0 uvicorn deploy_model:appExpected Output:
INFO: Started server process [1] INFO: Waiting for application startup. INFO:deploy_model:Loaded model version 1.0.0 from models/model_v1.joblib INFO: Application startup complete. INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
Calling the Endpoints
Four endpoints, each with a different consumer: one serves predictions, one answers orchestrators, one feeds Prometheus, and one reports which model is actually loaded.
Where do the four numbers come from? train_model.py trains on scikit-learn's iris dataset, so the model expects the four measurements that dataset is built from, in this exact order:
5.1sepal length (cm)3.5sepal width (cm)1.4petal length (cm)0.2petal width (cm)
That is the first sample in the dataset, an Iris setosa, which is class 0. Hence "prediction": 0.0 and a confidence of 1.0: every tree in the forest agreed. Your own model would take whatever features you trained it on. The API neither knows nor cares what they mean, which is why PredictionRequest can only validate count and numeric type. Ordering is on you: send the same four numbers reversed, [0.2, 1.4, 3.5, 5.1], and this model answers class 2 at 0.60 confidence. Still a 200, still well formed, simply wrong, with nothing in the response to hint at it. Column order is part of your model's contract, so pin it next to the artifact and assert it on the way in.
# 1. Predict. These four features are the first row of the iris
# dataset that train_model.py trained on.
curl -X POST "http://localhost:8000/predict" \
-H "Content-Type: application/json" \
-d '{"features": [5.1, 3.5, 1.4, 0.2], "request_id": "test_001"}'Expected Output:
{
"prediction": 0.0,
"prediction_class": "0",
"confidence": 1.0,
"model_version": "1.0.0",
"request_id": "test_001",
"latency_ms": 1.61
}Every response carries model_version and latency_ms. Log both downstream: when accuracy moves, the first question is which model version served the request, and you cannot answer it after the fact if the response never said.
# 2. Health. Returns 503 when the model cannot serve, so Docker, # Kubernetes and load balancers can act on the status code alone. curl http://localhost:8000/health
Expected Output:
{
"status": "healthy",
"model_loaded": true,
"model_info": {
"model_version": "1.0.0",
"loaded_at": "2026-08-13T23:07:02.948753",
"model_type": "RandomForestClassifier"
}
}# 3. Metrics, in Prometheus text format. This is the endpoint that # the prometheus.yml further down scrapes every 15 seconds. The full # response also carries the client's default python_* and process_* # collectors plus a bucket line per histogram boundary, so filter down # to your own totals when eyeballing it. curl -s http://localhost:8000/metrics | grep -E "^prediction.*(total|count|sum)"
Expected Output:
predictions_total{model_version="1.0.0"} 1.0
prediction_latency_seconds_count 1.0
prediction_latency_seconds_sum 0.0007854330000043319
prediction_errors_total 0.0# 4. Bad input is rejected before it ever reaches the model.
curl -s -w "\n-> HTTP %{http_code}\n" \
-X POST "http://localhost:8000/predict" \
-H "Content-Type: application/json" \
-d '{"features": [1.0, NaN, 3.0, 4.0]}'Expected Output:
{"detail":"Features contain NaN or Inf values"}
-> HTTP 400http://localhost:8000/docs gives you a browsable form for every endpoint, pre-filled with the sample in PredictionRequest. That json_schema_extra example exists precisely so the docs page is usable without reading the source.Best for:
- Real-time predictions (low latency required)
- User-facing applications
- Interactive systems
- Low to medium request volume
2. Batch Processing (Offline Inference)
Process large amounts of data periodically (hourly, daily, etc.) without real-time requirements.
# batch_inference.py - Production batch processing
import pandas as pd
import joblib
import logging
from datetime import datetime
import time
class BatchInferenceService:
"""Service for running batch predictions."""
def __init__(self, model_path: str, batch_size: int = 1000):
self.model = joblib.load(model_path)
self.batch_size = batch_size
self.logger = logging.getLogger(__name__)
def process_batch(
self,
input_path: str,
output_path: str,
feature_columns: list
) -> dict:
"""
Process batch predictions.
Args:
input_path: Path to input CSV/Parquet
output_path: Path to save predictions
feature_columns: List of feature column names
Returns:
Statistics about the batch job
"""
start_time = time.time()
# Load data
self.logger.info(f"Loading data from {input_path}")
if input_path.endswith('.parquet'):
df = pd.read_parquet(input_path)
else:
df = pd.read_csv(input_path)
total_rows = len(df)
self.logger.info(f"Loaded {total_rows:,} rows")
# Validate features
missing_cols = set(feature_columns) - set(df.columns)
if missing_cols:
raise ValueError(f"Missing columns: {missing_cols}")
# Process in batches
predictions = []
errors = 0
for i in range(0, total_rows, self.batch_size):
batch = df.iloc[i:i + self.batch_size]
try:
# Extract features
X = batch[feature_columns].values
# Predict
batch_preds = self.model.predict(X)
predictions.extend(batch_preds)
# Log progress
progress = min(i + self.batch_size, total_rows)
self.logger.info(
f"Processed {progress:,}/{total_rows:,} "
f"({progress/total_rows*100:.1f}%)"
)
except Exception as e:
self.logger.error(f"Batch {i} failed: {e}")
# Add NaN for failed predictions
predictions.extend([None] * len(batch))
errors += len(batch)
# Add predictions to dataframe
df['prediction'] = predictions
df['predicted_at'] = datetime.now()
# Save results
self.logger.info(f"Saving results to {output_path}")
if output_path.endswith('.parquet'):
df.to_parquet(output_path, index=False)
else:
df.to_csv(output_path, index=False)
# Calculate statistics
elapsed_time = time.time() - start_time
successful = total_rows - errors
stats = {
'total_rows': total_rows,
'successful': successful,
'errors': errors,
'success_rate': successful / total_rows * 100,
'elapsed_time_seconds': round(elapsed_time, 2),
'rows_per_second': round(total_rows / elapsed_time, 2),
'output_path': output_path
}
self.logger.info(
f"Batch complete: {successful:,}/{total_rows:,} successful "
f"({stats['success_rate']:.1f}%), "
f"{stats['rows_per_second']:.0f} rows/sec"
)
return stats
# Example: Run as scheduled job (cron/Airflow)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
service = BatchInferenceService(
model_path="models/model_v1.joblib",
batch_size=1000
)
stats = service.process_batch(
input_path="data/input/users_20240115.csv",
output_path="data/output/predictions_20240115.csv",
feature_columns=['age', 'income', 'clicks', 'time_on_site']
)
print(f"✓ Batch job complete: {stats}")Expected Output:
INFO:__main__:Loading data from data/input/users_20240115.csv
INFO:__main__:Loaded 5,000 rows
INFO:__main__:Processed 1,000/5,000 (20.0%)
INFO:__main__:Processed 2,000/5,000 (40.0%)
INFO:__main__:Processed 3,000/5,000 (60.0%)
INFO:__main__:Processed 4,000/5,000 (80.0%)
INFO:__main__:Processed 5,000/5,000 (100.0%)
INFO:__main__:Saving results to data/output/predictions_20240115.csv
INFO:__main__:Batch complete: 5,000/5,000 successful (100.0%), 116482 rows/sec
✓ Batch job complete: {'total_rows': 5000, 'successful': 5000, 'errors': 0, 'success_rate': 100.0, 'elapsed_time_seconds': 0.04, 'rows_per_second': 116481.91, 'output_path': 'data/output/predictions_20240115.csv'}Throughput is machine dependent, so treat the rate as a shape, not a target. What matters is that the job reports it at all: rows_per_second and success_rate are what tell you a nightly run has quietly doubled in duration or started dropping batches, long before anyone downstream notices the predictions went stale.
Best for:
- Large-scale predictions (millions of rows)
- Daily/hourly recommendation generation
- ETL pipelines with ML
- When latency doesn't matter
3. Edge Deployment (On-Device)
Deploy lightweight models directly on user devices (mobile, IoT, browser).
| Target | Runtime | Typical model budget | What you gain | What it costs |
|---|---|---|---|---|
| Mobile (iOS / Android) | LiteRT, Core ML, ONNX Runtime Mobile | Under ~10 MB | No network hop, works offline | Limited model complexity |
| Browser | TensorFlow.js, ONNX Runtime Web | A few MB, re-downloaded per session | No server cost, instant response | Small models only, and the download sits on the critical path |
| Microcontrollers / IoT | LiteRT for Microcontrollers | Kilobytes | Real-time, data never leaves the device | Very limited compute and memory |
Note the naming: Google renamed TensorFlow Lite to LiteRT, where active development now happens, and TensorFlow Lite Micro became LiteRT for Microcontrollers. On the web, onnxruntime-web is the maintained package that superseded the older ONNX.js. You will still find both old names throughout tutorials written before the change.
Whichever target you pick, quantization is the lever that gets you into the budget above: storing weights as INT8 instead of float32 cuts the model to a quarter of its size, usually for a small accuracy cost. Measure that cost rather than assuming it, since it varies sharply by architecture.
Best for:
- Privacy-sensitive applications
- Ultra-low latency requirements
- Offline-first applications
- Cost optimization at massive scale
Production Deployment with Docker
Containerizing your ML model ensures consistency across environments and simplifies deployment. The stack below is five services, and it is worth knowing what each one is for before wiring them together: only one of them actually serves predictions.
The Compose Stack
Figure 2: Note the arrow directions. Prometheus pulls from the API on its own schedule, and Grafana pulls from Prometheus. Neither is ever pushed to.
| Service | What it does | Why it is a separate container |
|---|---|---|
| nginx | The only service bound to a host port. Accepts every request on localhost:8000 and spreads it across the replicas. | Gives you one address that survives scaling, so nothing downstream has to know how many replicas exist. |
| ml-api | Serves predictions over HTTP. The only service here doing your actual work. | Replicas are how you add capacity, independently of everything else. |
| Prometheus | Scrapes /metrics every 15s and stores the values as a time series. | The API only ever reports a current value. History is what lets you alert on a trend or graph last week. |
| Grafana | Queries Prometheus and draws dashboards. | It stores no metrics of its own, so you can replace it without touching collection. |
| Redis | Shared cache and cross-replica state. | Declared in the compose file, but no code in this lesson connects to it. See the note below. |
The split between Prometheus and Grafana is the part people most often expect to be one box. Prometheus is the database and the collector: it decides when to scrape, and it holds the numbers. Grafana is only a viewer, and it keeps nothing. That is why losing Grafana costs you dashboards but no data, while losing Prometheus loses the history itself.
About that Redis container
Nothing in this lesson talks to Redis. It is included because it is the piece you reach for the moment replicas need to share something: a cache of repeated predictions, a rate limiter, or a feature lookup that should not hit your database once per replica. Until you write that code it is an idle container, and shipping idle containers to production is how compose files quietly grow. Delete the service until you need it.
Step 1: Lay Out the Project
The Dockerfile below ends with CMD ["uvicorn", "deploy_model:app", ...]and copies the working directory into the image, so it expects these files to sit together in a directory of their own. Build it from the root of an existing repo instead and the container starts, fails to import deploy_model, and exits.
ml-service/
├── Dockerfile
├── .dockerignore
├── docker-compose.yml
├── prometheus.yml
├── requirements.txt
├── train_model.py # creates the artifacts below
├── deploy_model.py # the API that Docker runs
├── batch_inference.py
├── models/
│ └── model_v1.joblib
└── data/
├── input/
└── output/Then give it a .dockerignore, which matters far more than it looks. COPY . . sends the whole directory to the Docker daemon first, so a virtualenv, a node_modules, or a stale build directory sitting next to your code gets shipped on every single build. This is the most common reason a build that should take seconds takes minutes, and it is silent: the only symptom is "transferring context" sitting there.
# .dockerignore .venv/ venv/ __pycache__/ *.pyc node_modules/ build/ dist/ .git/ data/output/ .pytest_cache/ *.md
transferring context: ... B). If that number is megabytes when your source is kilobytes, your .dockerignore is missing something. Note that models/ is deliberately not ignored here: the image needs the model. In a real pipeline you would pull it from a model registry at deploy time instead of baking it in, which is exactly what the CI job further down does with aws s3 cp.Step 2: Create Production Dockerfile
Build a secure, optimized container for your ML API with health checks and proper user permissions.
COPY requirements.txt above the code copies so editing your code never reinstalls the dependency tree, and use the cache mount below so changing a pin re-uses already downloaded wheels.# Dockerfile - Production ML API container
# 3.12 is not optional here: the pinned numpy in requirements.txt below
# requires Python >= 3.12, so a 3.11 base image fails at pip install.
FROM python:3.12-slim
# Set working directory
WORKDIR /app
# No build toolchain here. Every pinned dependency ships a manylinux wheel for
# Python 3.12, so apt-get installing gcc and g++ compiles nothing and adds
# ~355 MB to the final image (1.06 GB -> 705 MB once removed). Add a compiler
# back only when a dependency actually has no wheel for your platform.
# Requirements are copied before the source, so this layer stays cached and
# editing your code does not reinstall the dependency tree.
COPY requirements.txt .
# The BuildKit cache mount keeps pip's download cache OUT of the image while
# still reusing wheels between builds. Rebuilding after changing one pin drops
# from roughly 95s to 28s, and the image stays the same size.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Copy only what the service actually needs, named explicitly. "COPY . ." is
# the common shortcut and it is worse in two ways: a new file dropped next to
# your code silently lands in the image, and correctness depends on you keeping
# .dockerignore exhaustive forever. An explicit list fails closed instead.
# Note what is absent: train_model.py, batch_inference.py and the compose files
# are development tooling, not part of the running service.
COPY deploy_model.py .
# The model artifact. Run train_model.py before building, or drop this line and
# pull the model from a registry at deploy time, which is what the CI job
# further down does with "aws s3 cp".
COPY models/ ./models/
# Shared counter directory, required for Prometheus to aggregate across the
# uvicorn workers below instead of reporting whichever worker answered.
ENV PROMETHEUS_MULTIPROC_DIR=/app/prometheus
# Non-root user for security. The metrics directory must be writable by it,
# so create it before dropping privileges and hand it over with the app.
RUN mkdir -p /app/prometheus \
&& useradd -m -u 1000 appuser \
&& chown -R appuser:appuser /app
USER appuser
# Health check. Uses only the standard library, so it needs no extra
# dependency in the image, and relies on /health returning 503 when unhealthy.
# Do not reach for requests or curl here: neither is present in python:3.12-slim,
# so the check would fail forever while the API itself is perfectly fine.
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
# Expose port
EXPOSE 8000
# Run with multiple workers for concurrency
CMD ["uvicorn", "deploy_model:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]Step 3: Pin Dependencies
Lock dependency versions to ensure reproducible builds and avoid surprises. Pins are a snapshot, not a decision you make once: schedule a refresh (Dependabot, Renovate, or a periodic pip-compile --upgrade) so you move deliberately in small steps instead of discovering two years of breaking changes the day you finally need a patch.
# requirements.txt (installed and run together on Python 3.12, August 2026) # numpy 2.5.x requires Python >= 3.12, which is why the Dockerfile pins 3.12-slim. fastapi==0.141.1 uvicorn[standard]==0.52.1 pydantic==2.13.4 scikit-learn==1.9.0 numpy==2.5.2 pandas==3.0.5 joblib==1.5.3 prometheus-client==0.26.0 # scikit-learn pulls scipy in anyway, but the monitoring and A/B scripts import # it directly. Pin what you import: a transitive dependency can vanish when the # package that brought it changes its own requirements. scipy==1.18.0
Step 4: Docker Compose Stack
Set up complete production stack with API, monitoring (Prometheus, Grafana), and caching (Redis).
# docker-compose.yml
# No "version:" key: it is obsolete in Compose v2 and only produces a warning.
services:
# ML API with resource limits
ml-api:
build: .
# No host ports at all. Publishing "8000:8000" would let replica 1 start
# and fail the rest with "Bind for 0.0.0.0:8000 failed: port is already
# allocated", and a port RANGE only trades that for host ports you cannot
# predict. nginx below is the single entry point instead, which is what a
# real deployment does anyway. expose makes the port reachable inside the
# compose network only.
expose:
- "8000"
environment:
- MODEL_PATH=/app/models/model_v1.joblib
- MODEL_VERSION=1.0.0
- LOG_LEVEL=INFO
volumes:
- ./models:/app/models
deploy:
replicas: 3
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
# python:3.12-slim ships no curl, so a curl-based test can never pass.
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
# Single stable entry point, load balancing across the ml-api replicas.
# This is why localhost:8000 keeps working no matter how many replicas run.
nginx:
image: nginx:1.27-alpine
ports:
- "8000:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- ml-api
# Metrics collection
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
# Metrics visualization
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
# Caching layer
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
volumes:
prometheus-data:
grafana-data:
redis-data:The nginx service needs a config, and it contains the single most common mistake in this setup. Docker's embedded DNS returns every replica's address for the name ml-api, but nginx resolves a literal proxy_pass target once at startup and then reuses that one address forever. The result looks fine: requests succeed, nothing errors, and every one of them lands on the same replica while the other two sit idle. Forcing resolution through a variable is what actually distributes the load.
# nginx.conf - single stable entry point in front of the ml-api replicas
events {}
http {
server {
listen 80;
location / {
# Docker's embedded DNS server. Resolving through a variable is the
# important part: with a literal proxy_pass, nginx resolves ml-api
# once at startup and pins every request to whichever replica it
# got, silently defeating the point of running three.
resolver 127.0.0.11 valid=10s;
set $upstream http://ml-api:8000;
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}Sending 30 requests to localhost:8000 against three replicas gave 12 / 9 / 12 on one run and 9 / 14 / 8 on the next. The exact split moves around and does not need to be even: what matters is that all three are working. If you ever see one replica carrying everything and the others sitting at zero, that resolver line is the first place to look.
Step 5: Tell Prometheus What to Scrape
The compose file mounts this file into the Prometheus container. Without it, Prometheus starts cleanly and collects nothing from your API, which is a surprisingly easy way to run a monitoring stack that monitors nothing.
# prometheus.yml - scrape config for the ML API
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'ml-api'
metrics_path: /metrics
# DNS discovery returns every ml-api replica. A plain static target would
# resolve to whichever single replica the round-robin lookup returned.
dns_sd_configs:
- names: ['ml-api']
type: A
port: 8000
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']Run sum(predictions_total) against a freshly started stack and Prometheus reports no data, even with every target healthy. A labelled counter does not exist until the code calls .labels(...) for the first time, so predictions_total is genuinely absent from /metrics until someone makes a prediction. Send one request and the series appears on the next scrape.
Compare prediction_errors_total, which has no labels: it is created at import time and sits at 0 from the start. This is worth internalising before you write alerts, because a rule like rate(predictions_total[5m]) < 1 never fires when the metric is missing entirely, which is exactly the outage you most wanted to catch. Alert on absent(predictions_total) as well, or give every labelled metric a zero-valued starting point at startup.
Step 6: Deploy and Manage
Common commands for deploying, testing, and managing your containerized ML service.
# Build and start all services (Compose v2 syntax: "docker compose", no hyphen)
docker compose up --build -d
# Confirm every replica is actually healthy, not merely running
docker compose ps
# Always localhost:8000, whatever the replica count, because nginx is the
# entry point and the replicas publish no host ports of their own.
curl -X POST "http://localhost:8000/predict" \
-H "Content-Type: application/json" \
-d '{"features": [5.1, 3.5, 1.4, 0.2], "request_id": "test_001"}'
# View logs
docker compose logs -f ml-api
# Scale horizontally. No port juggling: the replicas publish nothing, and
# nginx picks up the new addresses within its resolver TTL.
docker compose up --scale ml-api=5 -d
# Roll out a new model. Compose recreates the container, so expect a short
# gap unless a load balancer drains connections first.
docker compose up -d --no-deps --build ml-api
# View metrics
# Prometheus targets: http://localhost:9090/targets
# Grafana: http://localhost:3000 (log in as admin / admin)
# Stop all services
docker compose downLogging into Grafana
Open http://localhost:3000 and sign in with admin / admin. The username is Grafana's built-in default, and the password is the one line of the compose file that sets it:
grafana:
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin # password only
# - GF_SECURITY_ADMIN_USER=admin # uncomment to change the username tooOnce you are in, Grafana still has no data: it stores none itself, so it needs to be pointed at Prometheus. Go to Connections → Data sources → Add new data source → Prometheus, and set the Connection URL to the compose service name:
http://prometheus:9090
Not http://localhost:9090. That is the single most common mistake here: inside the Grafana container, localhost is Grafana itself, so the health check fails with a connection refused that looks like Prometheus is down when it is perfectly healthy. Containers reach each other by service name on the compose network. Press Save & test and Grafana should answer "Successfully queried the Prometheus API."
Seeing Your First Graph
Send some traffic first. On an idle stack every panel is empty for the reason described above: the counters do not exist until a prediction is served, so there is literally nothing to draw.
# Give Grafana something to plot
for i in $(seq 1 60); do
curl -s -o /dev/null -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"features": [5.1, 3.5, 1.4, 0.2]}'
doneNow open Explore (http://localhost:3000/explore), pick the Prometheus data source, switch the query editor to Code rather than Builder, and paste one of these. Explore is the right place to start: it is a scratchpad for one query at a time, so you can get a query right before committing it to a dashboard panel.
| Query | What it tells you | Measured on the stack above |
|---|---|---|
sum(predictions_total) | Total predictions served since start | 62 |
sum(predictions_total) by (instance) | Per replica, so you can see whether load is actually spread | 16 / 29 / 17 |
sum(rate(predictions_total[5m])) | Throughput in predictions per second. The counter itself only ever climbs, so rate() is what makes it a useful graph | 0.154/s |
histogram_quantile(0.95, sum(rate(prediction_latency_seconds_bucket[5m])) by (le)) | p95 latency in seconds. Percentiles come from the bucket series, never from _sum | 0.00475 (4.75 ms) |
sum(rate(prediction_latency_seconds_sum[5m])) / sum(rate(prediction_latency_seconds_count[5m])) | Mean latency. Compare it with p95: the gap is your tail | 0.000906 (0.91 ms) |
up{job="ml-api"} | 1 per replica Prometheus can reach, 0 when a scrape fails | 1, 1, 1 |
To keep any of these, click Add to dashboard in Explore, or build one from scratch at /dashboard/new. A first dashboard worth having is four panels: throughput, p95 latency, error rate (sum(rate(prediction_errors_total[5m]))), and up. That is enough to answer "is it serving, is it fast, is it failing, is it there", which is most of what you want at 3am.
admin / admin is a local-only convenience
A default password committed to a compose file is exactly the kind of thing that reaches production because nobody revisited it, and a Grafana instance is a map of your entire system. Anywhere real, inject the password from your secret store rather than the file: Grafana reads GF_SECURITY_ADMIN_PASSWORD__FILEpointing at a mounted secret, which keeps it out of both the compose file and docker inspect. Put the dashboard behind your own auth while you are at it, since Prometheus and Grafana ship with no authentication on their APIs.
- Multi-worker API for concurrency (4 workers)
- Health checks using only the standard library, so the image stays slim
- Resource limits to prevent OOM crashes
- Prometheus metrics aggregated across all workers, not just the one that answered
- Grafana dashboards for visualization
- Redis for caching and coordination
- Non-root user for container security
- Easy horizontal scaling with replicas
Model Monitoring & Drift Detection
Models degrade over time as data distributions change. Monitoring is critical to detect when your model needs retraining.
Data Drift
Input features distribution changes over time.
Example: User behavior changes (COVID-19 lockdown), new products launched, seasonal patterns.
Detection: Compare feature distributions between training data and production data.
Model Drift
Model performance degrades over time.
Example: Recommendation accuracy drops from 85% to 70% because user preferences evolved.
Detection: Track prediction accuracy, precision, recall over time windows.
# model_monitoring.py - Comprehensive monitoring system
import numpy as np
import pandas as pd
from scipy import stats
from typing import Any, Dict
from datetime import datetime, timedelta
import logging
class ModelMonitor:
"""Monitor model performance and detect drift."""
def __init__(self, training_stats: dict):
"""
Initialize with baseline statistics from training data.
Args:
training_stats: Dict with feature means, stds, distributions
"""
self.training_stats = training_stats
self.logger = logging.getLogger(__name__)
def detect_data_drift(
self,
production_data: pd.DataFrame,
threshold: float = 0.05
) -> Dict[str, Any]:
"""
Detect data drift using statistical tests.
Args:
production_data: Recent production data
threshold: P-value threshold for drift detection
Returns:
Drift report with alerts
"""
drift_report = {
'timestamp': datetime.now().isoformat(),
'samples_analyzed': len(production_data),
'features_with_drift': [],
'drift_scores': {}
}
for feature in production_data.columns:
# Get training distribution statistics
if feature not in self.training_stats:
continue
train_mean = self.training_stats[feature]['mean']
train_std = self.training_stats[feature]['std']
# Production statistics
prod_data = production_data[feature].dropna()
prod_mean = prod_data.mean()
prod_std = prod_data.std()
# Kolmogorov-Smirnov test
# Tests if distributions are significantly different
training_samples = np.random.normal(
train_mean,
train_std,
size=len(prod_data)
)
ks_statistic, p_value = stats.ks_2samp(
training_samples,
prod_data
)
drift_report['drift_scores'][feature] = {
'train_mean': float(train_mean),
'prod_mean': float(prod_mean),
'mean_shift_pct': abs(prod_mean - train_mean) / abs(train_mean) * 100,
'ks_statistic': float(ks_statistic),
'p_value': float(p_value),
'has_drift': p_value < threshold
}
if p_value < threshold:
drift_report['features_with_drift'].append(feature)
self.logger.warning(
f"Data drift detected in '{feature}': "
f"p-value={p_value:.4f}, "
f"mean shift={drift_report['drift_scores'][feature]['mean_shift_pct']:.1f}%"
)
# Overall drift assessment
drift_report['has_significant_drift'] = len(drift_report['features_with_drift']) > 0
drift_report['drift_severity'] = self._assess_drift_severity(drift_report)
return drift_report
def _assess_drift_severity(self, drift_report: dict) -> str:
"""Assess overall drift severity."""
num_drifted = len(drift_report['features_with_drift'])
if num_drifted == 0:
return "none"
elif num_drifted <= 2:
return "low"
elif num_drifted <= 5:
return "medium"
else:
return "high"
def detect_model_drift(
self,
recent_metrics: pd.DataFrame,
baseline_metric: str = 'accuracy',
threshold_pct: float = 5.0
) -> Dict[str, Any]:
"""
Detect model performance drift.
Args:
recent_metrics: DataFrame with timestamp and metric columns
baseline_metric: Metric to monitor (accuracy, precision, etc.)
threshold_pct: Alert if metric drops by this percentage
Returns:
Performance drift report
"""
if len(recent_metrics) < 2:
return {"status": "insufficient_data"}
# Calculate baseline (e.g., 30-day average)
cutoff_date = datetime.now() - timedelta(days=30)
baseline_data = recent_metrics[
recent_metrics['timestamp'] < cutoff_date
]
if len(baseline_data) == 0:
baseline_performance = recent_metrics[baseline_metric].mean()
else:
baseline_performance = baseline_data[baseline_metric].mean()
# Recent performance (last 7 days)
recent_cutoff = datetime.now() - timedelta(days=7)
recent_data = recent_metrics[
recent_metrics['timestamp'] >= recent_cutoff
]
if len(recent_data) == 0:
return {"status": "no_recent_data"}
recent_performance = recent_data[baseline_metric].mean()
# Calculate drift
performance_change_pct = (
(recent_performance - baseline_performance) / baseline_performance * 100
)
has_drift = abs(performance_change_pct) > threshold_pct
is_degradation = performance_change_pct < 0
report = {
'timestamp': datetime.now().isoformat(),
'metric': baseline_metric,
'baseline_performance': float(baseline_performance),
'recent_performance': float(recent_performance),
'change_pct': float(performance_change_pct),
'has_drift': has_drift,
'is_degradation': is_degradation,
'severity': 'critical' if is_degradation and abs(performance_change_pct) > 10 else
'warning' if is_degradation and abs(performance_change_pct) > 5 else
'info'
}
if has_drift:
direction = "degraded" if is_degradation else "improved"
self.logger.warning(
f"Model performance {direction} by {abs(performance_change_pct):.1f}%: "
f"{baseline_performance:.3f} → {recent_performance:.3f}"
)
return report
# Example usage
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
# Seeded so this example prints the same numbers every run. Without it the
# simulated drift lands anywhere from -6% to -10%, which straddles the 10%
# threshold below and makes the reported severity flip between "warning"
# and "critical" run to run.
np.random.seed(0)
# Training data statistics (save these after training)
training_stats = {
'age': {'mean': 35.5, 'std': 12.3},
'income': {'mean': 55000, 'std': 25000},
'clicks': {'mean': 45, 'std': 20}
}
# Initialize monitor
monitor = ModelMonitor(training_stats)
# Simulate production data (with drift)
production_data = pd.DataFrame({
'age': np.random.normal(40, 15, 1000), # Drifted: older users
'income': np.random.normal(55000, 25000, 1000), # No drift
'clicks': np.random.normal(30, 18, 1000) # Drifted: fewer clicks
})
# Detect data drift
drift_report = monitor.detect_data_drift(production_data)
print(f"\nData Drift Report:")
print(f" Severity: {drift_report['drift_severity']}")
print(f" Features with drift: {drift_report['features_with_drift']}")
# Simulate performance metrics
metrics = pd.DataFrame({
'timestamp': pd.date_range(end=datetime.now(), periods=60, freq='D'),
'accuracy': np.concatenate([
np.random.normal(0.85, 0.02, 40), # Baseline
np.random.normal(0.78, 0.03, 20) # Recent degradation
])
})
# Detect model drift
perf_report = monitor.detect_model_drift(metrics, baseline_metric='accuracy')
print(f"\nModel Performance Drift:")
print(f" Severity: {perf_report.get('severity', 'N/A')}")
print(f" Change: {perf_report.get('change_pct', 0):.1f}%")
print(f" Action needed: {perf_report.get('has_drift', False)}")Expected Output:
WARNING:__main__:Data drift detected in 'age': p-value=0.0000, mean shift=10.8% WARNING:__main__:Data drift detected in 'clicks': p-value=0.0000, mean shift=35.4% Data Drift Report: Severity: low Features with drift: ['age', 'clicks'] WARNING:__main__:Model performance degraded by 7.8%: 0.849 → 0.783 Model Performance Drift: Severity: warning Change: -7.8% Action needed: True
Read the two halves separately. The drift scan flags age and clicks but not income, which is correct: the simulation shifted exactly those two. It reports severity low because severity here counts how many features moved, not how far. The performance half is the one that would page someone: accuracy fell 7.8%, which clears the 5% threshold and sets Action needed: True. Note that income not being flagged is as important as the two that were, since a drift detector that fires on everything gets muted within a week.
- Monitor both data drift and model drift continuously
- Set up alerts for drift detection (email, Slack, PagerDuty)
- Track business metrics (revenue, conversions) not just ML metrics
- Automated retraining triggers when drift detected
- Keep baseline statistics from training for comparison
- Dashboard visualizations (Grafana) for real-time monitoring
CI/CD Pipeline for ML Models
Automate model training, testing, and deployment just like you do with code.
A common first attempt is to point the existing Deployment at the new image and scale it down to a single pod, on the theory that one pod is a small blast radius. It is not a canary. kubectl set image replaces the image on every pod in that Deployment, so all traffic moves to the untested model at once, and the scale-down removes capacity at exactly the moment risk is highest. A canary only means anything while the previous version is still serving. So ml-api-canary below is a separate Deployment that shares the Service's pod labels: one canary pod beside nine stable pods takes roughly 10% of traffic, and the other 90% keeps running the model you already trust.
# .github/workflows/ml-pipeline.yml - GitHub Actions CI/CD
name: ML Model CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * 0' # Weekly retraining on Sunday 2 AM
# Required for OIDC federation with AWS, so no long-lived access keys are
# stored as secrets. Every job that touches AWS or the cluster assumes a role.
permissions:
id-token: write
contents: read
env:
MODEL_VERSION: ${{ github.sha }}
PYTHON_VERSION: '3.12' # must match the Dockerfile base image
AWS_REGION: us-east-1
EKS_CLUSTER: ml-cluster
jobs:
data-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
pip install great-expectations pandas
- name: Validate training data
run: |
python scripts/validate_data.py \
--data-path data/training.csv \
--expectations expectations/training_data.json
- name: Upload data profile
uses: actions/upload-artifact@v4
with:
name: data-profile
path: reports/data_profile.html
train-model:
needs: data-validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Train model
run: |
python train.py \
--data data/training.csv \
--output models/model_${{ env.MODEL_VERSION }}.joblib \
--metrics-output metrics.json
- name: Upload model artifact
uses: actions/upload-artifact@v4
with:
name: trained-model
path: |
models/model_${{ env.MODEL_VERSION }}.joblib
metrics.json
test-model:
needs: train-model
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Download model
uses: actions/download-artifact@v4
with:
name: trained-model
- name: Run model tests
run: |
# Unit tests for model code
pytest tests/test_model.py -v
# Model performance tests
python tests/test_model_performance.py \
--model models/model_${{ env.MODEL_VERSION }}.joblib \
--test-data data/test.csv \
--min-accuracy 0.80
- name: Check model size
run: |
SIZE=$(stat -c%s models/model_${{ env.MODEL_VERSION }}.joblib)
MAX_SIZE=$((100 * 1024 * 1024)) # 100MB
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
echo "Model too large: $SIZE bytes"
exit 1
fi
evaluate-model:
needs: test-model
runs-on: ubuntu-latest
# Publishes whether the new model earned a deployment rather than failing
# the run. A retrained model that is merely not better is a normal weekly
# outcome, not a broken build, and a red pipeline every Sunday is a
# pipeline people stop reading.
outputs:
deploy: ${{ steps.gate.outputs.deploy }}
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Download model
uses: actions/download-artifact@v4
with:
name: trained-model
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}
aws-region: ${{ env.AWS_REGION }}
- name: Compare with production model
run: |
# Download current production model
aws s3 cp s3://my-model-registry/production/model.joblib models/prod_model.joblib
# Compare metrics
python scripts/compare_models.py \
--new-model models/model_${{ env.MODEL_VERSION }}.joblib \
--prod-model models/prod_model.joblib \
--test-data data/test.csv \
--output comparison.json
- name: Gate on a 2% improvement
id: gate
run: |
NEW_ACC=$(jq -r '.new_model.accuracy' comparison.json)
PROD_ACC=$(jq -r '.prod_model.accuracy' comparison.json)
echo "New model accuracy: $NEW_ACC"
echo "Production model accuracy: $PROD_ACC"
if [ "$(echo "$NEW_ACC >= $PROD_ACC * 1.02" | bc -l)" -eq 1 ]; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
echo "::notice::Not a 2% improvement over production. Skipping deployment."
fi
# The image the cluster actually runs. Without this job the pipeline produces
# a .joblib and then deploys a container tag that nothing ever built.
build-image:
needs: evaluate-model
if: needs.evaluate-model.outputs.deploy == 'true'
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
# The Dockerfile copies models/ into the image, so the artifact has to
# land in the build context before docker build runs.
- name: Download model
uses: actions/download-artifact@v4
with:
name: trained-model
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}
aws-region: ${{ env.AWS_REGION }}
- name: Log in to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Resolve image tag
id: meta
run: echo "image=${{ steps.ecr.outputs.registry }}/ml-api:${{ env.MODEL_VERSION }}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.image }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: [evaluate-model, build-image]
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}
aws-region: ${{ env.AWS_REGION }}
- name: Configure kubectl
run: aws eks update-kubeconfig --name ${{ env.EKS_CLUSTER }} --region ${{ env.AWS_REGION }}
- name: Deploy to staging
run: |
# Deploy the image build-image actually pushed
kubectl set image deployment/ml-api \
ml-api=${{ needs.build-image.outputs.image }} \
--namespace=staging
# Wait for rollout
kubectl rollout status deployment/ml-api --namespace=staging --timeout=5m
- name: Run integration tests
run: |
python tests/integration_tests.py \
--api-url https://staging-api.yourcompany.com \
--test-data data/test.csv
deploy-production:
needs: [evaluate-model, build-image, deploy-staging]
runs-on: ubuntu-latest
environment: production
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Download model
uses: actions/download-artifact@v4
with:
name: trained-model
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}
aws-region: ${{ env.AWS_REGION }}
- name: Configure kubectl
run: aws eks update-kubeconfig --name ${{ env.EKS_CLUSTER }} --region ${{ env.AWS_REGION }}
- name: Publish model to the registry
run: |
aws s3 cp models/model_${{ env.MODEL_VERSION }}.joblib \
s3://my-model-registry/production/model_${{ env.MODEL_VERSION }}.joblib
# A real canary. ml-api-canary is a SEPARATE Deployment carrying the same
# pod labels as ml-api, so the existing Service load balances across both.
# One canary pod beside nine stable pods is roughly 10% of traffic, and
# the stable pods keep serving the old model the entire time.
- name: Deploy canary
run: |
kubectl set image deployment/ml-api-canary \
ml-api=${{ needs.build-image.outputs.image }} \
--namespace=production
kubectl scale deployment/ml-api-canary --replicas=1 --namespace=production
kubectl rollout status deployment/ml-api-canary --namespace=production --timeout=5m
- name: Watch canary metrics
id: canary
run: |
python scripts/check_canary_metrics.py \
--duration 5m \
--error-threshold 0.05
- name: Roll back canary
if: always() && steps.canary.outcome == 'failure'
run: |
kubectl scale deployment/ml-api-canary --replicas=0 --namespace=production
echo "::error::Canary failed its checks and was scaled to zero. Stable pods never stopped serving."
- name: Promote to every pod
id: promote
run: |
kubectl set image deployment/ml-api \
ml-api=${{ needs.build-image.outputs.image }} \
--namespace=production
kubectl rollout status deployment/ml-api --namespace=production --timeout=10m
kubectl scale deployment/ml-api-canary --replicas=0 --namespace=production
- name: Roll back production
if: always() && steps.promote.outcome == 'failure'
run: |
kubectl rollout undo deployment/ml-api --namespace=production
kubectl rollout status deployment/ml-api --namespace=production --timeout=10m
echo "::error::Promotion failed. Rolled back to the previous ReplicaSet."
- name: Update model metadata
run: |
# Record deployment in model registry
python scripts/update_model_registry.py \
--version ${{ env.MODEL_VERSION }} \
--status deployed \
--metrics metrics.json- Automated data validation before training
- Model training on schedule or code changes
- Comprehensive testing (unit, performance, integration)
- Model comparison with production baseline, gating deployment without failing the run when the new model simply is not better
- The container image built and pushed from the same commit that trained the model
- Staged deployment (staging → canary → production), the canary being a second Deployment beside the stable one rather than a scaled-down replacement
- Automatic rollback: the canary scales to zero on bad metrics, and a failed promotion triggers
kubectl rollout undo - Short-lived OIDC credentials instead of stored AWS keys
- Model versioning and registry
A/B Testing ML Models
Never replace your production model without testing. A/B test new models against the current version to validate improvements.
# ab_testing.py - A/B testing framework for ML models
import hashlib
import logging
import time
from datetime import datetime
from typing import Dict
import numpy as np
from scipy import stats
class ModelABTest:
"""Run two model versions side by side and compare them on quality."""
def __init__(
self,
model_a,
model_b,
experiment: str,
model_a_name: str = "control",
model_b_name: str = "treatment",
traffic_split: float = 0.5
):
"""
Initialize an A/B test.
Args:
model_a: Control model (current production)
model_b: Treatment model (new version)
experiment: Name of this experiment, used to salt the assignment
model_a_name: Name for model A
model_b_name: Name for model B
traffic_split: Fraction of traffic sent to model B (0.0-1.0)
"""
self.model_a = model_a
self.model_b = model_b
self.experiment = experiment
self.model_a_name = model_a_name
self.model_b_name = model_b_name
self.traffic_split = traffic_split
# Latency answers "is it fast enough". Correct and scored answer
# "is it better", which is the question the test exists to settle.
self.metrics = {
model_a_name: {'predictions': 0, 'latencies': [], 'correct': 0, 'scored': 0},
model_b_name: {'predictions': 0, 'latencies': [], 'correct': 0, 'scored': 0},
}
self.logger = logging.getLogger(__name__)
def _assign_variant(self, user_id: str) -> str:
"""
Consistently assign a user to a model variant.
The experiment name is hashed along with the user id. Hashing the user
id alone looks equivalent but is not: every experiment would then split
the population exactly the same way, so the same users sit in treatment
forever and effects from separate experiments pile onto one group.
"""
digest = hashlib.md5(
f"{self.experiment}:{user_id}".encode(), usedforsecurity=False
).hexdigest()
assignment = (int(digest, 16) % 10_000) / 10_000
if assignment < self.traffic_split:
return self.model_b_name
return self.model_a_name
def predict(self, features, user_id: str) -> Dict:
"""
Make a prediction with the variant assigned to this user.
Returns:
The prediction plus metadata about which model served it
"""
variant = self._assign_variant(user_id)
model = self.model_b if variant == self.model_b_name else self.model_a
start = time.perf_counter()
prediction = model.predict(features)
latency = time.perf_counter() - start
self.metrics[variant]['predictions'] += 1
self.metrics[variant]['latencies'].append(latency)
# Per request, so debug: at 1k requests/s this line at INFO is a
# log bill, not an insight.
self.logger.debug(
f"user={user_id}, variant={variant}, latency={latency * 1000:.2f}ms"
)
return {
# float(), not the raw array: model.predict returns an ndarray,
# which is not JSON serialisable and cannot leave an API.
'prediction': float(prediction[0]),
'variant': variant,
'latency_ms': latency * 1000,
'timestamp': datetime.now().isoformat()
}
def record_outcome(self, user_id: str, was_correct: bool) -> None:
"""
Record whether a served prediction turned out to be right.
Ground truth almost never arrives with the request: it shows up when
the user clicks, the order ships, or a human labels the case. Route it
back through the same assignment function so it lands on the variant
that actually served the prediction.
"""
variant = self._assign_variant(user_id)
self.metrics[variant]['scored'] += 1
if was_correct:
self.metrics[variant]['correct'] += 1
def get_metrics(self) -> Dict:
"""Summarise each variant."""
results = {}
for variant_name, data in self.metrics.items():
if data['predictions'] == 0:
continue
results[variant_name] = {
'total_predictions': data['predictions'],
'accuracy': data['correct'] / data['scored'] if data['scored'] else None,
'scored': data['scored'],
'avg_latency_ms': np.mean(data['latencies']) * 1000,
'p95_latency_ms': np.percentile(data['latencies'], 95) * 1000,
'p99_latency_ms': np.percentile(data['latencies'], 99) * 1000
}
return results
def compare_accuracy(self) -> Dict:
"""
Test whether the accuracy difference between variants is real.
A chi-squared test on the correct/incorrect counts, which is the
comparison the rollout decision hinges on. Testing latency instead
answers a different question: at a few hundred samples any consistent
speed difference is significant, and it says nothing about quality.
"""
a = self.metrics[self.model_a_name]
b = self.metrics[self.model_b_name]
table = [
[a['correct'], a['scored'] - a['correct']],
[b['correct'], b['scored'] - b['correct']],
]
chi2, p_value, _, _ = stats.chi2_contingency(table)
return {
'accuracy_control': a['correct'] / a['scored'],
'accuracy_treatment': b['correct'] / b['scored'],
'difference_pct_points': (b['correct'] / b['scored'] - a['correct'] / a['scored']) * 100,
'chi2': chi2,
'p_value': p_value,
'significant': p_value < 0.05,
}
# Example: running an A/B test
if __name__ == "__main__":
import joblib
logging.basicConfig(level=logging.INFO)
rng = np.random.default_rng(0)
model_a = joblib.load('models/production_v1.joblib') # current
model_b = joblib.load('models/candidate_v2.joblib') # new
ab_test = ModelABTest(
model_a=model_a,
model_b=model_b,
experiment="ranker-v2-2026-08",
model_a_name="v1_production",
model_b_name="v2_candidate",
traffic_split=0.5
)
for i in range(1000):
user_id = f"user_{i % 100}" # 100 unique users
features = rng.normal(size=(1, 10))
label = float(features[0, 0] > 0) # the outcome observed later
result = ab_test.predict(features, user_id=user_id)
ab_test.record_outcome(user_id, was_correct=result['prediction'] == label)
print("\nA/B Test Results:")
print("-" * 60)
for variant, data in ab_test.get_metrics().items():
print(f"\n{variant}:")
print(f" Predictions: {data['total_predictions']:,}")
print(f" Accuracy: {data['accuracy']:.3f} over {data['scored']:,} scored")
print(f" Avg latency: {data['avg_latency_ms']:.2f}ms")
print(f" P95 latency: {data['p95_latency_ms']:.2f}ms")
verdict = ab_test.compare_accuracy()
print("\nDoes the accuracy difference hold up?")
print(f" Control accuracy: {verdict['accuracy_control']:.3f}")
print(f" Treatment accuracy: {verdict['accuracy_treatment']:.3f}")
print(f" Difference: {verdict['difference_pct_points']:+.1f} points")
print(f" chi2={verdict['chi2']:.4f}, p={verdict['p_value']:.4f}")
print(f" Significant: {verdict['significant']}")Expected Output:
A/B Test Results: ------------------------------------------------------------ v1_production: Predictions: 510 Accuracy: 0.994 over 510 scored Avg latency: 0.46ms P95 latency: 0.51ms v2_candidate: Predictions: 490 Accuracy: 0.998 over 490 scored Avg latency: 0.86ms P95 latency: 0.94ms Does the accuracy difference hold up? Control accuracy: 0.994 Treatment accuracy: 0.998 Difference: +0.4 points chi2=0.2125, p=0.6448 Significant: False
This is the result worth sitting with: the candidate is more accurate, and you should still not ship it. It wins by 0.4 percentage points, but p=0.64 says a gap that small across a thousand requests is indistinguishable from noise, and it costs roughly double the latency. "Better on the metric" and "better" are not the same claim. Either run the test until the sample can actually resolve a difference this small, or accept that there is nothing here worth the latency. The counts, accuracies and p-value above are identical on every run because assignment is hashed rather than random; the latency figures vary with the machine, though the 2x ratio between the two models does not.
- Decide on a quality metric, not latency. Latency tells you what a change costs, never whether it is an improvement
- Salt the assignment hash with the experiment name, so consecutive experiments do not keep testing the same half of your users
- Run tests long enough for statistical significance (thousands of samples)
- Use consistent user assignment (hash-based) so users get same experience
- Monitor both ML metrics (accuracy) and business metrics (revenue, engagement)
- Start with small traffic split (5-10%) then increase if successful
- Have automatic rollback if new model significantly worse
- Track both performance metrics and latency
MLOps Best Practices
1. Version Everything
Code, data, models, configs - all versioned and tracked. You should be able to reproduce any past prediction. Use tools like DVC for data versioning, Git for code, and MLflow or Weights & Biases for experiment tracking.
2. Automate Retraining
Set up automated retraining pipelines triggered by schedules, data drift, or performance degradation. Models need fresh data to stay accurate. Don't let models go stale.
3. Monitor in Production
Track everything: prediction latency, throughput, error rates, data drift, model drift, business metrics. Set up alerts for anomalies. Use dashboards (Grafana) for visibility. You can't fix what you can't see.
4. Test Like Software
Unit tests for data processing, integration tests for pipelines, performance tests for models. Test on holdout data, adversarial examples, edge cases. ML code is still code - it needs tests.
5. Enable Fast Rollback
When a new model causes issues, you need to rollback instantly. Keep previous model versions available, use feature flags or traffic routing for instant switches. Blue-green or canary deployments minimize risk.
6. Document Everything
Model cards describing what the model does, training data, performance characteristics, limitations, and intended use. Future you (and your team) will thank you.
7. Start Simple, Scale Later
Don't build a complex MLOps platform on day one. Start with basic deployment, add monitoring, then CI/CD, then automated retraining. Build complexity only when needed.
Key Takeaways
- MLOps is essential - Production ML requires the same rigor as any production software
- Choose deployment pattern wisely - REST API for real-time, batch for scale, edge for privacy
- Monitor continuously - Track data drift, model drift, and business metrics
- Automate the pipeline - CI/CD for models just like code, including automated testing
- A/B test new models - Never replace production models without validation
- Version everything - Data, code, models, configs must all be reproducible
- Enable fast rollback - When things go wrong (and they will), recover quickly
- Start simple - Build complexity incrementally as scale demands it