Chapter 6. Validate inference workload changes with controlled deployment


You can use controlled deployment with Distributed Inference with llm-d to validate engine upgrades, model version rotations, and configuration changes on a fraction of production traffic before committing to a full rollout. You deploy two or more versions of an inference workload side by side in the same routing group, control the traffic fraction each version receives using declarative weight-based traffic splitting, and compare per-version Prometheus metrics in real time.

6.1. Controlled deployment for inference workloads

You can use controlled deployment with Distributed Inference with llm-d to validate engine upgrades, model version rotations, and configuration changes on a fraction of production traffic before committing to a full rollout. Controlled deployment replaces all-or-nothing updates with a declarative, reversible workflow that avoids the risk of degrading inference quality for all users when a new version performs worse than expected.

Controlled deployment uses a symmetric group-and-weight model. Each LLMInferenceService independently declares the routing group it belongs to and its proportional share of traffic. There is no distinction between a "baseline" and a "canary" version: all members of a routing group are peers that share weighted traffic distribution through a Gateway API HTTPRoute.

6.1.1. How the group and weight model works

You assign a routing group by setting the spec.router.route.group field on each LLMInferenceService that participates in traffic splitting. You set the proportional traffic share by configuring the spec.router.route.weight field. The controller translates declared weights into Gateway API HTTPRoute weighted backendRef entries by using proportional semantics.

Weights are ratios, not percentages. There is no requirement that weights sum to 100. For example, if version A has weight 9 and version B has weight 1, the gateway distributes approximately 90% of traffic to version A and 10% to version B. You can use any integer values between 0 and 1,000,000 to express the required ratio. A weight of 0 removes the member from new request routing after gateway propagation completes. In-flight requests and requests arriving during the configuration propagation window can still reach the member. The propagation window is typically seconds, with no guaranteed upper bound. The backendRef remains in the HTTPRoute at weight 0 rather than being removed.

6.1.2. Version isolation

Each version in a controlled deployment is a fully independent LLMInferenceService resource with its own runtime pods, InferencePool, and inference scheduler instance. This per-version isolation ensures that a failed version cannot affect the other version’s scheduling, KV cache state, or resource allocation.

Running multiple versions requires provisioning separate compute resources, including GPU nodes, for each version.

6.1.3. Per-member HTTPRoute lifecycle

Each LLMInferenceService in a routing group creates and owns its own HTTPRoute. Every member’s HTTPRoute carries weighted backendRef entries for all active, non-stopped members of the group that serve the same model. Gateway API precedence rules select the oldest matching route as the active route for traffic decisions.

The controller uses finalizers to manage HTTPRoute lifecycle safely. When you delete a version, the controller removes the corresponding backendRef from peer members' HTTPRoutes before allowing the deletion to proceed. A member’s own HTTPRoute is deleted when the member itself is removed.

6.1.4. Observability

Per-version metric collection requires creating PodMonitor and ServiceMonitor resources for each version. A PodMonitor with a relabeling rule injects an llm_isvc_name label into vLLM metrics, enabling cross-version comparison of inference health metrics such as time to first token, throughput, error rate, and queue depth. For more information about configuring per-version observability, see Monitor per-version inference metrics during controlled deployment.

6.1.5. Controlled deployment lifecycle

A controlled deployment follows four phases:

  1. Deploy: Create a second LLMInferenceService with the same routing group name as the production version. Start with a low weight, such as 1, so only a small fraction of traffic reaches the new version.
  2. Observe: Compare per-version inference metrics, such as time to first token, throughput, error rate, and queue depth, using Prometheus queries filtered by llm_isvc_name. Use the metrics comparison to make an evidence-based promotion or rollback decision.
  3. Promote or roll back: Shift traffic by adjusting weights on both versions. To promote, set the old version weight to 0. To roll back, set the new version weight to 0 and restore the original version weight.
  4. Clean up: After promotion, optionally force-stop the old version to reclaim GPU resources. Delete the old version only after verifying that all group members have reconciled.

Promotion by weight change and deletion by resource cleanup are separate operations. You can separate them by hours or days, keeping the old version available for rollback while it receives no traffic.

6.1.6. Constraints

The following constraints apply to controlled deployments:

  • All members of a routing group must be in the same namespace.
  • Controlled deployment requires Gateway API mode. Ingress mode is not supported.
  • Controlled deployment requires controller-managed routes. Custom HTTPRoute refs, also known as bring-your-own (BYO) routes, are not supported with traffic splitting.
  • All members of a routing group must have the same authentication posture: the security.opendatahub.io/enable-auth annotation must have the same value on every member. A mismatch produces an AuthPostureMismatch warning event.

6.1.7. Model access patterns

When you deploy multiple versions in a routing group, clients can access the model through different URL patterns:

Per-participant path
A direct URL to a specific LLMInferenceService instance, following the pattern /_<namespace>_/_<llmisvc-name>_. This path is version-specific and changes when you rotate versions.
Publisher path
A stable, version-independent URL that follows the pattern /publishers/_<namespace>_/models/_<model-name>_. This path survives version rotations because it references the model name rather than the LLMInferenceService instance name. Publisher paths require model-level RBAC authorization.
Model-routing header
A request-level header that routes traffic to a specific model through the gateway scheduler. Model-routing uses the model name for routing decisions and does not require a version-specific URL.

6.1.8. Scope limitations

