all_lessons/kubernetes_genai/02lesson 2 / 18

Part I - Inference foundations

First Manual LLM Deployment

Lesson 01 argued that GenAI workloads break the assumptions Kubernetes was tuned for: the pods are huge, they pin scarce accelerators, and they take minutes to become useful. That was the why. This lesson is the how, stripped of every convenience: you deploy one LLM server by hand — a Deployment, a Service, a GPU request, a model mount, probes — and send it a first request. The point is not that you will run production this way; you will not. The point is that KServe, Ray Serve, Helm charts, and platform APIs are all automation wrapped around exactly these objects, and you cannot debug a wrapper whose primitives you have never assembled yourself.

Source coverage
PDF Chapter 1 - deploying models manually to Kubernetes before adding controllers. We expand the chapter's bare manifest into a production-minded one, add the cold-start arithmetic the chapter only gestures at, and turn its troubleshooting notes into an explicit failure taxonomy.
Linear position
Prerequisite: Lesson 01 (Why GenAI Is Different on Kubernetes) — you know that an LLM pod is large, GPU-bound, and slow to warm, and why naive autoscaling and bin-packing misfire on it.
New capability: One complete, working serving path built from plain Kubernetes objects — so every controller and operator in the rest of the track reads as "automation around this," and a broken deploy can be classified before a single line of YAML is edited.
The plan
Five moves. (1) Name the contract a serving pod must satisfy, and the one idea that everything else hangs on: separating process started from model ready. (2) Make readiness honest with a startup/readiness probe that means "the model can answer," with the latency reasoning behind each probe field. (3) Build a minimal but production-minded manifest end to end — Deployment, GPU request, model mount, metrics, probes — and explain every line. (4) Decide how the weights reach the pod, with a worked cold-start number that makes the trade-off arithmetic, not taste. (5) A failure taxonomy that classifies a broken deploy before you touch YAML, then the failure-mode / checklist pair and the hand-off to controllers.

1 · The contract a serving pod must satisfy

Before any framework, an inference service is a pod with five obligations. It must run a runtime image — the server binary plus the CUDA stack (the NVIDIA libraries and kernels the GPU code links against). It must obtain the model weights from somewhere. It must be scheduled onto a node that actually has a GPU, which means requesting one explicitly. It must expose a port for inference requests and, in practice, a second one for metrics. And it must tell Kubernetes when it is ready to receive traffic. Higher-level tools hide each of these, but none of them removes the obligation — they just decide it for you.

runtime image → pinned server + CUDA/driver-compatible libraries
model weights → baked in, mounted from a volume, or downloaded at startup
GPU requestnvidia.com/gpu: 1 so the scheduler lands the pod on an accelerator node
service contract → an inference port, a metrics port, structured logs
readiness → green only once the target model is loaded and can answer

The single idea this whole lesson turns on is the last one, and it is where engineers coming from ordinary web services get burned. A normal web server is ready the moment its process binds a port — the handler is in memory, there is nothing to warm. An LLM server is different: it binds its port in a second or two, then spends minutes doing real work before it can answer anything — reading tens of gigabytes of weights off disk, copying them into GPU memory (HBM, the high-bandwidth memory soldered onto the accelerator, distinct from the host's system RAM), compiling or autotuning CUDA kernels (CUDA is NVIDIA's GPU programming stack; the server JIT-compiles or selects the optimal kernel variants for the specific GPU model on the node), building the KV-cache allocator (the KV cache is the buffer that stores the attention keys and values for tokens already processed, so the model does not recompute them on every decode step; the allocator carves up leftover HBM into pages for it), and loading any LoRA adapters (LoRA — Low-Rank Adaptation — produces small fine-tuned weight deltas that are merged onto or served alongside the base model). "Process up" and "model ready" are separated by a multi-minute gap, and confusing the two is the defining failure of manual LLM deployment.

It is worth making "model ready" physically concrete, because it is not one event but a sequence, and each stage can be the thing that hangs. A useful mental model is a four-stage warm-up, each with its own dominant cost:

1 · Read weightsPull tens of GB off the PVC, image layer, or network into host memory. Dominated by storage/network read bandwidth — this is the cold-start term you compute in §4.
2 · Load to HBMCopy the weights across the PCIe/NVLink bus into GPU memory and lay out the tensors. Bounded by host-to-device bandwidth; a few seconds for a single GPU.
3 · Compile kernelsJIT-compile or autotune CUDA/attention kernels and, on engines that do it, capture CUDA graphs for the decode loop. Tens of seconds, paid once per process.
4 · Allocate KV cacheReserve the HBM left after weights as paged KV-cache blocks and run a dummy forward pass to warm them. Fast, but it is what makes the first real request not also pay a warm-up.

Only after stage 4 can the server honestly answer a request inside its normal latency. Stages 1–4 are exactly the work that a TCP-port check is blind to.

Process started ≠ model ready
If readiness means "the TCP port is open," Kubernetes marks the pod Ready within seconds, adds it to the Service endpoints, and routes live traffic to a server whose model is still loading. Every request in that window fails or hangs. The readiness signal must represent the serving contract: the target model is loaded and can produce a response within the expected latency envelope — not merely that a socket accepts connections.
Why the weights even fit — an HBM budget
Stage 2 above only succeeds if the weights fit in HBM, and that is arithmetic, not hope. An 8-billion-parameter model at fp16 (2 bytes per parameter) needs 8 × 10⁹ × 2 = 16 GB just for weights. On an 80 GB GPU (e.g. an H100/H200-class card) that leaves 80 − 16 = 64 GB for everything else, most of which becomes KV cache. If you instead loaded a 70B model at fp16 — 70 × 2 = 140 GB — it does not fit on one 80 GB GPU at all; you would either quantize it (e.g. to 8-bit, ~70 GB) or split it across GPUs (lesson 07). The point for this lesson: a server that starts, loads weights, and then OOMs while sizing the KV cache is failing stage 2 or 4, and §5 has a bucket for exactly that.

2 · Making readiness honest with probes

Kubernetes gives you three probes, and an LLM pod needs all three doing distinct jobs. A probe is a periodic health check Kubernetes runs against the container — here, an HTTP GET against a path, where any 2xx/3xx status counts as a pass. The three differ only in what a pass or fail does:

startupProbe
Guards the long load. While it is still failing, Kubernetes suppresses the other two probes entirely and will not kill the container for being slow. Once it passes once, it never runs again and hands off to the other two.
readinessProbe
Gates traffic. A pod is added to the Service endpoints only while this passes, and is pulled the moment it fails — without restarting the container. This is the one that must mean "model loaded and can answer."
livenessProbe
Decides whether to restart a wedged process. A failure here kills and recreates the container, so its deadline must never be shorter than a legitimate load, or it will kill healthy slow starts.

The mistake is using a liveness-style probe (or a short readiness probe) to cover the cold start: the model has not finished loading inside the deadline, Kubernetes concludes the container is dead, kills it, and the replica enters a restart loop that never completes a load. The startupProbe exists precisely to break that loop — it is the only probe whose job is to say "still loading, leave it alone." Because it suppresses the liveness probe, you are then free to make the liveness deadline tight (catch a truly hung server fast) without it ever firing during the legitimate multi-minute warm-up.

readinessProbe:
  httpGet:
    path: /v1/models        # endpoint returns 200 only once the model is loaded
    port: 8000
  periodSeconds: 10
  failureThreshold: 3       # 3 consecutive fails -> pulled from Service endpoints

startupProbe:
  httpGet:
    path: /health           # cheap "process is alive and loading" check
    port: 8000
  periodSeconds: 10
  failureThreshold: 60      # tolerate up to 60 x 10s = 600s of model loading

