, ,

Serving a Model Batch and Real Time for Infrastructure Engineers (Infra to Data Science Series, Part 17)

Your trained model becomes two deployables, a batch scoring job on a schedule and a real time endpoint. Here is how an infrastructure engineer serves both, and which one most infra work actually needs.

Infra to Data Science Series · Part 17 of 26

A model I spent three weeks training answers one prediction in about six milliseconds. Arguing over how to put it in front of real traffic took longer than the training did, and it should not have, because both answers were already sitting in runbooks I owned. Serving is where an infrastructure background stops being an interview story and starts paying rent, so this part is deliberately concrete: two ways to serve the classifier from Part 13, working code for each, and a clear rule for which one your problem actually needs.

Who this is for: An infrastructure engineer, SRE or platform engineer who trained the incident classifier across Parts 13 to 15 and now needs it to produce predictions on real data. You can run a service; you have not yet deployed a model. Terms on first use: batch scoring means running the model over a pile of rows on a schedule and writing the results somewhere; a real time endpoint means the model sits behind an HTTP API and answers one request at a time; latency is how long a single prediction takes to come back; throughput is how many predictions you can produce per second.

Two serving modes, one model

Last part mapped your service runbooks onto the model and named the two checks that are new. This part serves the model those runbooks describe. Same artifact, the incident classifier trained on a month of one cluster cpu and memory metrics, now put to work in two shapes. Batch scoring runs it over a file of new windows on a cron and writes a scored table. A real time endpoint wraps it in an API so another system can ask about one window and get an answer in milliseconds. Nothing about the model changes between them; what changes is how the rows arrive and how fast the answer must come back.

Most tutorials open a serving lesson by standing up a REST API, and for infrastructure work that default is usually wrong. A capacity forecast read once a week, an anomaly scan over yesterday metrics, a nightly risk score per cluster: none of these needs a request to return in six milliseconds. They need a reliable job that runs on time and lands its output where a dashboard or a ticketing rule can read it. Reach for a batch job first. Add a real time endpoint only when a prediction has to happen inside a request that a human or another service is waiting on. Deep mechanics of batch, real time and streaming serving live in the Data Science Series; this part is the operator cut, the parts that touch cron, ports, workers and pagers.

DimensionBatch scoring jobReal time endpoint
Latency needminutes to hours is finea single request is waiting, milliseconds matter
What you operatea scheduled job, like any cron you runa long lived service with health checks and autoscaling
Cost shapepay for one run, then nothingpay to keep it warm around the clock
Typical infra usecapacity review, nightly risk scan, backfillsinline gating, an operator tool asking live
Failure blast radiusone late or wrong file, easy to rerunevery caller feels it at once
Serving throughput, one call vs one row at a timerows scored per second, measured on a laptop, log scale2,420,000 rows/s172 rows/sbatch, one predict callloop, one row at a timelog scale
Vectorised scoring beats a per row loop by four orders of magnitude. This gap is the whole reason batch and real time are different problems, not one problem at two speeds.

Saving the model as a servable artifact

Before either serving mode, the trained model has to leave the notebook as a file you can version and ship. scikit-learn persists with joblib, and two habits from operations carry straight over: put the version in the filename so a rollback is obvious, and read the path from the environment so the same code runs in every stage. One caution the scikit-learn docs are blunt about, a joblib file is a pickle and loading an untrusted one runs arbitrary code, so treat model files like any other artifact, from a source you control only.

# tested with python 3.12, scikit-learn 1.6.1, joblib 1.4.2, pandas 2.2.3
import os, joblib

# clf was trained in Part 13 on a month of one cluster cpu and memory metrics
joblib.dump(clf, 'incident_clf-v3-sklearn1.6.1.joblib')

# read the path from the environment, never hardcode it in the service
os.environ.setdefault('MODEL_PATH', 'incident_clf-v3-sklearn1.6.1.joblib')
print('feature order the model expects:')
print(list(clf.feature_names_in_))
feature order the model expects:
['cpu_p95', 'mem_p95', 'cpu_slope', 'mem_slope', 'load1_mean']

That printed list is not decoration. scikit-learn 1.x records the feature names it was fit on in feature_names_in_, and it will check them at prediction time. Writing the sklearn version into the filename matters for a plain reason: there is no supported way to load a model into a different scikit-learn version than the one that saved it, so a version bump is a retrain, not a silent upgrade. Versioning the file, the pipeline and the config together is a Part 20 job, a model registry, which is an artifact repository with model metadata; for now the filename convention is enough to roll back by hand.

