Where the model actually runs
The previous stage taught you what happens inside one GPU: prefill and decode, KV cache, continuous batching, quantization, prefix caching. This stage is about everything around that GPU — getting bytes to it, getting a GPU allocated to you in the first place, and keeping the whole thing alive under real traffic.
Kubernetes was designed to ship 200 MB of stateless code to a CPU. You are shipping 20 GB of stateful weights to a $600/month accelerator. Almost everything hard about this stage comes from that mismatch.
Sessions
All four sessions are in this file — one tab each. §1 was covered in a prior session; S1–S4 are the full stage.
What "done" looks like for this stage
Driving this workbook
| Say this | And I |
|---|---|
next | Add the next session's tab, refresh this tracker, leave every earlier tab untouched. |
re-teach | Redo the current tab, concepts only — slower, more diagrams, no code. |
go deeper on X | Expand one concept to Pass-3 depth inside the current tab. |
more visual | Convert text walls in the current tab into diagrams and steppers. |
ground it | Fetch fresh sources and swap source-only claims for current, cited reality. |
fix file | Rebuild the file if tabs or JS break, preserving all content. |
Why this section exists
On Modal, you wrote modal.Volume.from_name("huggingface-cache") and forgot about it forever.
The weights appeared. That single line was hiding an entire engineering discipline, and Kubernetes will not
hide it for you.
Here is the problem in one sentence: a pod cannot serve a single token until every byte of the model is physically present on the node it landed on. Not in a bucket. Not in a registry. On that node's disk, then in its RAM, then in the GPU's memory. Until then you are paying for an idle accelerator and the user is watching a spinner.
A container image is 200 MB of code that changes daily. A model is 20 GB of numbers that change monthly. Kubernetes ships the first one beautifully. It was never asked about the second.
Three questions fall out of that picture, and they are the spine of this session:
- What am I actually moving? — a model on disk is a directory of files, not a file. Which files, and which ones can you drop?
- Where does it live before it moves? — Hugging Face, an S3 bucket, a registry, a database of pointers. These are not interchangeable.
- How does it get onto the node? — five real mechanisms with genuinely different failure modes. This is the part you will be asked about in an interview.
Taught from zero
Four concepts, each in three passes: intuition, then mechanism, then the numbers and the edges where it breaks.
A · What a "model" actually is on disk
A model you download is a box of flat-pack furniture. Inside there are three kinds of thing:
- The planks — the weights. Ninety-something percent of the mass. Just numbers.
- The assembly instructions — how the layers wire together, how many attention heads,
what the hidden size is. A file called
config.json. - The little Allen key — the tokenizer. Tiny, unglamorous, and without it the whole thing is useless because you cannot turn text into numbers.
Formats differ mainly in how many of those three they put in the box. A ships planks alone and assumes the runtime already owns the manual. A "self-contained" format tries to ship all three.
state_dict (.pt, .pth), TensorFlow checkpoints (.ckpt),
raw NumPy arrays. The loading code must already know the network shape to reconstruct the model.
Fine during training and experimentation; a liability in production, because the weights are now welded
to a specific Python codebase.
The punchline of the section's format section: as of 2026 no widely-used format is truly self-contained. The honest label is mostly self-contained. Safetensors leaves out the tokenizer and the architecture. ONNX carries architecture and weights but has no idea what a tokenizer is. GGUF gets closest — weights, metadata and tokenizer in one file — and pays for it by being tied to a narrow set of runtimes. There is no Docker moment for models yet.
Here is what a real Hugging Face repository looks like on disk after download. Click any file.
Two mechanisms in that picture deserve naming, because they come back constantly.
Sharding and the index file. Large models are split into numbered
.safetensors files with a companion model.safetensors.index.json that maps every
tensor name to the shard holding it. Llama 3.1 405B ships as 30 shards. This exists partly for filesystem
sanity and partly because shards can be downloaded in parallel — which turns weight
transfer from a single-stream problem into a bandwidth problem you can actually throw money at.
Zero-copy loading. A .safetensors file starts with a JSON header giving
every tensor's dtype, shape and byte offset. Because the offsets are explicit, the loader can
mmap the file and hand the GPU driver pointers into it, rather than deserializing objects into
Python and copying. Compare the format it replaced: PyTorch's .bin uses Python
, which is both slower and a remote-code-execution
hazard.
.bin file on
a model hub is therefore a working exploit against anyone who loads it. Safetensors' entire reason to
exist is that it stores tensors and nothing else, so there is nothing to execute.
Hugging Face, 2021. Header of JSON metadata, then raw tensor bytes at known offsets. The de facto standard for anything you will serve with vLLM, SGLang or TensorRT-LLM.
The catch: it is a weight container, not a model. Ship it without
config.json and tokenizer.json and you have shipped a very large file of
nothing. Every packaging decision in this session must carry the small files too.
Also: Run:ai Model Streamer, the fastest object-store loader in vLLM, only works on safetensors. Format choice quietly constrains your loading strategy.
From the llama.cpp project. Magic number, then quantized tensor data with byte offsets, then a metadata block carrying architecture, quantization type and token mappings. Built for CPU and edge inference; now also runs on GPU via llama.cpp and vLLM.
Why operators like it: one file is a dream for Kubernetes. One object, one digest, one thing to cache. No index file, no directory of stragglers.
Why you probably won't use it here: your serving path is vLLM on datacenter GPUs with FP8. GGUF's design centre is 4-bit on a laptop.
Microsoft + Facebook, 2017. One Protobuf file containing the computational graph, the parameters and I/O metadata. Genuinely framework-independent — runs on ONNX Runtime, TensorRT, OpenVINO, Triton.
Why it lost the LLM race: no tokenizer, no vocabulary, no preprocessing. And portability is conditional on the operator set — if your model uses an op the target runtime doesn't implement, it simply fails to load. For computer vision it is still excellent.
Worth knowing as the blueprint for what a truly self-contained format would look like.
The format safetensors replaced. Still present on older Hugging Face repos, and still the thing your fine-tuning code emits by default if you're not careful.
Operational rule: convert to safetensors before it enters your registry. A cluster
that pulls .bin files from the public internet and loads them into a privileged container
is a supply-chain incident waiting for a date.
.bin, plus config.json, plus tokenizer.json. When
someone says "just mount the HF model", they mean mount that directory. Every mechanism in the rest of this
session is a different way of making that directory appear at /mnt/models.
B · The distance ladder — where bytes live before they reach the GPU
Stop thinking "download the model". Think: a relay race with five hand-offs, from a registry somewhere on the internet all the way into the GPU's memory. Every strategy in this session is simply a decision about which hand-offs you pay for, and how often you pay for them — once per cluster, once per node, or once per pod.
The costly insight is that last part. A hand-off you pay once per cluster is free at scale. A hand-off you pay once per pod is a tax on every autoscaling event you will ever have.
- Time to first byte on the node — dominated by which tier you start from, and whether the transfer is parallel. Sequential single-stream registry pulls are the villain of this section.
- Bytes duplicated across the fleet — node-local approaches store one copy per node. Ten nodes × 20 GB = 200 GB of disk you are paying for to hold one model.
- Contention under simultaneous cold start — the killer. When an autoscaler adds ten replicas at once, they all reach for the same source at the same moment. A 2 GB/s NFS server serving ten concurrent readers gives each of them 200 MB/s. Your carefully measured single-pod boot time was a lie.
C · The five ways to put weights on a node
There are only three underlying ideas, and five named techniques that implement them:
- Fetch it when the pod starts — simple, always works, slow, repeated forever.
- Keep it somewhere shared and mount it — one copy for the fleet, but every read crosses a network.
- Package it like software — put the weights in a container image and let the container infrastructure you already run do distribution, caching, deduplication and signing for you.
The industry has spent three years moving from the first idea to the third. As of Kubernetes 1.36 the third one is finally a stable, boring, built-in feature.
Before the five techniques, the flow they all plug into. Step through it.
Every technique below is a different answer to step 3 and step 4. Everything else is identical. Expand each card — the YAML fragment shown is the field that is the mechanism, nothing more.
An runs before the model server, downloads the weights from Hugging Face or S3 into a shared volume, and exits. The server container then starts and reads that directory.
# the mechanism, in four lines volumes: - name: models emptyDir: {} # node-local scratch, dies with the pod
This is exactly what KServe's storage initializer does. The
storageUri scheme picks which initializer runs: s3://, gs://,
https://, hf://, hdfs://. You can register your own scheme with
a ClusterStorageContainer resource.
When it's right: day one. One replica. Proving the thing works. Also perfectly fine forever if your model is 2 GB and your traffic never scales to zero.
When it bites: the model is re-downloaded on every single pod start, from a third party you don't control, over a link you don't control.
Store the model once on a distributed filesystem — NFS, Ceph, EFS, Azure Files, Filestore, Hyperdisk ML — and mount it read-only into every replica. A describes the storage; a is how a pod asks for it.
# the mechanism: one volume, many simultaneous readers accessModes: - ReadOnlyMany # ROX — many pods mount it read-only at once persistentVolumeReclaimPolicy: Retain
There is no copy step. Unlike s3://, KServe's pvc://
scheme mounts the claim straight into the container at /mnt/models — the storage
initializer still runs but does essentially nothing. Startup is fast because mounting is fast.
Read-only is a performance feature, not just safety: the kernel can cache
aggressively when it knows nothing will change, and there is no lock contention between replicas.
Set it at both levels — ReadOnlyMany on the PV and readOnly: true on the
mount.
The ceiling: the material puts comfortable operation at roughly 10–20 GPU replicas. Past that you see disk pressure on the backend, rising I/O wait and inconsistent latency. There is no canonical threshold — Ceph and EFS take far more punishment than a single NFS box.
Bake the weights into an — a
"passive data image" you never execute. An init container from that image copies
/models into an emptyDir, then the server reads it.
You get versioning, immutability, digests, signing, and your existing registry credentials. What you don't get is any saving on time: you now pay a registry pull and a full disk-to-disk copy of 20 GB on every pod start. Worse than download-at-boot in wall-clock terms.
Why it exists: it was the only way to use OCI packaging before Kubernetes could mount images. It is a stepping stone, and the next two cards are what it became.
A genuinely clever hack. The model image runs as a container that does nothing but create a symlink and sleep forever. The trick is one rarely-used pod field:
# the mechanism: let containers see into each other spec: shareProcessNamespace: true # sidecar then runs: # ln -s /proc/$$/root/models /mnt/models && sleep infinity
With process-namespace sharing on, containers in a pod can see each other's processes — and
therefore each other's root filesystems, via /proc/<pid>/root/. The sidecar links its
own /models into a shared emptyDir. No bytes are copied. The server
reads model files directly out of the sidecar's image layers. Idle cost: under 10 MB of memory.
Four real drawbacks. Startup order — sidecar and server start in parallel,
so the server can look for weights that aren't linked yet (mitigate with native sidecars, K8s 1.28+, or
pre-pull in an init container). Security — shareProcessNamespace exposes every
container in the pod to every other one, which has been exploited against Istio sidecars that assumed
isolation. Unpredictable startup — fast if the image is on the node, minutes if not.
Multi-arch — the keep-alive process is architecture-specific, so you need one image per arch.
Status: still shipped, still off by default (enable it in the
inferenceservice-config ConfigMap). Treat it as the bridge for clusters that can't do
card 5 yet.
Kubernetes learned a new volume type. You name an image; the kubelet asks the container runtime to pull it and mount its contents as a read-only volume. That is the whole feature.
# the mechanism: an image *is* a volume volumes: - name: model-volume image: reference: registry.example.com/gemma-e4b:v1 pullPolicy: IfNotPresent # mount with subPath: models for modelcar compatibility
Everything good about modelcars, nothing weird. It reuses the node's existing image layer cache, so the second pod on that node mounts instantly. And because OCI images are layered, ten LoRA-tuned variants built on one base share the base layers on disk — you store the foundation once.
Use subPath: models. Structure your model images with the weights in a
/models subdirectory. That single convention makes the same image work with both
modelcars and native image volumes, so you can migrate without rebuilding anything.
Limits as of now: read-only (no writeable layer), directories only — you cannot mount an individual file.
This one isn't in the material's five, and it is where the industry actually landed. Rather than changing how a pod gets weights, you make sure the bytes are already on the node before the pod is scheduled there.
KServe implements it as three CRDs: LocalModelCache (which model to cache),
LocalModelNodeGroup (which nodes, backed by local NVMe), and LocalModelNode
(per-node status). A agent keeps each
node's cache in sync. NVIDIA's NIM Operator does the same job with a NIMCache resource.
GKE does it with secondary boot disks and Hyperdisk ML; EKS with Bottlerocket image caching and
Mountpoint-for-S3 shared node caches.
The mental model: techniques 1–5 answer "how do bytes reach the pod". The cache tier answers "how do bytes reach the node, ahead of time". They stack.
persistentVolumeReclaimPolicy: Retain means the data survives deletion of the
claim, which is what you want for model weights.
ReadWriteOnce (one node),
ReadOnlyMany (many nodes, read-only), ReadWriteMany (many nodes, read-write).
restartPolicy: Always) which guarantees the sidecar is up before the
main container starts.
| Approach | Storage efficiency | Read speed | Startup | Best for | Where it hurts |
|---|---|---|---|---|---|
| Download at boot | Low — a copy per pod | Fast (node-local after copy) | Slow, every time | Day one; single replica; small models | Repeated transfer; external dependency; no caching |
| PVC (ROX) | Highest — one copy total | Moderate — network on every read | Fast (mount only) | Tens of replicas; weights owned outside the cluster | Contention past ~10–20 replicas; infra to run; no benefit from node cache |
| OCI + init copy | Low — a copy per pod | Fast | Slowest | Nothing, now. Historical | Pull and copy on every start |
| Modelcar | High — layer sharing | Fast (node-local layers) | Moderate | Clusters that can't run image volumes yet | shareProcessNamespace; startup races; per-arch images |
| Image volume | High — layer sharing + dedup | Fast (node-local layers) | Moderate; instant when cached | The default in 2026. Many variants on one base; signed supply chain | Read-only; directories only; first pull on a cold node is still a pull |
| + node cache tier | Per-node copy, deliberately | Fastest | Near zero when warm | Scale-to-zero with a real cold-start SLO | Another controller; disk budget per node; cache warming is now your problem |
D · Model registries — the difference between a pointer and a payload
Two things get called "model registry" and they do opposite jobs.
A catalogue stores metadata and a pointer: name, version, accuracy, lineage, who trained it, and a URL to where the bytes actually live. A content store holds the bytes themselves. MLflow and Kubeflow Model Registry are catalogues. An OCI registry is a content store. Hugging Face Hub is both, which is why it feels so convenient and why it becomes a production dependency you didn't mean to take.
The reason a catalogue earns its keep is one layer of indirection. Instead of pinning a bucket path into every deployment, you pin a logical name and version:
# KServe resolves this through the registry at deploy time
storageUri: model-registry://iris/v1
Move the bytes to a different bucket, a different region, a different storage class — nothing in the
deployment changes. The registry translates. That indirection is registered with a
ClusterStorageContainer, the same extension point that lets you add any URI scheme you like.
Catalogue and content store. Unbeatable for discovery and experimentation. In production it is an external dependency with rate limits, gated licences requiring a token, and no SLA to you.
Production posture: mirror what you use into your own registry or bucket. Pin a
commit SHA, never a branch — MODEL_REVISION in your Modal file is the right instinct, and
it is currently None.
Catalogue, built for the data-science side: experiments, runs, parameters, metrics, lineage. Easy to install locally, which is why teams adopt it. Deploys on Kubernetes as a plain web service with a Postgres backend, but ships no CRDs — integration is glue you write.
MLflow 3.x improved LLM handling considerably: memory-efficient logging, a prompt registry, GenAI evaluation, and reference-based logging that stores a Hub pointer instead of the weights. Its artifact store is still not built for repeatedly serving multi-gigabyte files.
Catalogue, Kubernetes-native. CRDs, controllers, a REST API and a Python SDK; MySQL behind it for the entity-relationship metadata model (inspired by Google's ML Metadata). Needs a PV for durability.
The payoff is the model-registry:// scheme above: your InferenceService names a model
and version, and the registry supplies the real location.
Content store. Holds the actual bytes, with versioning, immutability, content-addressed dedup, digests, signing (cosign/Notary) and a global CDN-backed distribution network you already operate.
Since OCI 1.1's artifact support this is a legitimate model home, not a hack. Combined with native image volumes it is the shortest path from "a model exists" to "a pod is reading it".
Where it's weak: it knows nothing about accuracy, lineage or evaluation. Pair it with a catalogue if you need governance.
How should weights reach the pod?
Read top to bottom. Follow no ↓ until a yes → exits right. If nothing exits, the bottom-left box is your answer — and it is the right answer most of the time.
Do the arithmetic first
Everything above is a shape. Here is the shape with numbers in it, using your own deployment as the worked example.
Worked example · what it costs to move Gemma E4B onto a GKE node
Assumptions — state them, then verify them
- Model directory ≈ 10 GB (bf16 safetensors + tokenizer + config). Verify with
hf download <repo> --dry-runor the file list on the repo page before you trust any number below. - Target node: g2-standard-8 — 1× L4, 24 GB VRAM, 8 vCPU, us-central1.
- On-demand list price mid-2026: ≈ $0.85/hr (≈ $620/month running 24/7). The g2-standard-4 is ≈ $0.71/hr (≈ $514/month).
Step 1 · Time to get 10 GB onto the node
The whole calculation is seconds = bytes ÷ throughput. What changes is the throughput,
and it changes by two orders of magnitude.
| Path | Effective throughput | 10 GB takes | Why |
|---|---|---|---|
| Hugging Face Hub, public internet | ~80 MB/s | ≈ 2 min 05 s | Best case with hf_transfer. Often half that. |
| Container registry (single-stream + gunzip) | ~60 MB/s, then ~0.5× for extraction | ≈ 4 min 10 s | Layers pulled sequentially, then CPU-bound decompression. |
| GCS same region, parallel range reads | 1.5–2 GB/s | ≈ 5–7 s | Many concurrent HTTP Range requests saturate the NIC. |
| Managed file share (PVC), 1 reader | ~600 MB/s | ≈ 17 s | Mount is instant; you pay per read instead. |
| Same PVC, 10 replicas booting at once | ~60 MB/s each | ≈ 2 min 47 s each | The thundering herd. Aggregate bandwidth ÷ readers. |
| Node-local layer cache already warm | n/a | ≈ 0 s | The bytes are on the node. Only VRAM load remains. |
Add engine initialisation on top of every row — weight load into VRAM, CUDA graph capture unless
--enforce-eager. You already know that cost from the inference stage; the point here is that
byte movement can dwarf it by 10×, and byte movement is the part you control with a YAML
field.
Step 2 · What the waiting costs
An idle GPU bills exactly like a busy one.
- $0.85/hr = $0.0142 per minute.
- A 4-minute cold start burns $0.057 of GPU doing nothing.
- 30 cold starts a day (a realistic scale-to-zero pattern) = 120 min/day = $1.70/day ≈ $51/month.
- That is ~8% of a $620/month node, spent watching a progress bar.
- Cut the cold start to 30 s and the same churn costs $6/month.
The $45/month is not the real prize. The real prize is that a 30-second cold start makes scale-to-zero viable, which is what lets you run a $620/month node for $80.
Step 3 · The storage bill, which is smaller than the material implies
The material ranks PersistentVolumes as having the highest storage efficiency — one copy, shared. True. But price it:
- Node-local duplication (image volumes, emptyDir): 10 GB × 10 nodes = 100 GB of attached disk. At roughly $0.10/GB-month for balanced persistent disk that is about $10/month. Rounding error.
- Shared file service: managed NFS bills on provisioned capacity, and the entry tiers start in the hundreds of GB to multiple TiB. Price your actual tier — a "one shared copy" of a 10 GB model routinely costs more per month than ten duplicated node-local copies.
Step 4 · The one formula to remember
# per-replica time to ready, under simultaneous scale-up t_ready = (model_bytes / (source_bandwidth / n_replicas)) + t_engine_init # the cache tier exists to make the first term ≈ 0 for every # replica after the first one on a given node.
What changed since the material went to press
shareProcessNamespace is a security trade you should think about,
and subPath: models is the convention that makes modelcar images forward-compatible with
native image volumes. That last tip is worth more than most of the version numbers.
Three production examples from the last few months
A Llama 3.1 8B container: 2.8 GB CUDA + 1.4 GB Torch + 1 GB libs + 15 GB weights = 20.2 GB. Naive cold start ≈ 11 minutes.
Three changes: (1) pull image bytes from object storage instead of a registry — 60 MB/s → 2 GB/s, ~350 s → ~10 s, because object stores do parallel range requests and skip manifest processing; (2) skip extraction by keeping layers in a seekable-tar format behind a FUSE mount, so files are read on demand; (3) stream weights straight into GPU memory instead of remote → disk → RAM → GPU.
The transferable lesson: container registries were designed for 200 MB of code. Every fast-cold-start story in 2026 is some version of "stop using the registry as a data path".
Hyperdisk ML is the managed version of "PVC done properly": you hydrate a block device from Cloud Storage once, then attach it read-only-many to up to 2,500 nodes concurrently at 1.2 TiB/s. Google documents up to 11.9× faster weight loading versus pulling from a model registry. Zonal, so multi-zone serving clones the disk per zone.
Cloud Storage FUSE Profiles (April 2026) replaced hand-tuning the FUSE CSI driver
with three StorageClasses; gcsfusecsi-serving auto-sizes caches against the node's RAM and
local SSD and turns on Rapid Cache. Needs GKE 1.35.1-gke.1616000+.
Read it as: the cloud vendors have productised the exact trade-off in this session's decision tree.
KServe now ships a three-CRD caching system — LocalModelCache,
LocalModelNodeGroup, LocalModelNode — that pre-downloads models onto node
NVMe via a DaemonSet agent, so the second cold start on a node is nearly free. Off by default; enable
localModel in the inferenceservice-config ConfigMap. It currently supports
InferenceService, with LLMInferenceService support planned.
NVIDIA's NIM Operator does the same job with a NIMCache resource that
pulls from NGC or Hugging Face into a PVC it can create for you. Version 3.0 added DRA support and
tighter KServe integration, built with Red Hat.
Read it as: "pre-warm the node" stopped being a shell script and became API surface, in both the open-source and the NVIDIA stack, within about a year.
--use-model-pack flag, or plain docker build) if the artifact is destined for an
image volume. CNCF ModelPack entered the Sandbox in May 2025 and is the standardisation effort here;
modctl and KitOps are the two implementations.
The same idea, three ways
Career-relevant framing: every capability in this session exists in an open-source form, an NVIDIA form, and a managed cloud form. Knowing the mapping is most of what "knows the ecosystem" means.
| Capability | Open source / K8s-native | NVIDIA stack | GKE & EKS managed |
|---|---|---|---|
| Package weights as an artifact | OCI image; CNCF ModelPack via modctl or KitOps |
NIM containers with baked model profiles from NGC | Artifact Registry (GCP) · ECR (AWS) — same OCI images |
| Mount an image as a volume | volumes[].image — stable in K8s 1.36; KServe modelcar for older clusters |
Works with any NIM or custom model image | Available once your node pool is on 1.36; GKE also offers secondary boot disks for image pre-cache |
| Shared read-only weights | PV/PVC on NFS or Ceph, ReadOnlyMany |
NIMCache backed by a PVC |
Filestore CSI + Hyperdisk ML (ROX, 2,500 nodes) · EFS CSI + FSx for Lustre |
| Object store as a filesystem | s3:// / gs:// storage initializers; vLLM --load-format runai_streamer |
NIM Hugging Face and NGC sources | Cloud Storage FUSE CSI + Profiles (GKE) · Mountpoint for S3 CSI v2 with shared node cache (EKS) |
| Pre-warm the node | KServe LocalModelCacheLocalModelNodeGroup + DaemonSet agent |
NIMCache CRD, pre-cache on CPU or GPU nodes; unblocks air-gapped installs |
GKE secondary boot disks, Hyperdisk ML hydration · EKS Bottlerocket image caching, SOCI parallel pull |
| Catalogue & governance | Kubeflow Model Registry (model-registry://), MLflow |
NGC catalogue and model profiles | Vertex AI Model Registry · SageMaker Model Registry |
Your Gemma deployment, three ways
Now the manifests. Target: GKE, one L4, the same vLLM flags you are already running on Modal. GPU scheduling fields appear here because the pod needs them to run — S2 explains every one of them. Ignore them for today; focus on the volumes.
0 · The Secret — modal.Secret.from_name("huggingface-secret")
kubectl create namespace llm kubectl create secret generic hf-token \ --from-literal=HF_TOKEN="$HF_TOKEN" \ -n llm
Gemma is gated, so any path that downloads from the Hub needs this. Note which of the three options below doesn't — that is a real security win, not a footnote.
Option A · Download at boot — the closest translation of what Modal does today
# deployment-a-download.yaml — init container + emptyDir apiVersion: apps/v1 kind: Deployment metadata: { name: gemma-e4b, namespace: llm } spec: replicas: 1 selector: { matchLabels: { app: gemma-e4b } } template: metadata: { labels: { app: gemma-e4b } } spec: nodeSelector: { cloud.google.com/gke-accelerator: nvidia-l4 } tolerations: - { key: nvidia.com/gpu, operator: Exists, effect: NoSchedule } initContainers: - name: fetch-weights image: python:3.12-slim command: ["sh", "-c"] args: - pip install -q "huggingface_hub[hf_transfer]" && HF_HUB_ENABLE_HF_TRANSFER=1 hf download google/gemma-4-E4B-it --local-dir /mnt/models env: - name: HF_TOKEN valueFrom: { secretKeyRef: { name: hf-token, key: HF_TOKEN } } volumeMounts: - { name: model, mountPath: /mnt/models } containers: - name: vllm image: vllm/vllm-openai:v0.11.0 args: - --model=/mnt/models - --served-model-name=gemma-4-e4b - --max-model-len=10000 - --gpu-memory-utilization=0.92 - --max-num-seqs=256 - --enable-prefix-caching - --async-scheduling - --quantization=fp8 - --kv-cache-dtype=fp8 - --tensor-parallel-size=1 - --limit-mm-per-prompt={"image":0,"video":0,"audio":0} ports: [{ containerPort: 8000, name: http }] resources: limits: { nvidia.com/gpu: "1" } volumeMounts: - { name: model, mountPath: /mnt/models } - { name: vllm-cache, mountPath: /root/.cache/vllm } - { name: shm, mountPath: /dev/shm } startupProbe: httpGet: { path: /health, port: 8000 } periodSeconds: 10 failureThreshold: 60 # 10 minutes to become ready readinessProbe: httpGet: { path: /health, port: 8000 } periodSeconds: 10 volumes: - { name: model, emptyDir: { sizeLimit: 30Gi } } - { name: vllm-cache, emptyDir: {} } - { name: shm, emptyDir: { medium: Memory, sizeLimit: 2Gi } }
Two things to notice. The vLLM args lost their shell quoting — your Modal file wraps the
--limit-mm-per-prompt JSON in single quotes because it goes through a shell;
args: is passed directly to exec, so quotes would become part of the value.
The startup probe is doing load-bearing work — without a generous
failureThreshold, Kubernetes kills the pod mid-download and you get a crash loop that looks
like a model bug.
Option B · PVC — modal.Volume("huggingface-cache"), literally
# pvc-and-hydrate.yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: { name: model-weights, namespace: llm } spec: accessModes: [ ReadWriteMany ] # what the Filestore CSI actually provisions storageClassName: standard-rwx resources: { requests: { storage: 1Ti } } # check the tier minimum — it bites --- apiVersion: batch/v1 kind: Job # run once, populate the share, exit metadata: { name: hydrate-gemma, namespace: llm } spec: template: spec: restartPolicy: OnFailure containers: - name: fetch image: python:3.12-slim command: ["sh", "-c"] args: - pip install -q "huggingface_hub[hf_transfer]" && HF_HUB_ENABLE_HF_TRANSFER=1 hf download google/gemma-4-E4B-it --local-dir /weights/gemma-4-e4b env: - name: HF_TOKEN valueFrom: { secretKeyRef: { name: hf-token, key: HF_TOKEN } } volumeMounts: [{ name: w, mountPath: /weights }] volumes: - name: w persistentVolumeClaim: { claimName: model-weights }
Then in the Deployment, swap the model volume and drop the init container entirely:
volumeMounts:
- name: model
mountPath: /mnt/models
subPath: gemma-4-e4b
readOnly: true # enforce ROX at the mount
volumes:
- name: model
persistentVolumeClaim:
claimName: model-weights
readOnly: true
ReadOnlyMany on a hand-written NFS PersistentVolume, which is
correct for a statically provisioned volume. Dynamic provisioners usually hand you
ReadWriteMany instead — you cannot write to a ROX volume, so you could never hydrate it.
The production pattern is: provision RWX, hydrate once with a Job, then enforce
readOnly: true at every serving mount. You get the caching and lock-contention
benefits either way.
Option C · OCI image volume — the default from the decision tree
First build a model image. Weights go in /models so the same image also works as a
modelcar later.
# Dockerfile.model FROM python:3.12-slim AS fetch RUN pip install --no-cache-dir "huggingface_hub[hf_transfer]" ENV HF_HUB_ENABLE_HF_TRANSFER=1 ARG REPO=google/gemma-4-E4B-it ARG REV=<pin-a-commit-sha> RUN --mount=type=secret,id=hf \ HF_TOKEN=$(cat /run/secrets/hf) \ hf download $REPO --revision $REV --local-dir /models FROM scratch # nothing executes; smallest possible image COPY --from=fetch /models /models
export IMG=us-central1-docker.pkg.dev/$PROJECT/models/gemma-e4b:v1
docker build -f Dockerfile.model --secret id=hf,env=HF_TOKEN -t $IMG .
docker push $IMG
crane digest $IMG # deploy by digest, not tag, if you care about reproducibility
Then the volume is three lines and the HF token disappears from the cluster entirely:
volumeMounts:
- name: model
mountPath: /mnt/models
subPath: models # forward/backward compatible with modelcars
readOnly: true
volumes:
- name: model
image:
reference: us-central1-docker.pkg.dev/PROJECT/models/gemma-e4b:v1
pullPolicy: IfNotPresent # pull once per node, then reuse the layer cache
Requires: node pool on Kubernetes 1.36 (or 1.35 with the feature gate on).
vllm-cache volume must stay writable. Image volumes are strictly read-only, and vLLM
writes torch.compile artefacts and CUDA graph captures into
/root/.cache/vllm at startup. Keep it as an emptyDir (lost on every pod restart,
so you recompile) or give it a small ReadWriteOnce PVC per node (survives restarts, saves
30–90 s of boot). This is a distinct caching problem from weights and it is easy to miss until your cold
starts are mysteriously slow on a "cached" model.
Modal → Kubernetes, the model-data rows
The full mapping table is the S4 capstone. These are the rows this section owns.
| gemma_modal.py | Kubernetes | What actually changes |
|---|---|---|
modal.Image.from_registry(...).uv_pip_install("vllm==0.21.0") |
A pre-built image reference: vllm/vllm-openai:<tag> |
Modal builds your image from Python on deploy. Kubernetes wants the image to already exist in a
registry. Your .env({...}) block becomes a plain env: list. |
hf_cache_vol = modal.Volume("huggingface-cache") |
This is the decision this whole session is about | Option A (emptyDir + download), B (PVC), or C (image volume). Modal picked for you; now you pick. |
vllm_cache_vol = modal.Volume("vllm-cache") |
emptyDir, or a small RWO PVC |
Different problem, must be writable. Cannot be an image volume. Holds compile and CUDA-graph artefacts, not weights. |
modal.Secret.from_name("huggingface-secret") |
SecretsecretKeyRef, or Workload Identity |
Needed for options A and B. Not needed at all for option C — the token is used at image-build time and never enters the cluster. |
MODEL_REVISION = None |
Image digest, or --revision <sha> |
Pin it. A floating main branch means your "identical" replicas can serve different
weights depending on when each one booted. |
gpu="L4:1" |
resources.limits: nvidia.com/gpu: 1 + nodeSelector + toleration |
Session 2. |
min_containers / max_containers / @modal.concurrent |
replicas + HPA/KEDA/Knative |
Session 3. Note that scale-to-zero is only affordable if this session's work made cold start cheap. |
Exercise
1. Find the real size of your model directory. Sum the file sizes on the HF repo
page, or run hf download --dry-run.
2. Time one real download from your machine and record the MB/s you actually get — not the number in this tab.
3. Fill in t_ready for options A, B and C at
1, 4 and 10 simultaneous replicas. Option C's node-warm case is the interesting
column.
4. Multiply idle GPU minutes by $0.0142/min and your expected cold starts per day. Now you have the dollar value of moving from A to C — that is the number you put in a design doc.
No GPU required — use a tiny ungated model so you can iterate fast.
1. kind create cluster --image kindest/node:v1.36.0
2. Build a model image for Qwen/Qwen2.5-0.5B-Instruct using the
Dockerfile.model above, with weights under /models. Load it into kind with
kind load docker-image.
3. Run a plain busybox pod with an image volume mounted at
/mnt/models and subPath: models. Confirm with
kubectl exec ... -- ls -la /mnt/models.
4. The measurement that teaches the lesson: kubectl delete pod and
recreate it. Time both starts. The gap between them is the node layer cache, and it
is the whole argument of this session.
5. Bonus: rebuild the image with one extra small file changed and re-push. Watch how few bytes move. That is layer sharing, and it is why ten LoRA variants on one base are nearly free.
Why this section exists
Install Kubernetes on a machine with an H100 in it. Run kubectl describe node. Under
Capacity you will see cpu, memory, pods,
ephemeral-storage. You will not see a GPU. Not "0 GPUs" — the concept does not exist.
Kubernetes natively understands exactly two compute resources: CPU and memory. Everything else is a plugin somebody had to write. Before a pod can ask for an accelerator, four separate pieces of software have to cooperate to teach the cluster that accelerators are a thing, which ones this node has, and how to hand one to a container.
On Modal you wrote gpu="L4:1" and a GPU appeared. That string was a
purchase order. In Kubernetes it is a claim against an inventory that does not exist until you build it.
Pending forever, the debugging routine is to walk down this stack asking which layer went
quiet.
Three questions structure this session:
- How does a GPU become something the scheduler can allocate? — and what does that abstraction hide from you.
- How do I get the right pod onto the right GPU? — labels, selectors, taints, and the new declarative model that is replacing all of it.
- What if I need less than one GPU, or more than one? — MIG, time-slicing, MPS in one direction; tensor and pipeline parallelism in the other.
Taught from zero
A · How a GPU becomes schedulable
A contractor walks into the building on Monday. They are physically present and fully capable, and yet no work can be assigned to them, because four different systems don't know they exist:
- Facilities has to install their laptop and software so the machine actually works — that's the .
- HR has to notice a person is in the building — that's .
- Someone has to record what they're qualified to do — that's .
- The staffing system has to carry them as available headcount so work can be assigned — that's the .
feature.node.kubernetes.io/pci-0302_10de.present: "true" (10de is NVIDIA's PCI vendor ID).
Generic: it knows there is a PCI device from NVIDIA, not that it is an L4 with 24 GB.
nvidia.com/gpu.product, nvidia.com/gpu.count,
nvidia.com/gpu.memory, nvidia.com/cuda.driver.major, MIG profile labels, and so
on. This is where "give me an A100, not a T4" becomes expressible.
nvidia.com/gpu), allocates one exclusively to a
container when asked, and monitors health so unhealthy hardware stops being scheduled. There are
plugins for NVIDIA, AMD ROCm, Intel GPUs and Google TPUs.
The entire user-facing surface is this:
resources:
limits:
nvidia.com/gpu: 1 # an "extended resource" — a plain integer count
Step through what that triggers.
Three consequences of modelling a GPU as a number, all of which you will hit:
- Extended resources must be whole integers. You cannot request
nvidia.com/gpu: 0.5. Every sub-GPU technique later in this session is a workaround for that one restriction. - Limits always equal requests. Kubernetes forbids overcommitting extended resources. With CPU you can request 0.5 and burst to 2; with GPUs there is no burst, no oversubscription, no throttling. It is allocated or it isn't.
- The scheduler sees a count, not a capability. A cluster with H100s and T4s reports
nvidia.com/gpuon both. A pod asking for one GPU is equally happy with either — right up until your 20 GB model lands on a 16 GB T4 and OOMs at load time.
nodeSelector on a GFD label such as
nvidia.com/gpu.product: NVIDIA-L4. It works. It also means your workload manifests now encode
your hardware inventory, and every new GPU generation is a find-and-replace across every repo. Fixing that
properly is the entire motivation for Dynamic Resource Allocation, later in this session.
B · Steering pods — selectors, affinity, taints and tolerations
- — "I need a desk in the east wing." A hard requirement, expressed as exact label matches. Simple and blunt.
- — the same idea with grammar: required or preferred, set operators, weights, multiple fallback tiers.
- — the inverse. The node pushes pods away, and only pods carrying the matching pass are allowed to stay.
Pending.
requiredDuringSchedulingIgnoredDuringExecution is a hard filter with
operators (In, NotIn, Exists, Gt, Lt).
preferredDuringSchedulingIgnoredDuringExecution is a soft weighting — the scheduler tries,
then places the pod elsewhere rather than leaving it pending. "IgnoredDuringExecution" means a running pod
is never evicted just because the node's labels changed.
key=value:effect. Three effects —
NoSchedule (don't place new pods here), PreferNoSchedule (try not to), and
NoExecute (also evict pods already running that don't tolerate it). A toleration in
a pod spec cancels a matching taint. Read this twice: a toleration is permission, not a
request. It lets a pod onto a tainted node; it does not steer it there.
Cloud providers automatically taint GPU node pools so that ordinary workloads don't squat on
hundred-dollar-a-day hardware. GKE applies nvidia.com/gpu=present:NoSchedule. That means your
GPU pod needs a toleration and a way to be steered — because the toleration alone only says "I am
allowed on GPU nodes", not "put me on one".
# permission — get past the node pool's taint tolerations: - { key: nvidia.com/gpu, operator: Exists, effect: NoSchedule } # steering — actually land on the right hardware nodeSelector: { cloud.google.com/gke-accelerator: nvidia-l4 }
Everything above is a workaround for the device plugin's core limitation: it can only count. DRA replaces counting with describing.
With a pod stops saying "give
me one of the things called nvidia.com/gpu" and starts saying "give me a device from the
nvidia-gpu class whose memory attribute is greater than 40 GiB". The scheduler and a vendor-supplied
driver resolve that at bind time, not at manifest-writing time.
DeviceClass (a category of device, defined by the cluster admin),
ResourceSlice (what a node's driver advertises it has, with attributes),
ResourceClaim / ResourceClaimTemplate (what a workload asks for, using CEL
expressions over those attributes), and the driver that binds them. Modelled deliberately on how
PersistentVolumeClaims work — describe intent, let the platform resolve it.
# the mechanism: a CEL expression over device attributes
selectors:
- cel:
expression: "device.attributes['gpu.nvidia.com'].memory.isGreaterThan(quantity('40Gi'))"
What this buys you beyond tidiness: allocation can consider things a static count cannot — current utilisation, NVLink topology between the specific cards being handed out, power state, MIG profile availability. And it makes sharing a first-class API concept rather than a device-plugin config hack.
C · Splitting one GPU four ways
- Whole GPU — you have the kitchen to yourself. Everything works, nothing is contended, and if you only ever boil one egg you have wasted a kitchen.
- Time-slicing — four cooks take turns at the one stove. Nobody has their own fridge shelf, so one cook can fill the fridge and starve everyone else. No memory isolation at all.
- MPS — the cooks work at the stove simultaneously under one shared plan, rather than swapping in and out. Better utilisation and lower latency than taking turns; still one fridge.
- MIG — the kitchen is physically walled into seven small kitchens, each with its own stove and its own fridge. Real isolation. Also: you can never cook anything that needs more than one small kitchen.
Time-slicing is configured on the device plugin, not in your workload. The whole feature is two fields:
sharing:
timeSlicing:
renameByDefault: true # expose as nvidia.com/gpu.shared, not nvidia.com/gpu
resources:
- name: nvidia.com/gpu
replicas: 8 # one physical card now advertises 8 schedulable units
Set renameByDefault. Without it, a node with one L4 reports
nvidia.com/gpu: 8 and every existing manifest in your cluster silently starts landing eight
pods on one card.
Blunt version: for LLM inference you almost always want the whole GPU. Three reasons, all downstream of how vLLM works.
- vLLM claims the VRAM up front. Your own config sets
--gpu-memory-utilization 0.92, which pre-allocates 92% of the card for weights plus KV cache. Two such pods time-slicing one L4 do not each get 46% — the first one to start takes 92% and the second fails to allocate. Time-slicing shares compute time, never memory. - The KV cache wants every spare byte. Throughput on a decode-bound workload is roughly proportional to how many sequences fit in the KV cache. Giving away half the VRAM does not halve throughput, it collapses your batch size and hurts more than half.
- Requesting two time-sliced GPUs is a trap.
nvidia.com/gpu: 2in shared mode gets you a slice on two different physical cards, each shared with strangers. That is almost never what anyone means. Configure the plugin to reject it.
MIG is the exception that proves the rule: it is genuinely useful when you host many small models. Seven 5 GB models on one A100 each get a hardware-isolated mini-GPU with a guaranteed memory fence. But MIG cannot help a model that needs more than one slice, and the slice sizes are fixed profiles — there is no 6 GB option if NVIDIA didn't ship one.
Whole GPU, MIG, time-slicing or MPS?
Follow no ↓ until a yes → exits right. Bottom-left is the default, and for LLM serving it is the default by a wide margin.
D · Where GPU nodes come from
A GPU is welded to a VM shape. You cannot add a GPU to a running node, and you cannot rent 0.4 of one. The unit of capacity is the , and adding one node means booting a whole machine, installing drivers, and pulling images before a single token gets served.
This is the part that surprises people migrating from a serverless platform. There are two independent control loops, and the slow one is invisible from your Deployment.
- Pod-level — an HPA (or KEDA, or Knative) decides more replicas are needed and creates pods. Fast: seconds.
- Node-level — those pods sit
Pendingbecause no node has a free GPU. A notices and provisions a machine. Slow: minutes, and it can fail outright if the region is out of L4s.
NodePool/NodeClass spec, typically in 30–60 seconds. GKE's equivalents are
Node Auto-Provisioning, which creates whole new node pools on demand, and ComputeClasses,
a CRD for expressing prioritised machine-shape preferences with fallback.
Spot / preemptible GPUs. Reclaimed with about 30 seconds' notice on GCP and two minutes on AWS. The discount varies enormously by SKU and region: scarce datacentre cards like A100 and H100 routinely run 60–70% below on-demand, while smaller inference cards sometimes show discounts in the 10–20% range because demand is steady. Check the live rate for your exact SKU and region rather than assuming a rule of thumb. For inference, spot is viable only when three things are true: cold start is short (Session 1's work), you keep an on-demand baseline for the floor, and your router can drain a node inside the notice window.
When one GPU isn't enough. The inverse problem, and the vocabulary is worth owning:
Every GPU holds a complete model and serves different requests. This is just
replicas: N in a Deployment. It raises throughput and does nothing for the latency of any
single request.
This is what you want for Gemma E4B on L4s, and it is the cheapest kind of scaling because the replicas never have to talk to each other.
Each individual layer's matrices are split across GPUs, so every GPU does part of every operation. Extremely chatty — the GPUs synchronise many times per token via NCCL all-reduce.
Stay inside one node. NVLink/NVSwitch runs up to ~900 GB/s between GPUs in a server; 100-Gbit Ethernet between nodes is about 12.5 GB/s. That is a ~70× cliff, and tensor parallelism falls off it.
In vLLM this is --tensor-parallel-size. Your config sets it to 1, correctly.
Layers 0–15 on node A, 16–31 on node B. Each stage sends one activation tensor onward per microbatch — much larger chunks, far less often. That tolerance for latency is why pipeline parallelism is the strategy that survives crossing a network.
Cost: pipeline bubbles (stages idle waiting for work) and the fact that a failure anywhere kills the whole pipeline.
Multi-node model parallelism is all-or-nothing. Seven of eight pods scheduled is worth exactly zero, and it holds seven GPUs hostage while it waits. If one pod dies, the whole group restarts.
Plain Kubernetes has no concept of this — it schedules pods one at a time. You need a gang
scheduler (Kueue, Volcano, NVIDIA's KAI Scheduler) or a workload API that models the group, such as
LeaderWorkerSet. §7 territory; Session 3 will meet
LeaderWorkerSet again under disaggregated serving.
Do the arithmetic first
Worked example · what a GPU node pool actually costs you
Step 1 · The baseline bill
One g2-standard-8 (1× L4, 24 GB) in us-central1, mid-2026 list:
- On-demand ≈ $0.85/hr → ≈ $620/month running 24/7.
- Committed-use discounts on GPU machines are shallow — roughly 13% for 1 year, 19% for 3 years, versus 37–55% on general-purpose CPU shapes. Do not budget as if GPU CUDs behave like CPU CUDs.
- Add the control plane: GKE Standard charges a per-cluster fee of about $0.10/hr ≈ $73/month. Autopilot bills per-pod-request instead.
Step 2 · The question nobody asks — is MIG on a big card cheaper per GB?
Intuition says "one A100 split seven ways must beat seven small cards". Check it.
| Option | Rate | Usable VRAM | $ per GB-hour | Isolation |
|---|---|---|---|---|
| L4, whole card | ~$0.85/hr | 24 GB | $0.035 | Total |
| A100 40 GB, whole card | ~$3.67/hr | 40 GB | $0.092 | Total |
| A100 40 GB → 7× MIG 1g.5gb | ~$0.52/hr per slice | 5 GB per slice | $0.105 | Hardware |
So MIG slicing an A100 costs about 3× more per GB-hour than simply renting L4s, and you also lose a little capacity to partition overhead. MIG's value is isolation and density on hardware you already own — not price per gigabyte. If the question is "cheapest way to serve seven small models", the answer is usually seven small cards, or one card with seven models loaded into one vLLM process.
Rates are illustrative list prices for the arithmetic; re-derive with your region's live numbers before quoting them.
Step 3 · Sizing the pool for Gemma
Your README's own L4 benchmark shape: batch 1 gives 41.5 tok/s, batch 32 gives 934 tok/s. Take the high end as the steady-state ceiling for one replica.
# requests one replica can absorb, at 100 output tokens each 934 tok/s ÷ 100 tok/req ≈ 9.3 req/s ≈ 800,000 requests/day # your stated workload 10,000 req/day → 0.116 req/s average, ~0.6 req/s at a 5x peak # headroom 9.3 / 0.6 ≈ 15x
Conclusion: one replica covers your peak fifteen times over. You do not have a scaling problem, you have a utilisation problem — a $620/month node running at about 6% of capacity. That reframes the whole exercise: the money is in scale-to-zero and cold-start work, not in autoscaling. Session 3 picks this up.
What changed since the material went to press
kubernetes-sigs/dra-driver-nvidia-gpu. Its two halves have diverged in maturity:
ComputeDomains (multi-node NVLink for GB200-class systems) is officially supported, and
GPU allocation has moved out of standalone-Helm-chart territory and is now installed and
managed by the GPU Operator itself.
Practical prerequisites if you want to try it: Kubernetes 1.34.2+, GPU Operator 25.10 or later with the classic device plugin disabled, CDI enabled in containerd or CRI-O, and NVIDIA driver 580+.
DRAExtendedResource feature gate that lets the scheduler
translate a plain nvidia.com/gpu: 2 request into a ResourceClaim handled by
the DRA driver. Your existing manifests keep working unchanged while the allocation path underneath
becomes DRA. That is the answer to "do I have to rewrite every workload?" — no, you can flip the engine
before you flip the API.
Three things happening right now
NVIDIA open-sourced the KAI Scheduler and it has become the reference point for AI-aware scheduling on Kubernetes: gang scheduling, hierarchical queues, fair-share between teams, and preemption that understands that killing one worker of eight wastes the other seven.
Meanwhile Kueue (Kubernetes SIG) covers the quota-and-queue half of the same problem for batch and training workloads.
Read it as: the default kube-scheduler was designed for interchangeable stateless pods. Neither training nor multi-node inference is that, and the ecosystem has stopped pretending otherwise.
EKS Auto Mode is Karpenter plus Bottlerocket, managed for you — nodes are
provisioned as individual right-sized instances from NodePool/NodeClass
specs, typically in 30–60 seconds, with the most mature multi-instance-type spot fallback of the
three clouds.
GKE has not adopted Karpenter — there is still no
karpenter-provider-gcp. It stayed with Cluster Autoscaler plus Node Auto-Provisioning,
and layered ComputeClasses on top for prioritised machine-shape fallback. GKE
Autopilot now supports A100/H100 and TPUs, and you can even run Autopilot-managed workloads inside a
Standard cluster via an Autopilot ComputeClass.
Career note: since you're targeting both clouds, learn the Karpenter
NodePool API and the GKE ComputeClass API. They solve the same problem with different
nouns.
What was five separate installs — driver, container toolkit, device plugin, NFD/GFD, DCGM
exporter — is one Helm chart with one ClusterPolicy CRD. The current line is GPU
Operator v26.x, and it now also manages the DRA driver as a component rather than a bolt-on.
DCGM Exporter is the piece to notice for Session 3: it is what turns real GPU telemetry — utilisation, memory, temperature, throttling, ECC errors — into Prometheus metrics. Without it you are autoscaling blind.
One gotcha: containers that grab GPUs via NVIDIA_VISIBLE_DEVICES
rather than through Kubernetes allocation must now set runtimeClassName: nvidia. That
pattern should be reserved for monitoring agents, never workloads.
The same idea, three ways
| Capability | Open source / K8s-native | NVIDIA stack | GKE & EKS managed |
|---|---|---|---|
| Make GPUs visible | NFD + a vendor device plugin, installed by hand | GPU Operator: driver, toolkit, device plugin, GFD, DCGM in one ClusterPolicy |
GKE installs the driver DaemonSet for you · EKS ships GPU-optimised AMIs and the Bottlerocket NVIDIA variant |
| Ask for a specific GPU | nodeSelector on GFD labels; or DRA DeviceClass + CEL selectors |
DRA driver for NVIDIA GPUs (kubernetes-sigs), GPU allocation + ComputeDomains |
cloud.google.com/gke-accelerator label · EKS instance-type selectors in the Karpenter NodePool |
| Share one GPU | Device plugin time-slicing config; DRA sharing strategies | MIG via GPU Operator MIG manager; MPS; time-slicing | GKE GPU time-sharing and multi-instance GPU flags on the node pool · EKS via the operator |
| Add GPU nodes on demand | Cluster Autoscaler; Karpenter (also on-prem via other providers) | — | GKE Node Auto-Provisioning + ComputeClasses · EKS Auto Mode (Karpenter + Bottlerocket) |
| Schedule groups & quotas | Kueue, Volcano, LeaderWorkerSet |
KAI Scheduler; Grove / PodCliqueSets for multi-component topologies | GKE Kueue add-on · EKS documents Kueue in its AI/ML best practices |
| GPU telemetry | DCGM Exporter → Prometheus | Same, shipped inside the GPU Operator | GKE managed Prometheus scrapes DCGM · EKS via the add-on plus CloudWatch Container Insights |
Give your Gemma pod a GPU
1 · Create the node pool — this is what gpu="L4:1" really costs
gcloud container node-pools create l4-pool \ --cluster=llm-cluster --region=us-central1 \ --machine-type=g2-standard-8 \ --accelerator=type=nvidia-l4,count=1,gpu-driver-version=latest \ --num-nodes=0 --enable-autoscaling --min-nodes=0 --max-nodes=3 \ --node-labels=workload=llm \ --spot # drop this for the on-demand baseline pool # GKE auto-applies the taint nvidia.com/gpu=present:NoSchedule # and installs the driver DaemonSet because of gpu-driver-version=latest.
The EKS equivalent, as a Karpenter NodePool rather than a fixed group:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: gpu }
spec:
template:
spec:
requirements:
- { key: node.kubernetes.io/instance-type, operator: In, values: ["g6.2xlarge"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["spot", "on-demand"] }
taints:
- { key: nvidia.com/gpu, value: "present", effect: NoSchedule }
nodeClassRef: { group: karpenter.k8s.aws, kind: EC2NodeClass, name: gpu }
limits: { "nvidia.com/gpu": 8 }
disruption: { consolidationPolicy: WhenEmptyOrUnderutilized }
2 · The Deployment, with Session 1's image volume and this session's GPU fields
apiVersion: apps/v1
kind: Deployment
metadata: { name: gemma-e4b, namespace: llm }
spec:
replicas: 1
selector: { matchLabels: { app: gemma-e4b } }
template:
metadata: { labels: { app: gemma-e4b } }
spec:
# --- steering: land on an L4, not "any GPU" -----------------
nodeSelector:
cloud.google.com/gke-accelerator: nvidia-l4
# --- permission: get past the node pool's taint --------------
tolerations:
- { key: nvidia.com/gpu, operator: Exists, effect: NoSchedule }
containers:
- name: vllm
image: vllm/vllm-openai:v0.11.0
args:
- --model=/mnt/models
- --served-model-name=gemma-4-e4b
- --max-model-len=10000
- --gpu-memory-utilization=0.92
- --max-num-seqs=256
- --enable-prefix-caching
- --async-scheduling
- --quantization=fp8
- --kv-cache-dtype=fp8
- --tensor-parallel-size=1 # one card. no TP. correct for E4B.
- --limit-mm-per-prompt={"image":0,"video":0,"audio":0}
ports: [{ containerPort: 8000, name: http }]
# --- the allocation: integer, limits == requests -------------
resources:
limits:
nvidia.com/gpu: "1"
cpu: "6"
memory: 24Gi
requests:
cpu: "4"
memory: 16Gi
volumeMounts:
- { name: model, mountPath: /mnt/models, subPath: models, readOnly: true }
- { name: vllm-cache, mountPath: /root/.cache/vllm }
- { name: shm, mountPath: /dev/shm }
startupProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 60
readinessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
# NOTE: no livenessProbe. A liveness probe that fires during a slow
# model load will kill the pod and restart the load. Forever.
volumes:
- name: model
image:
reference: us-central1-docker.pkg.dev/PROJECT/models/gemma-e4b:v1
pullPolicy: IfNotPresent
- { name: vllm-cache, emptyDir: {} }
- { name: shm, emptyDir: { medium: Memory, sizeLimit: 2Gi } }
3 · The DRA version — what this becomes next
# Instead of nodeSelector + integer count, describe the device you need. apiVersion: resource.k8s.io/v1 kind: ResourceClaimTemplate metadata: { name: l4-claim, namespace: llm } spec: spec: devices: requests: - name: gpu deviceClassName: nvidia-gpu count: 1 selectors: - cel: expression: "device.attributes['gpu.nvidia.com'].memory.isGreaterThan(quantity('20Gi'))" --- # ...and in the pod spec, replace resources.limits with: resources: claims: [{ name: gpu }] resourceClaims: - { name: gpu, resourceClaimTemplateName: l4-claim }
Read the difference out loud: the first version says "an L4 node, and one of whatever
nvidia.com/gpu means there". The second says "a GPU with more than 20 GiB". Only
one of those survives the day you add H100s to the cluster.
4 · Diagnostics you will use constantly
# What does the node think it has? kubectl get nodes -o custom-columns=\ NAME:.metadata.name,GPU:.status.allocatable.'nvidia\.com/gpu' # Which GFD labels exist to select on? kubectl get node <node> -o jsonpath='{.metadata.labels}' | tr ',' '\n' | grep nvidia # Why is my pod Pending? (read the Events, not the Status) kubectl describe pod <pod> | sed -n '/Events:/,$p' # Talk to the actual hardware kubectl exec -it deploy/gemma-e4b -- nvidia-smi
Modal → Kubernetes, the GPU rows
| gemma_modal.py | Kubernetes | What actually changes |
|---|---|---|
gpu="L4:1" |
resources.limits: nvidia.com/gpu: "1"nodeSelectortolerations |
One string becomes three fields, because Modal was expressing both "which GPU" and "how many" and Kubernetes splits those across different mechanisms. |
N_GPU = 1 → --tensor-parallel-size |
Same flag, same value | Unchanged. TP is an engine concern, not a Kubernetes one — the engine owns inside-one-node, Kubernetes owns which node. |
| Modal picks and provisions the machine | Node pool / Karpenter NodePool + cluster autoscaler | The big one. You now own capacity planning, region availability, spot policy and a 4–10 minute node cold start that used to be invisible. |
timeout=10*MINUTES (container start) |
startupProbe.failureThreshold × periodSeconds |
Same intent, different knob. Get it wrong and Kubernetes crash-loops a pod that was loading fine. |
| Modal's driver/CUDA base image | GPU Operator or the managed node pool's driver DaemonSet | Your modal.Image.from_registry("nvidia/cuda:12.9.0-devel") no longer needs to carry the
driver — the node provides it. Use a runtime image, not a devel image. |
Exercise
Close this tab. On paper, write a GPU Deployment containing: the resource limit, a toleration, a nodeSelector, a model volume, a Secret reference, a startup probe, and no liveness probe.
Then check it against the lab above and note what you forgot. The exit criteria for this stage says "hand-write a GPU Deployment from memory" — this is that test, and the two things people always miss are the toleration and the startup probe's failure threshold.
1. Create a one-node L4 pool with --spot and
--min-nodes=0.
2. Apply a pod with the GPU request but no toleration. Watch it stay Pending. Read the exact wording of the event. Learn to recognise it.
3. Add the toleration. Time how long from kubectl apply to
Running on a cold pool — that is your node-provisioning band from the timeline
diagram.
4. Run nvidia-smi in the pod. Confirm the card, the driver version,
and that CUDA_VISIBLE_DEVICES is scoped to your one GPU.
5. Scale to 2 replicas on a 1-node pool. Watch the second pod go Pending, then watch the autoscaler react. Delete the pool when you are done — an idle spot L4 still bills.
Why this section exists
You now have one replica that boots fast and holds a GPU. Everything from here answers two questions a single replica never has to face: when do I add another one, and which one gets this request.
Kubernetes has confident, well-tested, twelve-year-old answers to both. Scale on CPU utilisation. Distribute round-robin. Both answers are correct for the workload Kubernetes was designed around, and both are wrong here — not subtly wrong, but wrong in the direction that makes your service slower the more machinery you add.
A microservice replica is interchangeable and its requests are uniform. An LLM replica is neither. It has state you want to reuse, and every request costs a different, unpredictable amount.
Taught from zero
A · Autoscaling — the signal matters more than the autoscaler
An autoscaler is a thermostat: measure something, compare it to a target, add or remove capacity. Almost all the discussion about which autoscaler to use is misplaced energy. The only decision that really matters is what you measure. Autoscaling an LLM on CPU utilisation is checking the room temperature with a barometer — the instrument works perfectly and tells you nothing.
vLLM exposes Prometheus metrics on /metrics without any extra work. Four of them are the
candidates worth scaling on:
| Metric | What it means | Use it when |
|---|---|---|
vllm:num_requests_waiting |
Requests queued but not yet in a batch. Rises the instant the engine is saturated. | The default choice. Direct, leading, and it means the same thing on every model. |
vllm:num_requests_running |
Requests currently in the running batch. | You have benchmarked the concurrency at which your latency SLO breaks, and you want to hold below it. |
vllm:gpu_cache_usage_perc |
How full the KV cache is. When it saturates, the engine starts preempting sequences. | Long-context or many-concurrent-session workloads, where KV pressure hits before compute does. |
vllm:time_to_first_token_secondsvllm:time_per_output_token_seconds |
The user-visible latencies, as histograms. | You want to scale directly against the SLO. Most correct, most lagging — by the time p95 TTFT degrades, users already noticed. |
Now the autoscalers, in the order you should consider them:
Kubernetes' native autoscaler. Out of the box it watches CPU and memory, which as established measures nothing useful here.
It can scale on custom or external metrics through the custom-metrics API, but you have to stand up an adapter (Prometheus Adapter, or the KEDA metrics server) to feed it. At which point you have built most of KEDA by hand.
What you get with KServe in Knative mode. Scales on concurrency or requests-per-second, with a "stable" averaging window and a "panic" mode that reacts on a much shorter window.
Better than CPU. Still wrong in principle: it counts requests, and one request may be 50× the work of the next. It was also designed for microservices that start in a second, so its default windows fight a workload that takes minutes to become ready.
Built for queue-driven workloads, which turns out to be exactly the right shape. You give it a query and a target; it does the rest, and it can scale to zero.
KServe wires it in through one annotation and lets you pull metrics either from the pod directly (lower latency, per-pod scope) or from an external Prometheus (more flexible, can join across replicas).
annotations: serving.kserve.io/deploymentMode: Standard serving.kserve.io/autoscalerClass: "keda"
This is the default answer for a team not yet running llm-d.
llm-d's autoscaler. Instead of one metric and one target, it models what each pod can handle, accounts for requests costing different amounts, and scales against your actual latency targets.
The payoff is running the fleet at higher utilisation before adding a pod, while still hitting the SLO — which matters when each pod is a $620/month node.
Thrashing. Queue depth is spiky by nature. Pair any aggressive metric with a stabilisation window longer than your pod start time — otherwise the autoscaler adds a replica, the spike passes, it removes the replica, and the replica was never ready in the first place. You have paid five minutes of GPU for nothing and done it repeatedly.
Scale-to-zero. The material's position is that it "remains impractical for most LLM deployments due to model loading times measured in minutes". That was the right conclusion given minutes-long loads — and it is exactly what Session 1 was for. The honest 2026 framing is a calculation, not a rule:
# scale-to-zero pays when idle time saved > cold-start time wasted saved = (24h − active_hours) × $/hr wasted = cold_starts_per_day × cold_start_seconds ÷ 3600 × $/hr # at $0.85/hr, 6 active hours, 30 cold starts of 45 s: saved = 18 × 0.85 = $15.30/day wasted = 30 × 45/3600 × .85 = $0.32/day
The interesting term is user-visible cold start, not the dollar cost. $0.32 is nothing; a 45-second wait for the first request of the morning may or may not be acceptable. That is a product decision, and now you can price both sides of it.
B · LLM-aware routing — the largest single win in this session
You ask a librarian a follow-up question about a source. They send you to a random desk. The librarian at desk 3 has the material open in front of them and has read the first two hundred pages; you get sent to desk 7, who starts from the cover.
That is round-robin in front of a fleet with prefix caching enabled. Every replica has a KV cache full of conversations it has already processed, and the load balancer — which knows nothing about any of that — systematically routes each turn of a conversation to a different one. You paid for prefix caching in the last stage and the load balancer is throwing it away.
An LLM-aware router considers things a Service cannot:
- Queue depth per replica —
vllm:num_requests_waiting. Send work to the least-loaded engine, not the next one in the ring. - KV cache / prefix state — which replica already holds the prefix for this conversation. Called prefix-aware or cache-aware routing.
- Prefill vs decode role — if the fleet is disaggregated, prefill-heavy requests belong in a different pool.
- Priority / SLO class — interactive traffic outranks batch, and under pressure the router can shed the batch rather than degrade everyone.
- LoRA adapter placement — with
--enable-lora, one vLLM process serves a base model plus several adapters. The one-model-per-endpoint assumption breaks, and the router has to know which pod has which adapter loaded.
The standard way to build this is the plus an . Two objects and one protocol:
InferencePool
(stable v1, group inference.networking.k8s.io) is a set of model-server pods
selected by label, plus a reference to an Endpoint Picker. InferenceObjective (still alpha,
group inference.networking.x-k8s.io) attaches serving priority to a pool.
ext_proc) gRPC protocol, which lets an outside service inspect
and modify request headers and body mid-flight. The EPP scrapes vLLM metrics from the pods in the pool,
scores them, and returns a destination. llm-d's inference-scheduler is the reference
implementation.
kind: InferencePool
spec:
selector: { app: gemma-e4b }
targetPorts: [{ number: 8000 }]
endpointPickerRef: { name: gemma-epp } # the brain lives here
Walk the request path — this is the part worth being able to draw on a whiteboard.
What it buys. The llm-d project reports an order-of-magnitude TTFT reduction against a round-robin baseline on its benchmark topologies. That number is workload-dependent and comes from the project itself, so treat it as a direction rather than a promise — but the direction is not in doubt. Cache-aware routing turns prefix caching from a per-replica optimisation into a fleet-wide one, and the payoff scales with how much your prompts repeat. RAG, agents and multi-turn chat repeat enormously.
What it costs. An extra component in the request path that must be highly available and fast, plus metric-scraping traffic to every pod, plus a new failure mode: an EPP that returns a bad endpoint is worse than round-robin. It also only pays when you have several replicas. With one replica an Endpoint Picker is pure overhead.
C · Disaggregated serving — the appliance end of the spectrum
You already know that prefill is compute-bound and decode is memory-bandwidth-bound. Running both on the same GPU means the hardware is permanently the wrong shape for one of them: while a long prompt is prefilling, the memory bandwidth sits idle; while tokens stream out, the compute units do.
Disaggregation splits them into two pools that can be sized, tuned and scaled independently. A prefill-heavy workload — RAG with 8,000-token contexts — gets more prefill replicas without paying for decode capacity it will not use.
Splitting the phases creates one hard problem: prefill produces the KV cache, and decode needs it. Those blocks have to move between pods, fast, or you have made everything worse.
- LMCache — "Redis for LLMs". An external, shareable KV block store, so blocks outlive a single replica and can be reused across the fleet.
- NIXL (NVIDIA Inference Xfer Library) — a thin point-to-point transfer library that abstracts over GPU memory, CPU memory, files and object storage. Usable even without disaggregation, e.g. to spill KV cache into host RAM for a bigger effective cache.
And here is the gate that keeps most people out:
# the bandwidth arithmetic that decides whether you can do this at all standard pod networking 10–20 Gbps KV-block transfer requirement 500–600 Gbps # ~30x more # what actually clears the bar NVLink / NVSwitch up to ~900 GB/s intra-node InfiniBand / RoCE up to ~800 Gbps (RDMA, bypasses the CPU)
Without dedicated high-bandwidth networking, disaggregated serving is slower than not doing it. This is not a tuning parameter; it is a hardware purchase.
Founded by Red Hat, Google Cloud, IBM Research, CoreWeave and NVIDIA; accepted into the CNCF Sandbox on 24 March 2026. It is a first-class Kubernetes citizen — standard CRDs, the Gateway API Inference Extension for routing, vLLM underneath — and it deliberately supports hardware beyond NVIDIA.
Recent releases: v0.5 (Feb 2026) added hierarchical KV offloading, cache-aware LoRA routing, active-active HA and scale-to-zero autoscaling. v0.7 (May 2026) took predicted-latency scheduling to GA and added an experimental batch gateway.
An orchestration layer above the engine, tightly coupled to NVIDIA's DGX/HGX stack and using NIXL for KV transfer. Supports vLLM, SGLang and TensorRT-LLM as backends. It integrates with GAIE, and its endpoint-picker plugin does the token-aware KV routing described above.
NVIDIA also ships Grove / PodCliqueSet for expressing multi-component
inference topologies as a single scheduling unit.
Career-relevant: given your NVIDIA-stack direction, know both. The interview question is not "which is better" — it is "llm-d is Kubernetes-native and hardware-neutral; Dynamo goes deeper on NVIDIA hardware and is a separate control plane."
How should I scale and route this?
Follow no ↓ until a yes → exits right. Bottom-left is the default, and most production LLM services should genuinely stop there.
Do the arithmetic first
Worked example · choosing a KEDA threshold instead of guessing one
Everyone picks a number out of the air here. Derive it instead.
Step 1 · Find the knee
Run a concurrency sweep against one replica and record p95 TTFT at each level. Using the shape from your own L4 benchmark (batch 1 → 987 ms; batch 32 → 99 ms per-token latency at 934 tok/s aggregate), a realistic curve looks like this:
| Concurrent requests | Throughput | p95 TTFT | Verdict vs a 2 s SLO |
|---|---|---|---|
| 1 | 42 tok/s | ~0.2 s | Wasting the GPU |
| 8 | ~400 tok/s | ~0.5 s | Comfortable |
| 32 | ~934 tok/s | ~1.2 s | The knee. Best throughput still inside SLO. |
| 64 | ~980 tok/s | ~2.8 s | Throughput flat, latency broken |
Throughput stops improving after the knee but latency keeps degrading — the classic saturation shape. The knee is your scaling target, not your maximum.
Step 2 · Turn the knee into a threshold
# scale out when the queue starts growing, i.e. the engine is past the knee target on vllm:num_requests_waiting = 4 # reasoning: max_num_seqs is 256, but useful concurrency saturates near 32. # A persistent waiting queue of 4 means arrivals now exceed the knee, # and there is enough lead time to boot a replica before p95 breaks. # pair it with a stabilisation window LONGER than pod start time cooldownPeriod: 300 # 5 min — do not thrash a 3-minute boot pollingInterval: 15
Step 3 · Sanity-check against your actual traffic
From Session 2: your workload peaks near 0.6 req/s and one replica absorbs about 9 req/s. The queue will never reach 4. The autoscaler you just configured will correctly never fire — and that is the right outcome, not a wasted afternoon. You have a documented, defensible threshold that will start working the day traffic grows 15×.
The money at your scale is elsewhere:
- Always-on single replica: $620/month, running at roughly 6% utilisation.
- Scale-to-zero with a 5-minute idle window and bursty daytime traffic — say 6 active hours: ≈ $155/month.
- Cold-start penalty at 30 starts/day × 45 s: ≈ $10/month and a 45-second wait for whoever arrives first.
Net: about $455/month saved, entirely because Session 1 made the cold start short enough to make Guard 1 answerable.
What changed since the material went to press
KServe was accepted as a CNCF incubating project on 29 September 2025.
LLMInferenceService arrived in v0.16, Envoy AI Gateway integration in v0.15, and the v0.18
line is adding end-to-end tests for llm-d's Workload Variant Autoscaler. Treat the material's KServe version
references as a floor, not a description of today.
LeaderWorkerSet,
topology-aware scheduling and high-bandwidth networking, and resemble distributed training more than
stateless services. And the observation that these deployments become "an appliance instead of a
traditional Kubernetes deployment" is the single most useful sentence in the section.
Three things happening right now
Google's managed inference gateway is explicitly powered by llm-d — same EPP, same InferencePool, managed for you. The interesting part is what they added on top: predicted latency-based routing, using an XGBoost model retrained continuously on live traffic to forecast per-request TTFT and TPOT and route accordingly.
Also on by default: model-specific serving priority (shed batch traffic when constrained), and safety filtering at the gateway via Model Armor or NVIDIA NeMo Guardrails.
Read it as: routing has moved from heuristics over metrics to learned models over traffic — in a managed product, not a research paper.
NVIDIA now ships benchmark recipes rather than claims: paired baseline and optimised Kubernetes deployments for specific models, each stating whether it includes GAIE integration, with published numbers.
The comparisons are the useful part — aggregated versus disaggregated serving with KV-aware routing on a Mooncake-style synthetic coding trace, so you can see the delta the architecture actually buys on a workload shaped like yours.
Use them as: a template for how to benchmark your own setup. A baseline config committed next to the optimised one is a genuinely good engineering habit.
A year ago every vendor had a bespoke LLM proxy. Now nearly everything converges on the Gateway API
Inference Extension: InferencePool reached stable v1, and Envoy AI Gateway,
KServe, llm-d, Dynamo and GKE all speak it.
The differentiation moved up a layer — to token-based rate limiting, multi-tenant auth, usage accounting, semantic routing and guardrails — rather than to the endpoint-selection protocol itself.
Practical consequence: learn InferencePool and
ext_proc. That knowledge now transfers across the whole ecosystem instead of expiring
with one vendor.
The same idea, three ways
| Capability | Open source / K8s-native | NVIDIA stack | GKE & EKS managed |
|---|---|---|---|
| Autoscale on the right signal | KEDA on vLLM Prometheus metrics; KServe autoscalerClass: keda; llm-d WVA |
DCGM metrics for GPU-side signals; Dynamo Planner rebalances roles | GKE managed Prometheus + custom-metrics HPA · EKS KEDA add-on + CloudWatch adapter |
| LLM-aware routing | Gateway API Inference Extension: InferencePool + EPP; llm-d inference-scheduler | Dynamo router — token-aware KV routing with the tokenizer inline | GKE Inference Gateway (llm-d-powered, predicted-latency routing) · EKS via Envoy AI Gateway or GAIE on any Gateway |
| Disaggregated prefill/decode | llm-d + LMCacheLeaderWorkerSet |
Dynamo + NIXL; Grove / PodCliqueSets for topology | GKE recipes on A3/A4 with GPUDirect · EKS on P5/P6 with EFA |
| Lifecycle for all of it | KServe LLMInferenceServiceLLMInferenceServiceConfig presets |
NIM Operator NIMService; Dynamo Kubernetes Platform |
Red Hat OpenShift AI packages KServe · Vertex AI and SageMaker endpoints as the fully-managed alternative |
| Gateway-layer safety & limits | Envoy AI Gateway: token rate limiting, unified API, multi-tenant auth | NeMo Guardrails | Model Armor on GKE Inference Gateway · AWS Bedrock Guardrails alongside EKS |
Scale and route the Gemma deployment
1 · Expose the metrics vLLM is already producing
apiVersion: v1
kind: Service
metadata:
name: gemma-e4b
namespace: llm
labels: { app: gemma-e4b }
spec:
selector: { app: gemma-e4b }
ports: [{ name: http, port: 80, targetPort: 8000 }]
---
apiVersion: monitoring.googleapis.com/v1 # GKE managed Prometheus
kind: PodMonitoring
metadata: { name: gemma-e4b, namespace: llm }
spec:
selector: { matchLabels: { app: gemma-e4b } }
endpoints:
- { port: 8000, path: /metrics, interval: 15s }
Confirm before going further — if this returns nothing, everything below is decoration:
kubectl exec -it deploy/gemma-e4b -n llm -- \ curl -s localhost:8000/metrics | grep -E 'num_requests_(waiting|running)'
2 · KEDA on the queue depth
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: gemma-e4b, namespace: llm }
spec:
scaleTargetRef: { name: gemma-e4b }
minReplicaCount: 0 # Modal's min_containers=0
maxReplicaCount: 2 # Modal's max_containers
cooldownPeriod: 300 # Modal's scaledown_window (5 min)
pollingInterval: 15
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # longer than pod start. non-negotiable.
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
metricName: vllm_num_requests_waiting
query: sum(vllm:num_requests_waiting{service="gemma-e4b"})
threshold: "4" # derived above, not guessed
minReplicaCount: 0 there is nothing behind the Service when traffic arrives, and the
request fails rather than waiting. You need something that holds the connection while a pod boots — an
HTTP scaler (KEDA's add-on), Knative's activator, or KServe in Knative mode. Do not ship
minReplicaCount: 0 without one and assume Modal-like behaviour; Modal was doing this
buffering for you.
3 · The whole thing as one KServe resource
Everything above — Deployment, Service, autoscaler, and the routing layer — collapses into this:
apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceService
metadata: { name: gemma-e4b, namespace: llm }
spec:
model:
uri: oci://us-central1-docker.pkg.dev/PROJECT/models/gemma-e4b:v1
name: gemma-4-e4b
replicas: 1
router:
scheduler: {} # generates the InferencePool + Endpoint Picker
gateway: {} # attaches to your Gateway
template:
containers:
- name: main
resources:
limits: { nvidia.com/gpu: "1" }
args: ["--max-model-len=10000", "--enable-prefix-caching", "--quantization=fp8"]
Name what the controller just did for you — this is an exit criterion for the stage.
It generated the Deployment, the Service, the InferencePool, the Endpoint Picker deployment
and its RBAC, the HTTPRoute onto your Gateway, and the autoscaler wiring. Everything in steps 1 and 2, plus
the routing layer you had not built yet. Check the API version against the KServe release you install —
LLMInferenceService is moving quickly.
4 · Two production details that are easy to skip
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: gemma-e4b, namespace: llm }
spec:
minAvailable: 1
selector: { matchLabels: { app: gemma-e4b } }
# Without this, a node upgrade or a spot reclamation can take your
# only replica away and you eat a full cold start in front of users.
---
# And in the pod spec: give in-flight generations time to finish.
terminationGracePeriodSeconds: 120
# A streaming response can run 30+ seconds. The 30 s default cuts it off.
Modal → Kubernetes, the scaling rows
| gemma_modal.py | Kubernetes | What actually changes |
|---|---|---|
min_containers=0 |
minReplicaCount: 0 + an HTTP scaler or Knative activator |
Modal buffers the first request during a cold start. Plain Kubernetes does not — you have to add that component yourself or the request just fails. |
max_containers=2 |
maxReplicaCount: 2 |
Direct translation. Also cap it in the node pool so a runaway autoscaler cannot bankrupt you. |
scaledown_window=5*MINUTES |
cooldownPeriod: 300stabilizationWindowSeconds |
Two knobs where Modal had one, and they interact. Both must exceed pod start time. |
@modal.concurrent(max_inputs=64, target_inputs=32) |
KEDA threshold on a vLLM metric |
The closest analogue, and the most interesting difference. Modal counts requests in flight per container. KEDA can count the engine's own queue, which is strictly better information — it knows the difference between 32 cheap requests and 32 expensive ones. |
| Modal's built-in load balancing | Service (round-robin) → InferencePool + EPP | You lose Modal's routing and get the chance to build a better one. With one replica: identical. With five and long shared prefixes: substantially better. |
| Modal handles draining | PodDisruptionBudgetterminationGracePeriodSeconds |
Explicit now. The default 30-second grace period will truncate long streaming responses. |
Exercise
1. Run a concurrency sweep against your current Modal endpoint at 1, 8, 16, 32 and 64 concurrent requests. Record p95 TTFT and total throughput at each.
2. Plot it and find your knee — the highest concurrency still inside your latency SLO.
3. Derive a num_requests_waiting threshold from it, and write down
the one-sentence justification. If you cannot justify the number, you have not finished.
4. Compare that threshold with your current
@modal.concurrent(target_inputs=32). Are they consistent? If not, which one is
wrong?
1. Deploy the Service and PodMonitoring. Confirm the vLLM metrics reach Prometheus.
2. Apply the ScaledObject with minReplicaCount: 1 first — prove
scale-out works before attempting scale-to-zero.
3. Drive load until the queue builds. Watch kubectl get hpa -w
alongside the metric. Measure the lag from queue-builds to
replica-serving-traffic — that number is what your stabilisation window has to respect.
4. Drop to minReplicaCount: 0. Send a request into a cold service.
Observe that it fails. Now you understand why the activator exists, in a way no diagram teaches.
Should you migrate at all?
Three sessions have taught you how to move a model onto Kubernetes. This one starts by asking whether you should — because the most valuable thing a platform engineer can produce is sometimes a well-argued no.
§1 of this material put it as a ladder, and the rule from that session still holds: Kubernetes is an exit you earn, not a default. Your Modal deployment works. It scales to zero. It cost you roughly one file. The question is not "is Kubernetes better" — it is "what specifically does Modal not give me, and is that worth the operational surface I am about to take on?"
Serverless GPU platforms sell you the elimination of a problem. Kubernetes sells you control over it. You should only buy control when you have a use for it.
Duty cycle. Per-second serverless billing is superb below a certain utilisation and expensive above it. There is a crossover, and you can compute it.
Data residency and network position. The model has to sit inside your VPC, next to your database, under your compliance boundary.
Hardware you cannot get otherwise. Specific SKUs, reserved capacity, committed-use discounts, or your own on-prem cards.
Co-location with the rest of the stack. Your FastAPI router, Prometheus, Grafana and Langfuse already want to be somewhere. One platform beats two.
Multi-model economics. Ten LoRA variants sharing a base model on one GPU is a Kubernetes-shaped problem, and a serverless-per-model platform prices it badly.
Career. Entirely legitimate, as long as you name it as the reason rather than dressing it up as a cost argument.
"It'll be cheaper." Only above the crossover duty cycle, and only if you charge nothing for your own time. A GKE Standard control plane alone is about $73/month before a single pod runs.
"We need more control." Over what? If you cannot name the specific knob, you are buying an on-call rotation to get access to settings you will never change.
"Serverless doesn't scale." It scales fine. It bills differently.
"Kubernetes is the industry standard." True and irrelevant to the question of whether this workload belongs there this quarter.
The honest version for your situation: you are migrating primarily to learn the stack you want to work in professionally. That is a good reason. It also means you should optimise the migration for learning surface, not for cost — and you should keep Modal running while you do it.
Where should this model actually run?
Follow no ↓ until a yes → exits right. Bottom-left is the default — and for a model serving 10,000 requests a day, the default is genuinely the right answer.
gemma_modal.py, field by field
Every line of your Modal file and what it becomes. Sessions 1–3 covered these in pieces; this is the whole thing in one place.
| Modal | Kubernetes | Notes |
|---|---|---|
| IMAGE & RUNTIME | ||
modal.Image.from_registry("nvidia/cuda:12.9.0-devel...") |
image: vllm/vllm-openai:v0.11.0 |
Drop the CUDA base entirely — the node supplies the driver. Use the runtime image, not
devel; you are shipping several GB of compilers for nothing. |
.uv_pip_install("vllm==0.21.0") |
Baked into the image tag | Modal builds at deploy time. Kubernetes needs a pre-built, pushed image. This is the single biggest workflow change: you now have a container build step. |
.env({"HF_XET_HIGH_PERFORMANCE": "1", ...}) |
env: list, or a ConfigMap |
Direct. HF_XET_HIGH_PERFORMANCE becomes irrelevant if you bake weights into an image. |
.entrypoint([]) |
command: / args: |
Same intent. Remember args are exec'd, not shelled — no quoting. |
| MODEL DATA — SESSION 1 | ||
hf_cache_vol = modal.Volume("huggingface-cache") |
Image volume, PVC, or emptyDir + init download | The Session 1 decision. Default: volumes[].image with
subPath: models. |
vllm_cache_vol = modal.Volume("vllm-cache") |
emptyDir, or a small RWO PVC |
Must be writable — cannot be an image volume. Holds compile and CUDA-graph artefacts. A per-node RWO PVC saves 30–90 s per restart. |
modal.Secret.from_name("huggingface-secret") |
SecretsecretKeyRef, or Workload Identity |
Disappears entirely if you bake weights at build time. |
MODEL_REVISION = None |
Image digest, or --revision <sha> |
Pin it. Unpinned means replicas can silently differ. |
| GPU — SESSION 2 | ||
gpu="L4:1" |
resources.limits: nvidia.com/gpu: "1"nodeSelectortolerations |
Three fields, because Kubernetes separates "how many" from "which kind" from "am I allowed here". |
| (implicit — Modal owns the fleet) | Node pool / Karpenter NodePool + autoscaler | New responsibility: capacity planning, regional availability, spot policy, and a 4–10 minute node cold start. |
timeout=10*MINUTES |
startupProbe: failureThreshold × periodSeconds |
Same job. Under-set it and Kubernetes crash-loops a pod that was loading correctly. |
N_GPU=1 → --tensor-parallel-size |
Identical flag | Engine concern, unchanged. Correct at 1 for E4B on an L4. |
| SCALING & SERVING — SESSION 3 | ||
@modal.web_server(port=8000) |
ServicecontainerPort + probes + Ingress/Gateway |
One decorator becomes four objects. TLS, DNS and auth are now yours. |
min_containers=0 |
minReplicaCount: 0 + activator or HTTP scaler |
Modal buffers the cold-start request. Plain Kubernetes fails it. You must add the buffering component. |
max_containers=CFG[...] |
maxReplicaCount |
Also cap the node pool. Two ceilings, not one. |
scaledown_window=5*MINUTES |
cooldownPeriodstabilizationWindowSeconds |
Two interacting knobs. Both must exceed pod start time. |
@modal.concurrent(max_inputs, target_inputs) |
KEDA threshold on vllm:num_requests_waiting |
Kubernetes can scale on the engine's real queue rather than an in-flight count — better information, more setup. |
| Modal's internal load balancing | Service round-robin, or InferencePool + EPP | Round-robin is a downgrade at multi-replica scale; the Endpoint Picker is an upgrade. With one replica, identical. |
| THE OFFLINE BATCH PATH | ||
@app.clsOfflineEnricherbatch_enrich |
Job (or CronJob) reading from a volume or queue |
Cleaner on Kubernetes than on Modal, actually. A Job with restartPolicy: OnFailure on a
spot GPU node pool is the reference text use case — no HTTP layer, no autoscaler, and preemption just means
the Job retries. |
modal.parameter(default="") for the quant A/B |
Two Jobs, or one Job with an env var | Your PROFILE switch pattern maps well to Kustomize overlays or a Helm value. |
The cost model, done properly
Three billing shapes, and the only honest way to compare them is to compute the crossover rather than quote three monthly numbers.
The three shapes
cost = active_seconds × rate
You pay only while a container is up, including the cold-start seconds. No control plane, no idle nodes, no ops. Rate per GPU-hour is higher than raw cloud list price — that premium is the service.
Wins when: duty cycle is low and traffic is bursty.
cost = nodes × 730h × rate + control_plane + storage + egress + your_time
You pay for the node whether or not it is serving. The last term is real and usually the largest: budget the on-call, the upgrades, the driver incidents.
Wins when: the GPU is genuinely busy, or you have committed-use discounts, or the cluster is already running for other reasons.
cost = replica_hours × rate, with a minimum replica count.
Your vertex_ai_deploy.py already does this with T4s and autoscaling. Node-hour pricing
with an autoscaling range, no cluster to operate, but scale-to-zero is limited and you inherit the
platform's serving runtime constraints.
Wins when: you want cloud-native integration and no cluster, and your traffic is steady enough that a minimum replica is not waste.
The crossover, in one formula
# let R_s = serverless rate per GPU-hour # R_n = node rate per hour (incl. amortised control plane) # D = duty cycle, i.e. active hours ÷ 730 serverless_month = R_s × 730 × D selfmanaged_month = R_n × 730 # node runs regardless # break-even duty cycle: D* = R_n / R_s # worked, with R_n = 0.85 + 0.10 control plane = 0.95 # and a serverless rate of, say, 1.60/GPU-hour: D* = 0.95 / 1.60 = 0.59 → about 59% duty cycle, ~430 h/month
Below 59% busy, serverless is cheaper. Above it, the node is. Substitute your own numbers — pull the serverless rate from your Modal usage dashboard rather than trusting the figure above, which is illustrative only. Then look at your actual duty cycle. At 10,000 requests a day on a replica that handles nine per second, you are nowhere near 59%.
What the formula leaves out — and shouldn't
| Line item | Modal | GKE self-managed | Vertex AI endpoint |
|---|---|---|---|
| GPU compute | Per active second | ~$620/mo per node, 24/7 | Per replica-hour, min ≥ 1 |
| Control plane | — | ~$73/mo (GKE Standard) | — |
| Model storage | Included in Volumes | Registry + disk, ~$10/mo | GCS bucket |
| Egress | Platform's | Yours. Watch cross-zone. | Yours |
| Observability | Built in | You run Prometheus + Grafana | Cloud Monitoring |
| Cold-start idle | You pay for it | You pay for it | Minimum replica avoids it |
| Engineering time | ~0 | The dominant cost | Low |
| Upgrade & driver ops | — | Quarterly, yours | — |
Three phases, each independently valuable
Structured so you can stop after any phase and still have gained something. Modal stays up the whole time — this is a parallel build, not a cutover.
Build: namespace, Secret, model OCI image, Deployment with image volume + GPU fields, Service, PodDisruptionBudget, an L4 node pool with autoscaling 0→2.
Prove: a curl to the OpenAI-compatible endpoint returns the same output as Modal.
Measure: cold start on a warm node, cold start on a cold node, p95 TTFT at your knee concurrency. Write all three down.
Why stop here is fine: you can now hand-write a GPU Deployment from memory, which is two of the six exit criteria.
Build: PodMonitoring + Prometheus, Grafana dashboards for TTFT/ITL/queue depth, KEDA ScaledObject on your derived threshold, an Ingress or Gateway with TLS, a spot node pool with an on-demand baseline.
Prove: drive load, watch it scale out, watch it scale back down without thrashing.
Then attach the app: your vLLM tutorial repo's FastAPI router, intelligent routing and Langfuse tracing go in front of the cluster as a normal Deployment. That is the moment the two projects become one system.
Build: install KServe, then replace phases 1 and 2 with a single
LLMInferenceService.
The exercise that matters: run
kubectl get all -n llm before and after and diff it. Enumerate every object the
controller created that you had written by hand — and the ones it created that you had not thought
of.
Then answer in writing: "what exactly did the controller automate?" That sentence is an exit criterion, and you can only write it honestly if you built phase 1 first. This is why the phases are in this order.
Assembling the manifests
Everything from the three labs, in apply order:
llm-platform/ ├── 00-namespace.yaml # namespace: llm ├── 01-secret.yaml # S1 — only if NOT baking weights ├── 02-node-pool.sh # S2 — gcloud / Karpenter NodePool ├── 03-deployment.yaml # S1 image volume + S2 GPU fields ├── 04-service.yaml # S3 ├── 05-podmonitoring.yaml # S3 ├── 06-scaledobject.yaml # S3 — KEDA ├── 07-pdb.yaml # S3 └── kserve/ └── llminferenceservice.yaml # Phase 3 — replaces 03 through 07 # build the model image first (S1), then: kubectl apply -f 00-namespace.yaml bash 02-node-pool.sh kubectl apply -f 03-deployment.yaml -f 04-service.yaml kubectl rollout status deploy/gemma-e4b -n llm --timeout=15m kubectl apply -f 05-podmonitoring.yaml -f 06-scaledobject.yaml -f 07-pdb.yaml
The parity test — run this before you trust anything
# same prompt, both endpoints, temperature 0, compare token-for-token for URL in "$MODAL_URL" "$K8S_URL"; do curl -s "$URL/v1/chat/completions" \ -H 'Content-Type: application/json' \ -d '{"model":"gemma-4-e4b","temperature":0, "messages":[{"role":"user","content":"What is EBITDA?"}]}' \ | jq -r '.choices[0].message.content' done # If they differ: check --revision, --quantization and --kv-cache-dtype. # FP8 on-the-fly quantization is not bit-identical across vLLM versions.
The runbook you should write before going live
- Pod Pending → check taints, then GPU allocatable on nodes, then node pool quota. In that order.
- Pod CrashLoopBackOff during load → almost always the startup probe firing too early.
Raise
failureThreshold. - OOM at model load → wrong GPU SKU (missing nodeSelector), or
gpu-memory-utilizationtoo high for the KV cache. You already learned 0.95 OOMs on an L4; write it down. - Cold starts got slow again → the node layer cache is cold. Check whether the pod landed on a new node.
- Autoscaler thrashing → stabilisation window shorter than pod start time.
- Spot node vanished mid-generation → PodDisruptionBudget plus an on-demand baseline replica.
Exit criteria, checked
The six things this stage promised, and where each one was earned.
kubectl get all before and after is the answer.