Model Serving Infrastructure at Scale

How to build reliable model serving infrastructure for production AI. Covers inference optimization, GPU orchestration, batching strategies, model routing, and cost management.

Model serving infrastructure at scale is the system that delivers trained machine learning models to production applications, handling thousands of concurrent inference requests with low latency, high throughput, and predictable reliability. It matters when models are not just batched predictions but live components of real-time systems—recommender engines, image classifiers, natural language processors, anomaly detectors—where a model’s output directly affects user experience, business decisions, or operational workflows.

Deploying a Model for Production Inference

To serve a model, you first package it into a standardized artifact—typically a .pt, .onnx, or .pb file—and define how to load and execute it. The deployment process begins with a container image built from a base runtime: python:3.11-slim, tensorflow/serving, or pytorch/torchserve. A minimal Dockerfile might look like:

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY model.pt ./
COPY serve.py ./

EXPOSE 8000

CMD ["python", "serve.py"]

The entrypoint script (serve.py) uses a standard HTTP server to expose the model via a REST API. Using uvicorn and fastapi, the server handles inference with a single endpoint:

from fastapi import FastAPI
import torch
import numpy as np

app = FastAPI()


model = torch.jit.load("model.pt")
model.eval()

@app.post("/predict")
def predict(input: dict):
    # Parse input: expects {"data": [float, ...]}
    data = np.array(input["data"], dtype=np.float32)
    tensor = torch.from_numpy(data)

    with torch.no_grad():
        output = model(tensor)

    return {"prediction": output.tolist()}

This service runs on a cluster of machines or a Kubernetes namespace, with kubectl managing the deployment.

kubectl apply -f deployment.yaml

where deployment.yaml defines a Deployment with replicas: 16, resources: {requests: {cpu: "1", memory: "2Gi"}, limits: {cpu: "2", memory: "4Gi"}}, and readinessProbe with httpGet: {path: "/health", port: 8000}, initialDelaySeconds: 10, periodSeconds: 5.

Scaling Inference with Dynamic Load Balancing

As request volume grows, the model service must scale horizontally. Kubernetes auto-scales the deployment using the HorizontalPodAutoscaler (HPA), which monitors CPU and custom metrics via the Metrics Server.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: model-inference-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: model-inference
  minReplicas: 8
  maxReplicas: 64
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: External
    external:
      metric:
        name: requests_per_second
        selector:
          matchLabels:
            job: model-inference
      target:
        type: AverageValue
        averageValue: "100"

Requests per second are collected via Prometheus and exposed as a Metric in the Custom Metrics API. When the average request rate exceeds 100 per second, the HPA increases replicas.

The load is distributed by a reverse proxy—typically nginx or traefik—configured to balance traffic across pods and route /predict to the service.

upstream inference {
    server model-inference-0:8000;
    server model-inference-1:8000;
    server model-inference-2:8000;
    # ... all pods
}

server {
    listen 80;
    location /predict {
        proxy_pass http://inference;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_buffering on;
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 512k;
    }
}

A silent failure occurs when a pod starts but fails the readiness probe before the first request arrives: clients receive 503 Service Unavailable until the pod is ready.

Managing Model Versions and Rollouts

Models are updated regularly, and each version must coexist in production. The model serving system tracks versions via a versioned artifact store—S3, GCS, or a database. A new version is registered with metadata: version: "v2.1.0", created_at: "2024-06-15T08:00:00Z", trained_on: "2024-06-10".

Rollouts use canary or blue-green strategies. A canary rollout splits traffic: 90% to current version, 10% to new version.

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: model-inference-vs
spec:
  hosts:
    - model.example.com
  http:
  - route:
    - destination:
        host: model-inference
        subset: v1
      weight: 90
    - destination:
        host: model-inference
        subset: v2
      weight: 10

Istio manages this via DestinationRule, which defines subsets and their load-balancing policies.

Rollout failures are common: a new model version may fail to load due to missing torch version, or a shape mismatch error when input tensor dimensions don’t match expected shape.

{
  "error": "Shape mismatch",
  "details": {
    "expected": [1, 128, 768],
    "actual": [1, 127, 768]
  },
  "status": 400
}

This error silently breaks inference for 10% of traffic until the rollout is rolled back.

Caching Inference Results

To reduce latency and cost, inference results are cached at multiple levels. At the edge, Redis caches predictions keyed by input hash.

import hashlib
import redis

r = redis.Redis(host="redis-cache", port=6379, db=0)

def cached_predict(input_data):
    key = hashlib.md5(str(input_data).encode()).hexdigest()
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    # Compute prediction
    response = requests.post("http://model-inference:8000/predict", json={"data": input_data})
    result = response.json()

    # Cache for 10 minutes
    r.setex(key, 600, json.dumps(result))
    return result

The cache miss rate is critical: a 30% miss rate increases latency by 150ms on average, which is noticeable in real-time applications.

