genaipros← The path
Line C · Operate08 · K8s & Infra for LLMs

Kubernetes & Infrastructure for LLMs

applied guides · Generative AI on Kubernetes · 2026
Session 1 ~10% of this tab

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.

COLD START · LLAMA 3.1 8B CONTAINER (20.2 GB) · NAIVE PATH Image pull 5–6 min · registry at ~60 MB/s Extract 3–4 min · CPU-bound gunzip Start ~2 min ≈ 11 min SAME MODEL · OBJECT STORE + FUSE + STREAM-TO-GPU (BENTOML, 2025) ≈ 25× faster image pull ~10 s · no extraction step · weights streamed straight into GPU memory THE WHOLE OF CHAPTER 2 IS ABOUT CLOSING THAT GAP →
Hover the bars The delay is not computation. It is byte movement, and every segment of it is something you get to choose differently.

Three questions fall out of that picture, and they are the spine of this session:

  1. 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?
  2. Where does it live before it moves? — Hugging Face, an S3 bucket, a registry, a database of pointers. These are not interchangeable.
  3. 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.

Core concepts ~50% of this tab

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

Pass 1 · IntuitionFlat-pack furniture

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.

Weight-only format Stores just the learned parameters (weights and biases) with no architecture description — PyTorch's 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.

Pass 2 · MechanismOpen the box

Here is what a real Hugging Face repository looks like on disk after download. Click any file.

~/.cache/huggingface/hub/models--google--gemma/snapshots/<sha>/ model-00001-of-00003.safetensors tensor shard · 3.9 GB weights model-00002-of-00003.safetensors · model-00003-of-00003.safetensors model.safetensors.index.json 42 KB · tensor name → which shard SMALL FILES — TOTAL < 20 MB — AND THE MODEL IS DEAD WITHOUT THEM config.json 4 KB · architecture tokenizer.json 17 MB · vocab + merges generation_config.json 1 KB · default sampling chat_template.jinja 3 KB · role formatting tokenizer_config.json · special_tokens_map.json FOOTPRINT 99% of the bytes · moves slowly · never changes 1% · changes often THIS ASYMMETRY IS WHY LAYERED PACKAGING WORKS SO WELL FOR MODELS
Click a file Notice the split: a few enormous immutable shards, plus a handful of tiny files that change whenever anyone tweaks a chat template. Every packaging decision later in this session exploits that split.

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.

Pickle Python's native object-serialization format. Deserializing a pickle can execute arbitrary Python code by design — the format includes opcodes that call into the interpreter. A malicious .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.
Pass 3 · Trade-offsWhich format, and what it costs you
default on HFzero-copyno RCEneeds config + tokenizer

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.

single filetokenizer insidequantization-nativenarrow runtime support

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.

graph + weightsportableno tokenizerop-set mismatches

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.

pickle / RCEslow loadstill common on old repos

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.

The one term you must not confuse "Hugging Face Transformers format" is not a file format. It is a directory convention: weights in safetensors or .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

Pass 1 · IntuitionA relay race, not a download

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.

Pass 2 · MechanismThe ladder, with real bandwidths
DISTANCE FROM GPU T0 T1 T2 T3 T4 T5 Model hub / OCI registry public internet · 30–120 MB/s · single-stream, sequential layers Cloud object store, same region S3 / GCS · 0.5–2+ GB/s with parallel range reads Network filesystem via PVC NFS / EFS / Filestore / Ceph · 0.1–3 GB/s — divided by every reader Node-local disk image layers · emptyDir · local NVMe · 0.5–7 GB/s Host page cache (RAM) 5–20 GB/s · free if already resident GPU VRAM — the finish line BAR LENGTH ≈ TYPICAL THROUGHPUT. THE JOB IS TO MAKE THE POD START AS FAR DOWN THIS LADDER AS POSSIBLE. PAID ONCE PER CLUSTER = FREE AT SCALE · PAID ONCE PER POD = A TAX ON EVERY SCALE-UP HOVER ANY TIER FOR THE OPERATIONAL CONSEQUENCE →
Hover a tier A cold pod starts at Tier 0 and has to walk all the way down. A warm pod on a node that already has the bytes starts at Tier 3 or 4. That difference is minutes.
Pass 3 · Trade-offsThe three numbers that decide everything
  1. 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.
  2. 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.
  3. 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.
The mistake nearly everyone makes Benchmarking cold start with one pod. Kubernetes never scales one pod. Measure with the replica count your HPA will actually reach, or your capacity plan is fiction.

C · The five ways to put weights on a node

Pass 1 · IntuitionDelivery, storage, or packaging

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.

