Chapter 11 Production Deployment & MLOps

Building a model is the easy part. Deploying it reliably, monitoring it continuously, and updating it safely is where production data science lives or dies. This chapter synthesizes deployment patterns from all previous projects into a coherent MLOps strategy.

11.1 Deployment Patterns

11.1.1 Pattern 1: Batch Prediction

Best for: Churn scoring, segmentation, reporting

# Scheduled job (cronR or targets)
run_batch_predictions <- function() {
  customers <- fetch_customers_to_score()

  predictions <- customers %>%
    build_churn_features() %>%
    predict(model, ., type = "prob")

  write_to_db(predictions, table = "churn_scores")
  trigger_workflows(predictions %>% filter(.pred_1 > 0.7))
}

11.1.2 Pattern 2: Real-Time API

Best for: Fraud detection, recommendation systems

library(plumber)

#* @post /score
function(req) {
  body <- jsonlite::fromJSON(req$postBody)
  features <- extract_features(body)
  prediction <- predict(model, features)
  list(score = prediction, timestamp = Sys.time())
}

11.1.3 Pattern 3: Streaming

Best for: Real-time dashboards, event-driven systems

library(kafka)
# Consume from Kafka topic, score, produce to output topic

11.2 Model Versioning with pins

The pins package provides versioned storage for model artifacts.

library(pins)

board <- board_s3(bucket = "ml-artifacts", region = "us-east-1")

# Save model with version
pin_write(board, final_fit, "churn-model", versioned = TRUE)

# Load specific version
model_v3 <- pin_read(board, "churn-model", version = "20240115")

# List versions
pin_versions(board, "churn-model")

11.3 Containerization with Docker

FROM rocker/r-ver:4.3.1

# Install system dependencies
RUN apt-get update && apt-get install -y \
    libpq-dev \
    libssl-dev \
    libcurl4-openssl-dev \
    libxml2-dev

# Install R packages
RUN install2.r -e tidyverse tidymodels xgboost plumber shiny DBI RPostgres

# Copy application
COPY . /app
WORKDIR /app

# Expose port and run
EXPOSE 8000
CMD ["R", "-e", "pr('plumber.R') %>% pr_run(host='0.0.0.0', port=8000)"]

Build and run:

docker build -t churn-api:latest .
docker run -p 8000:8000 --env-file .env churn-api:latest

11.4 Monitoring & Observability

11.4.1 Model Performance Metrics

library(yardstick)

monitor_model <- function(predictions, actuals) {
  tibble(
    timestamp = Sys.time(),
    auc = roc_auc_vec(actuals, predictions),
    precision = precision_vec(actuals, predictions > 0.5),
    recall = recall_vec(actuals, predictions > 0.5),
    prediction_drift = ks.test(predictions, training_predictions)$statistic
  )
}

11.4.2 Logging with logger

library(logger)

log_threshold(INFO)
log_layout(layout_glue_colors)

# Structured JSON logging for production
log_layout(layout_json())
log_appender(appender_file("/var/log/ml-api.jsonl"))

log_info("Prediction made", 
         customer_id = customer_id, 
         score = score, 
         latency_ms = latency)

11.4.3 Health Checks

#* @get /health
function() {
  list(
    status = "healthy",
    timestamp = format(Sys.time(), "%Y-%m-%d %H:%M:%S"),
    version = Sys.getenv("APP_VERSION", "unknown"),
    db_connected = safe_execute(DBI::dbIsValid(con), FALSE),
    model_loaded = !is.null(model)
  )
}

11.5 CI/CD for R

11.5.1 GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Deploy R API

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup R
        uses: r-lib/actions/setup-r@v2

      - name: Restore renv
        uses: r-lib/actions/setup-renv@v2

      - name: Run tests
        run: |
          Rscript -e 'testthat::test_dir("tests")'

      - name: Build Docker image
        run: docker build -t churn-api:${{ github.sha }} .

      - name: Push to registry
        run: |
          docker tag churn-api:${{ github.sha }} registry/churn-api:latest
          docker push registry/churn-api:latest

11.6 A/B Testing Models in Production

Deploy new models alongside existing ones and compare performance:

# Traffic splitting
route_request <- function(customer_id) {
  bucket <- hash(customer_id) %% 100
  if (bucket < 10) {
    predict(model_v2, customer_id)  # 10% to new model
  } else {
    predict(model_v1, customer_id)  # 90% to current model
  }
}

11.7 Disaster Recovery

# Fallback model (simple business rules)
fallback_predict <- function(features) {
  if (features$days_since_last_active > 60) {
    return(0.8)  # High churn risk
  } else if (features$mrr > 10000) {
    return(0.1)  # Low churn risk
  } else {
    return(0.3)  # Medium risk
  }
}

# Wrapper with fallback
safe_predict <- function(model, features) {
  tryCatch(
    predict(model, features),
    error = function(e) {
      log_error("Model prediction failed, using fallback")
      fallback_predict(features)
    }
  )
}

11.8 Security Best Practices

  1. Never log PII: Hash customer IDs before logging
  2. Use HTTPS: Terminate TLS at the load balancer
  3. Rate limiting: Prevent abuse with plumber middleware
  4. Input validation: Sanitize all user inputs
  5. Secrets management: Use HashiCorp Vault or AWS Secrets Manager
# Rate limiting middleware
#* @filter rate-limit
function(req) {
  key <- req$REMOTE_ADDR
  current <- redis_con$INCR(key)
  if (current == 1) redis_con$EXPIRE(key, 60)
  if (current > 100) {
    res$status <- 429
    return(list(error = "Rate limit exceeded"))
  }
  plumber::forward()
}

11.9 Exercises

  1. Set up a GitHub Actions workflow that runs R CMD check on every pull request.
  2. Implement blue-green deployment for a Plumber API using Docker Compose.
  3. Build a monitoring dashboard in Shiny that displays model performance over time.
  4. Write a renv restore script that fails fast if any package cannot be installed.

This concludes the project chapters. The Appendix contains reference materials for packages, functions, and further reading.