Batch scoring on a schedule

Where the project stands: last part we mapped runbooks, two parts back we trained the classifier. This part scores real windows with it. A batch job is thirty lines, load the model, read a file of new metric windows, predict, write the results. Here is the first cut, and it has a bug I have shipped for real more than once.

# batch_score.py  tested with scikit-learn 1.6.1, pandas 2.2.3
import os, joblib, pandas as pd

model = joblib.load(os.environ['MODEL_PATH'])
new = pd.read_csv('metrics_2026_07.csv')   # 100000 rows, one cluster, hourly windows

# grabbing columns by hand, in the order they felt natural
features = new[['mem_p95', 'cpu_p95', 'cpu_slope', 'mem_slope', 'load1_mean']]
new['risk'] = model.predict(features)
new[['window_start', 'risk']].to_csv('scored.csv', index=False)
print(new['risk'].value_counts())
ValueError: The feature names should match those that were passed during fit.
Feature names must be in the same order as they were in fit.

I hand picked the columns and put mem_p95 before cpu_p95. Older code would have shrugged, lined the arrays up by position and returned confidently wrong risk scores, which is the worse outcome because nothing errors. scikit-learn 1.x refuses, because it kept the names and the order from fit. Do not paper over it by dropping the names; fix the cause by asking the model what order it wants and reindexing to exactly that.

# let the model define the contract
features = new.reindex(columns=model.feature_names_in_)
new['risk'] = model.predict(features)
print(new['risk'].value_counts())
risk
0    93412
1     6588
Name: count, dtype: int64

Now the run is honest: 6,588 of 100,000 windows flagged, scored in one vectorised call, written to a file a capacity review or an alert rule can read. Put that script on the same scheduler you already trust, add the data validation gate from last part in front of it, and you have a production scorer without a single long lived service to babysit. This batch_score.py is the reference artifact for this part; it runs standalone against any CSV whose columns are a superset of the model features.

War story: I once built a real time endpoint for a capacity model whose only consumer was a weekly review that a human read on Monday. It cost roughly three weeks of on call surface, a service to keep warm, health checks to tune, an autoscaler to argue with, for a prediction nobody looked at before the weekend was over. I deleted it and replaced it with a thirty line batch job on a Sunday cron. Pager events from that service went to zero. The reversed decision taught me to ask two questions before choosing a mode: who reads this prediction, and how fast do they need it. For most infra models the honest answer sends you to a batch job.

A real time endpoint with FastAPI

When a prediction has to happen inside a live request, wrap the model in an HTTP service. FastAPI is the common choice, uvicorn runs it, and pydantic validates the incoming JSON so a malformed request fails cleanly instead of reaching the model. One rule decides whether this service is fast or slow: load the model once when the process starts, not on every request. Here is the shape that gets it right.

# serve.py  tested with fastapi 0.115.6, uvicorn 0.34.0, pydantic 2.9, pandas 2.2.3
import os, joblib
import pandas as pd
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel

ml = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    ml['model'] = joblib.load(os.environ['MODEL_PATH'])   # loaded once, at startup
    ml['features'] = list(ml['model'].feature_names_in_)
    yield
    ml.clear()

app = FastAPI(lifespan=lifespan)

class Window(BaseModel):
    cpu_p95: float
    mem_p95: float
    cpu_slope: float
    mem_slope: float
    load1_mean: float

@app.post('/predict')
def predict(w: Window):
    row = pd.DataFrame([[w.cpu_p95, w.mem_p95, w.cpu_slope, w.mem_slope, w.load1_mean]],
                       columns=ml['features'])
    return {'risk': int(ml['model'].predict(row)[0])}

Building the input as a one row DataFrame with the model own feature list keeps the same contract the batch job enforces, so a client cannot silently swap two fields. Run it and call it:

$ uvicorn serve:app --host 0.0.0.0 --port 8000 --workers 4
INFO:     Started server process [12841]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000

$ curl -s -X POST http://localhost:8000/predict -H 'content-type: application/json' 
    -d '{"cpu_p95":0.94,"mem_p95":0.88,"cpu_slope":0.02,"mem_slope":0.05,"load1_mean":7.1}'
{"risk":1}

Here is the failure that trips people who follow older tutorials. Nearly every serving guide still loads the model in an @app.on_event(‘startup’) handler, and current FastAPI answers with a warning:

DeprecationWarning:
        on_event is deprecated, use lifespan event handlers instead.
        Read more about it in the FastAPI docs for Lifespan Events.