Pass 2 · MechanismFrom kubectl apply to /mnt/models

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.

zero infrastructureslowestpaid every podexternal dependency

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.

one copy for the fleetfast mountnetwork on every readcontention at scale

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.

versioned + immutableregistry auth you already haveexpensive copy on every pod

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.

no copy at allworks on old clustersshared PID namespacebridge technology

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). SecurityshareProcessNamespace 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.

stable in K8s 1.36no copy, no symlink, no PID sharinglayer sharing across variantsread-only, directories only

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.

fixes cold start for realcomposes with all fiveextra controller to run

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.

Init container A container in a pod that runs to completion before the main containers start. Kubernetes runs them in order and will not start the app until they all exit successfully. The classic use is exactly this: populate a shared volume with data the main container needs.
emptyDir A volume that starts as an empty directory on the node when the pod is assigned there, and is deleted permanently when the pod is removed. Every container in the pod can mount it. It is node-local scratch space — fast, ephemeral, and counted against the node's ephemeral storage.
PersistentVolume (PV) A cluster-level object representing a real piece of storage — an NFS export, a cloud disk, a Ceph volume. Created either by an administrator or dynamically by a StorageClass. Its lifecycle is independent of any pod. persistentVolumeReclaimPolicy: Retain means the data survives deletion of the claim, which is what you want for model weights.
PersistentVolumeClaim (PVC) A namespaced request for storage — "I need 20 GiB, ReadOnlyMany". Kubernetes binds it to a matching PV, or asks a StorageClass to provision one. Pods reference the claim, never the volume, which is what keeps manifests portable across clusters. Access modes: ReadWriteOnce (one node), ReadOnlyMany (many nodes, read-only), ReadWriteMany (many nodes, read-write).
OCI image / OCI registry / OCI artifact The Open Container Initiative standardised Docker's image format in 2015. An image is a stack of read-only tar layers plus a JSON manifest; layers are content-addressed, so identical layers are stored and transferred once. A registry (Docker Hub, Quay, ECR, Artifact Registry) serves them. Since OCI 1.1, registries also accept artifacts — arbitrary payloads that aren't runnable images at all, which is what makes them legitimate homes for model weights.
Sidecar A container that runs alongside the main container for the pod's whole life, providing auxiliary function — logging, proxying, or here, holding a filesystem. Since Kubernetes 1.28 there is native sidecar support (an init container with restartPolicy: Always) which guarantees the sidecar is up before the main container starts.
DaemonSet A workload that runs exactly one pod on every node matching a selector, and automatically adds one when a new node joins. Used for anything node-scoped: log shippers, CNI agents, the NVIDIA device plugin, and here, the agent that manages each node's model cache.
Pass 3 · Trade-offsSide by side
ApproachStorage efficiencyRead speedStartup Best forWhere it hurts
Download at bootLow — a copy per podFast (node-local after copy)Slow, every time Day one; single replica; small modelsRepeated transfer; external dependency; no caching
PVC (ROX)Highest — one copy totalModerate — network on every readFast (mount only) Tens of replicas; weights owned outside the clusterContention past ~10–20 replicas; infra to run; no benefit from node cache
OCI + init copyLow — a copy per podFastSlowest Nothing, now. HistoricalPull and copy on every start
ModelcarHigh — layer sharingFast (node-local layers)Moderate Clusters that can't run image volumes yetshareProcessNamespace; startup races; per-arch images
Image volumeHigh — layer sharing + dedupFast (node-local layers)Moderate; instant when cached The default in 2026. Many variants on one base; signed supply chainRead-only; directories only; first pull on a cold node is still a pull
+ node cache tierPer-node copy, deliberatelyFastestNear zero when warm Scale-to-zero with a real cold-start SLOAnother controller; disk budget per node; cache warming is now your problem

D · Model registries — the difference between a pointer and a payload

Pass 1 · IntuitionLibrary catalogue vs the library

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.

Pass 2 · MechanismThe indirection that matters

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.

Pass 3 · Trade-offsPick by what you actually need

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.


Decision tree · guard clauses

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.

GUARD 1 Do the weights change faster than you can rebuild and ship an image? YES PVC, ReadOnlyMany data scientists write, cluster reads. No rebuild loop. NO GUARD 2 Is the model bigger than the node's usable ephemeral disk? YES Stream from object store vLLM --load-format runai_streamer. Never lands on disk. NO GUARD 3 Cluster older than K8s 1.33, or containerd older than 2.1? YES Modelcar (KServe oci://) Same OCI benefits. Migrate when you upgrade. NO GUARD 4 Scaling to zero with a hard budget on cold-start time? YES Add a node-local cache tier LocalModelCache · NIMCache · Hyperdisk ML NO GUARD 5 One model, one replica, and you need it running this afternoon? YES Download at boot (hf:// or s3://) Ship it. Come back when it hurts. It will tell you. NO DEFAULT Native OCI image volume, subPath: models Stable since Kubernetes 1.36. No copy, no symlinks, no shared PID namespace. MOST TEAMS SHOULD END UP HERE. THE GUARDS ABOVE ARE THE EXCEPTIONS, NOT THE MENU.
Hover any box Guards are ordered by how hard they are to work around. Guard 1 is a fact about your organisation; guard 5 is a fact about today's calendar.

Reality check ~25% of this tab

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-run or 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).
Your own numbers have already aged Your vLLM tutorial README quotes "GCP L4 GPU: $0.45/hour = $324/month". That was never the price of a whole VM — it looks like the bare accelerator SKU. The instance you actually rent is g2-standard-4 at roughly $0.71/hr today. Every cost table in that repo is understating GPU spend by about 55%. Worth fixing before anyone reads it as a benchmark.

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.

PathEffective throughput10 GB takesWhy
Hugging Face Hub, public internet~80 MB/s≈ 2 min 05 sBest case with hf_transfer. Often half that.
Container registry (single-stream + gunzip)~60 MB/s, then ~0.5× for extraction≈ 4 min 10 sLayers pulled sequentially, then CPU-bound decompression.
GCS same region, parallel range reads1.5–2 GB/s≈ 5–7 sMany concurrent HTTP Range requests saturate the NIC.
Managed file share (PVC), 1 reader~600 MB/s≈ 17 sMount is instant; you pay per read instead.
Same PVC, 10 replicas booting at once~60 MB/s each≈ 2 min 47 s eachThe thundering herd. Aggregate bandwidth ÷ readers.
Node-local layer cache already warmn/a≈ 0 sThe 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.
The re-ranking for small models Storage efficiency was the decisive axis when the material's Table 2-1 was full of 700 GB models. For a 10 GB model on 10 nodes it is nearly irrelevant. Optimise for startup time and blast radius, not bytes on disk. That flips the recommendation toward node-local approaches — which is exactly where Kubernetes 1.36 landed.

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

Aged claim 1 · Image volumes are no longer beta §2 says image volume mounts are "a beta feature" in Kubernetes 1.35 and elsewhere calls them "experimental as of Kubernetes 1.33". The real timeline: alpha in 1.31 → beta in 1.33 but disabled by default → beta enabled by default in 1.35 → graduated to stable in Kubernetes 1.36, released 22 April 2026 (KEP-4639, OCI VolumeSource). The section's advice — "use OCI image volumes whenever you can; rely on modelcars if you can't" — is now simply the default path, not aspirational.
Aged claim 2 · The comparison table's "limitations" column The material's Table 2-3 lists OCI Volume Mount limitations as "Beta feature (K8s 1.35+), limited runtime support". Runtime support is now the normal case: CRI-O has had it since v1.33 and containerd's beta-level support landed for 1.35, which is why the feature could be enabled by default. The remaining real limits are that image volumes are read-only and mount directories, not individual files. Plan around those two, not around the feature gate.
Still true, and worth trusting The section's reasoning has held up completely: layered packaging is the right shape for models, the copy step is the enemy, 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.

One gotcha worth banking now Image volumes mount OCI layers. If your packaging tool writes custom layer media types — as KitOps' native ModelKit format does — the container runtime silently ignores those layers and you get an empty mount with no error. Pack with standard OCI layer types (KitOps' --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

Apply to my stack · lab ~10% of this tab

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
The access-mode trap The material's example declares 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
What you just gained No init container. No copy step. No HF token at runtime — so a compromised serving pod cannot exfiltrate your Hugging Face credentials or pull an arbitrary model. The image is content-addressed and signable, so admission control can verify exactly which weights are running. And the second pod on that node starts with the weights already local.

Requires: node pool on Kubernetes 1.36 (or 1.35 with the feature gate on).
The one thing you cannot do with an image volume Your 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.pyKubernetesWhat 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.

Next · Session 2 Kubernetes and GPUs: how a pod actually gets an accelerator — node feature discovery, the device plugin, the GPU Operator, taints and tolerations, GPU node pools and autoscaling, slicing one GPU four ways (MIG / time-slicing / MPS), Dynamic Resource Allocation now that it is GA, and whether spot GPUs are a real strategy or a way to lose a night's sleep.

Say next when ready.
← 07The path
Next stage · C1 →genaipros · 08 · K8s & Infra for LLMsAI for Everyone ↗