Files
2026-07-13 12:24:33 +08:00

357 lines
19 KiB
Markdown

# LMCache Kubernetes Operator
A Kubernetes operator that automates the deployment and lifecycle management of [LMCache](https://github.com/LMCache/LMCache) multiprocess cache servers. It manages a single CRD (`LMCacheEngine`) and reconciles it into a DaemonSet, ConfigMap, Service, and optional ServiceMonitor.
See [DESIGN.md](DESIGN.md) for architecture details, reconciliation logic, and CRD spec reference.
## Prerequisites
- Kubernetes 1.20+
- `kubectl` configured to access your cluster
- For NVIDIA GPUs (default): NVIDIA GPU Operator with the `nvidia` RuntimeClass available on GPU nodes
- For AMD GPUs: set `spec.gpuVendor: amd` in your `LMCacheEngine` (see [AMD GPUs (ROCm)](#amd-gpus-rocm) below)
- (Optional) [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) for ServiceMonitor support
- (CacheBlend only) [cert-manager](https://cert-manager.io) for the injection webhook's serving cert — see [CacheBlend](#cacheblend) below
> [!IMPORTANT]
> By default the operator runs LMCache pods with `runtimeClassName: nvidia` and `NVIDIA_VISIBLE_DEVICES=all` to gain GPU visibility without consuming GPU resources via the device plugin. This allows the serving engine (e.g., vLLM) to claim all GPUs on the node. On most clusters that is enough; on some, the engine cannot see the GPUs unless the pod is also privileged. Set `spec.privileged: true` to run the engine container in privileged mode (default `false`). When it is enabled, clusters using Pod Security Standards must allow the `privileged` profile for the LMCache namespace.
>
> On AMD ROCm clusters, `spec.gpuVendor: amd` omits `runtimeClassName` and skips NVIDIA-specific env vars.
> [!WARNING]
> **Upgrade note:** earlier operator versions always ran the engine container privileged. `spec.privileged` now defaults to `false`. Upgrading rewrites the DaemonSet pod template (forcing a rolling pod replacement), and on any cluster where privileged was load-bearing for GPU visibility the engine pods will come back up **without** GPU access. If your cluster relied on privileged mode (always the case for `gpuVendor: amd`), set `spec.privileged: true` on existing CRs before upgrading.
## Quick Start
### 1. Install the Operator
**Option A: One-line install from release (recommended)**
Install the latest stable release:
```bash
kubectl apply -f https://github.com/LMCache/LMCache/releases/download/operator-latest/install.yaml
```
Or use the nightly build from the `dev` branch:
```bash
kubectl apply -f https://github.com/LMCache/LMCache/releases/download/operator-nightly-latest/install.yaml
```
**Option B: Build from source**
```bash
cd operator
make build
make install
make deploy IMG=<your-registry>/lmcache-operator:latest
```
### 2. Deploy an LMCacheEngine
The minimal CR just needs `l1.sizeGB`. Apply the sample (a fully-commented field reference covering every option):
```bash
kubectl apply -f config/samples/lmcache_v1alpha1_lmcacheengine.yaml
```
The operator automatically handles `hostIPC`, GPU visibility (`runtimeClassName: nvidia`, `NVIDIA_VISIBLE_DEVICES=all`; set `spec.privileged: true` if your cluster also needs privileged mode), node-local service routing, resource sizing, and Prometheus metrics — see [DESIGN.md](DESIGN.md) for details.
### 3. Connect vLLM to LMCache
The operator creates a ConfigMap named `<engine-name>-connection` with the `kv-transfer-config` JSON vLLM needs. A **mutating webhook** injects it into an opted-in vLLM pod automatically — see [Connection injection](#connection-injection) and the sample [`config/samples/vllm_lmcache_deployment.yaml`](config/samples/vllm_lmcache_deployment.yaml).
Key points for vLLM pods:
- **`hostIPC: true` is required** — CUDA IPC (`cudaIpcOpenMemHandle`) needs a shared IPC namespace between vLLM and LMCache. Without this, GPU memory mapping fails.
- **ConfigMap mount** — the `$(cat ...)` pattern reads the connection JSON and passes it inline to `--kv-transfer-config`. The ConfigMap name is always `<LMCacheEngine name>-connection`.
- **External LMCache connector required** — the operator-generated config now sets `kv_connector_module_path=lmcache.integration.vllm.lmcache_mp_connector` so vLLM loads the external LMCache MP connector instead of silently resolving a vendored builtin path.
- **No `hostNetwork` needed** — the operator creates a ClusterIP Service with `internalTrafficPolicy=Local`. kube-proxy routes traffic to the LMCache pod on the same node automatically. The ConfigMap points to the service DNS name, so neither LMCache nor vLLM pods need `hostNetwork`.
> [!IMPORTANT]
> Use a pinned vLLM image that is new enough to honor `kv_connector_module_path` for KV connector loading. In practice, that means a build that includes the external-module selection fix from vLLM PR #38301 (merged April 7, 2026). Builds that also include vLLM PR #42596 (merged May 15, 2026) are preferred because they default LMCache MP to the external connector with builtin fallback. If your existing vLLM build predates those changes or you are unsure, upgrade it before enabling this operator path.
> [!WARNING]
> **Do NOT mount an emptyDir at `/dev/shm`** on either LMCache or vLLM pods. With `hostIPC: true`, both pods share the host's `/dev/shm`. Mounting an emptyDir (even with `medium: Memory`) shadows it with a private tmpfs, breaking CUDA IPC — `cudaIpcOpenMemHandle` fails because IPC handles from one pod become invisible to the other.
### 4. Verify the Deployment
```bash
# Check LMCacheEngine status
kubectl get lmc
```
```
NAME PHASE READY DESIRED AGE
my-cache Running 3 3 5m
```
```bash
# Check the connection ConfigMap
kubectl get configmap my-cache-connection -o yaml
# Check LMCache pods
kubectl get pods -l app.kubernetes.io/managed-by=lmcache-operator
# Check detailed status with endpoints
kubectl describe lmc my-cache
```
## Examples
Every scenario has a ready-to-edit manifest under [`config/samples/`](config/samples/) (`kubectl apply -f config/samples/<file>`):
| Scenario | Sample |
|---|---|
| Minimal + **full commented field reference** (GPU `nodeSelector`, custom `server.port`, L2 `raw`/`raw_block`, `resourceOverrides`, …) | [`lmcache_v1alpha1_lmcacheengine.yaml`](config/samples/lmcache_v1alpha1_lmcacheengine.yaml) |
| Production: Prometheus `ServiceMonitor`, custom port, `priorityClassName` | [`lmcache_v1alpha1_lmcacheengine_production.yaml`](config/samples/lmcache_v1alpha1_lmcacheengine_production.yaml) |
| L2 storage: Redis/Valkey (optional Secret auth) | [`lmcache_v1alpha1_lmcacheengine_l2_redis.yaml`](config/samples/lmcache_v1alpha1_lmcacheengine_l2_redis.yaml) |
| AMD GPUs (ROCm) | [`lmcache_v1alpha1_lmcacheengine_amd.yaml`](config/samples/lmcache_v1alpha1_lmcacheengine_amd.yaml) |
| vLLM Deployment wired to an LMCacheEngine — webhook-injected (see [Connection injection](#connection-injection)) | [`vllm_lmcache_deployment.yaml`](config/samples/vllm_lmcache_deployment.yaml) |
| CacheBlend engine + opted-in vLLM (see [CacheBlend](#cacheblend)) | [`lmcache_v1alpha1_cacheblendengine.yaml`](config/samples/lmcache_v1alpha1_cacheblendengine.yaml), [`vllm_cacheblend_deployment.yaml`](config/samples/vllm_cacheblend_deployment.yaml) |
| MP coordinator (fleet-wide registry, L2 quota eviction, global CacheBlend directory) + **commented field reference** | [`lmcache_v1alpha1_lmcachecoordinator.yaml`](config/samples/lmcache_v1alpha1_lmcachecoordinator.yaml) |
Notes:
- **GPU targeting** — `nodeSelector: {nvidia.com/gpu.present: "true"}` runs LMCache only on GPU nodes; new GPU nodes auto-get a pod.
- **AMD (ROCm)** — `spec.gpuVendor: amd` omits `runtimeClassName` and the NVIDIA env vars; vLLM connects via HIP IPC over `hostIPC` the same way (`PYTHONHASHSEED=0` still required). Supply a `nodeSelector` matching your platform's AMD label and a ROCm-built `spec.image`. AMD has no RuntimeClass-based device injection, so set `spec.privileged: true` to let the engine reach `/dev/kfd`/`/dev/dri` (see the [AMD sample](config/samples/lmcache_v1alpha1_lmcacheengine_amd.yaml)).
- **Custom port** — set `server.port`; the connection ConfigMap updates automatically and vLLM picks it up on restart.
- **L2 adapters** — only one at a time today. Redis/Valkey is natively typed; cross-namespace auth Secrets are copied automatically and injected via env (never in args or `kubectl describe`). Other types (`nixl_store`, `fs`, `mock`, `raw_block`) use the `raw` escape hatch — see the commented blocks in the minimal sample. For `raw_block` with `use_odirect: true`, `--l1-align-bytes` must be ≥ `block_align`.
- **Resources** auto-compute from `l1.sizeGB`; override with `resourceOverrides`.
## Connection injection
Wiring a vLLM Deployment to an LMCacheEngine by hand means mounting the
`<engine>-connection` ConfigMap and passing
`--kv-transfer-config "$(cat /etc/lmcache/kv-transfer-config.json)"`. A **mutating
webhook** can do this for you so the vLLM manifest stays clean.
Opt a vLLM pod in with the label `lmcache.ai/lmcache-inject: "true"` and the
annotation `lmcache.ai/lmcache-engine: "<engine>"` on its pod template, launching
vLLM via the image ENTRYPOINT (args-only — a `sh -c` wrapper is skipped). At pod
CREATE the webhook injects, reading the engine's `<engine>-connection` ConfigMap:
- `--kv-transfer-config <JSON>` — the `LMCacheMPConnector` config, inlined onto
the vLLM container args (no volume mount needed);
- `hostIPC: true` — CUDA IPC with the node-local LMCache server;
- `PYTHONHASHSEED=0` — deterministic prefix hashing (only if you didn't set it).
### Optional: code-payload staging
By default the webhook only wires the connection — vLLM runs whatever `lmcache`
is baked into its image. To instead pin the vLLM pod to a specific `lmcache`
build (so the vLLM client and the engine server share one version), set
`spec.injection.payloadImage` on the `LMCacheEngine`. The webhook then also
stages that image's `lmcache` tree into the vLLM container, mirroring CacheBlend:
- a shared `emptyDir` (`lmcache-payload`) + a payload **init container** that
copies the image's `/payload` tree into it (busybox `cp -a`, no command
override);
- a read-only mount of that volume on the vLLM container;
- `PYTHONPATH=/lmcache-payload` prepended so vLLM imports the staged `lmcache`;
- the engine's `injection.imagePullSecrets` merged onto the pod (override per-pod
with the `lmcache.ai/lmcache-image-pull-secrets` annotation) for private images.
`payloadImage` is a **separate, purpose-built** image: it must ship the unpacked
`lmcache` tree under `/payload` and copy it to `$SHARED_DIR` on start (the same
contract as the CacheBlend payload image). Its `repository` has no valid default,
so you must set it explicitly; leave `injection` unset for connection-only wiring.
Editable sample: [`config/samples/vllm_lmcache_deployment.yaml`](config/samples/vllm_lmcache_deployment.yaml).
> [!IMPORTANT]
> The webhook needs `make deploy` (not `make run`) + cert-manager, and the vLLM
> pod's namespace labeled `pod-security.kubernetes.io/enforce=privileged` (the
> injected `hostIPC` is rejected by `baseline`/`restricted`). The engine must
> already be reconciled in the same namespace (its `<engine>-connection`
> ConfigMap must exist — the webhook reads it).
The webhook mutates **Pods**, not the Deployment, so verify on a pod
(`kubectl get pod -l app=vllm-lmcache -o yaml | grep -E "hostIPC|kv-transfer-config|lmcache-injected"`).
If nothing was injected, check the pod's `lmcache.ai/lmcache-skip-reason`
annotation (`command-override`, `kv-transfer-config-present`, `engine-not-found`,
or `target-container-not-found`).
## CacheBlend
CacheBlend reuses cached KV at shifted positions. The operator manages it as a
second CRD (`CacheBlendEngine`) plus a **mutating webhook** that injects the
`lmcache-cacheblend` plugin into your vLLM pods — no vLLM image rebuild. See
[DESIGN.md](DESIGN.md#cacheblend-cacheblendengine-crd--injection-webhook) for the
architecture and the full field reference.
Quick start: deploy an engine, then opt a vLLM pod in with the label
`lmcache.ai/cacheblend-inject: "true"` and the annotation
`lmcache.ai/cacheblend-engine: "<engine>"` on its pod template (launch vLLM via the
image ENTRYPOINT — a `sh -c` wrapper is skipped). Editable samples:
- [`config/samples/lmcache_v1alpha1_cacheblendengine.yaml`](config/samples/lmcache_v1alpha1_cacheblendengine.yaml) — the `CacheBlendEngine`
- [`config/samples/vllm_cacheblend_deployment.yaml`](config/samples/vllm_cacheblend_deployment.yaml) — an opted-in vLLM Deployment
> [!IMPORTANT]
> CacheBlend needs the **webhook**, so deploy with `make deploy` (not `make run`,
> which is controller-only) and install **cert-manager** first
> (`kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml`).
> If Pod Security Standards are enforced, label the engine's and the vLLM pod's
> namespaces `pod-security.kubernetes.io/enforce=privileged` — the webhook injects
> `hostIPC`/`privileged`, which `baseline`/`restricted` reject.
> [!IMPORTANT]
> CacheBlend is still in early stage development and under heavy testing. Its
> docker image will not be publicly released until we are confident that it is
> ready to be shipped for general use cases. If you would like to try it first,
> please contact us in Slack Channel.
The webhook mutates **Pods**, not the Deployment, so verify on a pod
(`kubectl get pod -l app=vllm-cacheblend -o yaml | grep -E "cb-plugin|cacheblend-injected|skip-reason"`).
If nothing was injected, check the pod's `lmcache.ai/cacheblend-skip-reason`
annotation (`command-override`, `kv-transfer-config-present`, `engine-not-found`,
`payload-image-unset`, or `target-container-not-found`).
## Development
```bash
make generate # Generate DeepCopy methods
make manifests # Generate CRD YAML + RBAC
make build # Compile operator binary
make fmt # go fmt
make vet # go vet
make test # Run unit tests (envtest, CPU-only)
make lint # Run golangci-lint
```
### End-to-End Tests
Four `make` targets cover the e2e tiers. The `-kind` variants create
a throwaway Kind cluster and tear it down on exit; the `-cluster`
variants run against whatever your current `KUBECONFIG` points at.
M2 (GPU) targets additionally run a runtime HTTP check against the
LMCache server and a vLLM round-trip that proves the KV cache
stores on the first request and retrieves on the second.
```bash
make test-e2e-kind # local Kind, no GPU, ~5 min
make test-e2e-cluster IMG=<registry/image:tag> # existing cluster, no GPU
make test-e2e-gpu-kind # local Kind, GPU, ~30 min
make test-e2e-gpu-cluster IMG=<registry/image:tag> # existing GPU cluster
```
#### Tool prerequisites
| Target | Tools to install |
|---|---|
| `test-e2e-kind` | `kind`, `kubectl`, `docker` |
| `test-e2e-cluster` | `kubectl` (cluster access via `KUBECONFIG`) |
| `test-e2e-gpu-kind` | `kind`, `kubectl`, `docker`, `helm` (v3) |
| `test-e2e-gpu-cluster` | `kubectl`, `helm` (cluster access via `KUBECONFIG`) |
```bash
# Install kind (the other tools are distro-specific)
go install sigs.k8s.io/kind/cmd/kind@latest
```
#### One-time host setup for `test-e2e-gpu-kind`
Beyond the tools above, your host needs the NVIDIA driver and
`nvidia-container-toolkit` installed (distro-specific), plus two
`nvidia-ctk` commands that flip docker's default runtime to `nvidia`
and toggle the volume-mount-based GPU injection mechanism the
target's inline Kind config relies on:
```bash
sudo nvidia-ctk runtime configure --runtime=docker --set-as-default --cdi.enabled
sudo nvidia-ctk config --set accept-nvidia-visible-devices-as-volume-mounts=true --in-place
sudo systemctl restart docker
```
The target fails fast with a copy-pasteable fix command if either
piece of host config is missing. Note that flipping docker's default
runtime is a **host-wide** change — every container on the host then
starts through `nvidia-container-runtime`. Non-GPU workloads still
work but go through one extra hook.
The target installs the [NVIDIA GPU Operator](https://github.com/NVIDIA/gpu-operator)
inside the Kind cluster to handle the toolkit / containerd reconfig
that lets pods scheduled by Kind's inner containerd see the driver
libraries. That install takes 5-10 min, which is the bulk of the
`test-e2e-gpu-kind` runtime. (`nvkind` is **not** required — we
tried it and the current target uses a hand-rolled Kind config
instead.)
#### Cluster prerequisites for `test-e2e-gpu-cluster`
The existing cluster must have:
- at least one node labeled `nvidia.com/gpu.present=true`
- the `nvidia` `RuntimeClass` installed (GPU Operator or equivalent)
- the operator image pushed to a registry the cluster's image puller can reach (pass as `IMG=…`)
#### Knobs (env vars)
| Variable | Default | Used by |
|---|---|---|
| `KIND_CLUSTER` | `operator-test-e2e-<id>` | `test-e2e-kind` — set to target an existing Kind cluster |
| `GPU_KIND_CLUSTER` | `operator-test-e2e-gpu-<id>` | `test-e2e-gpu-kind` — same idea |
| `KEEP_CLUSTER_ON_FAILURE` | unset | `test-e2e-gpu-kind` — set to `1` to keep the cluster alive after a failure for live debugging |
| `VLLM_MODEL` | `Qwen/Qwen2.5-0.5B` | vLLM integration spec — Hugging Face model id |
| `VLLM_IMAGE` | `lmcache/vllm-openai:latest` | vLLM integration spec |
| `SKIP_VLLM_INTEGRATION` | unset | set to `true` to skip the heavyweight vLLM spec but still run the runtime HTTP check |
If a GPU run fails during setup, use the diagnostic target:
```bash
make diagnose-test-e2e-gpu-kind GPU_KIND_CLUSTER=<name>
```
It dumps `ClusterPolicy` status, GPU-Operator pod state, events,
toolkit/device-plugin daemonset logs, `/dev/nvidia*` inside the Kind
worker, and the `nvidia` stanza of the worker's containerd config.
For deeper design notes (why GPU Operator over the bare device plugin,
how the volume-mount marker propagates GPUs into the Kind worker, the
test specs themselves), see [`AGENTS.md`](./AGENTS.md).
### Pushing a Custom Operator Image
```bash
# Docker Hub
docker login
make docker-build docker-push IMG=docker.io/<your-user>/lmcache-operator:latest
make deploy IMG=docker.io/<your-user>/lmcache-operator:latest
# Private registry
docker login <your-registry>
make docker-build docker-push IMG=<your-registry>/lmcache-operator:latest
make deploy IMG=<your-registry>/lmcache-operator:latest
# Multi-platform (amd64 + arm64)
make docker-buildx IMG=<your-registry>/lmcache-operator:latest
```
If your cluster needs pull credentials, create a secret and reference it in `config/manager/manager.yaml`:
```bash
kubectl create secret docker-registry regcred \
--docker-server=<your-registry> \
--docker-username=<username> \
--docker-password=<password> \
-n lmcache-operator-system
```
## License
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.