on_event has been deprecated since FastAPI 0.93 in favour of the lifespan context manager the code above uses, which keeps startup and shutdown in one place. A nastier version of the same mistake is loading the model inside the endpoint function, so every request reads a two megabyte file off disk before it can answer. That turns a six millisecond prediction into a call dominated by a load of roughly 180 milliseconds, and it scales with traffic, so the service gets slower exactly when it is busiest. Load once at startup, hold it in memory, and the request path does only the prediction. The two serving paths, both fed by the one artifact, look like this.

flowchart LR
  R[Model artifact in registry] --> B[Batch job on a schedule]
  R --> S[Real time endpoint]
  B --> CSV[Scored table]
  S --> API[JSON risk per request]
  CSV --> D[Capacity review and dashboards]
  API --> A[Inline gating and automation]
One trained artifact, two serving paths. The batch path lands a table that people read later; the real time path answers a caller that is waiting now.

Latency, workers and serving capacity

Capacity planning for a model endpoint is the exercise you already run for any service, with one measurement you have to take yourself. A short benchmark settles it. This is where the batch versus per row gap in the opening chart came from.

# bench.py  python 3.12, scikit-learn 1.6.1
import time, os, joblib, pandas as pd

model = joblib.load(os.environ['MODEL_PATH'])
X = pd.read_csv('metrics_2026_07.csv').reindex(columns=model.feature_names_in_)

t0 = time.perf_counter()
_ = model.predict(X)                       # one vectorised call, 100000 rows
print('batch 100k rows:', round((time.perf_counter() - t0) * 1000, 1), 'ms')

t0 = time.perf_counter()
for _, r in X.head(1000).iterrows():       # one row at a time
    _ = model.predict([r.values])
print('loop 1k rows:', round((time.perf_counter() - t0) * 1000, 1), 'ms')
batch 100k rows: 41.3 ms
loop 1k rows: 5820.0 ms

Read those two numbers. Scoring 100,000 rows in one call took 41 milliseconds; scoring 1,000 rows one at a time took 5.82 seconds, which is about 5.8 milliseconds of overhead per call and would put a full 100,000 rows near ten minutes. That per call cost is your real time latency floor, roughly six milliseconds of model plus framework, and it is why a batch job that has all the rows at once is thousands of times cheaper than the same model answering them one by one. As an aside, passing r.values as a bare array makes scikit-learn warn that the input has no valid feature names, another reason to keep a DataFrame with named columns on the request path.

From that floor, sizing is arithmetic you have done before. A synchronous scikit-learn prediction holds the worker while it runs, so throughput per process is roughly one over the latency, near 160 predictions per second per worker at six milliseconds. uvicorn with four workers on a four core box gets you into the mid hundreds per second, and past that you scale out with the same autoscaler and load balancer you already operate. Two additions are model specific. Batch predictions where you can, since one call of 500 rows is far cheaper than 500 calls, the same batching and caching logic that cuts LLM latency. And budget the second cost centre, retraining, as a scheduled job with its own bill rather than something you fire off whenever accuracy feels low.

Verdict: For an infrastructure model, serve batch first and reach for real time only when a request is genuinely waiting on the answer. The pick is a scheduled scoring job that writes a table, guarded by the data validation from last part, because it is cheaper, has a smaller blast radius and is a scheduler you already run. When you do need the endpoint, the one to avoid is loading the model per request or on an on_event handler; load once in a lifespan context, hold it in memory, and size workers off a measured latency floor, not a guess.

Serve batch first, add real time when you need it

Both serving modes are shapes you already know, a cron job and a small HTTP service, wrapped around an artifact that happens to have been fit from data. Your instinct to reach for the simplest reliable thing is correct here, and for most infra predictions the simplest reliable thing is a batch job. Save real time for the requests that cannot wait, and when you build it, put the model load and the feature contract where they belong, at startup and inside a named DataFrame.

Do this on Monday: Take the model file from Part 13 and stand up batch_score.py against one exported CSV of your own metrics. Run it on your scheduler once, confirm the scored file lands, and check the flagged count is sane against what you know about that week. That is a working batch scorer on your own data in an afternoon. Only after it runs clean should you copy serve.py and hit the endpoint with one curl; if nothing is waiting on a live answer yet, stop at the batch job, because it is already doing the work.

Next part watches this served model in production, because a model that is up and fast can still be quietly wrong, and drift is just observability pointed at prediction quality.

Infra to Data Science Series · Part 17 of 26
« Previous: Part 16  |  Guide  |  Next: Part 18 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading