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 push actually 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 past predict_concurrency, and a concurrency slot that comes back even when predict() 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-line base_url change.

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 ignored resourcses: is how you serve a 70B model on a CPU); every requirement must be pinned (torch==2.4.0, not torch>=2.4); every weight source must name an immutable revision (revision: main means today's build and tomorrow's build serve different weights under the same image tag); and secrets are names only — the value is injected at runtime and must never enter a layer. use_gpu and total_vram_gb are derived, never hand-written.
  • build_plan(config, code_digest) → ordered Layersbase_image → system_packages → python_requirements → model_weights → user_code, each addressed by sha256(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 a weights_cached branch that swaps a cross-internet hub download for a local NVMe read. This one boolean is worth minutes per replica start.
  • ModelServerstart() (load exactly once), try_admit()/release() (the admission gate), handle() (readiness → admission → preprocess → predict → postprocess, slot released in a finally), handle_stream() (eager validation, slot released even when the client disconnects mid-stream), and health().
  • to_openai_chat_completion(...) — the response envelope, with a deterministic id and a created you pass in rather than read from the clock.

Key concepts

ConceptWhat to understand
Layer volatility ordera layer busts every layer after it; weights (GB, rarely change) must precede code (bytes, changes hourly), or every edit re-downloads the model
Content addressingthe 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 everythingtorch>=2.4 and revision: main make a "rebuild" a different model. Reproducibility is a validation problem, not a discipline problem
Secrets by namea 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 releasethe single most common capacity leak in model servers: an exception path that never gives the slot back, so throughput decays until restart
Eager stream validationmake handle_stream itself a generator and no check runs until the first next() — after you already committed a 200
Cold start ≠ latencycold 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

FilePurpose
lab.pyskeleton with # TODO markers — your implementation
solution.pycomplete reference; python solution.py runs a worked example
test_lab.pythe proof — run it red, make it green (62 tests)
requirements.txtpytest 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_code is 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_raises is 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_config rejects 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 miniatureThe production mechanismWhere to verify it
TrussConfig / parse_configTruss 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 hooksTruss's model/model.pyclass 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() / readinessthe container's /health (readiness) endpoint that Kubernetes polls before adding the pod to the service's endpointskubectl describe pod → readiness probe; Baseten's deployment status BUILDING → DEPLOYING → ACTIVE
predict_concurrency admissionTruss runtime.predict_concurrency; vLLM's --max-num-seqs; TGI's --max-concurrent-requestsload-test until you see 429s instead of OOMs
build_plan layer orderthe Dockerfile the packager generates — FROMapt-getpip install → weight fetch → COPY model/docker history <image>; truss image build --dry-run-style inspection
plan_cache_reusethe OCI/BuildKit layer cache and the registry's blob dedupwatch CACHED lines in a docker build after a code-only edit
model_cache + weights_cachedBaseten's model cache / accelerated weight loading; a K8s PersistentVolume of weights; HF_HOME on a warm nodecompare first-ever deploy vs. re-deploy cold-start times in the dashboard
cold_start_breakdownthe deploy timeline you stare at while a replica bootsBaseten build/deploy logs; kubectl get events; vLLM's startup log lines
to_openai_chat_completionthe OpenAI-compatible route your deployment exposes so clients only change base_urlvllm servePOST /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 in load(), truss push, then truss predict -d '{"prompt":"hi"}'. Time the first deploy vs. the second.
  • Emit a real Dockerfile from build_plan and confirm with docker build that a code-only edit prints CACHED for 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_s for real: build a TensorRT-LLM engine, cache the artifact, and show the second cold start skipping the build entirely.
  • Extend to_openai_chat_completion into the streaming shape (chat.completion.chunk, delta, SSE framing) and point the real OpenAI SDK at it.
  • Add a truss watch-style dev loop: hash model/, and on change push only the user_code layer.

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: main a 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.