Model Deployment for AI Engineers

Deploy a CNN Model as a REST API


Here is the situation this project puts you in. A quality team at a small manufacturer photographs machined parts on a conveyor and wants a service that classifies each photo. They will call it from a Raspberry Pi on the line, over the local network, at about two images per second during a shift. They need an answer in under 200 milliseconds, they cannot install Python on the Pi beyond requests, and the person maintaining it after you leave has never used Docker.

That set of constraints — modest traffic, a hard latency budget, a non-expert operator, and a plain HTTP interface — describes an enormous share of real deployed machine learning. It is also small enough to build completely in a few weekends, which is why it is the right thing to build end to end at least once.

You will train a convolutional network on CIFAR-10 as the stand-in for the parts dataset, export it to a portable artefact, serve it behind FastAPI, containerise it, test it, ship it through CI, and deploy it somewhere reachable. The point is not the model. Any competent CIFAR-10 CNN will do. The point is everything that surrounds it.

Three levels of test, and what only the last one seesGreen pipelineUnit testsContainer testTransform shapeModel loadsReal HTTP callp95 under 100 ms
Unit tests that stub the model cannot notice a broken artefact; only a real request to the real image can.

What you are building, precisely

Write these numbers down before you start, because "it works" is not a finish line and without a target you will keep polishing.

RequirementTargetHow you will measure it
Test accuracy≥ 85% top-1 on the CIFAR-10 test splitscripts/evaluate.py, held-out 10,000 images
Per-class accuracyNo class below 75%Confusion matrix, not aggregate accuracy
Latencyp95 under 100 ms, batch size 1, 2 CPU cores200 timed requests after a warm-up
Container imageUnder 1.5 GBdocker images
Cold startReady within 30 s of container startTime from docker run to /readyz returning 200
RobustnessMalformed input returns 4xx, never 500Integration tests with deliberate garbage
ObservabilityEvery request has an ID and a structured log lineRead the logs after a load test

Write your acceptance numbers before you write any code; a project without a definition of done becomes a project without an end.

Project layout

Text
cifar-api/├── app/│   ├── __init__.py│   ├── main.py            FastAPI application and routes│   ├── model.py           load, preprocess, predict, postprocess│   ├── schemas.py         Pydantic request/response models│   └── logging_conf.py    JSON formatter, correlation IDs├── training/│   ├── train.py           training loop, checkpointing│   ├── export.py          TorchScript + ONNX export and verification│   └── evaluate.py        accuracy, per-class metrics, confusion matrix├── models/│   ├── model.pt           TorchScript artefact│   ├── model.onnx         ONNX artefact│   └── metadata.json      version, digest, metrics, opset, git commit├── tests/│   ├── unit/              preprocessing, schemas — model mocked│   ├── integration/       real container over HTTP│   ├── fixtures/          a dozen real images with known labels│   └── golden.npz         inputs + expected outputs, byte-stable├── .github/workflows/ci.yml├── Dockerfile├── docker-compose.yml├── requirements.txt└── README.md

The separation that matters most is app/model.py containing no FastAPI imports at all. Keep loading, preprocessing, inference, and postprocessing as plain functions, and the web layer becomes a thin adapter. That is what lets you later wrap the same core in a Lambda handler, a Gradio demo, or a batch script without touching the logic — and it is what makes the core unit-testable without spinning up a server.

Phase 1 — Train something good enough

Spend the least time here that gets you past 85%. A small VGG-style network trained for 30 epochs with augmentation comfortably reaches 87–90% on CIFAR-10.

Python
import torch, torch.nn as nn, torchvisionimport torchvision.transforms as TMEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)train_tfm = T.Compose([    T.RandomCrop(32, padding=4),    T.RandomHorizontalFlip(),    T.ToTensor(),    T.Normalize(MEAN, STD),])test_tfm = T.Compose([T.ToTensor(), T.Normalize(MEAN, STD)])def block(cin, cout):    return nn.Sequential(        nn.Conv2d(cin, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(),        nn.Conv2d(cout, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(),        nn.MaxPool2d(2),    )class SmallVGG(nn.Module):    def __init__(self, n_classes=10):        super().__init__()        self.features = nn.Sequential(block(3, 64), block(64, 128), block(128, 256))        self.head = nn.Sequential(nn.Flatten(), nn.Dropout(0.3), nn.Linear(256 * 4 * 4, n_classes))    def forward(self, x):        return self.head(self.features(x))model = SmallVGG()opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, nesterov=True)sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=0.1, total_steps=30 * len(train_loader))criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