When the model is updated, caches become stale. A common failure: a new model version expects inputs in a different format (e.g., normalized vs. raw), but the cache uses old input hashes, leading to mismatched results.

A stale cache is detected via cache-hit-rate metric: when below 70%, the system triggers a cache warm-up job that preloads the cache with high-traffic input patterns.

Monitoring and Observability

Each inference request is tracked with structured logs, traces, and metrics. The service emits logs in JSON format, with fields: timestamp, request_id, model_version, input_size, latency_ms, status.

{
  "timestamp": "2024-06-15T08:02:14.312Z",
  "request_id": "a7b3f8c1-9d2e-4a5b-8c1f-2e3d4c5b6a7f",
  "model_version": "v2.1.0",
  "input_size": 128,
  "latency_ms": 42,
  "status": 200,
  "error": null
}

These logs are ingested by a log aggregation system like Loki, ELK, or Fluentd. Traces are collected via OpenTelemetry, with spans for each stage: request_received, model_loaded, inference_executed, response_sent.

A critical failure mode: a sudden spike in request rate causes increased queue_time in the model service. The logs show:

{
  "timestamp": "2024-06-15T08:05:31.000Z",
  "request_id": "b1c2d3e4-f5g6-h7i8-j9k0-l1m2n3o4p5q6",
  "model_version": "v2.1.0",
  "input_size": 256,
  "latency_ms": 320,
  "status": 200,
  "queue_time_ms": 250
}

The queue_time_ms of 250ms indicates that requests are queuing in the FastAPI server before being processed. This happens when the number of model inference workers is less than the number of concurrent requests.

The queue_time metric is collected via prometheus using a gauge that increases on request arrival and decreases on completion. A high queue time triggers an alert: inference_queue_length > 500 for 5 minutes.

Handling Model Failures and Degraded Operation

When a model fails, the serving system must respond gracefully. A model may fail due to missing files, out-of-memory errors, or incorrect input formats.

When a pod fails to load the model, it returns 500 Internal Server Error:

{
  "error": "Model load failed",
  "details": {
    "file": "/app/model.pt",
    "reason": "File not found",
    "expected": [
      "/app/model.pt",
      "/app/config.json"
    ]
  },
  "status": 500
}

The system detects this via livenessProbe on the pod: if the model fails to load within 30 seconds, the pod is restarted.

When a model is partially degraded—e.g., it runs but returns incorrect outputs—a health check endpoint /health returns:

{
  "status": "degraded",
  "model_version": "v2.1.0",
  "latency_p95": 120,
  "error_rate": 0.08,
  "last_updated": "2024-06-15T08:00:00Z"
}

The /health endpoint is used by the load balancer to route traffic to healthy pods. A pod with error_rate > 0.1 is marked as unhealthy and removed from the load-balancing pool.

A silent failure: a model is trained with new data, but the inference pipeline does not validate the input schema. A request arrives with {"inputs": [1.0, 2.0, 3.0]} but the model expects {"data": [1.0, 2.0, 3.0]}. The result is 400 Bad Request, but the error is logged only at the service level—clients receive null responses, and the logs show KeyError: 'data'.

Managing Large Models and Memory Pressure

Large models—transformers, diffusion models, graph neural networks—require careful memory management. The model is loaded into GPU memory, and the serving system must handle memory pressure.

The torch.jit.load function allows model optimization:

model = torch.jit.load("model.pt")
model.eval()
model = torch.jit.optimize_for_inference(model)

This optimizes the model for inference: fusion of operations, constant folding, and removal of unused modules.

When memory pressure increases—detected via node-mem-utilization in Kubernetes—the system triggers eviction of less-used models. A model is evicted if it has not been used in the last 10 minutes.

Each model is served with a ModelServer process that maintains a pool of inference workers. The number of workers is tuned via the --workers flag:

python serve.py --workers 4 --model-path model.pt --host 0.0.0.0 --port 8000

The worker pool uses a threading.ThreadPoolExecutor or concurrent.futures.ProcessPoolExecutor to parallelize inference across multiple CPU cores.

A critical failure: a model is too large for GPU memory, causing CUDA out of memory errors:

{
  "error": "CUDA out of memory",
  "details": {
    "allocated": "8.2 GB",
    "available": "8.0 GB",
    "tensor": "embedding_layer.weight"
  },
  "status": 500
}

This error is silent: requests fail without a clear message, and the logs show CUDA error: 11 (out of memory).

To mitigate this, the system uses model sharding—splitting a single model across multiple GPUs—and batching to increase throughput.

Summary of Key Patterns

Each pattern is grounded in real tools and protocols: HTTP, JSON, Prometheus, Redis, Kubernetes, OpenTelemetry, FastAPI, nginx. The system works across platforms, and a reader can implement it in any environment without relying on a specific vendor’s product.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.