AI inference¶
Serve open-weight models from infrastructure in the Netherlands, on the same clusters as
the rest of your workloads. A served model is an ordinary workload in your project — a
Deployment running an inference engine, with a Service in front of it, scheduled by the
same control plane and secured by the same RBAC as any other Kubernetes object.
A served model differs from your other workloads in one respect: it holds a large amount of state in GPU memory, and that state determines almost everything about how it behaves. See Self-hosted models for the detail.
How it fits together¶
flowchart LR
CLIENT["Your application"] --> GW["LLM gateway<br/>keys · budgets · routing"]
GW --> ISVC["Model server<br/>vLLM on a GPU"]
GW --> EXT["External providers"]
ISVC --> GPU["NVIDIA GPU nodes"]
ISVC --> PVC["Model weights<br/>on platform storage"]
Most teams put the LLM gateway in front of inference rather than calling
endpoints directly — it centralises keys, budgets and failover across self-hosted and
external models alike. A model server has no authentication of its own, so anything that
can reach its Service can use the GPU.
Inference engines¶
Two engines are in use on the platform, both speaking the OpenAI API:
| Engine | Use it for |
|---|---|
| vLLM | the default choice; widest model and quantisation coverage |
| SGLang | workloads that benefit from its scheduler, notably structured output |
Neither engine is installed for you. You run it as your own workload, so the engine version and its arguments are yours to pin in Git.
The Deployment manifest¶
Everything model-specific lives in args. Substitute your own values for the
<angle-brackets>:
apiVersion: apps/v1
kind: Deployment
metadata:
name: <model-slug>
namespace: <your-project>
spec:
replicas: 1
strategy:
type: Recreate # (1)!
selector:
matchLabels:
app: <model-slug>
template:
metadata:
labels:
app: <model-slug>
spec:
runtimeClassName: nvidia # (2)!
containers:
- name: vllm
image: vllm/vllm-openai:<version> # (3)!
args:
- /models/<org>/<model> # (4)!
- --served-model-name
- <org>/<model> # (5)!
- --host
- 0.0.0.0
- --port
- "8000"
- --dtype
- half
- --tensor-parallel-size
- "1"
- --gpu-memory-utilization
- "0.85" # (6)!
- --max-model-len
- "4096"
ports:
- name: http
containerPort: 8000
resources:
requests:
cpu: "4"
memory: 24Gi
nvidia.com/gpu: 1 # (7)!
limits:
cpu: "8"
memory: 48Gi
nvidia.com/gpu: 1
volumeMounts:
- name: models
mountPath: /models
volumes:
- name: models
persistentVolumeClaim:
claimName: <model-pvc> # (8)!
-
Leave as is. A rolling update would want two pods holding the same GPU and the same volume at once, and deadlocks.
Recreatestops the old pod first, which does mean a gap in service on every change. -
Leave as is. What actually makes the card visible inside the container. Without it the GPU is requested and scheduled, and the engine still cannot see it.
-
Pin this. The engine version. Which one suits you depends on the model and the card together — see Choosing an engine version. Never
latest. -
Edit this. Where the weights are on the mounted volume. Point it at a Hugging Face repository id instead and the engine downloads on first start, which is slower and repeats on every restart without a cache.
-
Edit this. The name callers ask for, deliberately decoupled from the path above. Choose it once and keep it — it becomes an interface the moment anything depends on it.
-
Tune this. How much of the card the engine may use. Whatever is left after the weights becomes KV cache, which is what limits how many requests you can serve at once.
-
Edit this, in both places. GPUs are not oversubscribed — a card is held whole by one pod — so
requestsandlimitsmust carry the same number. More than one also needs--tensor-parallel-sizeset to match. -
Edit this. The claim holding the weights. It is not deleted with the Deployment, so the model reloads rather than re-downloads when you come back.
Choosing an engine version¶
There is no single right version, and the platform's own workloads deliberately run several at once. Which one suits you is a property of the model and the hardware together: a newer release may add the architecture, kernel or quantisation format your model needs, while an older one may be the last that performs well on an older card.
Pin it explicitly. latest turns an unrelated pod restart into a version change under a
model that was working.
Sizing for the GPU¶
GPU workers are NVIDIA Tesla V100-SXM2 with 16GB of memory per card. That ceiling drives most sizing decisions, because weights, KV cache and overhead all share it.
| Setting | What it does |
|---|---|
--dtype half |
16-bit weights, roughly two bytes per parameter |
--gpu-memory-utilization |
fraction of the card the engine may use; what is left after weights becomes KV cache |
--max-model-len |
maximum context; the cheapest thing to reduce when a model will not fit |
--max-num-seqs |
concurrent sequences; lower it when the KV cache is the constraint |
--enforce-eager |
skips CUDA graph capture, trading some throughput for the memory it would hold |
--max-num-seqs and --enforce-eager are the two most effective levers on a 16GB card.
Set both before switching to a smaller model.
More than one GPU. Tensor parallelism splits one model across several cards in the same
node. --tensor-parallel-size must equal the nvidia.com/gpu count, the value must divide
the model's attention heads exactly — powers of two are the safe choices — and /dev/shm
needs a real RAM-backed volume, because the 64MB default makes NCCL hang at startup rather
than fail. All the cards must be on one node; the pod stays Pending otherwise.
Storing model weights¶
Stage weights on a volume rather than downloading them on every restart — a pod restart otherwise becomes a long outage.
| Storage class | Backing | Access modes | Use for |
|---|---|---|---|
berry-storage-file |
CephFS | RWO, RWX | model weights shared by several replicas |
berry-storage-block |
Ceph RBD | RWO | a single-replica cache |
ReadWriteMany on berry-storage-file lets several replicas share one downloaded copy.
With a ReadWriteOnce class each replica needs its own volume and its own download.
Size the claim for the weights plus headroom — roughly two bytes per parameter at 16-bit, so about 15Gi for a 7B model and 30Gi for a 14B. Downloads are not atomic: a volume that fills part way through leaves a partial cache the engine will not recognise.
Connecting. The engine serves the OpenAI API on port 8000:
| What | Where |
|---|---|
| In-cluster endpoint | http://<model-slug>.<your-project>.svc.cluster.local:8000/v1 |
| Model list | GET /v1/models |
| Chat completions | POST /v1/chat/completions |
| Health | GET /health |
Keep the model reachable only inside the cluster and let the gateway be the front door — there is no authentication on the engine itself.
Deleting. Removing the Deployment releases the GPU; removing the Service takes the
endpoint away. The PVC holding the weights does not go with either, and continues to cost
storage until you remove it too. Take the model out of the gateway before removing the
workload, or every request to that model name fails.
Readiness does not confirm which model loaded
Readiness means the engine loaded weights and opened a port. It does not mean it loaded
the weights you intended, at the precision you intended. Query /v1/models to confirm
which model is being served before you trust a benchmark or a bill.
How to¶
| Task | Guide |
|---|---|
| Serve an open-weight model on a GPU | Self-hosted models |
| Put it behind a model name | Model providers |
| Give a caller scoped access | Virtual keys |
Related¶
- Self-hosted models — the step-by-step path
- LLM gateway — what sits in front of these endpoints
- Storage — where weights live
- Compute & virtualisation — the GPU nodes themselves