Two things to get right, because they are the ones that bite later rather than now.

Record the normalisation constants somewhere the serving code reads. Put MEAN and STD into metadata.json, and have the server load them from there rather than hard-coding them a second time. A mismatch between training and serving normalisation is the single most common cause of "the model scored 89% offline and 61% in production", and it produces no error at all.

Save a per-class breakdown, not just overall accuracy. CIFAR-10's cat and dog classes routinely sit ten points below the rest. If you only record 88.3% overall, you will not notice when a later change drops cats to 61%.

Python
from sklearn.metrics import classification_report, confusion_matrixreport = classification_report(y_true, y_pred, target_names=CLASSES, output_dict=True)json.dump({"overall_acc": acc, "per_class": report,           "confusion": confusion_matrix(y_true, y_pred).tolist()},          open("models/metrics.json", "w"), indent=2)

Phase 2 — Export and verify the artefact

The trained nn.Module is not deployable: loading it requires the SmallVGG class definition. Export to a self-contained artefact and prove it computes the same thing.

Python
# training/export.pyimport torch, numpy as np, onnx, onnxruntime as ort, hashlib, json, subprocessmodel.eval()                                # critical: freezes dropout and batch-normdummy = torch.randn(2, 3, 32, 32)           # batch 2: size-1 dims cannot be made dynamictraced = torch.jit.trace(model, dummy)      # TorchScript: deprecated, still workstraced.save("models/model.pt")torch.onnx.export(model, (dummy,), "models/model.onnx",                  input_names=["pixels"], output_names=["logits"],                  dynamic_shapes={"x": {0: torch.export.Dim("batch")}},                  opset_version=18)onnx.checker.check_model(onnx.load("models/model.onnx"))sess = ort.InferenceSession("models/model.onnx", providers=["CPUExecutionProvider"])worst = 0.0for batch in (1, 4, 16):                    # exercise the dynamic axis    x = np.random.randn(batch, 3, 32, 32).astype(np.float32)    with torch.no_grad():        expected = model(torch.from_numpy(x)).numpy()    actual = sess.run(None, {"pixels": x})[0]    worst = max(worst, float(np.abs(expected - actual).max()))assert worst < 1e-4, f"ONNX diverges from PyTorch by {worst:.3e}"digest = hashlib.sha256(open("models/model.pt", "rb").read()).hexdigest()json.dump({    "version": "1.0.0",    "sha256": digest,    "opset_version": 18,    "exported_from": torch.__version__,    "max_abs_diff_vs_pytorch": worst,    "normalisation": {"mean": list(MEAN), "std": list(STD)},    "classes": CLASSES,    "input_shape": ["batch", 3, 32, 32],    "git_commit": subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip(),}, open("models/metadata.json", "w"), indent=2)

Three parts of that file earn their keep later. model.eval() before tracing: export in training mode and dropout stays active in production, randomly zeroing 30% of your features on every request. The batch-size loop: exporting with one batch size and never testing another is how a dynamic axis bug reaches production. And metadata.json: when a prediction looks wrong in three months, the digest tells you which bytes produced it and the normalisation block tells you what preprocessing they expect.

Then freeze a golden set — twelve real images and the outputs the verified model produces for them:

Python
inputs = np.stack([test_tfm(load(p)).numpy() for p in GOLDEN_PATHS])np.savez("tests/golden.npz", inputs=inputs,         logits=sess.run(None, {"pixels": inputs})[0],         labels=np.array(GOLDEN_LABELS))