The following capabilities are not supported in controlled deployment:

  • Automated promotion or rollback based on metric thresholds. All promotion and rollback decisions are manual.
  • A/B testing with user segmentation or cookie-based session persistence.
  • KV cache migration between versions.
  • Prefill-decode disaggregated controlled deployment. Although the routing group controller does not block disaggregated workloads, this combination has not been validated.

You can deploy a canary version of an inference workload alongside an existing production version to test changes on a fraction of live traffic. By assigning both versions to the same routing group and configuring their weights, you control how much traffic each version receives while avoiding an all-or-nothing rollout.

Prerequisites

  • You have a production LLMInferenceService deployed and serving traffic.
  • Your LLMInferenceService uses Gateway API mode with controller-managed routes. Ingress mode and custom HTTPRoute refs, also known as bring-your-own (BYO) routes, are not supported with traffic splitting.
  • You have sufficient compute resources, such as GPUs, to run two versions of the inference workload simultaneously.
  • Prometheus metrics collection is enabled for your Distributed Inference with llm-d deployment.
  • You have access to the OpenShift CLI (`oc`).

Procedure

  1. Add the group and weight fields to your existing production LLMInferenceService if they are not already set.

    Open your production LLMInferenceService manifest and add spec.router.route.group and spec.router.route.weight:

    apiVersion: serving.kserve.io/v1alpha2
    kind: LLMInferenceService
    metadata:
      name: <production-llmisvc-name>
      namespace: <namespace>
    spec:
      model:
        name: <model-name>
        uri: <model-uri>
      router:
        route:
          group: <group-name>
          weight: 9
        gateway: {}
        scheduler: {}
      # ...existing spec fields...

    where:

    _<production-llmisvc-name>_
    Specifies the name of your existing production LLMInferenceService.
    _<namespace>_
    Specifies the namespace where the production deployment runs.
    _<model-name>_
    Specifies the model name.
    _<model-uri>_
    Specifies the URI from where the model is downloaded.
    _<group-name>_
    Specifies the routing group name. Use 1 to 63 lowercase alphanumeric characters or hyphens, matching the pattern ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$.

    Apply the updated manifest:

    $ oc apply -f <production-llmisvc-file>.yaml
  2. Create a manifest for the canary LLMInferenceService with the same routing group name as the production version, starting at weight 1:

    apiVersion: serving.kserve.io/v1alpha2
    kind: LLMInferenceService
    metadata:
      name: <canary-llmisvc-name>
      namespace: <namespace>
    spec:
      model:
        name: <model-name>
        uri: <canary-model-uri>
      replicas: <replica-count>
      router:
        route:
          group: <group-name>
          weight: 1
        gateway: {}
        scheduler: {}
      template:
        containers:
        - name: main
          resources:
            limits:
              nvidia.com/gpu: "<gpu-count>"
            # ...resource configuration...

    where:

    _<canary-llmisvc-name>_
    Specifies a name for the canary version that is distinct from the production version name.
    _<canary-model-uri>_
    Specifies the model URI for the canary version, such as a new model revision or engine build.
    _<group-name>_
    Specifies the same routing group name used by the production version.
    _<model-name>_
    Specifies the model name for the canary version. In a typical canary deployment, use the same model name as the production version.
    Important

    Start the canary weight at a small nonzero value such as 1 rather than 0. Deploying at weight 0 and then increasing the weight later can cause transient connection failures while the backend warms up. A small nonzero weight ensures the backend cluster is warmed immediately with minimal traffic. With a production weight of 9 and a canary weight of 1, approximately 10% of traffic goes to the canary version.

  3. Apply the canary manifest:

    $ oc apply -f <canary-llmisvc-file>.yaml
  4. Wait for the GroupReady condition to become True:

    $ oc wait llminferenceservice <canary-llmisvc-name> -n <namespace> \
        --for=condition=GroupReady --timeout=300s
  5. Verify the routing group membership by listing all LLMInferenceService resources in the namespace:

    $ oc get llminferenceservice -n <namespace> \
        -o custom-columns=NAME:.metadata.name,GROUP:.status.router.group.name,WEIGHT:.spec.router.route.weight,READY:.status.conditions[?(@.type=="GroupReady")].status

    The output lists all group members with their configured weights and GroupReady status:

    NAME                            GROUP            WEIGHT   READY
    _<production-llmisvc-name>_     _<group-name>_   9        True
    _<canary-llmisvc-name>_         _<group-name>_   1        True

    Verify that both versions belong to the same routing group and that GroupReady is True on both.

  6. Adjust the traffic split to shift more traffic to the canary by updating the weight fields on both versions.

    For example, to shift to a 50/50 split:

    $ oc patch llminferenceservice <production-llmisvc-name> -n <namespace> \
        --type merge -p {"spec":{"router":{"route":{"weight":5}}}}
    $ oc patch llminferenceservice <canary-llmisvc-name> -n <namespace> \
        --type merge -p {"spec":{"router":{"route":{"weight":5}}}}

Verification

  • Verify that the canary version is serving inference requests:

    $ curl -s -H "Authorization: Bearer <token>" \
        "$(oc get llminferenceservice <canary-llmisvc-name> -n <namespace> \
        -o jsonpath={.status.url})/v1/models"

    A successful response confirms that the canary version is ready to handle traffic.

Note

