Skip to content

Designing Production MLOps Pipelines: Orchestration, Versioning, and the Messy Reality

A practitioner's guide to building production MLOps pipelines with Airflow, MLflow, CI/CD for ML, retraining triggers, and monitoring. Based on nine years of deploying and maintaining ML systems in production.

Aryanto, M.Si

Designing Production MLOps Pipelines: Orchestration, Versioning, and the Messy Reality

MLOps has become one of those terms that means everything and nothing simultaneously. In my nine years of building production ML systems, I’ve learned that good MLOps isn’t about adopting the latest tool or framework — it’s about building systems that keep your models healthy, reproducible, and trustworthy in the face of constantly changing data, requirements, and team composition. This post is my honest take on what works, what doesn’t, and the patterns I’ve settled on after years of iteration.

The MLOps Maturity Model

Before diving into architecture, it’s worth understanding where your organization sits on the MLOps maturity spectrum. I use a simplified version of Google’s MLOps maturity model:

Level 0 — Manual Process: Data scientists train models in notebooks, manually export pickle files, and hand them to engineers for deployment. No automation, no monitoring, no reproducibility. This is where most organizations start, and it’s fine for proof-of-concept work. It becomes a problem when you have more than 3-5 models in production.

Level 1 — ML Pipeline Automation: Training pipelines are automated, but deployment is still manual or semi-automated. Models are versioned, but there’s no continuous training. This is where I aim to get teams within the first 6 months of building ML capability.

Level 2 — CI/CD for ML: Full automation from data to deployment. Pipelines trigger automatically on data changes or schedule. A/B testing infrastructure exists. Model monitoring feeds back into retraining triggers. This is the target state for any organization with more than 10 models in production.

Most organizations I consult with are at Level 0 or early Level 1. The jump to Level 2 is where the real engineering investment is needed, and it’s where I’ll focus this post.

Airflow as the Orchestration Backbone

Why Airflow

I’ve used Luigi, Prefect, Argo Workflows, and a few custom orchestration systems. I keep coming back to Airflow for ML pipelines. The reasons are pragmatic:

  1. Ubiquity: Most data engineering teams already know Airflow. Shared infrastructure means shared operational knowledge.
  2. Flexibility: Airflow’s DAG model handles the complex dependency graphs that ML pipelines require — data validation, feature engineering, training, evaluation, deployment, and monitoring all have dependencies that need explicit management.
  3. Ecosystem: The Airflow provider ecosystem (AWS, GCP, Kubernetes, Spark) means you rarely need to write custom operators.
  4. Scheduling and backfilling: ML pipelines often need to run on schedules (daily retraining) and handle backfills (reprocess the last 30 days of data). Airflow’s native scheduling and backfill support handles this elegantly.

Airflow DAG Design for ML

Here’s the DAG structure I use for production ML pipelines:

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from airflow.utils.dates import days_ago
from datetime import timedelta

default_args = {
    'owner': 'ml-team',
    'retries': 2,
    'retry_delay': timedelta(minutes=10),
    'email_on_failure': True,
    'email': ['ml-alerts@company.com'],
}

with DAG(
    dag_id='ml_training_pipeline',
    default_args=default_args,
    schedule_interval='0 2 * * *',  # 2 AM daily
    start_date=days_ago(1),
    catchup=False,
    tags=['ml', 'production'],
) as dag:

    # Step 1: Data validation
    validate_data = PythonOperator(
        task_id='validate_data',
        python_callable=validate_training_data,
    )

    # Step 2: Feature engineering
    compute_features = PythonOperator(
        task_id='compute_features',
        python_callable=run_feature_pipeline,
    )

    # Step 3: Training data snapshot
    snapshot_training_data = PythonOperator(
        task_id='snapshot_training_data',
        python_callable=create_training_snapshot,
    )

    # Step 4: Model training
    train_model = KubernetesPodOperator(
        task_id='train_model',
        image='ml-training:latest',
        cmds=['python', 'train.py'],
        resources={'request_memory': '16Gi', 'request_cpu': '4'},
        node_selector={'gpu': 'true'},
    )

    # Step 5: Model evaluation
    evaluate_model = PythonOperator(
        task_id='evaluate_model',
        python_callable=evaluate_and_register,
    )

    # Step 6: Conditional deployment
    deploy_model = PythonOperator(
        task_id='deploy_model',
        python_callable=conditional_deploy,
    )

    validate_data >> compute_features >> snapshot_training_data >> train_model >> evaluate_model >> deploy_model