The arithmetic is the point. A 600-second startup budget (60 × 10s) is not arbitrary padding — it must exceed your worst realistic load time, which §4 lets you estimate. Set failureThreshold too low and a slow node or a cold volume kills the pod mid-load; set it absurdly high and a genuinely hung load wastes a GPU for an hour before anyone notices. The /v1/models path in the readiness probe matters too: a server like vLLM only returns the model in that list after weights are resident in HBM, so checking it — rather than a generic /health — is what makes readiness mean "can answer."

3 · A minimal, production-minded manifest

Here is the whole serving path as plain objects. It is deliberately not abstracted: model path declared explicitly, GPU requested explicitly, runtime version pinned, the inference/metrics port exposed, probes matched to load time. (vLLM, like most OpenAI-compatible servers, serves both the /v1/* inference API and the Prometheus /metrics scrape endpoint on the same port, so one containerPort covers both — a separate metrics port is only needed for servers that split them.) Read it as the assembly diagram every later controller hides.

apiVersion: apps/v1
kind: Deployment
metadata: { name: llm-server }
spec:
  replicas: 1
  selector: { matchLabels: { app: llm-server } }
  template:
    metadata:
      labels: { app: llm-server }
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:v0.11.0         # PIN an exact runtime + CUDA stack; never :latest
        args: ["--model", "/models/llama-3-8b"]  # serve from the mounted path, not a hub id
        ports:
        - { name: http, containerPort: 8000 }    # vLLM serves /v1/* AND /metrics on this one port
        resources:
          limits:
            nvidia.com/gpu: 1                     # GPUs go under limits; K8s mirrors it into requests
        volumeMounts:
        - { name: model-store, mountPath: /models, readOnly: true }
        readinessProbe:
          httpGet: { path: /v1/models, port: 8000 }
          periodSeconds: 10
          failureThreshold: 3
        startupProbe:
          httpGet: { path: /health, port: 8000 }
          periodSeconds: 10
          failureThreshold: 60                    # ~600s load budget (see §4)
        livenessProbe:
          httpGet: { path: /health, port: 8000 }  # suppressed until startupProbe passes
          periodSeconds: 10
          failureThreshold: 3                      # tight: restart a TRULY wedged process fast
      volumes:
      - name: model-store
        persistentVolumeClaim: { claimName: llm-weights }  # PVC holds the weights
---
apiVersion: v1
kind: Service
metadata: { name: llm-server }
spec:
  selector: { app: llm-server }
  ports:
  - { name: http, port: 80, targetPort: 8000 }    # narrow contract: one inference port

And the first request, once the pod is Ready — this is the moment that proves the whole path:

kubectl port-forward svc/llm-server 8080:80
curl localhost:8080/v1/completions \
  -d '{"model":"/models/llama-3-8b","prompt":"Hello","max_tokens":16}'

Notice what the manifest does not do: no autoscaling, no canary, no traffic splitting, no model versioning. Those are precisely the jobs the controllers in later lessons add. By building the path without them, you learn that a controller's failure is always a failure to perform one of these plain responsibilities — it scheduled a pod that never got a GPU, or it flipped readiness before the model loaded. The manifest is your debugging baseline.

4 · How the weights reach the pod — with the number that decides it

The most consequential design choice is also the least visible: where the model bytes come from and when. There are four paths, and they trade deployable size, promotion speed, and — above all — cold-start time, the seconds between a new replica being scheduled and it being able to answer. Cold start is the GenAI tax: every scale-up event pays it, so it directly bounds how fast you can absorb a traffic spike.

ChoiceBuysCosts
Runtime image onlySmall image, fast runtime patching, weights and server versioned independentlyNeeds a separate model-delivery path (the PVC or init below)
Weights baked into imageOne immutable artifact; node image cache warms it; no external dependency at startupHuge image (35GB+), slow registry push/pull, slow promotion, rebuild on every weight change
PVC model mountShared read-only storage, familiar ops, no per-pod downloadCold start bounded by storage read bandwidth; shared PVC can throttle under fan-out
Init-container downloadTrivial demo path, weights live in object storageEvery scale-up is a network event; throttling, egress cost, and an external point of failure
Worked cold-start number — a 30 GB model
Take a 30 GB model and bring it to a fresh replica three ways.
Init download over 1 Gbit/s. Bytes are bits ÷ 8, so 30 GB is 30 × 8 = 240 gigabits; at 1 Gbit/s that is 240 / 1 = 240 s ≈ 4 min of network transfer per replica — and ten replicas scaling at once contend for the same egress and the link, so it gets worse, not better.
Baked into the image. Transfer happens once at docker push, and a node that has pulled the 35 GB image before serves a new pod from local cache in seconds — but a node that has not still pulls 35 GB, every weight change forces a full rebuild + repush, and registry storage balloons.
PVC mount. No download at all; cold start is bounded by how fast the volume reads 30 GB into HBM. At a 1 GB/s read bandwidth that is 30 / 1 = 30 s — eight times faster than the 1 Gbit/s download — but if 10 replicas read the same shared volume simultaneously, the bandwidth is split and each one slows.
The lesson: the right path is the one whose bottleneck (network egress, registry pull, or storage read bandwidth) you can actually afford under your worst scale-up burst. And whichever you pick, the number you computed is what the startupProbe budget in §2 must exceed.

This is also why §3 puts weights on a PVC and pins the runtime image separately: you can patch a CVE in the server without re-shipping 30 GB of weights, and you can roll a new model version without rebuilding the server. Coupling them — baking weights into the runtime image — is the convenient demo choice that quietly makes every promotion a multi-gigabyte event.

5 · A failure taxonomy — classify before you edit

When a manual deploy fails, the operational habit that pays off for the entire rest of the track is to classify the failure before changing any YAML. A broken serving pod falls into exactly one of six buckets, and the bucket tells you which signal to read:

Image failure
Wrong tag, missing CUDA stack, or unpullable image. Signal: ImagePullBackOff, or the container crashes on a missing-library error before it touches the model.
Model-data failure
Weights not where the args say. Signal: server logs a path-not-found, or — worse — silently falls back to downloading from a hub (see failure modes).
Accelerator failure
No GPU available, request omitted, or device-plugin mismatch. Signal: pod Pending with "Insufficient nvidia.com/gpu", or it runs but on CPU and is glacially slow.
Runtime-config failure
Bad server args — context length too large for HBM, unsupported quantization. Signal: process starts then exits with an OOM or arg-parse error during load.
Network-exposure failure
Service selector or port wrong, so requests never reach a healthy pod. Signal: pod Ready but the Service has no endpoints, or curl times out.
Readiness failure
Probe flips green before the model loads, or never flips at all. Signal: traffic hits cold runtimes (errors right after a scale-up), or the pod restart-loops mid-load.

Six labels, one decision tree, and three commands resolve it in order — never guess, read the signal:

kubectl get podsPending ⇒ accelerator (no GPU to schedule onto); ImagePullBackOff/CrashLoopBackOff before logs ⇒ image; Running but not Ready ⇒ keep going
kubectl logs → path-not-found ⇒ model-data; CUDA/driver mismatch or OOM-during-load ⇒ runtime-config (or accelerator if the device never initialized); log stops mid-load and the pod restarted ⇒ a liveness/startup deadline killed the load
kubectl get endpoints llm-server → empty while the pod is Ready ⇒ network-exposure (selector/port mismatch); populated but requests still fail right after scale-up ⇒ readiness flipped green too early

Internalize this and the rest of the track — controllers, operators, autoscalers — becomes "which of these six did the automation get wrong?" A KServe InferenceService stuck not-ready is the same readiness-vs-network question, just one layer up; an autoscaler that adds replicas which never serve is the cold-start arithmetic of §4 colliding with a too-short probe budget.

Failure modes

  • Readiness flips green before weights load. A port-bind or /health readiness probe passes in seconds, Kubernetes adds the pod to the Service, and traffic hits a cold runtime. The signal: a burst of 5xx/timeouts immediately after a scale-up that clears once loading finishes. Probe /v1/models, not the socket.
  • GPU requested, wrong CUDA stack shipped. The pod gets a GPU but the image's CUDA/driver libraries do not match the node, so the server fails to initialize the device. The signal: a CUDA/driver version error in logs on a pod that did schedule onto an accelerator node — distinguishing it from an accelerator-availability failure.
  • Silent download from the internet. A PVC mount path mismatch or a model arg that is a hub id, not the mounted path, makes the server quietly fetch weights from the public internet at startup. The signal: unexpectedly long, network-bound cold starts and egress traffic from a pod that was supposed to read locally.
  • Liveness probe kills a slow load. A livenessProbe (or short startupProbe) deadline shorter than the load time makes Kubernetes restart the container mid-load. The signal: a replica that restart-loops and never reaches Ready, with logs that always stop partway through weight loading.
  • Coupled weights and runtime. Weights baked into the runtime image turn every server patch and every model bump into a multi-gigabyte rebuild-and-repush. The signal: promotions and rollbacks that take many minutes, and a registry whose storage keeps growing.

Implementation checklist

  • Is the runtime image pinned (never :latest) and packaged separately from the model artifact, so you can patch one without re-shipping the other?
  • Is the GPU requested explicitly (nvidia.com/gpu: 1), and have you verified the pod actually landed on an accelerator node and is using the device — not falling back to CPU?
  • Does readiness mean "model loaded and can answer" (a /v1/models-style check), and is the startupProbe budget larger than the cold-start time you computed in §4?
  • Does the model path in the server args match the mount path exactly, so the pod reads locally and never silently downloads from the internet?
  • Is the Service contract narrow and correct — selector matches the pod labels, and only the inference, metrics, health, and log surfaces are exposed?
  • Have you sent a real completion request end to end and confirmed it answers within the latency envelope, not just that the pod is Ready?

Checkpoint exercise

Try it
You must serve a 24 GB model and your SLO says a new replica must be answering within 90 seconds of being scheduled. Compute the cold-start time for each of the three delivery paths in §4 (assume a 1 Gbit/s download link and a 1 GB/s PVC read bandwidth), decide which path(s) meet the 90 s budget, then set the startupProbe periodSeconds × failureThreshold to cover your chosen path with margin. State which one failure-taxonomy bucket each rejected path would most likely land in if you shipped it anyway.

Where this points next

You now have one working serving path and a vocabulary for why it breaks. But doing this by hand for every model, version, and rollout does not scale — and the readiness, GPU-request, and weight-delivery logic you just wrote longhand is exactly what a controller should own. Lesson 03, Model Servers and Controllers, introduces the model-server runtimes (vLLM and friends) and the Kubernetes controllers that wrap them, so that "deploy a model" becomes a single declarative spec instead of the manifest above. Every field that controller exposes — and every default it picks — maps back to a line you just wrote here, which is the whole reason we built it by hand first.

Takeaway
A manual LLM deployment is the assembly diagram for everything that follows: a Deployment running a pinned runtime image, an explicit nvidia.com/gpu request, weights delivered by a chosen path, a metrics + inference Service, and probes. Its one load-bearing idea is that process started and model ready are minutes apart, so readiness must mean "the target model is loaded and can answer," gated by a startupProbe whose budget exceeds your real cold-start time. That cold-start time is arithmetic, not taste — a 30 GB model is ~240 s over a 1 Gbit/s download, ~30 s off a 1 GB/s PVC read, or near-instant from a warm baked image at the cost of a 35 GB artifact — so you pick the delivery path whose bottleneck you can afford under your worst scale-up burst. And when a deploy breaks, you classify it into one of six buckets — image, model-data, accelerator, runtime-config, network-exposure, readiness — before editing YAML. KServe, Ray Serve, and Helm are all automation around these objects; you can only trust a wrapper whose primitives you have built yourself.

Interview prompts