Docs / Examples
kubeswap: a complete multi-engine Kubernetes configuration
A verified llama-swap config that runs llama-server, sd-server, whisper-server and audiocpp_server as Kubernetes workloads through the kubeswap wrapper.
A copy-pasteable config.yaml that serves four engine families behind one
llama-swap endpoint: chat (llama-server), image generation (sd-server),
speech recognition (whisper-server) and text-to-speech (audiocpp_server).
Every model is a Kubernetes workload managed through the kubeswap
wrapper — no special backend keyword, just cmd/cmdStop. All four
servers ship in one image (the unified llama-swap image), so every
model below uses the same --image and only --command selects the
server. This exact shape was verified end to end on a k3s cluster
(request in, generated artifact out, cmdStop teardown).
To have the chart generate the routing section instead of writing the matrix
DSL by hand, see "Matrix router builder" in guides/tools/kubeswap.
Prerequisites: a head-end deployment — the Helm chart in
cmd/kubeswap/chart/ is the standard path (below). The head-end
image must ship /usr/local/bin/kubeswap (a release, or an image built from
a revision containing cmd/kubeswap/). For a model-cache PVC instead of
emptyDirs, list it under the chart's extraResources (rendered verbatim and
tracked by helm) and point the models' --volume flags at it.
Model cache layout
All weights live on the shared ReadWriteMany PVC (llama-swap-models),
mounted read-only at /models in every backend pod. The layout is your
choice — the container args just point at paths:
/models/
LFM2.5-230M-Q4_0.gguf
krea-2-turbo-Q4_K_M.gguf
Qwen3VL-4B-Instruct-Q4_K_M.gguf
wan_2.1_vae.safetensors
ideogram4-Q4_0.gguf
ideogram4_uncond-Q4_0.gguf
Qwen3-VL-8B-Instruct-Q4_K_M.gguf
flux2-vae.safetensors
distil-large-v3-q5_0.bin
qwen3-tts-0.6b-base-q8_0.gguf
tts-ref.wav
qwen3-tts-server.json
config.yaml
# kubernetes models are just `cmd`/`cmdStop` pointing at the kubeswap
# wrapper. healthCheckTimeout is global (not per-model) and must cover the
# slowest backend: the image models load ~10-16GB of weights on first
# request, so size it accordingly (and keep it >= --startup-timeout).
healthCheckTimeout: 600
unloadTimeout: 60 # seconds to wait for cmdStop (pod termination)
routing:
router:
use: group
settings:
groups:
# Exclusive swap groups: one member at a time (single GPU).
# Add more members to a group and they swap on request.
llm:
swap: true
exclusive: true
members: [lfm25-230m]
image:
swap: true
exclusive: true
members: [krea2-turbo, ideogram4]
audio:
swap: true
exclusive: true
members: [distil-whisper-lgv3, qwen3-tts-06b]
models:
# --- chat (llama.cpp) --------------------------------------------------
# The unified image's entrypoint runs llama-swap (the head-end); --command
# overrides it to run a backend server instead. llama-server lives at
# /usr/local/bin/llama-server in the image.
lfm25-230m:
proxy: "http://127.0.0.1:${PORT}"
cmd: >-
kubeswap serve
--listen 127.0.0.1:${PORT}
--model lfm25-230m
--namespace {{ .Release.Namespace }}
--image ghcr.io/mostlygeek/llama-swap:unified-vulkan
--command llama-server
--gpu amd.com/gpu=1
--node-selector feature.node.kubernetes.io/amd-gpu=true
--volume pvc:llama-swap-models:/models:ro
--volume emptydir:slots:/slots
--
--model /models/LFM2.5-230M-Q4_0.gguf
--port 8080
-ngl 99
-np 2
--cache-ram 8
--slot-save-path /slots
cmdStop: kubeswap delete --model lfm25-230m --namespace {{ .Release.Namespace }} --wait 60s
ttl: 1800
# --- image generation (stable-diffusion.cpp) ---------------------------
# One sd-server is several files: diffusion model, VAE, text encoder,
# (optionally an unconditional model). sd-server binds 127.0.0.1 by
# default (--listen-ip 0.0.0.0) and has no /health route
# (--health-path /v1/models).
krea2-turbo:
proxy: "http://127.0.0.1:${PORT}"
capabilities: { in: [text], out: [image] }
cmd: >-
kubeswap serve
--listen 127.0.0.1:${PORT}
--model krea2-turbo
--namespace {{ .Release.Namespace }}
--image ghcr.io/mostlygeek/llama-swap:unified-vulkan
--command sd-server
--health-path /v1/models
--gpu amd.com/gpu=1
--node-selector feature.node.kubernetes.io/amd-gpu=true
--request cpu=4
--volume pvc:llama-swap-models:/models:ro
--
--diffusion-model /models/krea-2-turbo-Q4_K_M.gguf
--llm /models/Qwen3VL-4B-Instruct-Q4_K_M.gguf
--vae /models/wan_2.1_vae.safetensors
--listen-ip 0.0.0.0
--listen-port 8080
--diffusion-fa
--offload-to-cpu
--cfg-scale 1.0
-H 1024 -W 1024
cmdStop: kubeswap delete --model krea2-turbo --namespace {{ .Release.Namespace }} --wait 60s
ttl: 1800
ideogram4:
proxy: "http://127.0.0.1:${PORT}"
capabilities: { in: [text], out: [image] }
cmd: >-
kubeswap serve
--listen 127.0.0.1:${PORT}
--model ideogram4
--namespace {{ .Release.Namespace }}
--image ghcr.io/mostlygeek/llama-swap:unified-vulkan
--command sd-server
--health-path /v1/models
--gpu amd.com/gpu=1
--node-selector feature.node.kubernetes.io/amd-gpu=true
--request cpu=4
--volume pvc:llama-swap-models:/models:ro
--
--diffusion-model /models/ideogram4-Q4_0.gguf
--uncond-diffusion-model /models/ideogram4_uncond-Q4_0.gguf
--llm /models/Qwen3-VL-8B-Instruct-Q4_K_M.gguf
--vae /models/flux2-vae.safetensors
--listen-ip 0.0.0.0
--listen-port 8080
--diffusion-fa
--offload-to-cpu
--cfg-scale 1.0
-H 1024 -W 1024
cmdStop: kubeswap delete --model ideogram4 --namespace {{ .Release.Namespace }} --wait 60s
ttl: 1800
# --- speech recognition (whisper.cpp) ----------------------------------
# CPU is fine for distil-large-v3. whisper-server binds 127.0.0.1 by
# default (--host 0.0.0.0) and only loads Whisper-family weights
# (.bin/.gguf whisper or distil-whisper).
distil-whisper-lgv3:
proxy: "http://127.0.0.1:${PORT}"
capabilities: { in: [audio], out: [text] }
cmd: >-
kubeswap serve
--listen 127.0.0.1:${PORT}
--model distil-whisper-lgv3
--namespace {{ .Release.Namespace }}
--image ghcr.io/mostlygeek/llama-swap:unified-vulkan
--command whisper-server
--volume pvc:llama-swap-models:/models:ro
--
--host 0.0.0.0
--port 8080
--model /models/distil-large-v3-q5_0.bin
--inference-path /v1/audio/transcriptions
cmdStop: kubeswap delete --model distil-whisper-lgv3 --namespace {{ .Release.Namespace }} --wait 60s
ttl: 1800
# --- text to speech (audio.cpp) ----------------------------------------
# audiocpp_server reads a JSON config (below); its models[].id MUST equal
# the llama-swap model id — the server rejects unknown `model` request
# fields. CPU backend keeps it off the GPU.
qwen3-tts-06b:
proxy: "http://127.0.0.1:${PORT}"
capabilities: { in: [text], out: [audio] }
cmd: >-
kubeswap serve
--listen 127.0.0.1:${PORT}
--model qwen3-tts-06b
--namespace {{ .Release.Namespace }}
--image ghcr.io/mostlygeek/llama-swap:unified-vulkan
--command audiocpp_server
--volume pvc:llama-swap-models:/models:ro
--
server
--config /models/qwen3-tts-server.json
--backend cpu
cmdStop: kubeswap delete --model qwen3-tts-06b --namespace {{ .Release.Namespace }} --wait 60s
ttl: 1800
NVIDIA clusters use --gpu nvidia.com/gpu=1 and drop the AMD node
selector (or point it at your own GPU label). CPU-only is a first-class
mode, not a fallback: drop --gpu/--node-selector (and --diffusion-fa
for image models) and the pod schedules on any node — the whisper and TTS
models above already do exactly that. A CPU LLM works the same way on small
models (drop -ngl; expect CPU speed, not GPU speed).
/models/qwen3-tts-server.json
{
"host": "0.0.0.0",
"port": 8080,
"models": [
{
"id": "qwen3-tts-06b",
"family": "qwen3_tts",
"path": "/models/qwen3-tts-0.6b-base-q8_0.gguf",
"task": "tts",
"mode": "offline",
"voice_presets": {
"alloy": {
"voice_ref": "/models/tts-ref.wav",
"reference_text": "Some call me nature. Others call me Mother Nature. I've been here for over 4.5 billion years. 22,500 times longer than you."
},
"default": {
"voice_ref": "/models/tts-ref.wav",
"reference_text": "Some call me nature. Others call me Mother Nature. I've been here for over 4.5 billion years. 22,500 times longer than you."
}
},
"default_voice_preset_id": "default"
}
]
}
The voice value in a request must name one of these presets (the default
preset is only used when voice is absent). Voice cloning needs the
reference WAV and its exact transcript (reference_text). Use the
self-contained GGUF package from audio-cpp/audio.cpp-gguf for qwen3_tts —
older-format files fail to load.
Deploying this config
Save the config above as config.yaml (keep the {{ .Release.Namespace }}
tokens — the chart templates the inline config) and install the chart from a
checkout with it inlined:
helm install llama-swap ./cmd/kubeswap/chart \
-n llama-swap --create-namespace \
--set-file config.inline=./config.yaml
Optional exposure and storage (see the chart README for the full values list):
ingress: # or gateway.enabled for Gateway API
enabled: true
className: traefik
hosts:
- host: llama-swap.example.com
paths:
- path: /
pathType: Prefix
tls: # ingress-level list (k8s has no per-host tls)
- secretName: llama-swap-tls
hosts: [llama-swap.example.com]
service:
type: LoadBalancer # ClusterIP is the default
loadBalancerSourceRanges: ["203.0.113.0/24"]
extraResources: # full manifests, rendered verbatim
- apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: llama-swap-models
spec:
accessModes: [ReadWriteMany]
storageClassName: longhorn
resources:
requests:
storage: 35Gi
The chart's own defaults need no PVC (one tiny model, pulled from
Hugging Face into a per-pod emptyDir), so helm install with no values
file works out of the box. helm upgrade with a changed
config.inline hot-reloads the models (the head-end runs with
-watch-config); changing selectorLabels is not supported.
The same fleet as structured chart values
The chart can render config.yaml from structured values instead of
config.inline: config.top (scalars, macros, routing),
config.defaults (what most models share) and config.models (one entry
per model). The chart then generates each model's proxy, the whole
kubeswap serve ... -- <args> command and the cmdStop — a model entry
only carries its ID, backend args, and whatever differs from the
defaults. This is the same fleet as above, about four times shorter:
# values.yaml — replaces config.inline when config.models is non-empty
config:
top:
healthCheckTimeout: 600
unloadTimeout: 60
# kubeswap picks the server binary with --command, so these bases
# carry only flags (the backend port is fixed at 8080, kubeswap
# --port). "Last flag wins" still lets per-model args override.
macros:
server_base: --port 8080 -ngl 99 -np 2 --cache-ram 8 --slot-save-path /slots
sd_base: --listen-ip 0.0.0.0 --listen-port 8080 --diffusion-fa --offload-to-cpu --cfg-scale 1.0 -H 1024 -W 1024
routing:
router:
use: group
settings:
groups:
llm: { swap: true, exclusive: true, members: [lfm25-230m] }
image: { swap: true, exclusive: true, members: [krea2-turbo, ideogram4] }
audio: { swap: true, exclusive: true, members: [distil-whisper-lgv3, qwen3-tts-06b] }
defaults:
image: ghcr.io/mostlygeek/llama-swap:unified-vulkan
command: llama-server
gpu: [amd.com/gpu=1]
volumes: ["pvc:llama-swap-models:/models:ro", "emptydir:slots:/slots"]
extraKubeArgs: ["--node-selector feature.node.kubernetes.io/amd-gpu=true"]
ttl: 1800
models:
- id: lfm25-230m
args: ${server_base} --model /models/LFM2.5-230M-Q4_0.gguf
# image models: sd-server has no /health route (healthPath), and the
# text encoders are big — add CPU headroom via extraKubeArgs
- id: krea2-turbo
command: sd-server
healthPath: /v1/models
capabilities: { in: [text], out: [image] }
extraKubeArgs: ["--node-selector feature.node.kubernetes.io/amd-gpu=true", "--request cpu=4"]
args: ${sd_base} --diffusion-model /models/krea-2-turbo-Q4_K_M.gguf --llm /models/Qwen3VL-4B-Instruct-Q4_K_M.gguf --vae /models/wan_2.1_vae.safetensors
- id: ideogram4
command: sd-server
healthPath: /v1/models
capabilities: { in: [text], out: [image] }
extraKubeArgs: ["--node-selector feature.node.kubernetes.io/amd-gpu=true", "--request cpu=4"]
args: ${sd_base} --diffusion-model /models/ideogram4-Q4_0.gguf --uncond-diffusion-model /models/ideogram4_uncond-Q4_0.gguf --llm /models/Qwen3-VL-8B-Instruct-Q4_K_M.gguf --vae /models/flux2-vae.safetensors
# audio models: CPU-only — an empty list clears the default gpu
- id: distil-whisper-lgv3
command: whisper-server
capabilities: { in: [audio], out: [text] }
gpu: []
extraKubeArgs: []
args: --host 0.0.0.0 --port 8080 --model /models/distil-large-v3-q5_0.bin --inference-path /v1/audio/transcriptions
- id: qwen3-tts-06b
command: audiocpp_server
capabilities: { in: [text], out: [audio] }
gpu: []
extraKubeArgs: []
args: server --config /models/qwen3-tts-server.json --backend cpu
Every key on a model entry that is not a kubeswap field (command,
healthPath, gpu, extraKubeArgs, ...) renders verbatim as a
llama-swap model field — so capabilities, ttl, macros, filters
and aliases all pass straight through. Install with the values file
instead of --set-file config.inline:
helm install llama-swap ./cmd/kubeswap/chart \
-n llama-swap --create-namespace \
-f values.yaml
The same fleet with matrix routing
The group routing above says "these run together, those swap". The matrix router instead enumerates the combinations that may run together and evicts the cheapest way to serve each request (the groups-and-matrix article covers how the solver works). For this fleet the matrix view is: at most one GPU model at a time, both audio models always. There are two ways to write it.
Hand-written (no builder)
Put the matrix under config.top.routing, exactly as you would in a plain
config.yaml (or in config.matrix.manual, below). In the values above,
replace the routing: block under config.top with:
routing:
router:
use: matrix
settings:
matrix:
evict_costs:
krea2-turbo: 10 # image models load 10-16GB of weights -
ideogram4: 10 # the router evicts them last
sets:
gpu: (lfm25-230m | krea2-turbo | ideogram4)
all: "+gpu & distil-whisper-lgv3 & qwen3-tts-06b"
Any one of the three GPU models, plus both CPU models. Requesting a second
GPU model evicts the cheapest running one (evict_costs breaks the tie;
default 1).
Matrix builder
config.matrix renders the same routing from the model roster, using each
model's gpu flag for pool membership — the defaults give the three GPU
models amd.com/gpu=1, and the two audio models set gpu: []. Delete the
routing: block from config.top (the two are mutually exclusive) and add:
matrix:
builder: gpu-budget
budget: 1
evict_costs:
krea2-turbo: 10
ideogram4: 10
which renders the routing section above: gpu_pool from the three gpu
models, cpu_pool from the two gpu: [] models, and one +gpu_pool slot
for budget: 1. The builder exists so a large fleet (dozens of models,
mostly sharing the same pool shape) stays a few lines; for a small fleet
like this one, the hand-written matrix is about the same length and gives
you full DSL control. Other builder shapes (pools for per-category
budgets, manual for a verbatim pass-through) and the exclusive: option
are documented in the guides/tools/kubeswap article and the chart README.
Talking to it
All four engines sit behind the head-end's ${START_PORT} service:
LS=http://<head-end>:8080
# chat (llama-server)
curl -s $LS/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"lfm25-230m","messages":[{"role":"user","content":"Say hello"}],"max_tokens":50}'
# image (sd-server) — OpenAI-style JSON, PNG arrives base64 in data[0].b64_json
curl -s $LS/v1/images/generations -H 'Content-Type: application/json' \
-d '{"model":"krea2-turbo","prompt":"a red cube on a blue floor","size":"1024x1024"}' \
| jq -r '.data[0].b64_json' | base64 -d > out.png
# speech recognition (whisper-server) — multipart
curl -s $LS/v1/audio/transcriptions \
-F model=distil-whisper-lgv3 -F file=@recording.wav
# text to speech (audiocpp_server) — WAV bytes back
curl -s $LS/v1/audio/speech -H 'Content-Type: application/json' \
-d '{"model":"qwen3-tts-06b","input":"Hello from Kubernetes","voice":"alloy"}' -o out.wav
Gotchas
healthCheckTimeoutis global — a per-model entry is silently discarded. Size it for the slowest backend and keep it at or abovekubeswap's--startup-timeout(default 10m), which bounds how long the pod may take to load its model before being restarted.- sd-server and whisper-server both bind 127.0.0.1 by default — pass
--listen-ip 0.0.0.0/--host 0.0.0.0or nothing outside the pod can reach them (symptom: pod Running, never Ready). - sd-server has no
/healthroute — readiness uses--health-path /v1/models. llama-swap's own health check stays at the wrapper's self-answered/healthregardless. - audio.cpp: config model id == llama-swap model id;
voicemust name a declared preset. - The unified image's entrypoint runs llama-swap (the head-end), not a
backend server —
--commandoverrides it to run the chosen server binary (llama-server,sd-server,whisper-server,audiocpp_server). Every server ships in/usr/local/binof one image, so the same--imageworks for every engine. - First request to an image model loads 10-16GB of weights — expect minutes, not seconds, before the first PNG.
Related
guides/tools/kubeswap— how the wrapper works, lifecycle semantics, GPU/CPU resource mapping, hand-rolled RBACguides/operations/debugging-backends— the status → logs → kubectl failure chain and the symptom tableguides/operations/storage-options-kubernetes— RWX vs RWO vs emptyDir for the model cache, and the node-pin escapeguides/operations/building-unified-image— building the image, adding an engine, verifying kubeswap is insidecmd/kubeswap/README.md— full flag reference and troubleshootingcmd/kubeswap/chart/— the Helm chart (values reference, exposure and storage options)