One GPU, Two LLMs
Building a production-grade LLM inference platform on EKS with GPU time-slicing, KServe, Knative, and a custom gateway. Finally, debugging everything that broke.

What I was aiming simple in theory: deploy two open-weight LLMs behind a custom gateway, on Kubernetes, infrastructure as code. The contraint that made it interesting was a hard cost cap. One spot GPU node, a single Tesla T4 with ~15 GB of VRAM, serving both a fast 0.5B parameter model and a capable 7B model simultaneously.
This post covers the full architecture, the non-obvious technical decisions, and more usefully, the dozen things that didn't work and why.
What was built?
The stack runs on AWS EKS in eu-central-1. Karpenter provisions GPU nodes on demand using spot instances (primarily g4dn.xlarge, falling back to g5 and g6 families). KServe handles model serving through Kubernetes custom resources, with Knative underneath for the serverless model and raw deployment for the always-warm one.
Both models are AWQ 4-bit quantized variants from Hugging Face, served via KServe's huggingfaceserver runtime, which wraps vLLM and exposes an OpenAI-compatible API. The llm-gateway in front handles routing, rate limiting, semantic caching via Redis, and PII + jailbreak guardrails before any request reaches a model.
GPU Budget Issue
A Tesla T4 has 15 GB of VRAM. A 7B parameter model loaded in FP16 takes roughly 14 GB before any inference headroom. This left nothing for the 0.5B model or for the KV cache that actually makes inference usable at reasonable throughput.
Three decisions compounded to make this work:
1. AWQ 4-bit quantization
Switching from FP16 to AWQ (Activation-aware Weight Quantization) compressed the 7B model from ~14 GB to roughly 4.5 GB at load time. The quantized variants on Hugging Face (Qwen/Qwen2.5-7B-Instruct-AWQ) are pre-quantized, so vLLM loads them directly without additional runtime cost.
2. GPU time-slicing via the NVIDIA device plugin
By default, Kubernetes treats a GPU as an indivisible resource like a pod either owns it entirely or not at all. The NVIDIA GPU Operator's device plugin supports time-slicing: one physical GPU advertises as N logical nvidia.com/gpu slots. Each pod sees the full GPU, takes turns with nanosecond-precision preemption at the hardware level, and the kernel handles context switching.
sharing = {
timeSlicing = {
resources = [{
name = "nvidia.com/gpu"
replicas = 2
}]
}
}
With 2 replicas, one physical T4 appears to the scheduler as two nvidia.com/gpu slots. Both models can schedule concurrently on the same node.
Important caveat: time-slicing does not isolate VRAM. Both processes share the same physical memory space. If both models simultaneously exceed total VRAM, you get an OOM crash. The per-process --gpu-memory-utilization flag in vLLM is your budget enforcement: 0.8 for the 7B, 0.2 for the 0.5B.
3. The CPU swap space tax
vLLM allocates a swap_space buffer in CPU RAM by default: 4 GB per process, regardless of model size. With two processes, that's 8 GB of memory requests before any actual inference load. After right-sizing to --swap-space=1 and adjusting the pod memory requests accordingly, the Kubernetes resource budget dropped from 24 Gi to a manageable 11 Gi.
Important caveat: Very long conversations or high batch concurrency will hit VRAM limits sooner because of the scarce swap space.
Bugs Worth Mentioning
8.7 GB Docker image from a sentence-transformers transitive dependency
The llm-gateway's dependency graph pulled sentence-transformers, which dragged in the full CUDA build of PyTorch including nvidia-cudnn-cu12, triton, 15 NVIDIA packages. Fixed by pointing uv at PyTorch's CPU-only wheel index via a source override in pyproject.toml.
ResourceQuota rejected Knative's auto-injected sidecar
The models namespace had a ResourceQuota sized for the model containers only. Knative Serving injects a queue-proxy sidecar into every pod automatically and that container has no resource requests by default, which a quota that requires all containers to declare resources will reject with a cryptic admission error. Fixed with a LimitRange setting defaults for unlabeled containers.
Karpenter's NodePool limit variable was silently dead
A gpu_max_count variable existed in variables.tf as a cost guardrail. It wasn't wired into nodepool.tf. The hardcoded limit was 2. So during a rollout, Karpenter spun up a second GPU spot node instead of enforcing the one-node budget. Caught when an unexpected second node appeared mid-deploy.
Orphaned Karpenter GPU instance on teardown
Karpenter provisions EC2 instances itself so they aren't in Terraform state. When terraform destroy tore down the EKS cluster, nothing remained to issue the instance termination call. The GPU spot instance kept running, fully billed, with no owner. Caught only by inspecting EC2 instances tagged with the cluster name after the cluster was gone. Added a teardown checklist item: always check for orphaned Karpenter nodes explicitly.
Two deployment modes, deliberately
The 0.5B model runs in Knative Serverless mode and scales to zero after idle. The 7B runs as a static RawDeployment replica. The decision was both a try-out and a cost-vs-latency tradeoff. But the whole point of having two modes, and having both in the same cluster makes the comparison concrete.
Cold start on the 0.5B path after a scale-to-zero event is measured in minutes, not seconds. Therefore, Karpenter needs to provision a node, pull the vLLM image, and load the model weights. That's a real cost to surfacing in monitoring and design decisions around warming strategies.
What didn't get built
I decided to put some stuff to the backlog to maintain the scope. I might apply the following changes in the future.
Autoscaling: For my validation goal a single replica was sufficient.
VRAM Isolation: Since I used time-slicing, I allowed it to share the VRAM without hard per-process limits that could be problematic at production scale.
Versioning - A/B: Except the image tags, there is no canary deployment, model versioning or ArgoCD.
MIG: The platform also relies on the NVIDIA device plugin's time-slicing rather than MIG (Multi-Instance GPU), which provides real hardware isolation but requires Ampere-generation hardware (A100, A30, H100). T4 doesn't support MIG. For real production workloads with latency SLAs, a bigger GPU with MIG or a dedicated per-model node is the correct call.



