Kubernetes for LLM engineers: the mental model
The Kubernetes concepts that matter for model serving (pods, deployments, services, nodes, probes, resources, scheduling), what is different about GPU workloads, the kubectl commands you will use daily, and how to read a manifest. A fast, opinionated orientation for people who know vLLM and Docker but not clusters.
If you can run vLLM in a Docker container, Kubernetes is the system that runs many such containers across many machines, restarts them when they fail, replaces them during upgrades, routes traffic to the healthy ones and adds more when demand grows. It is the standard substrate for production LLM serving (production architecture). It is also large, with an overwhelming vocabulary. This lesson gives you the 20 percent that explains 90 percent of what you will do, with a focus on GPU inference.
#The one idea: desired state and controllers
You do not tell Kubernetes how to run things. You declare the desired state ("three replicas of this container, each with one GPU, reachable at this address") and controllers continuously compare reality with that declaration and fix the difference. A pod crashes: the controller starts another. A node dies: pods are rescheduled elsewhere. You change the image tag: the controller performs a rolling update.
This has a practical consequence: you edit files and apply them; you rarely run containers by hand. Treat manifests as code: review them, version them, test them (rollouts).
#The objects you need
Cluster
├─ Nodes (machines; some have GPUs)
└─ Namespaces (logical groups: team-a, llm-prod, monitoring)
├─ Deployment ──manages──▶ ReplicaSet ──creates──▶ Pods (your vLLM container)
├─ Service ──routes to──▶ ready Pods (stable address)
├─ Ingress / Gateway ──exposes──▶ Service to the outside world
├─ ConfigMap / Secret ──inject──▶ settings and credentials
└─ PersistentVolumeClaim ──mounts──▶ storage (weights, caches)| Object | What it is | LLM relevance |
|---|---|---|
| Pod | The smallest unit: one or more containers sharing network and storage; runs on one node | One vLLM or llama.cpp server (plus sidecars) |
| Deployment | Declares N identical pods, handles rollouts and rollbacks | The normal way to run a model server |
| StatefulSet | Pods with stable identity and storage | Rare for inference; used for databases and some distributed setups |
| Job / CronJob | Run-to-completion work | Batch inference, evaluation runs, model downloads (batch inference) |
| DaemonSet | One pod per node | GPU drivers, metrics exporters, weight caches |
| Service | Stable name and virtual IP in front of pods | Internal address for the gateway to reach model servers |
| Ingress / Gateway API | HTTP routing from outside the cluster | Public API endpoint with TLS |
| ConfigMap | Non-secret configuration | Engine flags, templates |
| Secret | Credentials | API keys, tokens (store encrypted; see stack security) |
| PersistentVolumeClaim | A request for storage | Model weights, compile caches |
| Namespace | Scope for names, quotas and access | Separate prod, staging and tenants |
| HorizontalPodAutoscaler | Adjusts replica count from metrics | Autoscaling (autoscaling policies) |
| PodDisruptionBudget | Limits voluntary disruption | Keeps enough replicas during upgrades and node drains |
#Anatomy of a manifest
Every object is a YAML document with the same four top-level parts: apiVersion, kind, metadata, spec.
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm
namespace: llm-prod
labels: { app: llm }
spec:
replicas: 2
selector:
matchLabels: { app: llm } # which pods this Deployment owns
template: # the pod template
metadata:
labels: { app: llm } # must match the selector
spec:
containers:
- name: server
image: vllm/vllm-openai:<pinned-tag>
args: ["--model", "/models/qwen2.5-7b", "--max-model-len", "16384"]
ports: [{ containerPort: 8000 }]
resources:
limits: { nvidia.com/gpu: 1 }Read it from the inside out: a container with an image, arguments and a GPU limit, inside a pod template, inside a Deployment that keeps two copies running. Labels and selectors are how objects find each other: the Service selects pods by label, the Deployment owns pods by label.
#What is different about GPU workloads
Kubernetes was designed for CPU microservices. Several assumptions break with LLM servers:
| Assumption for web services | Reality for LLM servers |
|---|---|
| Start in seconds | Start in minutes (image + weights + warm-up), cold starts |
| Resources are divisible (0.25 CPU) | GPUs are allocated whole by default (1, 2, 4, 8) |
| Pods are cheap | One idle pod can cost 2 to 40 per hour |
| Stateless and interchangeable | Replicas hold warm prefix caches, so identity matters a little |
| Requests last milliseconds | Streams last seconds to minutes; connection draining matters |
| CPU and memory limits matter | GPU memory (VRAM) is the binding constraint, and is not enforced by Kubernetes |
| Rolling update adds a pod first | A surge pod needs a spare GPU that may not exist |
| Scale on CPU | Scale on in-flight requests or queue depth |
| Failures are random | GPU and driver faults are a distinct failure class |
Much of the rest of this track is about handling these differences: GPU drivers and the device plugin (next lesson), packaging weights (packaging), safe manifests (vLLM on Kubernetes), scheduling (scheduling) and networking for streams (networking).
#Resources: requests, limits and GPUs
Each container declares what it needs:
resources:
requests: { cpu: "4", memory: 16Gi } # what the scheduler reserves for placement
limits: { memory: 16Gi, nvidia.com/gpu: 1 } # hard caps; GPU is an "extended resource"- Requests decide placement: the scheduler only puts the pod on a node with that much unreserved capacity.
- Limits are enforced at runtime. Exceeding a memory limit kills the container (OOMKilled). CPU limits throttle.
- GPUs are extended resources advertised by a device plugin. You request them in
limits(a request is implied). You cannot request half a GPU unless GPU sharing (time-slicing or MIG) is configured. - GPU memory is not a Kubernetes resource. The scheduler does not know how much VRAM your model needs. Two pods will not share a GPU by default, but if you enable sharing, nothing stops them from overcommitting VRAM and crashing each other (GPUs on Kubernetes).
- Host memory matters too: loading a 40 GB model can need tens of GB of CPU RAM during startup. Size memory requests accordingly or pods are OOMKilled while loading.
#Probes: how Kubernetes knows your server is OK
| Probe | Question | On failure |
|---|---|---|
| Startup | Has the app finished starting? | Keep waiting (until the failure threshold), then restart; disables the other probes while it runs |
| Readiness | Should this pod receive traffic? | Remove it from the Service endpoints (no restart) |
| Liveness | Is the process stuck? | Kill and restart the container |
For LLM servers: use a startup probe with a long allowance (for example 30 attempts × 10 s = 5 minutes) so a slow model load is not mistaken for a crash; make readiness mean "can serve a real request" (not just "port open"); keep liveness lenient so a busy but healthy server is not killed during a long generation (reliability patterns).
startupProbe: { httpGet: { path: /health, port: 8000 }, periodSeconds: 10, failureThreshold: 60 }
readinessProbe: { httpGet: { path: /health, port: 8000 }, periodSeconds: 5, failureThreshold: 3 }
livenessProbe: { httpGet: { path: /health, port: 8000 }, periodSeconds: 20, failureThreshold: 6 }#Rolling updates and rollbacks
Change the pod template (a new image tag, new args) and the Deployment replaces pods gradually:
kubectl -n llm-prod apply -f llm-deployment.yaml
kubectl -n llm-prod rollout status deployment/llm # watch progress
kubectl -n llm-prod rollout undo deployment/llm # go back to the previous version
kubectl -n llm-prod rollout history deployment/llmRemember the GPU twist: the default strategy creates an extra pod before removing an old one (maxSurge: 25%). If no spare GPU exists, the new pod stays Pending and the rollout stalls. The fix, covered in the vLLM lesson, is to set maxSurge: 0 and maxUnavailable: 1 (or keep spare GPUs).
#The kubectl commands you will actually use
# see what exists
kubectl get nodes -o wide
kubectl get pods -n llm-prod -o wide # which node each pod is on
kubectl get deploy,svc,hpa,pdb -n llm-prod
# why is it not working?
kubectl describe pod <pod> -n llm-prod # events: scheduling failures, probe failures, OOMKilled
kubectl logs <pod> -n llm-prod --tail=200 -f
kubectl logs <pod> -n llm-prod --previous # logs of the crashed container
kubectl get events -n llm-prod --sort-by=.lastTimestamp
# poke around
kubectl exec -it <pod> -n llm-prod -- nvidia-smi
kubectl port-forward svc/llm 8000:8000 -n llm-prod # test the Service from your laptop
# change things
kubectl apply -f manifest.yaml
kubectl scale deployment/llm --replicas=3 -n llm-prod
kubectl rollout restart deployment/llm -n llm-prod
kubectl delete pod <pod> -n llm-prod # the Deployment recreates it
# GPU view
kubectl describe node <gpu-node> | grep -A8 "Allocatable"
kubectl get nodes -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpuDebugging flow for a pod that will not start: get pods (status) → describe pod (events at the bottom) → logs (and --previous) → exec if it runs. Common statuses: Pending (cannot be scheduled: no GPU, taints, quota), ContainerCreating (pulling image, mounting volumes), CrashLoopBackOff (crashes repeatedly: read the logs), OOMKilled (memory limit), ImagePullBackOff (bad tag or registry credentials).
#Scheduling in one paragraph
The scheduler places each Pending pod on a node that (1) has enough unreserved resources, (2) satisfies node selectors and affinity (for example "only nodes with an H100"), (3) tolerates the node's taints (GPU nodes are usually tainted so ordinary pods stay off them), and (4) fits spread rules (do not put all replicas on one node). You steer placement with labels, taints and tolerations (scheduling and multi-tenancy).
#Managed or self-hosted?
| Option | Notes |
|---|---|
| Managed Kubernetes (EKS, GKE, AKS, and equivalents) | The provider runs the control plane; you manage node pools and workloads. The default choice. GPU node pools, drivers and autoscaling are supported with provider-specific details |
| Specialist GPU clouds with Kubernetes | Often cheaper or better GPU supply; verify the networking, storage and autoscaling features you need |
| Self-managed (kubeadm, k3s, RKE2) | Maximum control, on-prem or colocation; you own upgrades, etcd, networking and security |
| No Kubernetes (VMs with systemd or Docker Compose, a serverless GPU platform) | Perfectly valid for one or two models; see Docker deployment |
Kubernetes earns its complexity when you have several models, several teams, autoscaling needs, multiple environments or a platform group to run it. For a single model on one or two GPU servers, it can be overkill.
#Common mistakes
- Using
latestimage tags, so pods differ between restarts. - No resource requests, so memory-hungry loads OOM their neighbours.
- Liveness probes that kill a model during loading.
- Default rolling update on a full GPU pool, stalling on Pending pods.
- Running as root with a writable root filesystem and broad permissions (stack security).
- Editing live objects with
kubectl editinstead of changing files in git. - Mixing prod and experiments in one namespace with no quotas.
- Assuming Kubernetes knows about GPU memory.
#Exercise
A Deployment asks for 3 replicas, each with nvidia.com/gpu: 2. The cluster has 4 GPU nodes with 4 GPUs each; 6 GPUs are already used by other pods.
- How many GPUs does the Deployment need, and how many are free?
- How many replicas can run, and what do the others show?
- During a rolling update with the default surge of 25 percent, what could go wrong?
Answers:
- Needs
3 × 2 = 6GPUs; the cluster has16 − 6 = 10free (if they are spread so that two free GPUs exist on at least one node per replica; a replica needs both GPUs on the same node). - All three can run if three nodes each have at least 2 free GPUs; otherwise fewer. Replicas that cannot be placed stay Pending with an event like
Insufficient nvidia.com/gpu. - The extra surge pod needs another 2 GPUs. If none are free, it stays Pending and the rollout waits. Set
maxSurge: 0withmaxUnavailable: 1(and size your capacity and PodDisruptionBudget for it) or keep a spare node.
#Going deeper
- Read the manifest above again and annotate every field in your own words.
- Continue with GPUs on Kubernetes, then try everything in the hands-on lab.
- Compare with the non-Kubernetes route in Docker and Compose deployment.
Quick quiz
+10 XP per correct answer, +25 XP for a perfect score
1. What is the role of a Deployment in Kubernetes?
2. A pod requests 1 GPU but every GPU in the cluster is in use. What happens?
3. What is the difference between a liveness probe and a readiness probe?
4. Which Kubernetes object gives clients one stable address that load-balances across the replica pods?
5. Why should you describe the cluster state declaratively in files (YAML) kept in git?