Lab 01 — Truss-Style Model Packaging & the Serving Contract
Phase: 20 — Custom Models in Production Difficulty: ⭐⭐⭐☆☆ (no math; the invariants — reproducible build, readiness gate, released slot — are the lesson) Time: 3–4 hours
Every "my model works locally but the deploy is broken" story is one of five bugs, and this lab builds the machine that makes all five impossible. You write the two halves of what
truss pushactually does: the packager (validate the config, then turn it into ordered, content-addressed image layers so the registry cache hits instead of re-downloading 16 GB of weights on every one-line code edit) and the server (nothing heavy in__init__,load()exactly once, 503 before ready, 429 pastpredict_concurrency, and a concurrency slot that comes back even whenpredict()explodes). Then you model where a 6-minute cold start actually goes, and wrap the output in the OpenAI envelope that lets your custom model drop into any existing client with a one-linebase_urlchange.
What you build
parse_config(raw)→TrussConfig— the packager's validation, and it is deliberately hostile: an unknown top-level key is a typo you must reject (a silently ignoredresourcses:is how you serve a 70B model on a CPU); every requirement must be pinned (torch==2.4.0, nottorch>=2.4); every weight source must name an immutable revision (revision: mainmeans today's build and tomorrow's build serve different weights under the same image tag); andsecretsare names only — the value is injected at runtime and must never enter a layer.use_gpuandtotal_vram_gbare derived, never hand-written.build_plan(config, code_digest)→ orderedLayers —base_image → system_packages → python_requirements → model_weights → user_code, each addressed bysha256(canonical_json)[:16]. That order is the cache policy.plan_cache_reuse(old, new)— the longest common prefix by digest. Not a set intersection: OCI caching is positional, so the first differing layer invalidates everything downstream.cold_start_breakdown(...)— image pull + weight fetch + model load + engine build, with aweights_cachedbranch that swaps a cross-internet hub download for a local NVMe read. This one boolean is worth minutes per replica start.ModelServer—start()(load exactly once),try_admit()/release()(the admission gate),handle()(readiness → admission →preprocess → predict → postprocess, slot released in afinally),handle_stream()(eager validation, slot released even when the client disconnects mid-stream), andhealth().to_openai_chat_completion(...)— the response envelope, with a deterministic id and acreatedyou pass in rather than read from the clock.
Key concepts
| Concept | What to understand |
|---|---|
| Layer volatility order | a layer busts every layer after it; weights (GB, rarely change) must precede code (bytes, changes hourly), or every edit re-downloads the model |
| Content addressing | the digest is a hash of the content, so an identical config on another machine hits the same cache — reordering requirements must not change it (hence the sort) |
| Pinned everything | torch>=2.4 and revision: main make a "rebuild" a different model. Reproducibility is a validation problem, not a discipline problem |
| Secrets by name | a secret in a layer is a secret in your registry forever, even after you rotate it — layers are immutable and cached |
__init__ vs load() | __init__ runs at import (no GPU guarantees, blocks the health server); load() runs once in the container with the accelerator attached |
| Readiness gate (503) | a pod that answers before its weights are in VRAM either crashes or serves garbage; the orchestrator needs a truthful readiness signal to route traffic |
predict_concurrency (429) | admission control is what turns "slow" into "backpressure"; without it, an overloaded replica OOMs instead of shedding load |
The finally release | the single most common capacity leak in model servers: an exception path that never gives the slot back, so throughput decays until restart |
| Eager stream validation | make handle_stream itself a generator and no check runs until the first next() — after you already committed a 200 |
| Cold start ≠ latency | cold start is a scaling property (it gates scale-to-zero and burst response); p50 latency is a steady-state property. Confusing them is a design-review tell |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — your implementation |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green (62 tests) |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All 62 tests pass against your implementation (and
LAB_MODULE=solution). - You can explain why
test_cache_reuse_dependency_bump_invalidates_weights_and_codeis a soul test: bumping one pinned dependency re-runs every downstream layer including the multi-GB weight pull, which is why a "one-character change" can turn a 40-second deploy into a 9-minute one — and why you order layers by volatility. - You can explain why
test_slot_is_released_when_predict_raisesis the other soul test, and describe the production failure it prevents (capacity decaying by one per failed request until someone restarts the pod). - You can state, without looking, what
parse_configrejects and why each rejection corresponds to a real incident. - You can trace
handle()end to end and name the HTTP status each guard maps to (503 not-ready, 429 over-capacity, 400 malformed, 500 model failure). - Given
image_gb=4,weights_gb=16, you can say roughly how long a cold start takes from the hub vs from a warm cache, and which lever you'd pull first.
How this maps to the real stack
| The miniature | The production mechanism | Where to verify it |
|---|---|---|
TrussConfig / parse_config | Truss config.yaml (model_name, python_version, requirements, system_packages, resources.accelerator, runtime.predict_concurrency, model_cache, secrets) | run truss init my-model and read the generated config.yaml; see DEPLOYMENT-COOKBOOK.md §1 |
ModelServer + the model hooks | Truss's model/model.py — class Model with __init__(self, **kwargs), load(), optional preprocess(), predict(), optional postprocess() | the same cookbook section; also Cog's Predictor.setup()/predict() and BentoML's @bentoml.service |
start() / readiness | the container's /health (readiness) endpoint that Kubernetes polls before adding the pod to the service's endpoints | kubectl describe pod → readiness probe; Baseten's deployment status BUILDING → DEPLOYING → ACTIVE |
predict_concurrency admission | Truss runtime.predict_concurrency; vLLM's --max-num-seqs; TGI's --max-concurrent-requests | load-test until you see 429s instead of OOMs |
build_plan layer order | the Dockerfile the packager generates — FROM → apt-get → pip install → weight fetch → COPY model/ | docker history <image>; truss image build --dry-run-style inspection |
plan_cache_reuse | the OCI/BuildKit layer cache and the registry's blob dedup | watch CACHED lines in a docker build after a code-only edit |
model_cache + weights_cached | Baseten's model cache / accelerated weight loading; a K8s PersistentVolume of weights; HF_HOME on a warm node | compare first-ever deploy vs. re-deploy cold-start times in the dashboard |
cold_start_breakdown | the deploy timeline you stare at while a replica boots | Baseten build/deploy logs; kubectl get events; vLLM's startup log lines |
to_openai_chat_completion | the OpenAI-compatible route your deployment exposes so clients only change base_url | vllm serve → POST /v1/chat/completions; the OpenAI Python SDK pointed at your host |
Limits of the miniature (be honest in the interview): there is no Docker here — the
"layers" are hashes, not filesystems, and real BuildKit cache keys also include the
command string and build args, so a changed RUN line busts a layer even with
identical content. Our admission gate is a counter in one thread, not a real semaphore
across an async event loop with a request queue and timeouts. Cold start is modeled as
four serial phases; real systems overlap image pull with node provisioning and stream
weights layer-by-layer. And the OpenAI envelope here is the non-streaming shape —
streaming uses SSE chat.completion.chunk deltas with a terminating [DONE].
Extensions (build these for real)
- Do it for real:
truss init, drop a small HF model inload(),truss push, thentruss predict -d '{"prompt":"hi"}'. Time the first deploy vs. the second. - Emit a real
Dockerfilefrombuild_planand confirm withdocker buildthat a code-only edit printsCACHEDfor the first four layers. - Add a request queue in front of the admission gate with a max depth and a per-request deadline, and return 429 only when the queue is full — then measure how queueing changes p99 vs. shedding immediately.
- Add
engine_build_sfor real: build a TensorRT-LLM engine, cache the artifact, and show the second cold start skipping the build entirely. - Extend
to_openai_chat_completioninto the streaming shape (chat.completion.chunk,delta, SSE framing) and point the real OpenAI SDK at it. - Add a
truss watch-style dev loop: hashmodel/, and on change push only theuser_codelayer.
Interview / resume
- Talking points: "Why does the weight layer go before the code layer?" "What
happens to a request that arrives while the model is still loading, and what status
do you return?" "Where does your model server leak capacity?" "Your cold start is 6
minutes — walk me through where the time goes and what you'd fix first." "Why is
revision: maina production bug?" - Resume bullet: Built a dependency-free model-packaging and serving harness — hostile config validation (pinned deps, immutable weight revisions, secrets by reference), content-addressed build layers ordered by volatility with prefix-based cache-reuse accounting, a cold-start cost model, and a serving contract with readiness gating, concurrency admission control, leak-free slot release and eager stream validation — proven by a 62-test deterministic suite.