Key design decisions:

  • Data validation is a gate. If data quality checks fail, the entire pipeline stops. I use Great Expectations for data validation — it’s verbose but comprehensive. Common checks: schema conformance, null rate thresholds, distribution drift detection.
  • Training runs in a Kubernetes pod with GPU access. This isolates training from the Airflow worker and lets you specify resource requirements per model.
  • Evaluation and deployment are separate steps. A model might pass evaluation but fail deployment (e.g., latency budget exceeded). Separating these steps makes debugging easier.
  • Conditional deployment checks business rules before promoting a model. More on this in the CI/CD section.

Airflow Pitfalls I’ve Learned to Avoid

Don’t put ML code in Airflow DAGs. Airflow DAGs should be thin orchestration layers that call external code. Training logic, feature engineering, and evaluation code should live in separate repositories with their own testing and CI. Airflow DAGs import and call this code, but don’t contain it.

Don’t use XComs for large data. Airflow’s XCom mechanism is designed for small metadata, not DataFrames or model artifacts. Use S3/GCS for data exchange between tasks. Each task reads from and writes to cloud storage, passing only the S3 path via XCom.

Don’t ignore idempotency. Airflow tasks can be retried. Every task in your ML pipeline must be idempotent — running it twice with the same inputs must produce the same outputs. Use deterministic naming for output files (incorporate input data hash or run date) and check for existing outputs before recomputing.

MLflow for Experiment Tracking and Model Versioning

What MLflow Does Well

MLflow is the closest thing to a standard for ML experiment tracking. I use it for:

  1. Experiment tracking: Every training run logs parameters, metrics, and artifacts. This creates an auditable history of every model that’s been trained.
  2. Model registry: Models are versioned and tagged with stages (Staging, Production, Archived). This provides a clear promotion workflow.
  3. Artifact storage: Model files, evaluation reports, and feature importance plots are stored alongside the run metadata.

My MLflow Integration Pattern

import mlflow
import mlflow.sklearn

def train_and_log(config):
    mlflow.set_experiment("recommendation-ltr")

    with mlflow.start_run(run_name=f"ltr-{config['model_type']}-{datetime.now().strftime('%Y%m%d')}"):
        # Log parameters
        mlflow.log_params(config)

        # Train model
        model = train_model(config)
        metrics = evaluate_model(model, val_data)

        # Log metrics
        mlflow.log_metrics(metrics)

        # Log feature importance
        fig = plot_feature_importance(model)
        mlflow.log_figure(fig, "feature_importance.png")

        # Log model with signature
        signature = mlflow.models.infer_signature(X_train, model.predict(X_train))
        mlflow.sklearn.log_model(
            model,
            "model",
            signature=signature,
            registered_model_name="ltr-recommender",
        )

        # Log data snapshot reference
        mlflow.log_param("training_data_path", data_snapshot_path)
        mlflow.log_param("training_data_hash", compute_hash(data_snapshot_path))

        return metrics

Critical detail: always log the training data reference. When a model behaves unexpectedly in production, the first question is “what data was this trained on?” If you log the data snapshot path and hash, you can reproduce the exact training dataset.

Model Registry Workflow

I enforce a strict promotion workflow:

  1. None → Staging: Automatic after training, if offline evaluation metrics meet minimum thresholds.
  2. Staging → Production: Manual approval after A/B test results confirm the model improves business metrics.
  3. Production → Archived: Automatic when a newer model is promoted to Production.