This one file catches more later regressions than any other test you will write: label reordering, changed preprocessing, a corrupted artefact, or the wrong model baked into an image.

Phase 3 — Serve it

Python
# app/main.pyfrom fastapi import FastAPI, HTTPException, UploadFile, File, Dependsfrom contextlib import asynccontextmanagerimport torch, io, time, json, uuidfrom PIL import Imageimport torchvision.transforms as TSTATE = {}MAX_BYTES = 5 * 1024 * 1024@asynccontextmanagerasync def lifespan(app: FastAPI):    meta = json.load(open("models/metadata.json"))    torch.set_num_threads(1)    m = torch.jit.load("models/model.pt", map_location="cpu"); m.eval()    STATE.update(        model=m, classes=meta["classes"], version=meta["version"],        tfm=T.Compose([T.Resize((32, 32)), T.ToTensor(),                       T.Normalize(meta["normalisation"]["mean"],                                   meta["normalisation"]["std"])]),        warmup=torch.zeros(1, 3, 32, 32),    )    with torch.no_grad():        m(STATE["warmup"])            # first call is slow; pay it before traffic    yield    STATE.clear()app = FastAPI(title="CIFAR-10 Classifier", version="1.0.0", lifespan=lifespan)@app.get("/healthz")def healthz():    return {"status": "alive"}@app.get("/readyz")def readyz():    try:        with torch.no_grad():            STATE["model"](STATE["warmup"])        return {"ready": True, "model_version": STATE["version"]}    except Exception as e:        raise HTTPException(503, f"not ready: {e}")@app.post("/predict", response_model=Prediction)def predict(file: UploadFile = File(...), _=Depends(check_rate)):    if file.content_type not in {"image/jpeg", "image/png"}:        raise HTTPException(415, f"unsupported content type: {file.content_type}")    raw = file.file.read(MAX_BYTES + 1)    if len(raw) > MAX_BYTES:        raise HTTPException(413, "image exceeds 5 MB")    try:        img = Image.open(io.BytesIO(raw)).convert("RGB")    except Exception:        raise HTTPException(422, "file is not a decodable image")    t0 = time.perf_counter()    with torch.no_grad():        probs = torch.softmax(STATE["model"](STATE["tfm"](img).unsqueeze(0)), dim=1)[0]    ms = (time.perf_counter() - t0) * 1000    idx = int(probs.argmax())    top3 = torch.topk(probs, 3)    return Prediction(        label=STATE["classes"][idx],        confidence=round(float(probs[idx]), 4),        top3=[{"label": STATE["classes"][int(i)], "p": round(float(p), 4)}              for p, i in zip(top3.values, top3.indices)],        model_version=STATE["version"],        latency_ms=round(ms, 2),    )

Four decisions in that file are worth defending if someone reviews your work.

Why TorchScript here at all. It is deprecated in current PyTorch but still works, and it keeps this server in the same PyTorch code you trained with. If you are starting fresh, serving model.onnx with ONNX Runtime is the better long-term choice: the handler changes by a few lines (an InferenceSession instead of torch.jit.load, NumPy instead of tensors) and PyTorch drops out of the image entirely.

The model loads in lifespan, not in the handler. Loading inside predict makes every request pay deserialisation cost, which turns a 40 ms endpoint into a 400 ms one and looks like a mysterious performance problem.

def, not async def. The body contains a blocking PyTorch call and no await. Declared async, that call blocks the event loop for the whole forward pass, so ten simultaneous requests are served strictly one after another and your health check queues behind them.

The warm-up call. The first inference after load includes allocation and kernel selection and can be five times slower than steady state. Doing it before yield means the first real user does not absorb it — and it also makes /readyz meaningful, since a model that cannot run a forward pass is not ready.

Reading MAX_BYTES + 1. Checking size after reading the whole file means a 2 GB upload is already in memory when you reject it.

Phase 4 — Test it at three levels

