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."

Without MLOps
  • 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
With MLOps
  • 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

1. Data Collectioncollect, validate, version2. Model Developmentfeatures, train, tune, eval3. Model Validationmetrics, A/B, business checks4. Deploymentpackage, canary / blue-green5. Monitoringdata + model drift, cost6. Maintenanceretrain, rollback, improveretrain

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 8000
Running 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:app
Expected 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.1 sepal length (cm)
  • 3.5 sepal width (cm)
  • 1.4 petal length (cm)
  • 0.2 petal 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 400
Try it interactively: FastAPI builds OpenAPI docs from the Pydantic models, so http://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).

TargetRuntimeTypical model budgetWhat you gainWhat it costs
Mobile (iOS / Android)LiteRT, Core ML, ONNX Runtime MobileUnder ~10 MBNo network hop, works offlineLimited model complexity
BrowserTensorFlow.js, ONNX Runtime WebA few MB, re-downloaded per sessionNo server cost, instant responseSmall models only, and the download sits on the critical path
Microcontrollers / IoTLiteRT for MicrocontrollersKilobytesReal-time, data never leaves the deviceVery 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

Clientcurl, your appnginxlocalhost:8000ml-api3 replicas, no host portPrometheusport 9090Grafanaport 3000Redisport 6379POST /predictround robinscrapes /metricsqueriesunused here

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.

ServiceWhat it doesWhy it is a separate container
nginxThe 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-apiServes predictions over HTTP. The only service here doing your actual work.Replicas are how you add capacity, independently of everything else.
PrometheusScrapes /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.
GrafanaQueries Prometheus and draws dashboards.It stores no metrics of its own, so you can replace it without touching collection.
RedisShared 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
Check it, do not assume it. Docker prints the context size as the first line of a build (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.

Expect the first build to take minutes, not seconds. Most of that is pip pulling roughly 200 MB of scientific wheels (numpy, pandas, scipy via scikit-learn), plus Compose pulling the Prometheus, Grafana and Redis images. That cost is unavoidable once. What you control is every build after it: keep 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']
Your first query will return "Empty query result", and that is correct.

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 down
Logging 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 too

Once you are in, Grafana still has no data: it stores none itself, so it needs to be pointed at Prometheus. Go to Connections Data sourcesAdd 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]}'
done

Now 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.

QueryWhat it tells youMeasured on the stack above
sum(predictions_total)Total predictions served since start62
sum(predictions_total) by (instance)Per replica, so you can see whether load is actually spread16 / 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 graph0.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 _sum0.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 tail0.000906 (0.91 ms)
up{job="ml-api"}1 per replica Prometheus can reach, 0 when a scrape fails1, 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.

If a panel is empty, check the time range before anything else. Grafana defaults to the last 6 hours, and a stack you started ten minutes ago has a few minutes of data sitting in the far right edge of that window. Set it to Last 15 minutes. Between this and the counter-does-not-exist-yet behaviour, an empty panel almost never means the metrics pipeline is broken.
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.

Production deployment features:
  • 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.

Monitoring best practices:
  • 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.

Why the canary below is a second Deployment.

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
CI/CD pipeline includes:
  • 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.

A/B testing best practices:
  • 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