Deploying Large Language Models (LLMs) into production is a double-edged sword. On one hand, generative AI capabilities unlock immense business value; on the other, running models like Llama 3 70B, Mistral, or Qwen at scale can devastate your cloud budget if unoptimized.
In classical web workloads, scaling is straightforward: CPU and Memory utilization correlate directly with incoming HTTP traffic. LLM inference, however, breaks traditional horizontal pod autoscaling (HPA). GPU memory is locked upfront by model weights and KV caches, rendering standard CPU/RAM metrics completely useless. Furthermore, static GPU node allocation often results in an idle cluster during off-peak hours—costing tens of thousands of dollars per month for unused compute.
In this deep dive, we will architect a production-ready, ultra-efficient LLM inference platform on AWS EKS. By combining vLLM for high-throughput serving, KEDA for dynamic event-driven pod scaling via custom metrics, NVIDIA GPU Partitioning for hardware density, and Karpenter for fast node auto-provisioning, we achieved a 60% reduction in compute spend while maintaining sub-second Time To First Token (TTFT).
Architectural Overview
To solve both performance and cost inefficiencies, we must decouple request ingestion, pod autoscaling, and node provisioning into a tight, event-driven feedback loop.
[ User Requests ]
│
▼
[ AWS Application LB ]
│
▼
┌─────────────────────────┐
│ AWS EKS Cluster │
│ │
┌──────────────┐ │ ┌─────────────────┐ │
│ Prometheus │◄───┼───┤ vLLM Pods │ │
└──────┬───────┘ │ │ (PagedAttention)│ │
│ │ └────────┬────────┘ │
│ Metrics │ │ │
▼ │ │ Metrics │
┌──────────────┐ │ ▼ │
│ KEDA Operator│───►│ ┌─────────────────┐ │
└──────┬───────┘ │ │ Pod ScaledObject│ │
│ │ └─────────────────┘ │
│ Pod Specs └────────────┬────────────┘
▼ │
┌──────────────┐ │ Unschedulable Pods
│ Karpenter │◄────────────────┘
└──────┬───────┘
│ Provisioning
▼
┌─────────────────────────────────────────────┐
│ AWS EC2 Spot / On-Demand Instances (G5/P4) │
└─────────────────────────────────────────────┘
The Optimization Stack
- Inference Engine:
vLLMutilizes PagedAttention to eliminate memory fragmentation in the Key-Value (KV) cache, enabling high-throughput continuous batching. - GPU Virtualization: NVIDIA MIG (Multi-Instance GPU) or Time-Slicing to run multiple inference processes per physical GPU when handling smaller models or lower QPS.
- Pod Autoscaling:
KEDAscrapes native vLLM Prometheus metrics (such as request queue length and KV cache saturation) to scale pods responsively before latency spikes. - Node Autoscaling:
Karpenterreacts in seconds to pending pod requirements, launching optimal EC2 GPU Spot and On-Demand instances without relying on rigid AWS Auto Scaling Groups (ASGs).
Phase 1: High-Efficiency Serving with vLLM
Standard PyTorch or naive Hugging Face deployments allocate KV cache contiguously. This leads to virtual memory fragmentation of up to 60-80%, drastically bottlenecking concurrency.
vLLM solves this by managing KV cache keys in non-contiguous memory blocks—similar to virtual memory in operating systems.
Here is a production-hardened Deployment manifest running Llama-3-8B-Instruct on AWS EKS using an NVIDIA A10G GPU (g5.2xlarge instance):
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-llama3-8b
namespace: llm-inference
labels:
app: vllm-llama3-8b
spec:
replicas: 1
selector:
matchLabels:
app: vllm-llama3-8b
template:
metadata:
labels:
app: vllm-llama3-8b
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: vllm-container
image: vllm/vllm-openai:v0.6.2
args:
- "--model"
- "meta-llama/Meta-Llama-3-8B-Instruct"
- "--port"
- "8000"
- "--max-model-len"
- "8192"
- "--gpu-memory-utilization"
- "0.90"
- "--tensor-parallel-size"
- "1"
- "--enable-chunked-prefill"
env:
- name: HUGGING_FACE_HUB_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
ports:
- containerPort: 8000
name: http
resources:
limits:
cpu: "6"
memory: "24Gi"
nvidia.com/gpu: "1"
requests:
cpu: "4"
memory: "16Gi"
nvidia.com/gpu: "1"
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 120
periodSeconds: 15
Key Parameters Explained
--gpu-memory-utilization 0.90: Reserves 90% of available GPU VRAM for model weights and KV cache, leaving 10% safety margin for dynamic execution graphs.--enable-chunked-prefill: Splits large prompt prefills into smaller chunks and batches them with decode requests, smoothing out spikey TTFT latencies.
Phase 2: Maximizing Hardware Density with GPU Partitioning
Not all models require an entire dedicated physical GPU. For smaller tasks (e.g., embedding generation, classification, or small parameters fine-tunes), dedicating an entire NVIDIA A10G (24GB) or A100 (80GB) per pod results in low compute utilization and high cost.
We can split physical GPUs into smaller logical hardware interfaces using NVIDIA Time-Slicing (for shared compute workloads) or MIG (Multi-Instance GPU) (for strict hardware partition isolation on A100/H100 instances).
Configuring Time-Slicing in EKS
To implement Time-Slicing across g5 instance families, apply the following ConfigMap to the gpu-operator namespace:
apiVersion: v1
kind: ConfigMap
metadata:
name: device-plugin-config
namespace: gpu-operator
data:
g5-timeslice: |
version: v1
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 2
This configuration tells the Kubernetes device plugin that every single physical A10G GPU can be advertised as 2 virtual GPU resources. This effectively doubles the pod density for lightweight inference workloads on the same underlying node hardware.
Phase 3: Event-Driven Scaling with KEDA & Custom Metrics
Standard Horizontal Pod Autoscalers (HPA) rely on CPU and RAM usage. In LLM workloads, CPU remains flat while GPU VRAM allocation is statically set at container initialization (--gpu-memory-utilization 0.90). Therefore, CPU/Memory triggers fail completely.
Instead, we must scale dynamically based on real-time inference telemetry exposed by vLLM's /metrics endpoint:
vllm:num_requests_waiting: The number of requests sitting in the queue waiting for KV cache allocation. (Primary Scaling Indicator)vllm:gpu_cache_usage_perc: KV Cache memory saturation percentage.
If vllm:num_requests_waiting > 0, your current pod fleet is at capacity, and request latency is degrading exponentially.
Setting up KEDA ScaledObject
First, configure Prometheus to scrape the vLLM pods. Then, deploy a KEDA ScaledObject that scales our EKS deployment based on queuing activity:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-keda-autoscaler
namespace: llm-inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama3-8b
minReplicaCount: 1
maxReplicaCount: 10
cooldownPeriod: 300
pollingInterval: 15
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus-k8s.monitoring.svc.cluster.local:9090
metricName: vllm_num_requests_waiting
query: |
sum(vllm:num_requests_waiting{namespace="llm-inference"})
threshold: '3'
Why This Strategy Works
- Zero Aggressiveness on Scale Down: The 300-second
cooldownPeriodand controlledscaleDownpolicy prevent thrashing (scaling down while a secondary batch arrives). - Instant Scale Up: Sets
stabilizationWindowSeconds: 0. As soon as the queue aggregate crosses3waiting requests, KEDA instantly triggers extra pod creation.
Phase 4: Fast Node Provisioning with Karpenter
Autoscaling pods is only useful if the underlying Kubernetes cluster can expand hardware capacity in seconds. Traditional AWS Auto Scaling Groups (ASGs) take 3–5 minutes to launch GPU nodes.
Karpenter bypasses ASGs entirely, directly evaluating pod unschedulable constraints and calling AWS EC2 APIs to provision nodes in under 45 seconds.
Here is our production Karpenter configuration leveraging mixed g5 instance families with an aggressive Spot strategy:
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: gpu-node-pool
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: instance-family
operator: In
values: ["g5"]
nodeClassRef:
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
name: gpu-node-class
limits:
nvidia.com/gpu: "32"
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 2m
---
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
name: gpu-node-class
spec:
amiFamily: AL2
amiSelectorTerms:
- alias: al2@latest
role: "KarpenterNodeRole-EKSCluster"
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "eks-llm-cluster"
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "eks-llm-cluster"
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 150Gi
volumeType: gp3
iops: 3000
Cost Optimization Mechanics
- Spot Fallback Strategy: Karpenter prioritizes GPU Spot Instances (which offer up to 70% discounts over On-Demand). If Spot capacity for
g5.2xlargedries up, it smoothly falls back to On-Demand instances. - Fast Consolidation: Unused GPU nodes are terminated after
2mof emptiness, ensuring you stop paying for idle GPU hardware instantly.
Benchmark Results: Before vs. After
By combining vLLM, GPU Time-Slicing, KEDA metric scaling, and Karpenter Spot provisioning, we conducted load tests mimicking enterprise production traffic over a 30-day billing cycle.
Performance Metrics Comparison
| Metric | Legacy Setup (TGI + Static ASG) | Optimized Setup (vLLM + KEDA + Karpenter) | Improvement | | :--- | :--- | :--- | :--- | | P99 TTFT (Time To First Token) | 2.45 seconds | 0.38 seconds | 84% reduction | | Average GPU Utilization | 18% | 74% | 4.1x density | | Scale-up Reaction Time | 6.5 minutes | 45 seconds | 88% faster | | Monthly Compute Cost | $14,200 | $5,680 | 60% Savings |
Latency Profile Under Dynamic Load
When hit with a sudden spike of 200 concurrent requests:
- Legacy Stack: Requests sat in TCP buffer queues; HTTP client timeouts spiked to 12%. Nodes took over 6 minutes to spin up via cluster-autoscaler.
- Optimized Stack: vLLM’s continuous batching handled initial prefill spikes. Within 15 seconds, KEDA evaluated
vllm:num_requests_waiting > 3and spawned 4 additional pods. Karpenter provisioned two dual-GPUg5.12xlargespot instances simultaneously, absorbing the load spike effortlessly without a single dropped HTTP connection.
Key Takeaways for Production Deployments
- Abandon Native HPA for LLMs: Standard CPU/RAM metrics are a trap. You must expose engine queue dynamics (
vllm:num_requests_waiting) to scale proactively. - Optimize Memory Engine First: Running inefficient serving frameworks on top of dynamic autoscalers just scales waste. Implement PagedAttention (vLLM, TensorRT-LLM) before tuning infrastructure.
- Hardware Densification: Utilize GPU Time-Slicing or MIG for smaller models or internal enterprise micro-services to squeeze full throughput out of underlying silicon.
- Use Dynamic Provisioners: Traditional ASGs are too slow for high-variance LLM workloads. Dynamic provisioners like Karpenter cut cold-start delays from minutes to seconds, making GPU Spot strategies viable in production.
By treating LLM infrastructure as a deeply coupled stack—from CUDA memory layout all the way up to cloud node autoprovisioning—you can deliver low-latency AI experiences without ballooning operational infrastructure spend.