LevelWhat it coversModelRuntime
UnitPreprocessing shapes, schema validation, label mappingMockedSeconds
GoldenThe artefact still produces the recorded outputsReal, in-processSeconds
IntegrationThe built container answers real HTTP correctlyReal, in a containerMinutes
Python
# tests/integration/test_api.pyimport requests, pathlib, pytest, numpy as npdef test_ready(base_url):    assert requests.get(f"{base_url}/readyz", timeout=10).json()["ready"] is True@pytest.mark.parametrize("path,expected", [    ("tests/fixtures/cat_01.jpg", "cat"),    ("tests/fixtures/ship_03.jpg", "ship"),    ("tests/fixtures/truck_07.jpg", "truck"),])def test_known_images(base_url, path, expected):    with open(path, "rb") as f:        r = requests.post(f"{base_url}/predict", files={"file": f}, timeout=30)    assert r.status_code == 200    body = r.json()    assert body["label"] == expected    assert body["model_version"] == "1.0.0"      # right artefact is inside the imagedef test_rejects_text_file(base_url):    r = requests.post(f"{base_url}/predict",                      files={"file": ("a.txt", b"hello", "text/plain")}, timeout=10)    assert r.status_code == 415                  # not 500def test_rejects_corrupt_image(base_url):    r = requests.post(f"{base_url}/predict",                      files={"file": ("a.jpg", b"\xff\xd8notanimage", "image/jpeg")},                      timeout=10)    assert r.status_code == 422def test_p95_latency(base_url):    img = pathlib.Path("tests/fixtures/cat_01.jpg").read_bytes()    for _ in range(10):                          # warm up        requests.post(f"{base_url}/predict", files={"file": ("a.jpg", img)})    times = []    for _ in range(200):        r = requests.post(f"{base_url}/predict", files={"file": ("a.jpg", img)})        times.append(r.json()["latency_ms"])    p95 = float(np.percentile(times, 95))    assert p95 < 100, f"p95 {p95:.1f} ms exceeds budget"

The test_rejects_text_file and test_rejects_corrupt_image cases are the ones people skip and the ones that matter most for a service someone else operates. A 500 means "we are broken"; a 415 or 422 means "your input was wrong". If every failure is a 500, the operator cannot distinguish their mistake from yours, and your error dashboard is noise.

Phase 5 — Containerise and automate

