--- title: Serve models from a PVC description: "Serve model weights that already live on a PersistentVolumeClaim by pointing a BaseModel at a pvc:// URI, with no download to nodes." --- Every serving pod reads the model from the same PersistentVolumeClaim, so you keep one copy of the weights, and the [model agent](../operate-ome/model-agent.md) downloads nothing to the nodes. You point a [BaseModel](../../concepts/models/base-models.md) at the claim with a `pvc://` URI, and a metadata Job reads the model's details from the claim. Then the pods of an [InferenceService](../../concepts/serving/inference-services.md) mount the claim read-only. The steps serve `meta-llama/Llama-3.2-1B-Instruct`.
- OME installed, and `kubectl` access to the cluster. See [Install OME](../../getting-started/install.md). - A Bound PersistentVolumeClaim `model-storage` in the namespace `llama-demo`, with the access mode `ReadOnlyMany` or `ReadWriteMany`. The metadata Job and the serving pods can run on different nodes, and each of them mounts the claim. - On the claim, the files of `meta-llama/Llama-3.2-1B-Instruct` in a Hugging Face layout, `config.json` plus the weight files, in the directory `llama-3-2-1b-instruct` at the root of the volume. The metadata Job runs as user 65532, so that user must be able to read them. World-readable files work. - The [ClusterServingRuntime](../../concepts/runtimes/serving-runtimes.md) `srt-llama-3-2-1b-instruct`. Check with `kubectl get clusterservingruntime srt-llama-3-2-1b-instruct`. If it's missing, apply [`config/runtimes/srt/meta/llama-3-2-1b-instruct-rt.yaml`](https://github.com/ome-projects/ome/blob/main/config/runtimes/srt/meta/llama-3-2-1b-instruct-rt.yaml) from a clone of the OME repository. - An amd64 node with an NVIDIA GPU, 10 CPUs and 30 GiB of memory free, which is what the runtime requests.
## The `pvc://` storage URI A `pvc://` URI names the claim and the directory on it that holds the model. Its form depends on the kind of model: | Kind | URI form | Example | | --- | --- | --- | | BaseModel | `pvc://{pvc-name}/{sub-path}` | `pvc://model-storage/llama-3-2-1b-instruct` | | ClusterBaseModel | `pvc://{namespace}:{pvc-name}/{sub-path}` | `pvc://llama-demo:model-storage/llama-3-2-1b-instruct` | A BaseModel uses a claim in its own namespace. A ClusterBaseModel names the claim's namespace before the colon. Either way, the metadata Job runs in the claim's namespace. The InferenceServices that serve the model must run there too, because a pod can only mount a claim in its own namespace. The sub-path is required. It's the path from the root of the volume to the directory that holds `config.json`, or `model_index.json` for a diffusion model. It can be several levels deep, such as `models/llama-3-2-1b-instruct`. Serving pods mount it read-only at `/opt/ml/model`, and OME sets `MODEL_PATH` to that path. ## Step 1: Verify the PVC Check the claim's status and access mode: ```bash kubectl get pvc model-storage -n llama-demo \ -o 'custom-columns=NAME:.metadata.name,STATUS:.status.phase,ACCESS:.status.accessModes[*]' ``` ```output NAME STATUS ACCESS model-storage Bound ReadOnlyMany ``` `STATUS` must be `Bound`, and `ACCESS` should be `ReadOnlyMany` or `ReadWriteMany`. ## Step 2: Create the model Create a BaseModel in `llama-demo` that points at the claim. For a ClusterBaseModel, see [Use a ClusterBaseModel](#use-a-clusterbasemodel). Leave out `modelArchitecture`, `modelFormat` and the other metadata fields. OME fills them in from the model's files in Step 3, and keeps any that you set. ```yaml title="model.yaml" apiVersion: ome.io/v1beta1 kind: BaseModel metadata: name: llama-3-2-1b-instruct namespace: llama-demo spec: storage: storageUri: "pvc://model-storage/llama-3-2-1b-instruct" ``` Apply the file: ```bash kubectl apply -f model.yaml ``` ```output basemodel.ome.io/llama-3-2-1b-instruct created ``` ## Step 3: Watch the model become Ready OME now runs a metadata Job that reads the model's `config.json` from the claim. From it, OME fills in the model's empty metadata fields and sets the state to `Ready`. Watch the state: ```bash kubectl get basemodel llama-3-2-1b-instruct -n llama-demo -w \ -o custom-columns=NAME:.metadata.name,STATE:.status.state ``` ```output NAME STATE llama-3-2-1b-instruct In_Transit llama-3-2-1b-instruct Ready ``` Press Ctrl+C once the state is `Ready`. A state can repeat, and the column shows `` until OME sets one. Check what OME recorded: ```bash kubectl get basemodel llama-3-2-1b-instruct -n llama-demo \ -o custom-columns=ARCHITECTURE:.spec.modelArchitecture,FORMAT:.spec.modelFormat.name,FRAMEWORK:.spec.modelFramework.name,VERSION:.spec.modelFramework.version ``` ```output ARCHITECTURE FORMAT FRAMEWORK VERSION LlamaForCausalLM safetensors transformers 4.45.0.dev0 ``` `VERSION` comes from `transformers_version` in your copy's `config.json`, so yours can differ. [What OME learns from the model](../../concepts/models/base-models.md#what-ome-learns-from-the-model) lists the other fields that OME records. ## Step 4: Deploy an InferenceService Once the model is `Ready`, create an InferenceService that serves it: ```yaml title="isvc.yaml" apiVersion: ome.io/v1beta1 kind: InferenceService metadata: name: llama-3-2-1b-instruct namespace: llama-demo spec: model: name: llama-3-2-1b-instruct kind: BaseModel runtime: name: srt-llama-3-2-1b-instruct engine: minReplicas: 1 maxReplicas: 1 ``` `spec.model.kind` defaults to `ClusterBaseModel`, so `isvc.yaml` sets `kind: BaseModel`. The runtime `srt-llama-3-2-1b-instruct` declares support for what Step 3 recorded. It sets `autoSelect: false`, so OME uses it only when an InferenceService names it. If the runtime doesn't declare support for your copy of the model, OME uses it anyway and records a `RuntimeCompatibilityAdvisory` event. Since v1.3, `kubectl apply` also prints a warning. See [Reference a runtime explicitly](reference-a-runtime-explicitly.md). Apply the file: ```bash kubectl apply -f isvc.yaml ``` ```output inferenceservice.ome.io/llama-3-2-1b-instruct created ``` Wait for the InferenceService to be ready. The first start pulls the SGLang image, which can take several minutes: ```bash kubectl wait --for=condition=Ready inferenceservice/llama-3-2-1b-instruct -n llama-demo --timeout=30m ``` ```output inferenceservice.ome.io/llama-3-2-1b-instruct condition met ``` ## Step 5: Send a request In a second terminal, forward a local port to the engine's Service: ```bash kubectl port-forward -n llama-demo svc/llama-3-2-1b-instruct-engine 8080:8080 ``` ```output Forwarding from 127.0.0.1:8080 -> 8080 Forwarding from [::1]:8080 -> 8080 ``` Send a chat completion request. `jq` prints the model's answer: ```bash curl -s http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "meta-llama/Llama-3.2-1B-Instruct", "messages": [{"role": "user", "content": "In one sentence, what is a PersistentVolumeClaim?"}], "max_tokens": 64}' \ | jq -r '.choices[0].message.content' ``` ```output A PersistentVolumeClaim (PVC) is a request for storage in Kubernetes that a pod uses to mount a PersistentVolume. ``` The answer varies from run to run. ## Use a ClusterBaseModel A ClusterBaseModel works too, but only InferenceServices in the claim's namespace can use it. Its URI names that namespace: ```yaml title="model.yaml" apiVersion: ome.io/v1beta1 kind: ClusterBaseModel metadata: name: llama-3-2-1b-instruct spec: storage: storageUri: "pvc://llama-demo:model-storage/llama-3-2-1b-instruct" ``` !!! warning "Name clash with the catalog" The [catalog of pre-configured models](../../getting-started/pre-configured-models.md) has a ClusterBaseModel with this name that downloads the model from Hugging Face. If `kubectl get clusterbasemodel llama-3-2-1b-instruct` finds one, applying `model.yaml` changes it. Pick another name, and use it wherever the steps name the model. Apply the file in place of the one in Step 2: ```bash kubectl apply -f model.yaml ``` ```output clusterbasemodel.ome.io/llama-3-2-1b-instruct created ``` Then continue from Step 3. In the `kubectl get basemodel` commands, use `clusterbasemodel` and leave out `-n llama-demo`. In `isvc.yaml`, leave out `kind: BaseModel`. The metadata Job and the InferenceService still run in `llama-demo`. ## Configure the metadata Job The metadata Job's settings are values of the `ome-resources` chart, under `ome.omeAgent.metadataJob`. Change them when the claim mounts only on some nodes, or when the Job needs other resources: | Value | Default | Description | | --- | --- | --- | | `serviceAccount` | `ome-model-metadata` | ServiceAccount the Job runs as. OME creates it in the claim's namespace if it's missing. | | `memoryRequest` | `256Mi` | Memory request. | | `memoryLimit` | `512Mi` | Memory limit. | | `cpuRequest` | `100m` | CPU request. | | `cpuLimit` | `500m` | CPU limit. | | `backoffLimit` | `2` | Retries before the Job fails. | | `ttlSecondsAfterFinished` | `3600` | Seconds that Kubernetes keeps a finished Job. | | `nodeSelector` | `{}` | Node labels that the Job's pod requires. | | `tolerations` | `[]` | Taints that the Job's pod tolerates. | | `affinity` | `{}` | Affinity rules for the Job's pod. | | `priorityClassName` | `""` | PriorityClass of the Job's pod. | A `0` for `backoffLimit` or `ttlSecondsAfterFinished` means the default, and an empty resource value sets no request or limit. The Job runs the ome-agent image, set by `ome.omeAgent.image` and `ome.omeAgent.tag`. To pull it from your own registry, see [Install from a private registry](../../getting-started/private-registries.md). For example, if the claim's CSI driver runs only on the GPU nodes, and those nodes carry the `nvidia.com/gpu` taint, set `nodeSelector` and `tolerations`: ```yaml title="values.yaml" ome: omeAgent: metadataJob: nodeSelector: nvidia.com/gpu.present: "true" tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule ``` Add the values to the values file you install `ome-resources` with. `helm list -n ome` shows the installed chart version in its `CHART` column, such as `ome-resources-1.2.2`. Upgrade the release with that version, so that OME stays in step with the `ome-crd` chart: ```bash helm upgrade --install ome oci://ghcr.io/moirai-internal/charts/ome-resources \ --namespace ome --version 1.2.2 -f values.yaml ``` ```output Pulled: ghcr.io/moirai-internal/charts/ome-resources:1.2.2 Digest: sha256:703ff2932198d1356ec6977e7bba0bdfa013f50088eb96d097d6e49bf88914f5 Release "ome" has been upgraded. Happy Helming! NAME: ome LAST DEPLOYED: Mon Sep 28 02:41:07 2026 NAMESPACE: ome STATUS: deployed REVISION: 2 TEST SUITE: None ``` The controller reads these values when it starts. Since v1.3, `helm upgrade` restarts it when they change. On v1.2.2, restart it yourself: ```bash kubectl rollout restart deployment ome-controller-manager -n ome ``` ```output deployment.apps/ome-controller-manager restarted ``` New settings apply to the Jobs that OME creates after the restart. To rerun a model's Job with them, delete the Job, as in [The metadata Job fails](#the-metadata-job-fails). ## Troubleshooting Start with the model's state, and the reason and message on its `Ready` condition: ```bash kubectl get basemodel llama-3-2-1b-instruct -n llama-demo \ -o 'custom-columns=STATE:.status.state,REASON:.status.conditions[?(@.type=="Ready")].reason,MESSAGE:.status.conditions[?(@.type=="Ready")].message' ``` For a missing claim: ```output STATE REASON MESSAGE Failed PVCNotFound PVC llama-demo/model-storage not found ``` | State | Reason | Meaning | Fix | | --- | --- | --- | --- | | `In_Transit` | `PVCNotBound` | The claim isn't Bound yet. | Wait for it to bind, or fix its storage class or volume. | | `In_Transit` | `PVCMetadataExtracting` | The metadata Job is running, or waiting for its pod to start. | If it doesn't finish, see [The metadata Job fails](#the-metadata-job-fails). | | `Failed` | `PVCInvalid` | The URI is malformed, or has the wrong form for the model's kind. | Fix the URI. See [The `pvc://` storage URI](#the-pvc-storage-uri). | | `Failed` | `PVCNotFound` | OME can't find the claim. | Create the claim, or fix its name in the URI. OME checks again on its own. | | `Failed` | `PVCConfigMissing` | The ome-agent image is unset, or OME fails to build the Job. The message says which. | Fix the `ome.omeAgent` values, as in [Configure the metadata Job](#configure-the-metadata-job). | | `Failed` | `PVCMetadataExtractionFailed` | The Job failed. The message has its error. | See [The metadata Job fails](#the-metadata-job-fails). | A `Ready` model shows `In_Transit` for a moment each time Kubernetes deletes its finished Job after `ttlSecondsAfterFinished`, because OME runs the Job again. Serving isn't affected. ### The metadata Job fails List the model's Jobs and pods with `kubectl get jobs,pods -n llama-demo -l models.ome/model-name=llama-3-2-1b-instruct`, and read the pods' logs with `kubectl logs -n llama-demo -l models.ome/model-name=llama-3-2-1b-instruct`: - If a pod stays `Pending`, `kubectl describe pod` shows why. If the claim mounts only on some nodes, set `nodeSelector` and `tolerations` as in [Configure the metadata Job](#configure-the-metadata-job). A sub-path that's missing from the volume also keeps the pod from starting. - `no model_index.json or config.json found in /model` means that the sub-path is wrong, or that user 65532 can't read the directory. - `permission denied` means that user 65532 can't read the model's files. When the fix is a new sub-path in the URI, OME starts a new Job on its own. For other fixes, delete the Job, and OME creates a new one: ```bash kubectl delete job -n llama-demo -l models.ome/model-name=llama-3-2-1b-instruct ``` ```output job.batch "llama-3-2-1b-instruct-metadata-26cec0b4" deleted from llama-demo namespace ``` The old failure can show until the new Job finishes. ### The webhook rejects the model `kubectl apply` prints the reason after `denied the request:`. For a ClusterBaseModel whose URI has no namespace: ```text ClusterBaseModel PVC URI must specify a namespace (format: pvc://{namespace}:{pvc-name}/{sub-path}), got "pvc://model-storage/llama-3-2-1b-instruct" ``` For a BaseModel whose URI has a namespace: ```text namespaced BaseModel PVC URI must not specify a namespace; the BaseModel's own namespace is used, got "pvc://llama-demo:model-storage/llama-3-2-1b-instruct" ``` Other malformed URIs get `invalid PVC storage URI`, with the problem at the end. Here the sub-path is missing: ```text invalid PVC storage URI "pvc://model-storage": invalid PVC storage URI format: missing subpath ``` Fix the URI to match [The `pvc://` storage URI](#the-pvc-storage-uri), and apply `model.yaml` again. ### Sharded distribution is rejected {since=v1.3} The webhook rejects a `pvc://` model with `distribution: Sharded`: ```text PVC storage URIs are not compatible with distribution=Sharded; use distribution=PerNode (or omit) for pvc:// models ``` Leave `distribution` out, or set it to `PerNode`. ### The webhook rejects the InferenceService Since v1.3, the webhook rejects `isvc.yaml` until OME records the model's format in Step 3: ```text runtime srt-llama-3-2-1b-instruct does not support model llama-3-2-1b-instruct: invalid model specification: modelFormat.name: model format name is required ``` Wait for the model to be `Ready`, as in [Step 3](#step-3-watch-the-model-become-ready), then apply `isvc.yaml` again. Since v1.3, the webhook also rejects an InferenceService that uses the ClusterBaseModel from another namespace, here `default`: ```text ClusterBaseModel "llama-3-2-1b-instruct" references PVC in namespace "llama-demo", but InferenceService is in namespace "default"; Kubernetes does not allow pods to mount PVCs from another namespace. Either move the InferenceService to namespace "llama-demo" or replicate the PVC into namespace "default". ``` Create the InferenceService in `llama-demo`. For other messages, see [The webhook rejects the InferenceService](../troubleshoot/troubleshoot-an-inferenceservice.md#the-webhook-rejects-the-inferenceservice). ### Serving pods don't start Run `kubectl describe pod -n llama-demo -l ome.io/inferenceservice=llama-3-2-1b-instruct`, and read the `Events` at the end of each pod's description: - If there are no pods, check that `spec.model.kind` in `isvc.yaml` matches the kind of model you created. See [An InferenceService that serves a BaseModel gets no pods](../troubleshoot/troubleshoot-an-inferenceservice.md#an-inferenceservice-that-serves-a-basemodel-gets-no-pods). - If a scheduling event says that no node fits, check that a node has the free GPU, CPU and memory listed in [Before you begin](#before-you-begin). - A multi-attach error, or another volume event, means that the pod's node can't mount the claim. Usually another node has mounted a `ReadWriteOnce` claim. Use a `ReadOnlyMany` or `ReadWriteMany` claim. - If the container starts and then exits, read its logs with `kubectl logs -n llama-demo -l ome.io/inferenceservice=llama-3-2-1b-instruct`. A sub-path that points at a parent of the model's directory can pass Step 3, but the runtime can't load the model from it. Point the sub-path at the directory that holds `config.json`. ## Clean up Delete the InferenceService and the model: ```bash kubectl delete inferenceservice llama-3-2-1b-instruct -n llama-demo kubectl delete basemodel llama-3-2-1b-instruct -n llama-demo ``` ```output inferenceservice.ome.io "llama-3-2-1b-instruct" deleted from llama-demo namespace basemodel.ome.io "llama-3-2-1b-instruct" deleted from llama-demo namespace ``` For a ClusterBaseModel, run `kubectl delete clusterbasemodel llama-3-2-1b-instruct` instead of the second command. Deleting the model deletes its metadata Jobs. The claim and the weights on it stay as they are. OME keeps the ServiceAccount `ome-model-metadata` in `llama-demo` and the RoleBinding `ome-model-metadata-llama-demo` in `ome`, so that other models in `llama-demo` can use them. To remove them: ```bash kubectl delete rolebinding -n ome -l models.ome/metadata-source-namespace=llama-demo kubectl delete serviceaccount ome-model-metadata -n llama-demo ``` ```output rolebinding.rbac.authorization.k8s.io "ome-model-metadata-llama-demo" deleted from ome namespace serviceaccount "ome-model-metadata" deleted from llama-demo namespace ``` OME creates them again the next time it runs a metadata Job in `llama-demo`. ## Next steps - [Base models](../../concepts/models/base-models.md): every storage scheme, and what OME learns from a model. - [Serve models from node-local storage](serve-models-from-local-storage.md): serve weights that are already on the nodes' disks, with a `local://` URI. - [Expose a service without ingress](../networking/expose-without-ingress.md): reach the model through a LoadBalancer or NodePort Service. - [Configure ingress](../networking/configure-ingress.md): expose the model through Kubernetes Ingress or Gateway API.