Model serving¶
This is the application layer Pulumi project that deploys the distributed LLM model serving infrastructure using the llm-d framework on Kubernetes.
Overview¶
The app layer deploys a complete LLM serving stack including: - llm-d Infrastructure components from the llm-d-infra Helm chart - GAIE (Gateway AI Engine) for inference gateway - ModelService for serving specific models - HTTPRoutes for connecting generative model components - Embedding Services for direct-backend access to embedding models
Architecture¶
The deployment creates the following components:
- LLM-D Infrastructure (
pkg/iac/serving/internal/components/llmd-infra/deploy.go): Core llm-d components - Deployed via Helm chart from
../upstream/llm-d/llm-d-infra/charts/llm-d-infra - Creates Gateway with Istio ingress configuration
-
Sets up TLS and traffic policies
-
GAIE (
pkg/iac/serving/internal/components/gaie/deploy.go): AI inference engine gateway - InferencePool for model serving
-
Endpoint configurations
-
Model Service (
pkg/iac/serving/internal/components/modelservice/deploy.go): Model-specific serving - Supports any model via convention-based discovery from
deployments/models/directory -
Configurable via the
models.generative/models.embedderlists -
HTTPRoute (
pkg/iac/serving/internal/components/httproute/deploy.go): Traffic routing for generative models - Connects Gateway listeners to GAIE InferencePool
-
Created only for
generativecategory models -
Embedding Service (
pkg/iac/serving/internal/components/embeddingservice/deploy.go): Direct-backend Service for embedder models - Created only for
embeddercategory models; skipped for generative models - ClusterIP Service named
ms-<slug>-embeddingsin the model namespace - Selects decode pods via
llm-d.ai/modelandllm-d.ai/role: decodelabels - Exposes port 8200 — the inference server port used by vLLM in pooling (embedding) mode
- Reachable as
http://ms-<slug>-embeddings.<namespace>.svc.cluster.local:8200/v1/embeddings
Traffic flow¶
Generative models: shaide server → Istio Gateway → GAIE InferencePool → ModelService decode pod (port 8000 via routing proxy)
Embedding models:
shaide server → ms-<slug>-embeddings.<namespace>.svc.cluster.local:8200 → ModelService decode pod (port 8200 direct)
The Gateway/HTTPRoute path is not used for embedding models — the per-model gateway service returns route-level errors for embedding requests. Downstream apps (e.g. app-shaide) must use the direct ClusterIP Service DNS name for embeddings.
End-to-end call path for embeddings¶
app-shaide
│
│ POST /v1/embeddings
▼
ms-<slug>-embeddings.<namespace>.svc.cluster.local:8200
│
│ kube-proxy / iptables (ClusterIP)
▼
decode pod (vLLM --runner pooling, listening on :8200)
No Istio Gateway, no GAIE, no EPP scheduling — the request goes straight to the model server.
External ingress and TLS for generative models are handled by the cloud-specific infra layer (e.g., AWS Load Balancer Controller or GKE Gateway + Certificate Manager), keeping this app layer provider-agnostic.
Configuration¶
The application is configured via Pulumi config values in deployments/Pulumi.{stack}.yaml:
Required Configuration¶
cloudProvider: Target platform —cloud(GCP/AWS/Azure) oron-prem. Determines which credentials are required and how the Kubernetes provider is configured.models: Model list, split by category. Each entry names a folder underdeployments/models/<category>/and specifies per-model settings:All models share the same cluster credentials (app-serving:models: generative: - name: DeepSeek-Coder-V2-Lite-Instruct enabled: true # must be boolean true/false, not a string nodeSelector: nodegroup: g2-standard-48-l4 embedder: - name: BGE-M3 enabled: true nodeSelector: nodegroup: g2-standard-48-l4kubeconfig,harborHostname,harborUser,harborToken). Each model gets its own namespace and set of resources derived from its slug.harborToken: Harbor robot account secret (encrypted). Required on on-prem; also required on cloud stacks that usemodelSource.harborRef. Set viapulumi config set --secret harborToken <secret>.
Optional Per-Model Configuration¶
These can be added inside a model entry to override auto-derived values:
- nameSpace: Override auto-derived namespace (default: llm-d-<slug>).
- releaseName: Override auto-derived Helm release name (default: infra-<slug>).
On-Prem / Air-Gap Configuration (optional)¶
These fields are only needed when deploying to an air-gapped RKE2 cluster. All are absent from cloud stack configs.
kubeconfig: Path to the RKE2 cluster kubeconfig on the provisioner laptop (e.g.~/.kube/rke2-cluster.yaml). Omit to fall back toKUBECONFIGenv var or~/.kube/config.harborHostname: Internal Harbor registry hostname for containerd image pulls (e.g.harbor.internal.lan).harborUser: Harbor robot account name (e.g.robot$k8s-puller).harborToken: Harbor robot account secret (encrypted). Required for on-prem. Set viapulumi config set --secret harborToken <secret>. When set, aharbor-credspull secret is created in each model namespace.
Prerequisites¶
- Kubernetes Cluster: GCP/AWS/Azure cloud cluster, or an on-prem RKE2 cluster provisioned via
infra/on-prem - Gateway Provider Prereqs: Gateway API + GAIE CRDs and a Gateway implementation (Istio) installed via
infra/gateway-provider - Harbor Registry: Models stored as OCI artifacts in Harbor. On cloud, Harbor is deployed by
infra/cloud-harbor; on on-prem byinfra/on-prem/pulumi/services. Model artifacts uploaded viainfra/model-registry/model-sync.sh. - Go: Go 1.23+ with toolchain 1.24.5+
- Pulumi: Pulumi CLI
- On-prem only: Harbor registry deployed and configured, images uploaded via
infra/on-prem/ansible/harbor_upload.yml. ThehostpathStorageClass is used for model PVCs — PVs are created automatically by Pulumi whenmodelSource.hostpathNodeis set, but the backing directory must exist on the node first (managed via thehostpath_dirsAnsible role). - Real models only: Model OCI artifacts pushed to Harbor via
infra/model-registry/model-sync.shbeforepulumi up. See Model Weight Pre-loading below.
Deployment¶
-
Set Configuration:
# Set Harbor robot token (encrypted) pulumi config set --secret harborToken <robot-secret> -
Deploy:
cd app_serving/deployments pulumi up -
Verify Deployment:
kubectl get pods -n llm-d-<slug> kubectl get gateway -n llm-d-<slug> kubectl get httproute -n llm-d-<slug>
Single-GPU Swaps¶
If you want to deploy a different model on the same GPU, first remove the current deployment and then deploy the new one:
cd app_serving/deployments
pulumi destroy
pulumi up
This is needed when the old model must be removed before the new pod can be scheduled on the same GPU.
Supported Models¶
Generative Models¶
- sim: Simulation model for testing (GCP)
- sim-rke2-1 / sim-rke2-2: Simulation model for testing (RKE2 on-prem, Harbor images +
imagePullSecrets) - DeepSeek-Coder-V2-Lite-Instruct: Code generation model
- GLM-4.7-Flash
- GPT-OSS-20B
- Qwen3-Coder-30B-A3B-Instruct
- Devstral-Small-2-24B-Instruct-2512
| Model | Parameters | Tensor Type | Quantized |
|---|---|---|---|
| GLM-4.7-Flash | 31B | BF16 / F32 | FP8 |
| Qwen3-Coder-30B-A3B-Instruct-FP8 | 31B | BF16 | FP8 |
| Devstral-Small-2-24B-Instruct-2512 | 24B | BF16 | - |
| Qwen3.5-27B-FP8 | 28B | BF16 | FP8 |
Benchmarks¶
| Benchmark | GLM-4.7-Flash | GPT-OSS-20B | Qwen3-Coder-30B-A3B-Instruct | Devstral-Small-2-24B-Instruct-2512 | Qwen3.5-27B |
|---|---|---|---|---|---|
| AIME 25 | 91.6 | 91.7 | — | — | — |
| GPQA | 75.2 | 71.5 | — | — | 85.5 |
| HLE | 14.4 | 10.9 | — | — | — |
| SWE-bench Verified | 59.2 | 34.0 | 51.6 | 68.0% | 72.4 |
| LCB v6 | 64.0 | 61.0 | — | — | — |
| τ²-Bench | 79.5 | 47.7 | — | — | — |
| BrowseComp | 42.8 | 28.3 | — | — | 61.0 |
| Terminal-Bench | — | — | 31.3 | 22.5% | 41.6 |
| SWE-bench Multilingual | — | — | 34.7 | 55.7% | — |
Error¶
This error applies to both Qwen3.5-27B-FP8 and GLM-4.7-Flash-FP8-Dynamic when the installed transformers version does not yet recognize the model architecture.
Value error, The checkpoint you are trying to load has model type `qwen3_5` but Transformers does not recognize this architecture. This could be because of an issue with the checkpoint, or because your version of Transformers is out of date. You can update Transformers with the command `pip install --upgrade transformers`.
If this does not work, and the checkpoint is very new, then there may not be a release version that supports this model yet.
In this case, you can get the most up-to-date code by installing Transformers from source with the command `pip install git+https://github.com/huggingface/transformers.git`
Embedder Models¶
| Model | Parameters | Max context | Max-num-seqs | Max-num-batched-tokens | Output dimension |
|---|---|---|---|---|---|
| EmbeddingGemma-300M | ~300M | 2048 | 16 | 12288 | 768 |
| BGE-M3 | ~568M | 2048 | 16 | 8192 | 1024 |
| Jina-Embeddings-v5-text-small-retrieval | ~677M | 32K | 16 | 12288 | 1024 |
| Nomic-Embed-text-v1.5 | ~137M | 2048 | 8 | 8192 | 768 |
Each entry below is a folder under deployments/models/. The same model may have
multiple folders for different cluster targets (e.g. sim for GCP vs sim-rke2-1 for RKE2).
| Folder | Model | Target | Notes |
|---|---|---|---|
sim |
llm-d inference simulator | GCP | No GPU required; for testing |
sim-rke2-1 |
llm-d inference simulator | RKE2 on-prem | Harbor images, imagePullSecrets |
sim-rke2-2 |
llm-d inference simulator | RKE2 on-prem | Harbor images, imagePullSecrets |
DeepSeek-Coder-V2-Lite-Instruct |
DeepSeek Coder V2 Lite | GCP | 2× GPU, real model weights |
GLM-4.7-Flash |
Z.ai GLM-4.7-Flash | Cloud | 4× GPU, real model weights |
GPT-OSS-20B |
GPT-OSS 20B | GCP / on-prem | 1× GPU, real model weights |
Qwen3-Coder-30B-A3B-Instruct |
Qwen3 Coder 30B A3B Instruct | Cloud | BF16, tuned for 1× 96 GB GPU; use FP8 or TP>1 on 48 GB GPUs |
nomic-embed-text-v1-5-rke2 |
nomic-ai/nomic-embed-text-v1.5 | RKE2 on-prem | CPU-only test via infinity; model weights pre-loaded via Harbor + ORAS (modelSource.hostpathNode) |
Adding a New Model¶
The code uses convention-based model discovery via resolveModelPaths(). Users create deployments/models/<category>/<modelName>/gaie-<slug>/ and ms-<slug>/ folders, and the code auto-derives namespace, release names, and values paths from the slug.
How it works¶
Paths are auto-discovered by resolveModelPaths() from the folder structure.
Given a model entry DeepSeek-Coder-V2-Lite-Instruct in models.generative:
→ scans deployments/models/generative/DeepSeek-Coder-V2-Lite-Instruct/
→ finds gaie-deepseek-coder/ → gaieValuesPath = models/.../gaie-deepseek-coder/values.yaml
→ finds ms-deepseek-coder/ → modelValuesPath = models/.../ms-deepseek-coder/values.yaml
The Workflow¶
-
User creates folders:
deployments/models/generative/Test-Alma-V5-Pro/ gaie-alma-pro/values.yaml ms-alma-pro/values.yaml -
User adds the model to
modelsinPulumi.<stack-name>.yamlunder the appropriate category:config: app-serving:cloudProvider: cloud app-serving:models: generative: - name: Test-Alma-V5-Pro enabled: true nodeSelector: nodegroup: <node-pool-name> modelSource: harborRef: harbor.harbor.svc.cluster.local/ai-models/test-alma-v5-pro:1.0.0 modelUri: hub/test-org/Test-Alma-V5-Pro storageSize: 10Gi app-serving:harborHostname: harbor.harbor.svc.cluster.local app-serving:harborUser: robot$k8s-puller app-serving:harborToken: secure: v1:... -
Validation. The code catches mismatches immediately. In case the user mistypes subfolder names, for example:
Error message:deployments/models/Test-Alma-V5-Pro/ gaie-aLMa-pro/values.yaml ms-aMLa-pro/values.yamlIt also verifies bothError: slug mismatch: gaie-aLMa-pro vs ms-aMLa-pro in "models/Test-Alma-V5-Pro"values.yamlfiles actually exist. -
Code scans, extracts slug
alma-profrom folder names, derives everything else.
Naming flow¶
models entry: "DeepSeek-Coder-V2-Lite-Instruct"
│
▼
deployments/models/generative/DeepSeek-Coder-V2-Lite-Instruct/gaie-deepseek-coder/
│
slug = "deepseek-coder"
│
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
ns: llm-d-deepseek-coder release: infra-deepseek-coder gaie: gaie-deepseek-coder
ms: ms-deepseek-coder
Everything flows from the slug. The slug flows from the folder name the user created.
What gets auto-derived from the slug:
| Resource | Naming pattern | Example (slug: deepseek-coder) |
|---|---|---|
| Namespace | llm-d-<slug> |
llm-d-deepseek-coder |
| Infra release | infra-<slug> |
infra-deepseek-coder |
| GAIE release | gaie-<slug> |
gaie-deepseek-coder |
| Model service release | ms-<slug> |
ms-deepseek-coder |
| Gateway | infra-<slug>-inference-gateway |
infra-deepseek-coder-inference-gateway |
| HTTPRoute | llm-d-<slug> |
llm-d-deepseek-coder |
Steps¶
Required Steps¶
- Create the model directory
mkdir -p models/<category>/<ModelName>/gaie-<slug>/
mkdir -p models/<category>/<ModelName>/ms-<slug>/
<category>—generativeorembedder<ModelName>— the exact model name (e.g.DeepSeek-Coder-V2-Lite-Instruct)<slug>— a short, lowercase identifier (e.g.deepseek-coder)- Both subdirectories must use the same slug
Example:
mkdir -p models/generative/Test-Alma-V5-Pro/gaie-alma-pro/
mkdir -p models/generative/Test-Alma-V5-Pro/ms-alma-pro/
- Add Helm values files
Create values.yaml in each subdirectory:
deployments/models/generative/Test-Alma-V5-Pro/
gaie-alma-pro/values.yaml # GAIE InferencePool values
ms-alma-pro/values.yaml # llm-d-modelservice values
Use an existing model as reference for the values structure:
- Cloud target: copy from deployments/models/generative/sim/ (external image registries)
- On-prem target: copy from deployments/models/generative/sim-rke2-1/ (Harbor image references, imagePullSecrets)
- Add the model to the stack config file
Add the model to models in Pulumi.<stack-name>.yaml under the appropriate category (generative or embedder):
app-serving:models:
generative:
- name: ExistingModel
enabled: true
nodeSelector:
nodegroup: <node-pool-name>
- name: Test-Alma-V5-Pro
enabled: true
nodeSelector:
nodegroup: <node-pool-name>
Or create a new stack config from the template:
cp Pulumi.TEMPLATE.yaml Pulumi.<stack-name>.yaml
- Create a Pulumi stack and set the secret (new stacks only)
cd app_serving/deployments
pulumi stack init <stack-name>
pulumi config set --secret harborToken <robot-secret>
- Preview and deploy
pulumi preview # verify what will be created
pulumi up # deploy
Optional per-model overrides¶
These can be added inside a model entry to override auto-derived values:
app-serving:models:
generative:
- name: Test-Alma-V5-Pro
enabled: true
nodeSelector:
nodegroup: <node-pool-name>
nameSpace: custom-namespace # overrides llm-d-<slug>
releaseName: custom-release-name # overrides infra-<slug>
Validation¶
The code validates that:
- A gaie-* subdirectory exists under deployments/models/<category>/<ModelName>/
- A ms-* subdirectory exists under deployments/models/<category>/<ModelName>/
- Both use the same slug (e.g. gaie-alma-pro and ms-alma-pro)
- Both values.yaml files exist
Mismatches (e.g. gaie-alma-pro + ms-amla-pro) will produce a clear error at pulumi preview time.
Dependencies¶
The project uses:
- Pulumi Kubernetes Provider v4.25.0
- llm-d framework (local dependency via ../upstream/llm-d/llm-d-infra/)
- llm-d-infra chart v1.4.0
- llm-d-modelservice chart v0.4.12
- Gateway API Inference Extension chart v1.2.0
- Helm for component packaging
Model Weight Pre-loading¶
For deployments with real model weights (not sim), model OCI artifacts must be pushed to
Harbor before pulumi up. Pulumi creates a PVC and an ORAS pull Job that fetches the
artifact from Harbor at deploy time — no HuggingFace access is required from the cluster.
Use the scripts in infra/model-registry/:
cd infra/model-registry
# Build downloader image once
docker build -t hf-downloader:local downloader/
# Download from HuggingFace + push to Harbor
HF_TOKEN=<your-token> HARBOR_HOST=localhost:5000 CACHE_DIR=/tmp/hf-cache ./model-sync.sh
Model definitions (HF repo ID, Harbor project, artifact name, tag) live in
infra/model-registry/models.yaml.
The modelSource block in the stack config triggers Pulumi to create the PVC and ORAS Job:
modelSource:
harborRef: harbor.harbor.svc.cluster.local/ai-models/<artifact>:<tag>
modelUri: hub/<org>/<model>
storageSize: <size>Gi
hostpathNode: <node-hostname> # on-prem only — node where the hostpath PV is created
On on-prem clusters, hostpathNode is required. Pulumi creates the PV automatically
pointing to /var/lib/hostpath/models/<slug> on the specified node. The directory
must exist before pulumi up — add it to hostpath_pv_dirs in the node's
inventory-dev/host_vars/<node>.yml and run:
cd infra/on-prem/ansible
ansible-playbook setup/hostpath_dirs.yml -i inventory-dev --limit <node>
See MODEL_STORAGE.md for full details on the artifact layout and Job failure recovery.
Troubleshooting¶
Common Issues¶
- Missing
harborToken: EnsureharborTokenis configured as an encrypted secret (pulumi config set --secret harborToken <secret>). Required for on-prem and for cloud stacks usingmodelSource. - Missing models: Ensure
models(withgenerativeand/orembedderlists) is set in the stack config. - Namespace Issues: Verify target namespace exists or will be created.
- Gateway Provider Dependencies: Check that Gateway CRDs and Istio control plane are installed via
infra/gateway-provider. - ORAS Job failed: Check
kubectl logs -n <ns> job/<slug>-model-pull. After fixing root cause, delete the Job and PVC manually then re-runpulumi up. See MODEL_STORAGE.md for recovery steps. - Pod starts with empty PVC: The ORAS Job may have not finished or the ModelService chart was deployed without
DependsOnthe Job. Ensure the Job completed successfully before the pod starts. - Port conflict in decode pod: The routing-proxy init container binds port 8000. The inference server (
vllmServeorcustommodelCommand) must listen on port 8200 for decode pods. WithmodelCommand: vllmServethe chart handles this automatically; withmodelCommand: customset--port 8200explicitly. - Embeddings endpoint via gateway returns 400: The inference gateway (EPP) only validates chat-completion requests. For embedding models, access the inference server directly via pod port-forward on port 8200 or add a separate ClusterIP service pointing to port 8200.
Development¶
app_serving/ itself only contains main.go (a thin wrapper that calls
serving.DeployAppServing(ctx, "", nil)), deployments/, and charts/. The actual
implementation lives in the shared pkg module, at pkg/iac/serving/:
main.go: Compiles the stack binary; delegates topkg/iac/serving.DeployAppServingpkg/iac/serving/serving.go: Orchestration — loops over all models inconfig.Modelspkg/iac/serving/internal/config/config.go: Config loading and convention-based model discovery; returns a singleConfigcontaining oneModelentry per enabled modelpkg/iac/serving/internal/config/naming.go: Release name derivation from slugpkg/iac/serving/internal/components/*/deploy.go: Component-specific installation functions (llmd-infra, gaie, modelservice, httproute, embeddingservice)pkg/iac/serving/internal/platform/secret.go: Harbor pull secret creation (harbor-creds)pkg/iac/serving/internal/platform/model_storage.go: PVC + ORAS pull Job creation formodelSourcedeployments/models/: Model-specific configurations (one directory per category + model + cluster target combination)deployments/Pulumi.TEMPLATE.yaml: Template for creating new stack configs
State Management¶
Pulumi state is managed via the backend configured for the environment (S3 or GCS):
pulumi login <backend-url>
pulumi stack select <stack>