This workflow is implemented as a Python script that checks MLflow model versions and enforces transitions. It integrates with our CI/CD pipeline (more below).

CI/CD for ML: Beyond Traditional Software CI/CD

Why ML CI/CD Is Different

Traditional CI/CD tests code. ML CI/CD must test both code and data. A model can pass all unit tests but produce garbage results if the input data distribution shifts. My CI/CD pipeline has three layers:

Layer 1: Code Quality

Standard software engineering practices:

  • Linting: pylint, black, isort
  • Type checking: mypy with strict configuration
  • Unit tests: pytest for feature engineering logic, data transformations, and utility functions
  • Integration tests: Test the full training pipeline with a small synthetic dataset

Layer 2: Data Quality

Data tests run before any model training:

  • Schema validation: Column names, types, and constraints match expectations.
  • Distribution checks: Statistical tests (KS test, PSI) compare new data against reference distributions. If drift exceeds a threshold, the pipeline alerts and pauses.
  • Freshness checks: Training data must be recent enough. A model trained on data that’s 3 months stale is likely to underperform.
  • Label quality checks: For supervised learning, verify label distribution is reasonable and hasn’t shifted dramatically.

Layer 3: Model Quality

After training, the model must pass quality gates before promotion:

  • Offline metrics: AUC, NDCG, RMSE — whatever metrics are relevant — must exceed the current production model by a minimum threshold (or at least not degrade by more than a configured tolerance).
  • Fairness checks: Model performance must be consistent across user segments. A model that performs great overall but poorly for a specific demographic group is not acceptable.
  • Latency budget: The model must meet latency requirements for serving. I benchmark inference time on a reference machine and reject models that exceed the budget.
  • Model size: For edge or mobile deployment, model size matters. I set a maximum model size and reject models that exceed it.

The CI/CD Pipeline in Practice

# .github/workflows/ml-pipeline.yml
name: ML Pipeline CI/CD

on:
  schedule:
    - cron: '0 2 * * *'  # Daily retraining
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'configs/**'

jobs:
  test-code:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run unit tests
        run: pytest tests/ -v
      - name: Run linting
        run: pylint src/ --rcfile=.pylintrc

  validate-data:
    needs: test-code
    runs-on: ubuntu-latest
    steps:
      - name: Validate training data
        run: python scripts/validate_data.py
      - name: Check data drift
        run: python scripts/check_drift.py

  train-model:
    needs: validate-data
    runs-on: [self-hosted, gpu]
    steps:
      - name: Train model
        run: python src/train.py --config configs/production.yaml
      - name: Evaluate model
        run: python src/evaluate.py
      - name: Upload to MLflow
        run: python src/register_model.py

  deploy:
    needs: train-model
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to staging
        run: python scripts/deploy.py --env staging
      - name: Run smoke tests
        run: python scripts/smoke_test.py --env staging

Retraining Triggers: When Should You Retrain?

Scheduled Retraining

The simplest approach: retrain on a fixed schedule (daily, weekly, monthly). This works well when data distribution is relatively stable and you can afford the compute cost. I default to daily retraining for models where data freshness matters (recommendations, fraud detection) and weekly for more stable models (customer segmentation).

Data-Driven Retraining

More sophisticated: retrain when data distribution shifts beyond a threshold. I monitor feature distributions using Population Stability Index (PSI):

def calculate_psi(reference, current, bins=10):
    """Calculate Population Stability Index."""
    breakpoints = np.percentile(reference, np.linspace(0, 100, bins + 1))
    ref_counts = np.histogram(reference, bins=breakpoints)[0] / len(reference)
    cur_counts = np.histogram(current, bins=breakpoints)[0] / len(current)

    # Avoid division by zero
    ref_counts = np.clip(ref_counts, 1e-4, None)
    cur_counts = np.clip(cur_counts, 1e-4, None)

    psi = np.sum((cur_counts - ref_counts) * np.log(cur_counts / ref_counts))
    return psi

When PSI exceeds 0.2 for any critical feature, I trigger a retraining pipeline. This approach adapts to the actual data dynamics rather than a fixed schedule.

Performance-Driven Retraining

The most reactive approach: retrain when model performance degrades. This requires real-time monitoring of model predictions against actual outcomes (which introduces latency — you don’t know the “true” outcome until later). I use this for models where performance degradation has immediate business impact (fraud detection, ad click prediction).

My Recommendation

Use scheduled retraining as the baseline, with data-driven triggers as an override. Performance-driven retraining is valuable but adds complexity — start with it only for your most critical models.

Model Monitoring: The Often-Neglected Half

What to Monitor

  1. Prediction distribution: Are the model’s predictions still in a reasonable range? A sudden shift in predicted values often indicates data quality issues or distribution drift.
  2. Feature distribution: Are input features within expected ranges? Missing data, schema changes, and upstream pipeline failures will show up here.
  3. Performance metrics: For models with delayed ground truth (like churn prediction), track performance metrics as labels become available. Set up alerts for degradation.
  4. Infrastructure metrics: Latency, throughput, error rates, memory usage. These catch infrastructure issues before they affect model performance.

Monitoring Stack

I use a combination of:

  • Prometheus + Grafana for infrastructure metrics and real-time prediction monitoring.
  • Evidently AI for data drift detection and model performance reports.
  • Custom alerting via PagerDuty for critical degradation signals.

The Feedback Loop

The most important aspect of monitoring is closing the feedback loop. Monitoring data should feed back into retraining triggers and inform feature engineering decisions. When I see a pattern of repeated model degradation after certain data events, I investigate and add new features or preprocessing steps to make the model more robust.

Lessons from Nine Years of MLOps

Lesson 1: Start Simple, Add Complexity Only When Needed

I’ve seen teams adopt Kubernetes, feature stores, and model registries before they have a single model in production. Start with a cron job that retrains a model and a simple deployment script. Add orchestration when you have 3+ models. Add a feature store when feature reuse becomes a problem. Add CI/CD when manual deployment becomes a bottleneck.

Lesson 2: Invest in Data Validation Early

The most common production ML failure mode isn’t a bad model — it’s bad data. A model trained on corrupted or stale data will silently produce garbage predictions. Invest in data validation before you invest in model architecture improvements.

Lesson 3: Reproducibility Is Non-Negotiable

Every model in production must be reproducible. That means versioned code, versioned data (or at least a reference to the data snapshot), versioned dependencies, and deterministic training (or at least documented random seeds). When a model misbehaves, you need to be able to reproduce the exact conditions that produced it.

Lesson 4: Make Rollback Easy

Every deployment must have a rollback path. In practice, this means keeping the previous model version warm and ready to serve. Blue-green deployments work well for this — deploy the new model alongside the old one, validate it’s working correctly, then switch traffic. If anything goes wrong, switch back in seconds.

Lesson 5: Document Everything

ML systems have more moving parts than traditional software. Without documentation, tribal knowledge accumulates and becomes a single point of failure. I maintain:

  • Model cards for every model in production: what it does, what data it uses, what its limitations are.
  • Runbooks for common failure modes: what to do when data is stale, when model performance degrades, when the training pipeline fails.
  • Architecture diagrams that show how data flows from source to prediction.

Conclusion

Good MLOps is unglamorous work. It’s the plumbing that keeps your ML systems running reliably. The tools (Airflow, MLflow, Prometheus) are secondary to the practices: rigorous data validation, reproducible training, automated quality gates, comprehensive monitoring, and easy rollback. Get the practices right first, then optimize the tooling. The best MLOps pipeline is one that you barely notice — it just works, day after day, keeping your models healthy and your predictions trustworthy.