Dockerfile
FROM python:3.11-slim-bookwormRUN apt-get update \ && apt-get install -y --no-install-recommends libgomp1 libjpeg62-turbo curl \ && rm -rf /var/lib/apt/lists/*ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 OMP_NUM_THREADS=1WORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY models/ /app/models/COPY app/    /app/app/RUN useradd --create-home --uid 10001 appuser && chown -R appuser:appuser /appUSER appuserEXPOSE 8000HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \  CMD curl -fsS http://localhost:8000/readyz || exit 1CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Text
# requirements.txt  — the CPU index is what keeps the image under 1.5 GB--extra-index-url https://download.pytorch.org/whl/cputorch==2.14.0+cputorchvision==0.29.0+cpufastapi==0.141.1uvicorn[standard]==0.53.0pillow==12.3.0python-multipart==0.0.32

The +cpu wheels are the difference between roughly 5.5 GB and roughly 1.1 GB. The default Linux install of PyTorch pulls in about 4.5 GB of CUDA libraries that a CPU-only container will never execute. If your image is over budget, check this before anything else.

The dependency ordering — requirements, then models, then app/ — is what makes rebuilds fast. Docker rebuilds every layer after the first changed one, so putting application code last means a code-only change rebuilds in seconds instead of reinstalling PyTorch.

YAML
name: cion: [push, pull_request]jobs:  build-and-test:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v5        with: { lfs: true }      - uses: actions/setup-python@v6        with: { python-version: "3.11", cache: pip }      - run: pip install -r requirements.txt -r requirements-dev.txt      - run: pytest tests/unit -q      - run: python scripts/check_golden.py          # artefact still matches      - run: docker build -t cifar-api:test .      - name: Image size budget        run: |          BYTES=$(docker image inspect cifar-api:test --format '{{.Size}}')          echo "image size: $((BYTES / 1000000)) MB"          test "$BYTES" -lt 1500000000      - name: Start container and wait for readiness        run: |          docker run -d --name api -p 8000:8000 --memory 2g --cpus 2 cifar-api:test          for i in $(seq 1 30); do            curl -fsS http://localhost:8000/readyz && break            sleep 2          done      - run: pytest tests/integration -q --base-url http://localhost:8000      - if: failure()        run: docker logs api

The --memory 2g --cpus 2 flags matter: testing on an unconstrained GitHub runner tells you nothing about behaviour on the two-core box this will actually run on. The readiness loop rather than a fixed sleep is what keeps the pipeline from being flaky.

Where to deploy it

OptionEffortCostFits
Docker Compose on the customer's own machineAn hourNoneThe conveyor scenario: local network, no cloud dependency
Hugging Face Spaces (Gradio)An hourFree tierShowing it to stakeholders; sleeps when idle
A small cloud VM behind a proxyHalf a dayLow, fixedSteady low traffic, full control
Serverless container (Cloud Run, Lambda)Half a dayNear zero when idleSpiky or very low traffic; tolerate 4–10 s cold starts
Managed endpoint (SageMaker, Vertex AI)A dayInstance-hours, always onSteady traffic above a couple of requests per second

For the scenario at the top of this lesson, Compose on a machine at the factory is the right answer, and reaching for a managed cloud endpoint would be a mistake — it adds a network dependency the line cannot tolerate and a monthly bill for two requests per second. Deploy at least two of these anyway, because the second one is where you discover which of your assumptions were about your laptop rather than about your software.

What goes wrong, and what it looks like

SymptomLikely causeFix
Accuracy far worse in the API than in the notebookServing normalisation differs from trainingLoad mean and std from metadata.json; assert on the golden set
Predictions differ between two identical requestsmodel.eval() missing before export; dropout still activeRe-export in eval mode; verify determinism in a test
Container restarts in a loopHealth check fires before the model finishes loadingSet --start-period=40s; use /healthz for liveness
Every request takes several hundred msModel loaded inside the handlerMove it to lifespan; log once at load and count the lines
p99 far worse than p50 under concurrencyasync def around a blocking call, or thread oversubscriptionUse plain def; set OMP_NUM_THREADS=1 with multiple workers
Image is 5 GB or moreDefault PyTorch wheel with CUDAInstall from the CPU index; check with docker history
Memory climbs steadily under loadMissing torch.no_grad(), retaining activationsWrap inference; watch RSS over a ten-minute load test
Model file loads as 130 bytes in CIgit-lfs not fetched in the workflowAdd with: { lfs: true } to the checkout step
Works locally, 404s in the containerRelative model path resolved against a different working directoryUse absolute paths or set WORKDIR explicitly

Almost every failure in this list produces a working service that is quietly wrong, rather than an error — which is why the golden set and the integration tests are not optional extras.

How to know you have actually finished

The honest test is not whether the endpoint returns 200. It is whether someone else can run it, and whether you can diagnose it when it misbehaves. Three concrete exercises settle that.

Hand it over. Give a colleague only the repository URL and the README. They should get a working service with one command and no questions. Every question they ask is a gap in your README, and the fix is to write the answer down rather than to explain it.

Break it deliberately. Delete models/model.pt and start the container — it should fail loudly at startup, not serve 500s to users. Send a 50 MB file — it should return 413 quickly, not consume all your memory. Kill the container mid-request — the client should get a clean connection error, not a hang. Point it at the wrong metadata file — /readyz should refuse to report ready.

Reconstruct a past request. Run a load test, then pick one request from an hour ago and answer: which model version served it, how long inference took, what it predicted, and with what confidence. If you cannot answer all four from the logs in under two minutes, your logging is not finished, and you will find that out during an incident instead.

Anything beyond that — quantizing the model to shrink it further, adding a Redis cache, wiring up Prometheus, running a canary deployment — is a genuine improvement, but each one is optional. The three exercises above are not. A service that passes them is deployable by someone who is not you, which is the only definition of "deployed" that survives contact with a real organisation.