MLOps: Deploying ML Models in Production

Shipping a machine learning model that works in a Jupyter notebook is the easy part. Shipping it in a way that is reliable, reproducible, monitored, and deployable by a team — that is MLOps. The gap between a notebook and production is where most ML projects quietly die.

This article covers the complete lifecycle: from experiment tracking and model packaging to serving infrastructure, automated retraining, and production monitoring. It is based on patterns I've applied in real production systems handling both batch inference and low-latency real-time prediction.

Why MLOps Is a Software Architecture Problem

ML systems have all the challenges of traditional software — CI/CD, versioning, testing, observability — plus several unique ones:

Key insight: The most impactful thing you can do for an ML team is not a better algorithm — it is reducing the time from "model ready" to "model in production serving real traffic" from weeks to hours.

The MLOps Pipeline

A production-grade MLOps pipeline has six stages:

Data Ingestion
→
Feature Engineering
→
Training
→
Evaluation
→
Deployment
→
Monitoring
Each stage is automated, versioned, and observable

The critical property is that this pipeline is automated end-to-end. Manual steps — "run this notebook, then upload the pkl file to S3" — are a reliability hazard and a knowledge silo.

Experiment Tracking with MLflow

Before talking about deployment, you need reproducible experiments. MLflow is the de facto standard for tracking parameters, metrics, and artifacts across training runs.

import mlflow import mlflow.sklearn from sklearn.ensemble import GradientBoostingClassifier from sklearn.metrics import roc_auc_score mlflow.set_experiment("churn-prediction-v2") with mlflow.start_run(): params = {"n_estimators": 200, "max_depth": 4, "learning_rate": 0.05} mlflow.log_params(params) model = GradientBoostingClassifier(**params) model.fit(X_train, y_train) auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]) mlflow.log_metric("roc_auc", auc) mlflow.log_metric("precision", precision_score(y_test, model.predict(X_test))) mlflow.sklearn.log_model(model, "model", registered_model_name="churn-classifier")

Every run records its parameters, metrics, and the model artifact. The model registry tracks versions and their promotion through stages: Staging → Production → Archived. No artifact ever leaves MLflow without a registered version.

Packaging: From Artifact to Docker Image

A trained model artifact (pkl, ONNX, SavedModel) is not deployable by itself. It needs a serving runtime — an HTTP endpoint that can receive feature vectors and return predictions. Packaging as a Docker image makes this runtime portable and reproducible.

# Dockerfile for a FastAPI model serving container FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY model/ ./model/ COPY app/ ./app/ ENV MODEL_URI=./model/artifact ENV PORT=8080 EXPOSE 8080 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
# app/main.py — minimal FastAPI serving endpoint from fastapi import FastAPI import mlflow.pyfunc, pandas as pd app = FastAPI() model = mlflow.pyfunc.load_model("./model/artifact") @app.post("/predict") def predict(features: dict): df = pd.DataFrame([features]) prediction = model.predict(df) return {"prediction": int(prediction[0]), "model_version": model.metadata.run_id}

Feature Preprocessing Must Be Inside the Container

A common mistake is packaging only the model estimator and handling preprocessing in the calling service. This creates a training-serving skew: the preprocessing logic in the notebook diverges from the preprocessing in the API over time. Always package the full pipeline — scaler, encoder, and estimator — as a single MLflow pyfunc model.

CI/CD for Machine Learning

The ML CI/CD pipeline differs from software CI/CD in one critical way: the "build" step (training) can take hours and requires GPU resources. The pipeline must be designed to run training only when necessary.

# .github/workflows/ml-pipeline.yml (simplified) name: ML Pipeline on: push: paths: - 'src/features/**' - 'src/train.py' - 'data/schema.yaml' jobs: train-and-evaluate: runs-on: self-hosted # GPU runner steps: - uses: actions/checkout@v4 - name: Run training pipeline run: python src/train.py --config config/prod.yaml - name: Evaluate against threshold run: python src/evaluate.py --min-auc 0.82 - name: Build and push Docker image run: | docker build -t registry/churn-model:${{ github.sha }} . docker push registry/churn-model:${{ github.sha }} - name: Deploy to staging run: kubectl set image deployment/churn-model app=registry/churn-model:${{ github.sha }}

The evaluate step is a quality gate: if the new model's AUC drops below the threshold (compared to the champion model in MLflow), the pipeline fails and the image is never deployed. This prevents regressions from reaching production.

Deployment Strategies

Blue/Green Deployment

Run two identical environments — blue (current) and green (new). After deploying the new model to green and validating it with a subset of traffic, switch the load balancer. Rollback is instant: flip back to blue.

Canary Releases

Route a small percentage of traffic (5–10%) to the new model version. Monitor key metrics — prediction latency, error rate, and business metrics like conversion rate — before progressively increasing the percentage.

Shadow Mode

The new model receives all production traffic but its predictions are discarded. This lets you compare the new model's outputs against the champion model on real data, without any user impact. Invaluable for validating a major model architecture change before going live.

Recommendation: For business-critical models (fraud detection, pricing, personalization), always use canary releases with a minimum 24-hour observation window. Aggregate metrics can hide problems that only surface over a full daily traffic cycle.

Model Serving at Scale: Batch vs. Real-Time

Real-Time (Online) Inference

Low-latency prediction on demand. Served via a REST or gRPC endpoint. Requirements: p99 latency under 100ms, horizontal scaling, stateless containers. Tools: FastAPI + Kubernetes, AWS SageMaker, BentoML, Triton Inference Server.

Batch Inference

Generate predictions for a large dataset on a schedule (e.g., score all customers nightly for the next day's campaign). Requirements: throughput over latency, cost-efficiency, checkpoint/restart. Tools: Apache Spark, AWS Batch, Kubernetes Jobs, Airflow DAGs.

Architecture tip: For batch inference at scale, run inference in parallel chunks using Spark or Ray. Pre-load the model once per executor, not once per row. A 10GB model loaded 10 million times is a performance disaster.

The Feature Store

As models multiply, the same features get computed independently in multiple pipelines — different implementations, different bugs, different latencies. A Feature Store centralizes feature computation and serves them to both training pipelines (offline store) and inference endpoints (online store, typically sub-millisecond Redis/DynamoDB).

Key Takeaways

Back to all articles