Deploying LLMs at Scale with Kubernetes¶
A complete guide to deploying and scaling large language models (LLMs) in production using Kubernetes, vLLM and advanced optimization strategies.
🎯 Learning Objectives¶
By the end of this guide you will be able to:
- Deploy LLMs with vLLM on Kubernetes
- Set up auto-scaling driven by GPU metrics
- Apply caching and optimization strategies
- Run several models side by side in production
- Track inference performance and cost
📋 Prerequisites¶
- Working knowledge of Kubernetes
- Hands-on experience with Docker and Helm
- Familiarity with LLMs and vLLM
- A Kubernetes cluster with GPUs (optional, but recommended)
🏗️ Deployment Architecture¶
Core Components¶
graph TB
A[Ingress/Load Balancer] --> B[API Gateway]
B --> C[vLLM Service 1]
B --> D[vLLM Service 2]
B --> E[vLLM Service N]
C --> F[GPU Node Pool]
D --> F
E --> F
G[Prometheus] --> H[Metrics Server]
H --> I[HPA Controller]
I --> C
I --> D
I --> E
J[Model Registry] --> K[Init Container]
K --> C
Deployment Strategies¶
- Single Model per Pod: full isolation
- Multi-Model per Pod: better resource utilization
- Model Sharding: splitting large models across nodes
- Dynamic Loading: load models on demand
🚀 Basic Deployment with vLLM¶
1. Preparing the Cluster¶
# Check which GPUs are available
kubectl get nodes -o json | jq '.items[].status.capacity."nvidia.com/gpu"'
# Install the NVIDIA GPU Operator (if it isn't already installed)
helm repo add nvidia https://nvidia.github.io/gpu-operator
helm repo update
helm install gpu-operator nvidia/gpu-operator \
--create-namespace \
--namespace gpu-operator
2. Creating the Namespace and ConfigMaps¶
# vllm-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: vllm-system
labels:
name: vllm-system
# vllm-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: vllm-config
namespace: vllm-system
data:
MODEL_NAME: "microsoft/DialoGPT-medium"
MODEL_REVISION: "main"
DTYPE: "float16"
MAX_MODEL_LEN: "2048"
GPU_MEMORY_UTILIZATION: "0.9"
MAX_NUM_SEQS: "256"
TENSOR_PARALLEL_SIZE: "1"
3. Deploying with Helm¶
# vllm-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-deployment
namespace: vllm-system
spec:
replicas: 1
selector:
matchLabels:
app: vllm
template:
metadata:
labels:
app: vllm
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: vllm-config
resources:
limits:
nvidia.com/gpu: 1
requests:
nvidia.com/gpu: 1
# startupProbe: covers model loading. Until it succeeds,
# liveness and readiness are not even evaluated.
# 60 failures x 10s = 10 minutes of headroom to load the weights.
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 60
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
Without startupProbe the pod ends up in CrashLoopBackOff
A livenessProbe with initialDelaySeconds: 30 declares the container dead after 30 seconds. Loading the weights of a 13B model from disk into the GPU takes several minutes, so Kubernetes kills it right before it finishes starting, restarts it, and kills it again: an infinite loop that also burns GPU time on every attempt.
The startupProbe exists precisely for this: while it has not succeeded, the other two probes stay suspended. Tune failureThreshold to your model's real load time with room to spare — overshooting is safer than falling short.
4. Service and Ingress¶
# vllm-service.yaml
apiVersion: v1
kind: Service
metadata:
name: vllm-service
namespace: vllm-system
spec:
selector:
app: vllm
ports:
- port: 80
targetPort: 8000
type: ClusterIP
# vllm-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vllm-ingress
namespace: vllm-system
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: vllm.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vllm-service
port:
number: 80
📊 Auto-Scaling with HPA¶
Metrics Configuration¶
# metrics-server.yaml (if it isn't already installed)
apiVersion: v1
kind: ServiceAccount
metadata:
name: metrics-server
namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: metrics-server
namespace: kube-system
spec:
template:
spec:
containers:
- name: metrics-server
image: k8s.gcr.io/metrics-server/metrics-server:v0.6.3
args:
- --kubelet-insecure-tls
- --kubelet-preferred-address-types=InternalIP
HPA for vLLM¶
# vllm-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-hpa
namespace: vllm-system
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-deployment
minReplicas: 1
maxReplicas: 10
metrics:
# Primary metric: requests waiting in the scheduler queue.
# This is what actually reflects the service falling behind.
- type: Pods
pods:
metric:
name: vllm:num_requests_waiting
target:
type: AverageValue
averageValue: "5"
# Secondary: KV cache occupancy. Close to 100% means no more
# concurrent requests fit, however much GPU headroom is left.
- type: Pods
pods:
metric:
name: vllm:kv_cache_usage_perc
target:
type: AverageValue
averageValue: "900m" # 0.9 = 90%
behavior:
scaleUp:
# A new pod takes minutes to load the model: scaling aggressively
# buys nothing and just doubles GPU consumption.
stabilizationWindowSeconds: 120
scaleDown:
stabilizationWindowSeconds: 600
Do not scale an inference service by CPU
This is the most common mistake when reusing a web application's HPA. During token generation the process is waiting on the GPU, not computing: CPU stays low even while the service is saturated and the queue is growing. An HPA on CPU at 70% simply never fires, or fires when it no longer matters.
The two metrics that do correlate with real saturation are queue length (vllm:num_requests_waiting) and KV cache occupancy, which is what actually caps concurrency. Both need prometheus-adapter to be exposed to the HPA. Details on both in LLM Monitoring.
🔧 Advanced Optimizations¶
1. Model Caching and Warm-up¶
# vllm-deployment-optimized.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-deployment-optimized
namespace: vllm-system
spec:
template:
spec:
initContainers:
- name: model-cache
image: vllm/vllm-openai:latest
command: ["/bin/sh", "-c"]
args:
- |
python -c "
from vllm import LLM
llm = LLM(model='microsoft/DialoGPT-medium', download_dir='/tmp/models')
print('Model cached successfully')
"
volumeMounts:
- name: model-cache
mountPath: /tmp/models
containers:
- name: vllm
image: vllm/vllm-openai:latest
env:
- name: VLLM_CACHE_DIR
value: /tmp/models
volumeMounts:
- name: model-cache
mountPath: /tmp/models
volumes:
- name: model-cache
emptyDir: {}
2. Multi-Model Deployment¶
# multi-model-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: multi-model-config
namespace: vllm-system
data:
models.json: |
[
{
"name": "gpt2-medium",
"model": "microsoft/DialoGPT-medium",
"max_model_len": 1024
},
{
"name": "gpt2-large",
"model": "microsoft/DialoGPT-large",
"max_model_len": 1024
}
]
3. GPU Memory Optimization¶
# vllm-gpu-optimized.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-gpu-optimized
namespace: vllm-system
spec:
template:
spec:
containers:
- name: vllm
env:
- name: VLLM_GPU_MEMORY_UTILIZATION
value: "0.95"
- name: VLLM_MAX_NUM_SEQS
value: "128"
- name: VLLM_MAX_NUM_BATCHED_TOKENS
value: "4096"
- name: VLLM_ENABLE_CHUNKED_PREFILL
value: "true"
resources:
limits:
nvidia.com/gpu: 1
memory: 32Gi
requests:
nvidia.com/gpu: 1
memory: 16Gi
📈 Monitoring and Observability¶
vLLM Metrics¶
# prometheus-service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: vllm-monitor
namespace: vllm-system
spec:
selector:
matchLabels:
app: vllm
endpoints:
- port: metrics
path: /metrics
interval: 30s
Grafana Dashboard¶
{
"dashboard": {
"title": "vLLM Performance Dashboard",
"panels": [
{
"title": "GPU Utilization",
"type": "graph",
"targets": [
{
"expr": "nvidia_gpu_utilization{namespace=\"vllm-system\"}",
"legendFormat": "{{ pod }}"
}
]
},
{
"title": "Request Latency",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(vllm_request_duration_seconds_bucket[5m]))",
"legendFormat": "95th percentile"
}
]
}
]
}
}
🔄 Update Strategies¶
Rolling Updates¶
# vllm-deployment-rolling.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-deployment
namespace: vllm-system
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
# ... rest of the configuration
Blue-Green Deployment¶
# blue-green-deployment.sh
#!/bin/bash
# Create the new version (green)
kubectl apply -f vllm-deployment-green.yaml
# Wait until it is ready
kubectl wait --for=condition=available --timeout=300s deployment/vllm-deployment-green -n vllm-system
# Point the service at green
kubectl patch service vllm-service -n vllm-system -p '{"spec":{"selector":{"version":"green"}}}'
# Confirm everything works
# ... tests ...
# Remove blue
kubectl delete deployment vllm-deployment-blue -n vllm-system
🛡️ Security and Compliance¶
Network Policies¶
# vllm-network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: vllm-network-policy
namespace: vllm-system
spec:
podSelector:
matchLabels:
app: vllm
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- protocol: TCP
port: 8000
egress:
- to:
- podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
Secret Management¶
base64 is not encryption
A Kubernetes Secret stores the value in base64, which is encoding, not encryption: anyone who can read the YAML decodes it with a single command. A manifest like that is never committed to Git.
For GitOps use SOPS, Sealed Secrets or External Secrets Operator. A comparison of all three is in Secrets in GitOps.
Object reference (to create it from the command line, not to commit it):
# Create the Secret without the token ever touching a file in the repo
kubectl create secret generic vllm-secrets \
--namespace vllm-system \
--from-literal=huggingface-token="$HF_TOKEN" \
--from-literal=api-key="$VLLM_API_KEY"
# How the Deployment consumes it: by reference, without exposing the value
env:
- name: HUGGING_FACE_HUB_TOKEN
valueFrom:
secretKeyRef:
name: vllm-secrets
key: huggingface-token
📊 Cost Optimization¶
Spot and Preemptible Instances¶
# spot-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-spot
namespace: vllm-system
spec:
template:
spec:
tolerations:
- key: "cloud.google.com/gke-spot"
operator: "Equal"
value: "true"
effect: "NoSchedule"
nodeSelector:
cloud.google.com/gke-spot: "true"
# ... rest of the configuration
Cost-Driven Auto-scaling¶
# cost-based-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-cost-hpa
namespace: vllm-system
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-deployment
minReplicas: 1
maxReplicas: 5
metrics:
- type: External
external:
metric:
name: cloud_provider_cost_per_hour
target:
type: AverageValue
averageValue: 2.0
🔍 Troubleshooting¶
Common Problems¶
-
Out of Memory (OOM):
# Check the logs kubectl logs -f deployment/vllm-deployment -n vllm-system # Tune the configuration kubectl edit configmap vllm-config -n vllm-system -
GPU Not Available:
# Check GPU allocation kubectl describe node <node-name> # Check GPU operator kubectl get pods -n gpu-operator -
Slow Inference:
# Check the metrics kubectl exec -it deployment/vllm-deployment -n vllm-system -- curl http://localhost:8000/metrics # Tune the batch size kubectl edit configmap vllm-config -n vllm-system
🎯 Best Practices¶
Performance¶
- Use A100/H100 GPUs for the best throughput
- Set
tensor_parallel_sizewhen running on multiple GPUs - Cache models ahead of time
- Keep an eye on metrics continuously
Reliability¶
- Define meaningful health checks
- Use rolling updates for zero-downtime releases
- Set resource limits and requests
- Add circuit breakers
Security¶
- Store API tokens in secrets
- Enforce network policies
- Audit access logs
- Keep models up to date
Cost Management¶
- Use spot instances wherever it makes sense
- Implement smart auto-scaling
- Track costs in real time
- Squeeze the most out of every GPU
📚 Further Reading¶
🤝 Contributing¶
This guide is part of the Frikiteam Docs project. If you spot an error or want to contribute improvements:
- Fork the repository
- Create a branch for your feature
- Open a Pull Request
Thanks for helping grow shared knowledge!