What You'll Build
In this exercise you will build a production-style model serving API for the cricket in-form classifier, containerise it, and configure it to autoscale under load. You will wrap an ONNX model in a FastAPI service with a validated request contract, health and readiness endpoints, and basic metrics, package it into a lean Docker image, then define the autoscaling policy that adds and removes replicas as request volume changes. This pulls together packaging, serving, and the operational concerns of running a model under real traffic into one deployable unit. By the end you will have an endpoint that validates inputs, loads the model once per worker, reports whether it is healthy, and scales horizontally so it stays fast during traffic spikes without paying for idle capacity during quiet periods, the shape of a real production inference service rather than a notebook demo.
Prerequisites
- Completion of lessons 11 and 13 (packaging and REST serving), or equivalent familiarity with Docker and FastAPI.
- Python 3.11, Docker installed and running, and the ability to build and run a local container.
- An ONNX model file (form_net.onnx) such as the one exported in lesson 11, or any compatible classifier.
- Basic understanding of HTTP requests, JSON, and what horizontal scaling and replicas mean.
- Optional: access to a Kubernetes cluster (or a local one like kind or minikube) to apply the autoscaling manifest.
Setup & Project Structure
You will create a serving project that cleanly separates the application code, the container definition, and the deployment manifests, the layout a real serving repository uses. The FastAPI app and its model live together, the Dockerfile packages them, and Kubernetes manifests describe the deployment and its autoscaling policy. Keeping the deployment configuration separate from the application matters because the same image is promoted unchanged across environments while only the scaling and resource settings differ, exactly the separation that lets one validated artifact run consistently from local testing to production. Lay out the folders and gather the dependencies before writing the service.
# Create the serving project skeleton.
mkdir -p cricket-serving/{app,k8s}
cd cricket-serving
cp /path/to/form_net.onnx app/ # the model exported in lesson 11
# requirements.txt (pinned for reproducibility)
cat > app/requirements.txt <<'EOF'
fastapi==0.111.0
uvicorn[standard]==0.30.0
onnxruntime==1.18.0
numpy==1.26.4
prometheus-client==0.20.0
EOF
# Resulting structure:
# cricket-serving/
# |-- app/
# | |-- serve.py # Step 1-2: FastAPI service + metrics
# | |-- form_net.onnx # the model artifact
# | `-- requirements.txt
# |-- Dockerfile # Step 3: lean, layer-cached image
# `-- k8s/
# |-- deployment.yaml # Step 3: deployment + resource requests
# `-- hpa.yaml # Step 3: horizontal pod autoscaler
echo 'Serving project skeleton ready.'
Step 1 — Foundation
Step 1 builds the foundation of the service: loading the model once at startup and defining the validated request and response contract. The concept behind this step is that a serving endpoint's correctness and performance both start here, the model must be a long-lived resource loaded a single time per worker, and every incoming request must be validated against a typed schema before it ever reaches inference. Getting this foundation right prevents the two most common serving failures at once: the per-request model reload that collapses under load, and the malformed payload that produces garbage predictions. This step establishes the safe, efficient base that everything else builds on.
# app/serve.py (part 1) -- Step 1: load once + validated contract.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import numpy as np
import onnxruntime as ort
app = FastAPI(title='Cricket Form Serving')
# Loaded ONCE at import (per worker process), reused across all requests.
_session = ort.InferenceSession('form_net.onnx', providers=['CPUExecutionProvider'])
_ready = True # flips false if the model fails to load / a dependency is down
class InningsFeatures(BaseModel):
batting_average: float = Field(..., ge=0, le=120)
strike_rate: float = Field(..., ge=0, le=400)
boundary_pct: float = Field(..., ge=0, le=1)
class FormPrediction(BaseModel):
in_form: bool
confidence: float
def _infer(features: InningsFeatures) -> FormPrediction:
x = np.array([[features.batting_average, features.strike_rate,
features.boundary_pct]], dtype=np.float32)
logits = _session.run(['logits'], {'features': x})[0][0]
probs = np.exp(logits) / np.exp(logits).sum()
cls = int(np.argmax(probs))
return FormPrediction(in_form=bool(cls), confidence=float(probs[cls]))
Step 2 — Core Logic
Step 2 adds the serving endpoints and observability: the prediction route, health and readiness probes, and request metrics. This is the core logic that makes the service operable under an orchestrator and an autoscaler, the prediction endpoint delivers the actual value, the liveness and readiness endpoints let the platform know whether to send traffic, and the metrics expose request count and latency so the autoscaler and operators can see load. Without health probes an orchestrator cannot tell a starting or broken pod from a healthy one, and without metrics there is nothing for an autoscaler to react to. This step turns the foundation into a fully operable endpoint.
# app/serve.py (part 2) -- Step 2: endpoints, probes, metrics.
from fastapi import Response
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
import time
REQUESTS = Counter('predict_requests_total', 'Total prediction requests')
LATENCY = Histogram('predict_latency_seconds', 'Prediction latency')
@app.post('/predict', response_model=FormPrediction)
async def predict(features: InningsFeatures) -> FormPrediction:
REQUESTS.inc()
start = time.perf_counter()
try:
return _infer(features)
except Exception as e:
raise HTTPException(status_code=500, detail=f'inference failed: {e}')
finally:
LATENCY.observe(time.perf_counter() - start)
@app.get('/healthz') # liveness: is the process up?
async def healthz():
return {'status': 'alive'}
@app.get('/readyz') # readiness: should we receive traffic?
async def readyz():
if not _ready:
raise HTTPException(status_code=503, detail='model not ready')
return {'status': 'ready'}
@app.get('/metrics') # scraped by Prometheus -> drives autoscaling
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
# Run locally: uvicorn serve:app --host 0.0.0.0 --port 8000 --workers 2
Step 3 — Integration & Enhancement
Step 3 brings it together: package the service into a lean Docker image and define the Kubernetes deployment with resource requests and a horizontal pod autoscaler. This integration is what makes the endpoint production-ready, the image is the portable artifact, the deployment declares how many replicas and how much CPU each needs, and the autoscaler ties replica count to observed load so the service grows under traffic and shrinks when quiet. Setting CPU requests correctly is essential because the autoscaler scales on CPU utilisation relative to those requests; without sensible requests the autoscaler cannot reason about load. This step turns the operable endpoint into an elastic, self-scaling service.
# Dockerfile -- Step 3a: lean, layer-cached image.
dockerfile = '''
FROM python:3.11-slim
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # cached unless deps change
COPY app/ . # code+model layered last
EXPOSE 8000
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
'''
# k8s/deployment.yaml -- Step 3b: deployment with probes + resource requests.
deployment_yaml = '''
apiVersion: apps/v1
kind: Deployment
metadata: { name: cricket-form }
spec:
replicas: 2
selector: { matchLabels: { app: cricket-form } }
template:
metadata: { labels: { app: cricket-form } }
spec:
containers:
- name: server
image: myregistry.io/cricket-form:1.0.0
ports: [ { containerPort: 8000 } ]
resources:
requests: { cpu: "250m", memory: "256Mi" } # autoscaler baseline
limits: { cpu: "1", memory: "512Mi" }
readinessProbe: { httpGet: { path: /readyz, port: 8000 }, initialDelaySeconds: 5 }
livenessProbe: { httpGet: { path: /healthz, port: 8000 }, initialDelaySeconds: 10 }
'''
# k8s/hpa.yaml -- Step 3c: autoscale on CPU utilisation.
hpa_yaml = '''
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: cricket-form-hpa }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: cricket-form }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 } # add pods past 70%
'''
print('Image + deployment + HPA: an elastic, self-scaling serving service.')
Step 4 — Testing & Verification
Verify three things: the container builds and serves correct predictions, the health and readiness endpoints respond appropriately, and the autoscaler adds replicas under sustained load. Build and run the image locally, send a valid and an invalid request to confirm validation works, hit the probes, then deploy to a cluster and generate load while watching the replica count climb. This confirms the whole chain, from packaged model to validated endpoint to elastic scaling, is working end to end.
# Build, run, and exercise the service; then watch it autoscale.
cd cricket-serving
docker build -t cricket-form:1.0.0 .
docker run -d -p 8000:8000 cricket-form:1.0.0
# Valid request -> 200 with a prediction:
curl -s -X POST localhost:8000/predict -H 'content-type: application/json' \
-d '{"batting_average": 53.6, "strike_rate": 131.0, "boundary_pct": 0.61}'
# Expected: {"in_form": true, "confidence": 0.9x}
# Invalid request (strike_rate out of range) -> 422 from schema validation:
curl -s -X POST localhost:8000/predict -H 'content-type: application/json' \
-d '{"batting_average": 53.6, "strike_rate": 999, "boundary_pct": 0.61}'
# Expected: 422 Unprocessable Entity
curl -s localhost:8000/readyz # -> {"status":"ready"}
curl -s localhost:8000/healthz # -> {"status":"alive"}
# On a cluster: deploy and watch the autoscaler react to load.
kubectl apply -f k8s/deployment.yaml -f k8s/hpa.yaml
kubectl get hpa cricket-form-hpa --watch # REPLICAS rises as load grows
# Generate load (e.g. with hey): hey -z 60s -c 50 -m POST -D body.json \
# http://<service>/predict
# Expected: replica count climbs from 2 toward 10 while CPU > 70%, then settles back.
Warning: A common error is omitting CPU resource requests on the deployment, then wondering why the horizontal pod autoscaler never scales. The HPA computes utilisation as actual CPU divided by the requested CPU, so with no request there is no denominator and the autoscaler has no signal to act on. Always set realistic resource requests; they are what define what 'busy' means per replica and are the basis of every scaling decision.
Extension Challenge: Replace CPU-based scaling with a custom metric, scale on predict_requests_total per second via the Prometheus Adapter, so the service reacts directly to request rate rather than CPU. For a harder stretch, add a small dynamic batching layer that groups requests arriving within a 15ms window into one ONNX call, then compare throughput and tail latency with and without batching under the same load test.
- A production serving endpoint loads the model once per worker, validates inputs against a typed schema, and exposes health, readiness, and metrics.
- Liveness and readiness probes let the orchestrator restart broken pods and route traffic only to ready ones.
- Containerising with a lean, layer-cached image produces a portable artifact promoted unchanged across environments.
- A horizontal pod autoscaler adds and removes replicas based on observed load, keeping latency low under spikes without paying for idle peak capacity.
- CPU resource requests define per-replica utilisation and are the denominator the autoscaler uses, so the HPA cannot scale without them.
- Keeping deployment and scaling config separate from application code lets one validated image run consistently from local testing to production.