All members of a routing group must have matching security.opendatahub.io/enable-auth annotation values. If the authentication posture does not match across members, the controller emits an AuthPostureMismatch warning event. Check for mismatch events by running:

$ oc get events -n <namespace> --field-selector reason=AuthPostureMismatch

You can compare inference metrics across versions during a controlled deployment to make evidence-based promotion or rollback decisions. Per-version filtering requires a PodMonitor that injects an llm_isvc_name label into vLLM metrics through Prometheus relabeling.

Prerequisites

  • Prometheus metrics collection is enabled for your Distributed Inference with llm-d deployment.
  • You have an active controlled deployment with two or more LLMInferenceService versions in the same routing group.
  • You have access to the OpenShift web console or a Prometheus-compatible query interface.
  • You have access to the OpenShift CLI (`oc`).

Procedure

  1. Create a PodMonitor to scrape vLLM metrics with per-version labeling.

    vLLM exposes metrics on port 8000, but its model_name label is the same across versions that serve the same model. To distinguish versions, the PodMonitor copies the pod’s app.kubernetes.io/name label into an llm_isvc_name metric label:

    apiVersion: monitoring.coreos.com/v1
    kind: PodMonitor
    metadata:
      name: llmisvc-vllm
      namespace: <namespace>
    spec:
      selector:
        matchLabels:
          kserve.io/component: workload
      podMetricsEndpoints:
        - portNumber: 8000
          path: /metrics
          interval: 10s
          params:
            format:
              - prometheus
          relabelings:
            - sourceLabels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
              targetLabel: llm_isvc_name

    Apply the PodMonitor:

    $ oc apply -f <podmonitor-file>.yaml
  2. Verify that per-version metrics are available in Prometheus by checking for distinct llm_isvc_name values:

    vllm:request_success_total{llm_isvc_name=~"<v1-name>|<v2-name>"}

    Replace _<v1-name>_ and _<v2-name>_ with the LLMInferenceService names for each version. If the llm_isvc_name label is missing, verify that the PodMonitor is active and that the relabeling rule is correct.

  3. Verify that the actual traffic distribution matches your configured weights by comparing request rates across versions:

    sum by(llm_isvc_name) (
      rate(vllm:request_success_total{llm_isvc_name=~"<v1-name>|<v2-name>"}[5m])
    )

    The ratio of request rates between versions should approximate the ratio of configured weights. For example, a 9:1 weight split should produce approximately 90% and 10% of total requests per version.

  4. Compare time to first token across versions by running the following PromQL query in the OpenShift web console or your Prometheus interface:

    histogram_quantile(0.95,
      sum by(le, llm_isvc_name) (
        rate(vllm:time_to_first_token_seconds_bucket{llm_isvc_name=~"<v1-name>|<v2-name>"}[5m])
      )
    )
  5. Compare additional key metrics using PromQL queries filtered by llm_isvc_name.

    The following table lists recommended metrics for promotion decisions.

    Table 6.1. Key metrics for cross-version comparison
    MetricPromQL query pattern

    Generation throughput

    sum by(llm_isvc_name) (rate(vllm:generation_tokens_total{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m]))

    Request success rate

    sum by(llm_isvc_name) (rate(vllm:request_success_total{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m]))

    Queue depth

    sum by(llm_isvc_name) (vllm:num_requests_waiting{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"})

    End-to-end latency (P95)

    histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:e2e_request_latency_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

    KV cache utilization

    avg by(llm_isvc_name) (vllm:kv_cache_usage_perc{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"})

  6. If end-to-end latency is higher than expected, isolate whether the cause is a capacity issue or an engine bottleneck by comparing queue time against prefill and decode time:

    Table 6.2. Latency breakdown queries
    ComponentPromQL query pattern

    Queue time (P95)

    histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:request_queue_time_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

    Prefill time (P95)

    histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:request_prefill_time_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

    Decode time (P95)

    histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:request_decode_time_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

    If queue time is significantly higher than prefill and decode time combined, the version needs more replicas. If prefill or decode time is high but queue time is low, the latency is an engine-level bottleneck rather than a capacity issue.

  7. Based on the metric comparison, decide whether to promote or roll back.

    Table 6.3. Decision criteria for controlled deployment
    SignalPromoteRoll back

    Error rate

    Equal to or lower than baseline

    Higher than baseline

    Time to first token

    Within acceptable threshold of baseline

    Significantly higher than baseline

    End-to-end latency

    Within acceptable threshold of baseline

    Significantly higher than baseline

    Queue depth

    Stable and not growing

    Growing, indicating the version cannot handle its traffic share

    KV cache utilization

    Within expected bounds

    Approaching capacity or significantly higher than baseline

    Pod health

    All pods running and ready

    Pod restarts, readiness failures, or reduced pod count

Note

Automated promotion or rollback based on metric thresholds is not supported. All promotion and rollback decisions during controlled deployment are manual. Use the metrics comparison to inform your decision, then follow the promotion or rollback procedures.

6.4. Promote or roll back a controlled deployment

After you deploy a canary version and compare per-version inference metrics, you can promote the canary to full traffic or roll back to the previous version. You can also force-stop a version to reclaim GPU resources, and delete old versions after successful promotion.

Prerequisites

Procedure

  1. To promote the canary version, set the production version weight to 0 so that all traffic goes to the canary:

    $ oc patch llminferenceservice <production-llmisvc-name> -n <namespace> \
        --type merge -p {"spec":{"router":{"route":{"weight":0}}}}

    The production version pods remain active but idle. Because the production version still exists in the routing group at weight 0, you can roll back by restoring its weight.

  2. To roll back during a controlled deployment, restore the original version weight and set the canary version weight to 0:

    $ oc patch llminferenceservice <production-llmisvc-name> -n <namespace> \
        --type merge -p {"spec":{"router":{"route":{"weight":9}}}}
    $ oc patch llminferenceservice <canary-llmisvc-name> -n <namespace> \
        --type merge -p {"spec":{"router":{"route":{"weight":0}}}}
    Note

    Rollback options depend on the state of the original version:

    • If the original version still exists with weight 0, adjust weights back to restore traffic.
    • If the original version was stopped with the serving.kserve.io/stop annotation, remove the annotation and restore the weight.
    • If the original version was deleted, you must redeploy it from the original manifest.
  3. Optional: To reclaim GPU resources from a version that is no longer receiving traffic, force-stop it by applying the serving.kserve.io/stop annotation:

    $ oc annotate llminferenceservice <llmisvc-name> -n <namespace> \
        serving.kserve.io/stop=true

    A stopped member remains in the routing group but is omitted from backendRef entries in peer members' HTTPRoutes, regardless of the spec.router.route.weight value. The member stopped field in status.router.group.members is set to true.

    To resume a stopped version, remove the annotation:

    $ oc annotate llminferenceservice <llmisvc-name> -n <namespace> \
        serving.kserve.io/stop-
  4. To delete an old version after successful promotion, delete the LLMInferenceService resource:

    $ oc delete llminferenceservice <old-llmisvc-name> -n <namespace>
    Warning

    Do not delete a version that has a weight greater than 0. Deleting a member with active traffic causes 500 errors for in-flight requests until the remaining group members reconcile. Always set the weight to 0 and verify that the GroupReady condition reflects the updated group membership before deleting.

    The control plane holds the deletion until peer members remove the corresponding backendRef from their HTTPRoutes, so in-flight requests to other members are not disrupted.

Verification

  • After promotion or rollback, verify the routing group state:

    $ oc get llminferenceservice -n <namespace> \
        -o custom-columns=NAME:.metadata.name,GROUP:.status.router.group.name,WEIGHT:.spec.router.route.weight,READY:.status.conditions[?(@.type=="GroupReady")].status
  • After force-stopping a version, verify that the member shows as stopped by querying a peer member that is still running in the same routing group:

    $ oc get llminferenceservice <peer-llmisvc-name> -n <namespace> \
        -o jsonpath={.status.router.group.members} | jq .

    The peer’s status.router.group.members lists the stopped member with "stopped": true. The weight field reports the member’s declared spec.router.route.weight value.

    Note

    Query a peer member, not the stopped service itself. The stopped service’s own status.router is cleared when the stop annotation is applied.

The following table summarizes each lifecycle operation, its precondition, and expected outcome.

Table 6.4. Controlled deployment lifecycle operations
OperationCommandPreconditionExpected outcome

Promote canary

oc patch old version weight to 0

Both versions at GroupReady=True

All traffic goes to canary. Old version pods remain active but idle.

Roll back

oc patch canary weight to 0, restore old version weight

Old version still exists in group

All traffic returns to old version. Canary pods remain active but idle.

Force-stop

oc annotate with serving.kserve.io/stop=true

Version weight is 0

GPU resources are reclaimed. Member remains in group with stopped=true and is omitted from backendRef entries.

Delete

oc delete llminferenceservice

Version weight is 0 and GroupReady=True

Resource removed. Control plane holds deletion until peers reconcile.

6.5. Authorization model for controlled deployment

When you deploy multiple versions of an inference workload in a routing group, you can choose between two RBAC authorization patterns: instance-level access that targets a specific LLMInferenceService by name, or model-level access that targets a model name shared across all routing group members. Model-level access provides a stable authorization identity that does not change when you rotate versions.

Instance-level RBAC uses SubjectAccessReview (SAR) checks against the serving.kserve.io/llminferenceservices resource with the get verb. The resource name is the LLMInferenceService name extracted from the per-participant path URL. This pattern works for standalone deployments, but breaks during version rotations because the LLMInferenceService name changes when you create a new version.

Model-level RBAC uses SAR checks against the serving.opendatahub.io/models virtual resource with the post verb. The model name is extracted from the publisher-path URL pattern /publishers/_<namespace>_/models/_<model-name>_. Because the model name is defined by spec.model.name, which is the same across all routing group members, access grants survive version rotations without requiring per-instance RBAC updates for every new version.

Table 6.5. Two parallel RBAC domains
Access patternURL patternSAR resourceSAR verb

Per-participant (instance-level)

/_<namespace>_/_<llmisvc-name>_/v1/...

serving.kserve.io/llminferenceservices

get

Publisher path (model-level)

/publishers/_<namespace>_/models/_<model-name>_/v1/...

serving.opendatahub.io/models

post

6.5.2. The models virtual resource

The models resource in the serving.opendatahub.io API group is a virtual resource. It has no backing Custom Resource Definition (CRD), and oc get models.serving.opendatahub.io returns a "not found" error. This is by design: the resource exists only as a SAR authorization contract that the controller-managed AuthPolicy evaluates at request time.

The resourceNames values used in RBAC roles for model-level access follow the publisher-path format: publishers/_<namespace>_/models/_<model-name>_. These are path-shaped strings, not standard Kubernetes resource names.

6.5.3. Cross-tenant deny rule

The controller creates an AuthPolicy rule named deny-misrouted-model-header that blocks requests meeting all of the following conditions:

  • The path is not a publisher path: the path does not start with /publishers/.
  • The path is not a batch path: the path does not start with /v1/files or /v1/batches.
  • A valid model routing header is present in the request.

This rule prevents a request from being routed by one tenant’s model header while being authorized by a different tenant’s per-participant path identity. The deny rule fires at priority 0, before any SAR-based authorization rules, and returns an immediate 403 Forbidden response without an API server round-trip.

6.5.4. Backward compatibility

Model-level RBAC is additive. Existing per-participant path RBAC continues to work unchanged. You can use instance-level access for standalone deployments and model-level access for routing groups, or both patterns together in the same cluster.

Namespace viewers, editors, and administrators automatically receive model-level access through ClusterRole aggregation. The kserve-models-view ClusterRole aggregates to view, kserve-models-edit aggregates to edit, and kserve-models-admin aggregates to admin. No additional RBAC configuration is needed for users who already have namespace-level access.

6.5.5. Known limitations

  • Model names containing a literal /v1/ segment are not supported because the authorization rule extracts the model name from the URL path using /v1/ as a delimiter.
  • Direct /v1/ inference endpoints with a model routing header are denied by the deny-misrouted-model-header rule. This is a planned follow-up for backend-based routing (BBR) integration.

You can grant model-level RBAC access so that inference consumers can use publisher-path URLs to access models without needing per-instance RBAC updates on every version rotation. Model-level access uses the serving.opendatahub.io/models virtual resource for authorization, providing a stable identity that survives version changes in routing groups.

Note

Namespace viewers, editors, and administrators automatically receive model-level access through ClusterRole aggregation without additional configuration. You only need to create explicit Role and RoleBinding resources when you want to grant model-level access to users or service accounts that do not have namespace-level roles.

Prerequisites

  • Authentication is enabled on your LLMInferenceService resources. To verify, run oc get llminferenceservice _<name>_ -n _<namespace>_ -o jsonpath='{.metadata.annotations.security\.opendatahub\.io/enable-auth}' and confirm the result is true or empty, which indicates the default enabled state. For more information, see Enable authentication and authorization for an LLM inference service.
  • You have access to the OpenShift CLI (`oc`).
  • You have admin or cluster-admin permissions on the namespace where the LLMInferenceService resources are deployed.

Procedure

  1. Determine whether to use model-level or instance-level RBAC for your access pattern.

    Table 6.6. When to use each RBAC pattern
    Access patternUse model-level RBACUse instance-level RBAC

    Controlled deployment with version rotations

    Yes. Publisher-path URLs reference the model name, which is stable across versions.

    Not recommended. Per-participant URLs reference the LLMInferenceService name, which changes with each new version.

    Standalone deployment with a single version

    Optional. Model-level access works for standalone deployments.

    Yes. Per-participant URLs are stable when there is no version rotation.

    Batch processing with delegation

    Yes. Use the delegate sub-resource for batch processor service accounts.

    Not recommended. The batch processor should use model-level access for controlled deployments.

  2. Create a Role that grants post access to the models resource in the target namespace:

    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
      name: model-access
      namespace: <namespace>
    rules:
    - apiGroups:
      - serving.opendatahub.io
      resources:
      - models
      verbs:
      - post

    This grants access to all models in the namespace.

    Note

    The post verb and the models resource in the serving.opendatahub.io API group are virtual constructs enforced by the gateway authorization policy, not standard Kubernetes API resources. Running oc auth can-i post models.serving.opendatahub.io produces warnings about an unknown verb and resource type. These warnings are expected. To verify that model-level RBAC is configured correctly, send an authenticated inference request as shown in the Verification section.

    To restrict access to specific models, add a resourceNames field with publisher-qualified model names:

    rules:
    - apiGroups:
      - serving.opendatahub.io
      resources:
      - models
      verbs:
      - post
      resourceNames:
      - "publishers/<namespace>/models/<model-name>"
  3. Create a RoleBinding to bind the role to the target user or service account:

    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
      name: model-access-binding
      namespace: <namespace>
    subjects:
    - kind: User
      name: <username>
      apiGroup: rbac.authorization.k8s.io
    roleRef:
      kind: Role
      name: model-access
      apiGroup: rbac.authorization.k8s.io
  4. Apply the Role and RoleBinding:

    $ oc apply -f <role-file>.yaml
    $ oc apply -f <rolebinding-file>.yaml
  5. Optional: To grant delegation access for batch processing, create an additional Role that includes the delegate sub-resource:

    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
      name: model-access-delegate
      namespace: <namespace>
    rules:
    - apiGroups:
      - serving.opendatahub.io
      resources:
      - models/delegate
      verbs:
      - post-delegate

    Bind this role to the batch processor service account. Regular users who lack the post-delegate permission cannot forward requests on behalf of other users.

Verification

  • Send an authenticated request to a publisher-path URL to verify access:

    $ curl -H "Authorization: Bearer <token>" \
        https://<gateway-url>/publishers/<namespace>/models/<model-name>/v1/models

    A 200 OK response confirms that model-level RBAC is configured correctly. A 403 Forbidden response indicates that the user or service account lacks the required post permission on the models resource. A 500 Internal Server Error response might indicate that the LLMInferenceService backend is not ready rather than an RBAC misconfiguration. Before troubleshooting RBAC, verify that the LLMInferenceService shows Ready=True by running oc get llminferenceservice _<name>_ -n _<namespace>_.

6.6. Controlled deployment API fields

You can configure controlled deployment by using the spec.router.route.group and spec.router.route.weight fields on LLMInferenceService resources. The controller uses these fields to manage Gateway API HTTPRoute weighted backendRef entries for proportional traffic splitting across routing group members.

6.6.1. Spec fields for controlled deployment

The following fields are available on the LLMInferenceService resource under spec.router.route.

Table 6.7. Controlled deployment spec fields
FieldTypeRequiredDescription

spec.router.route.group

string

Yes, when using traffic splitting

The routing group name that this LLMInferenceService belongs to. All members with the same group name share weighted traffic distribution. The value must be 1 to 63 lowercase alphanumeric characters or hyphens, matching the pattern ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$. Must be set together with weight.

spec.router.route.weight

int32

Yes, when using traffic splitting

The proportional traffic share for this member. Follows Gateway API backendRef weight semantics where values are proportional ratios, not percentages. The valid range is 0 to 1,000,000. A weight of 0 removes the member from new request routing but the backendRef remains in the HTTPRoute. Must be set together with group.

6.6.2. Admission validation rules

The admission webhook validates the following cross-field constraints when you create or update an LLMInferenceService with traffic splitting fields.

Table 6.8. Traffic splitting validation rules
ConfigurationValidation result

Neither group nor weight is set

Valid. The LLMInferenceService operates as a standalone deployment.

weight is set without group

Rejected. The error message states that weight requires group.

group is set without weight

Rejected. The error message states that group requires weight.

Both group and weight are set

Valid, if no conflicting route configuration exists.

group and weight are set with route.http.refs (custom HTTPRoute refs)

Rejected. Traffic splitting requires controller-managed routes. Custom HTTPRoute refs are not supported.

6.6.3. Status fields for routing groups

The controller reports the observed routing group state in status.router.group.

Table 6.9. Routing group status fields
FieldTypeDescription

status.router.group.name

string

The name of the routing group this member belongs to.

status.router.group.members

array

A list of all observed group members, including their names, weights, stopped status, and resolved backend references.

status.router.group.members[].name

string

The name of the group member LLMInferenceService.

status.router.group.members[].weight

int32

The declared traffic weight for this member, as set in spec.router.route.weight.

status.router.group.members[].stopped

boolean

Set to true when the member has the serving.kserve.io/stop annotation. A stopped member remains in the group but is omitted from backendRef entries in peer members' HTTPRoutes.

status.router.group.members[].backendRef

object

The resolved backendRef for this member, pointing to the member’s InferencePool or Service.

6.6.4. Status conditions for routing groups

The controller sets the following conditions to report routing group health.

Table 6.10. Routing group conditions
ConditionDescription

GroupReady

Set to True when this member’s HTTPRoute has been admitted with the group’s weighted backendRef entries. This condition reflects only this member’s route; peers reconcile independently and may not have reached GroupReady at the same time.

GroupDegraded

Set to True with reason MemberDivergence when group members serve different models or LoRA adapter sets. The controller partitions the group so that each member’s HTTPRoute carries weighted backendRef entries only for members serving the same model, and status.router.group.members lists only same-model peers. Members in a different partition continue to serve their own model at their own publisher path. This condition does not block readiness: GroupReady and Ready can both be True while GroupDegraded is True. The controller emits a Warning event with reason MemberDivergence. Members that are not yet ready or not routable are silently excluded from the traffic split without setting this condition.

6.6.5. Weight semantics

Weights follow Gateway API proportional semantics as defined in Gateway Enhancement Proposal (GEP) 718:

  • Weights are ratios, not percentages. A weight of 9 and a weight of 1 produce a 90/10 traffic split, identical to a weight of 90 and a weight of 10.
  • There is no constraint that weights must sum to 100 or any other value.
  • A weight of 0 removes the member from new request routing after gateway propagation completes, but in-flight requests can still reach the member during the propagation window. The backendRef remains in the HTTPRoute at weight 0 rather than being removed. This is useful for completing the promotion lifecycle before deleting a version.
  • The valid range is 0 to 1,000,000.

6.6.6. Status addresses and URL patterns

The status.addresses field reports the network endpoints where the service is reachable. Each address entry includes a URL and can include an origin reference identifying the networking resource that produced it.

When an LLMInferenceService participates in a routing group, the following URL patterns are relevant:

Table 6.11. URL patterns for controlled deployment
URL patternDescriptionStable across version rotations

/_<namespace>_/_<llmisvc-name>_/v1/...

Per-participant path. Routes to a specific LLMInferenceService instance.

No. The URL changes when the instance name changes.

/publishers/_<namespace>_/models/_<model-name>_/v1/...

Publisher path. Routes to the model by name through weighted traffic splitting.

Yes. The URL references the model name, which is the same across all routing group members.

Publisher-path URLs require model-level RBAC authorization. For more information, see Authorization model for controlled deployment.

You can use the following vLLM Prometheus metrics to compare inference performance across versions during a controlled deployment. Per-version filtering uses the llm_isvc_name label, which is injected by the PodMonitor relabeling rule described in Monitor per-version inference metrics during controlled deployment.

6.7.1. Key metrics for cross-version comparison

The following table lists the vLLM metrics most relevant to promotion and rollback decisions during controlled deployment. Filter each query by llm_isvc_name to isolate per-version data.

Table 6.12. vLLM metrics for controlled deployment
Metric nameTypeDescriptionPromQL pattern for cross-version comparison

vllm:time_to_first_token_seconds

histogram

Time from request receipt to the first generated token.

histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:time_to_first_token_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

vllm:generation_tokens_total

counter

Total number of generated tokens.

sum by(llm_isvc_name) (rate(vllm:generation_tokens_total{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m]))

vllm:request_success_total

counter

Total number of successful inference requests. Use this metric to compare request success throughput across versions.

sum by(llm_isvc_name) (rate(vllm:request_success_total{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m]))

vllm:num_requests_waiting

gauge

Number of requests waiting in the inference queue.

sum by(llm_isvc_name) (vllm:num_requests_waiting{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"})

vllm:e2e_request_latency_seconds

histogram

End-to-end inference request latency, from request receipt to final response.

histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:e2e_request_latency_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

vllm:kv_cache_usage_perc

gauge

Percentage of KV cache currently in use.

avg by(llm_isvc_name) (vllm:kv_cache_usage_perc{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"})

vllm:request_queue_time_seconds

histogram

Time a request spent waiting in the queue before processing began. Compare against prefill and decode time to determine whether high latency is a capacity issue or an engine bottleneck.

histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:request_queue_time_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

vllm:request_prefill_time_seconds

histogram

Time spent on the prefill phase of a request.

histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:request_prefill_time_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

vllm:request_decode_time_seconds

histogram

Time spent on the decode phase of a request.

histogram_quantile(0.95, sum by(le, llm_isvc_name) (rate(vllm:request_decode_time_seconds_bucket{llm_isvc_name=~"_<v1-name>_|_<v2-name>_"}[5m])))

Replace _<v1-name>_ and _<v2-name>_ with the LLMInferenceService names for each version.

6.8. Model-level RBAC roles for inference access

OpenShift AI provides aggregate ClusterRoles that grant model-level access for publisher-path URLs. These ClusterRoles aggregate into the standard Kubernetes admin, edit, and view ClusterRoles so that namespace-level users automatically receive model-level access.

6.8.1. Aggregate ClusterRoles

The following aggregate ClusterRoles are available for model-level inference access.

Table 6.13. Model-level aggregate ClusterRoles
ClusterRoleVerbs grantedAggregates toDescription

kserve-models-admin

Inherits from kserve-models-edit

admin

Grants full model access. Aggregation role that inherits all rules from kserve-models-edit.

kserve-models-edit

post

edit, kserve-models-admin

Grants model inference access via publisher-path URLs. Users can send inference requests to models.

kserve-models-view

post

view

Grants model inference access via publisher-path URLs. The post verb is used because the virtual resource has no read semantics: model-level access is an inference action, not a read operation.

Note

Both kserve-models-edit and kserve-models-view grant the post verb. The models virtual resource has no distinct read semantics because accessing a model through a publisher-path URL is an inference action.

6.8.2. Virtual resource details

The authorization rules evaluate against the following virtual resource.

Table 6.14. Virtual resource for model-level RBAC
PropertyValue

API group

serving.opendatahub.io

Resource

models

Sub-resource

delegate (for batch processing delegation)

Verbs

post (inference access), post-delegate (delegation access)

Backing Custom Resource Definition (CRD)

None. This is a SubjectAccessReview (SAR)-only authorization contract.

6.8.3. AuthPolicy rules for model access

The controller generates the following AuthPolicy rules for LLMInferenceService resources with authentication enabled. When authentication is not set to anonymous, the policy applies at the Gateway level for all LLMInferenceService resources as a global rule.

Table 6.15. Controller-generated AuthPolicy rules
Rule nameFires whenSAR resourceSAR verbPriority

model-access-path

Path starts with /publishers/

serving.opendatahub.io/models

post

1

model-access-path-delegate

Path starts with /publishers/ and x-maas-user header is present

serving.opendatahub.io/models/delegate

post-delegate

1

deny-misrouted-model-header

All of the following conditions are true: the path does not match the publisher-path pattern /publishers/_<namespace>_/models/; the path does not match /v1/files, /v1/files/\*, /v1/batches, or /v1/batches/*; and a valid model routing header is present

N/A (immediate deny)

N/A

0

inference-access

Path has at least 2 segments, does not start with /v1/, and does not match the publisher-path pattern /publishers/_<namespace>_/models/. A path under /publishers/ without the /models/ segment is not recognized as a publisher path and falls through to this rule.

serving.kserve.io/llminferenceservices

get

1

inference-access-delegate

Same conditions as inference-access plus x-maas-user header

serving.kserve.io/llminferenceservices/delegate

post-delegate

1

The model routing header is x-gateway-model-name by default. You can override the header name per-cluster by setting ingress.modelBasedRoutingHeaderName in the inferenceservice-config ConfigMap. A valid header value matches the pattern ^publishers/[^/]+/models/.+$.

6.8.4. Publisher-path URL format

Publisher-path URLs follow this pattern:

/publishers/<namespace>/models/<model-name>/v1/<api-endpoint>

For example, the chat completions endpoint for a model named RedHatAI/Qwen3-8B-FP8-dynamic in the my-project namespace has the following publisher-path URL:

/publishers/my-project/models/RedHatAI/Qwen3-8B-FP8-dynamic/v1/chat/completions

The model name is extracted from the URL path segment between /models/ and /v1/. Multi-segment model names such as RedHatAI/Qwen3-8B-FP8-dynamic are supported.

6.9. Troubleshooting controlled deployment

If your controlled deployment is not working as expected, use the following troubleshooting steps to diagnose and resolve common issues.

  • Traffic is not splitting between versions:

    Verify that the HTTPRoute has weighted backend references for both versions:

    $ oc get httproute -n <namespace> -o yaml

    If only one backend reference is displayed, check that both LLMInferenceService resources declare the same spec.router.route.group value and that both have spec.router.route.weight set.

    Verify that both versions are ready:

    $ oc get llminferenceservice -n <namespace>

    A version that is not ready does not receive traffic even if its weight is nonzero.

  • The GroupReady condition is False:

    Check the condition details for the reason:

    $ oc get llminferenceservice <llmisvc-name> -n <namespace> \
        -o jsonpath={.status.conditions[?(@.type=="GroupReady")]}

    Common reasons:

    • FinalizationPending: A group member is being deleted and finalization has not completed. Wait for the deletion to finish.
    • If the condition is absent, verify that the group and weight fields are set on the LLMInferenceService.
  • The GroupDegraded condition shows MemberDivergence:

    Group members declare different spec.model.name values. The controller excludes divergent members from the traffic split and continues routing with matching members.

    Verify the model names on all group members:

    $ oc get llminferenceservice -n <namespace> \
        -o custom-columns=NAME:.metadata.name,MODEL:.spec.model.name,GROUP:.spec.router.route.group

    Update the divergent member’s spec.model.name to match the other group members.

  • The canary version is not receiving traffic:

    Check that the canary weight is greater than 0:

    $ oc get llminferenceservice <canary-llmisvc-name> -n <namespace> \
        -o jsonpath={.spec.router.route.weight}

    Verify that the canary version has ready pods. Check the LLMInferenceService status:

    $ oc get llminferenceservice <canary-llmisvc-name> -n <namespace> \
        -o jsonpath={.status.workloads.primary.readyReplicas}

    Or list the pods directly:

    $ oc get pods -n <namespace> -l app.kubernetes.io/name=<canary-llmisvc-name>

    If the version has zero ready pods, the gateway does not route traffic to it. Check the pod status for errors:

    $ oc get pods -n <namespace> -l app.kubernetes.io/name=<canary-llmisvc-name>,app.kubernetes.io/part-of=llminferenceservice
  • The HTTPRoute was not created:

    Controlled deployment requires Gateway API routing. Verify that you are not using Ingress-based routing or custom HTTPRoute references:

    $ oc get llminferenceservice <llmisvc-name> -n <namespace> -o yaml | grep -A5 router

    The spec.router section should include route.group and route.weight and should not include ingress or route.http.refs.

    Check the KServe controller logs for errors:

    $ oc logs -n redhat-ods-applications deployment/kserve-controller-manager --tail=50
  • The old version still receives traffic after setting its weight to 0:

    Active requests on the old version complete before traffic fully shifts. Wait for in-flight requests to finish.

    Verify that the HTTPRoute reflects the updated weights:

    $ oc get httproute -n <namespace> -o yaml

    If weights are not updated, check for errors in the KServe controller logs:

    $ oc logs -n redhat-ods-applications deployment/kserve-controller-manager --tail=50
  • A stopped version is unreachable after removing the stop annotation:

    After removing the serving.kserve.io/stop annotation, the version must become ready before it receives traffic. Wait for the version to become ready:

    $ oc wait llminferenceservice <llmisvc-name> -n <namespace> \
        --for=condition=Ready --timeout=300s

    If the version does not become ready, check the pod status for errors:

    $ oc get pods -n <namespace> -l app.kubernetes.io/name=<llmisvc-name>,app.kubernetes.io/part-of=llminferenceservice
  • Validation error when creating an LLMInferenceService with group or weight:

    Common validation failures:

    • weight requires group or group requires weight: Both fields must be specified together.
    • spec.router.route.group in body should match: The value must be a DNS-compatible label: lowercase alphanumeric characters or hyphens, 1 to 63 characters, starting and ending with an alphanumeric character.
    • spec.router.route.weight in body should be less than or equal to 1000000: The weight must be in the range 0 to 1,000,000.
    • traffic splitting requires Gateway API routing: Controlled deployment requires controller-managed Gateway API routes, not Ingress routing.
    • traffic splitting cannot be used with custom HTTPRoute refs: Use controller-managed routes instead of custom HTTPRoute references.
Red Hat logoGithubRedditYoutubeTwitter

Learn

Try, buy, & sell

Communities

About Red Hat

We deliver hardened solutions that make it easier for enterprises to work across platforms and environments, from the core datacenter to the network edge.

Making open source more inclusive

Red Hat is committed to replacing problematic language in our code, documentation, and web properties. For more details, see the Red Hat Blog.

About Red Hat Documentation

Legal Notice

Theme

© 2026 Red Hat
Back to top