Tune routing capacity polling Preview Unreleased: coming in v1.3
Cap a workload cluster's TrafficMap weight at the replicas that its endpoint reports it can serve, with an alpha poll set in the chart or for one InferenceService.
A capacity poll lets a workload cluster cap its share of an InferenceService's traffic at what it can serve. The control plane asks the cluster how many of the service's replicas can serve now, and counts no more than that in the cluster's TrafficMap weight. Use it when a cluster knows better than the control plane what can serve: for example, how many of its engine and decoder pairs work together during a rollout. A report can lower a cluster's weight but never raise it, and a failed poll leaves the weight as it would be without one.
Multi-cluster routing is alpha, still in development, and off by default. Its API, the capacity settings included, can change without notice. You turn it on with the ome-resources chart's values ome.multicluster.enabled, ome.multicluster.role and ome.multicluster.config.routing.enabled, as Step 1 shows.
How the poll caps a cluster's weight¶
A workload cluster that has admitted the InferenceService and reports an endpoint for it is a home, with an entry in the TrafficMap. OME polls a home every period, keeps its last samples readings, and takes the quorum-th lowest as its ceiling. With quorum: 2, the readings 4, 1 and 4 give a ceiling of 4, since one low reading can't set it alone, and 1, 4 and 1 give 1.
The entry's capacity.allocated becomes the lower of the admitted replicas and the ceiling, and the weight follows from it. A ceiling of 0 takes all of the home's traffic away, even with a probe's allFailedPolicy: PreserveTraffic.
After a failed poll, the home has no ceiling, and goes back to its admitted replicas until quorum polls in a row succeed. So a network fault can leave a home with more traffic than it can serve for a while, but can't take its traffic away. A home also starts over with no readings when the controller restarts or changes leader, when you change a capacity setting, or when its endpoint changes.
The report format¶
A poll is one request, with the poll's method and no body, to the entry's endpoint followed by the poll's path. The endpoint is the status.url of the service's copy on that workload cluster, so the poll URL depends on the cluster's ingress. For worker-a:
| Workload cluster's ingress | Poll URL |
|---|---|
| Gateway API with per-service subdomains | http://qwen3-0-6b.qwen3-0-6b.worker-a.example.com/capacity |
| Gateway API with the default shared host | http://llm.worker-a.example.com/qwen3-0-6b/qwen3-0-6b/capacity |
| Kubernetes Ingress | http://qwen3-0-6b.qwen3-0-6b.worker-a.example.com:8080/capacity, on the engine Service's port, not your Ingress controller's |
OME's HTTPRoute or Ingress sends all paths under the endpoint to the model server, which answers the poll with a 404. So add a route of your own that sends the poll URL to your reporter. For an HTTPRoute on the same Gateway, the Gateway API gives your longer path precedence over OME's. For a separate Ingress, precedence depends on your Ingress controller.
Your reporter answers with status 200 and a JSON body in the Report format, the only one that OME reads:
{"servable": 4, "observedAt": "2026-09-28T10:00:00Z"}| Field | What it holds |
|---|---|
servable |
Required. How many replicas the home can serve now, as a whole number, 0 or more. For separate engine and decoder components, count the pairs that work together. |
observedAt |
Required. When the reporter worked out the count, in RFC 3339 with a time zone. A report fails once it's maxAge old, or when it's from the future. |
- Count replicas, not utilization or request slots: OME compares the count with the admitted replicas.
- Answer at the path itself, with no login: the poll sends no credentials, and OME doesn't follow redirects.
- Keep the reporter's clock in step with the control plane's.
Step 1: Set the operator defaults¶
Set the poll for all routed InferenceServices under ome.multicluster.config.routing.capacity, in your ome-resources values file. Do this once they all have reporters: a service without one falls back on all its homes, and keeps its CapacityFallback condition True. To poll only some services, leave the block out, and give each a poll as in Step 2.
This example turns routing on, with the poll from the chart's comments:
ome:
multicluster:
enabled: true
role: control-plane
config:
routing:
enabled: true
capacity:
path: /capacity
method: GET
format: Report
samples: 10
quorum: 2
period: 10s
timeout: 3s
maxAge: 30sKeep your other values, such as a routing health probe, in the same file: helm upgrade resets the values that aren't in it to the chart's defaults.
| Field | What it sets |
|---|---|
path |
The path added to a home's endpoint, starting with /. Setting it turns the poll on. |
method |
GET or POST. |
format |
Report. |
options |
Leave it out: Report takes none. |
samples |
How many readings to keep for each home. At least 1, and at most the chart's routing.observer.maxSamples, 100 by default. |
quorum |
Which reading, from the lowest, sets the ceiling. At least 1, and at most samples. |
period |
How often to poll, as a duration such as 10s. At least the chart's routing.observer.minPeriod, 1s by default. |
timeout |
How long a poll can take. Shorter than period. |
maxAge |
How long a reading counts, from its observedAt. At least quorum times period. |
Once you set path, every field but options is required, or the controller doesn't start: see The controller rollout doesn't finish.
To choose the values:
- Raise
quorumwhen successive polls can reach different reporters behind a load balancer. A drop then applies only whenquorumreadings agree, after at leastquorumpolls. - Give
maxAgeroom abovequorumtimesperiodfor the time that the reporter holds a figure before it answers. A reporter that freezes loses its home's ceiling withinmaxAge.
Upgrade the release with the chart version that you already run, 1.3.0 or later:
helm upgrade --install ome oci://ghcr.io/moirai-internal/charts/ome-resources \
--namespace ome --version 1.3.0 -f values.yamlHelm reports that the release ome has been upgraded, and the controller restarts with the new settings. Check that it's back up:
kubectl rollout status deployment ome-controller-manager -n omeWhen the rollout finishes, kubectl prints:
deployment "ome-controller-manager" successfully rolled outStep 2: Set a poll for one service¶
Give one InferenceService its own poll in its spec.routing.capacity, with or without a poll in the chart. It replaces the chart's poll as a whole, so set every field but options. For endpoints that spread polls across several reporters, this poll keeps four readings, needs three to agree before it lowers a ceiling, and lets a reading count for four periods:
apiVersion: ome.io/v1beta1
kind: InferenceService
metadata:
name: qwen3-0-6b
namespace: qwen3-0-6b
spec:
model:
name: qwen3-0-6b
runtime:
name: srt-qwen3-0-6b
engine:
minReplicas: 2
maxReplicas: 2
placement:
policy: ClusterAffinity
mode: All
clusterAffinity:
- matchFields:
- key: metadata.name
operator: In
values: [worker-a, worker-b]
routing:
capacity:
path: /capacity
method: GET
format: Report
samples: 4
quorum: 3
period: 10s
timeout: 3s
maxAge: 40sThe API server rejects a poll that's missing a field, with the message an enabled capacity poll must specify path, method, format, period, timeout, samples, quorum, and maxAge. It accepts some other mistakes: see The TrafficMap doesn't pick up the poll.
kubectl apply -f isvc.yamlinferenceservice.ome.io/qwen3-0-6b configuredThe new settings start the homes over with no readings, so they get their ceilings from the third poll on, about 20 seconds later.
Step 3: Watch the capacity state¶
Check the TrafficMap, which has the InferenceService's name and namespace. Its CapacityFallback condition sums up the homes:
kubectl get trafficmap qwen3-0-6b -n qwen3-0-6b \
-o 'custom-columns=FALLBACK:.status.conditions[?(@.type=="CapacityFallback")].status,REASON:.status.conditions[?(@.type=="CapacityFallback")].reason,MESSAGE:.status.conditions[?(@.type=="CapacityFallback")].message'FALLBACK REASON MESSAGE
False EndpointCapacityAvailable endpoint capacity is available for all 2 home(s)While any home has no ceiling, it's True with the reason EndpointCapacityUnavailable: see A home falls back to its admitted replicas. Traffic map lists its other reasons.
List each home's weight and capacity:
kubectl get trafficmap qwen3-0-6b -n qwen3-0-6b \
-o jsonpath='{range .spec.entries[*]}{.cluster}{" "}{.weight}{" "}{.capacity.allocated}{" "}{.capacity.ready}{" "}{.capacity.source}{" "}{.capacity.reported}{"\n"}{end}'When worker-a reports 3 and worker-b reports 1, and both have two ready replicas:
worker-a 2 2 2 ControlPlane 3
worker-b 1 1 2 Endpoint 1On worker-b, the ceiling of 1 lowers allocated from 2 to 1, so source is Endpoint. On worker-a, the ceiling of 3 is above the two admitted replicas, so source stays ControlPlane. Its reported value is 3, which a failed poll would leave empty. The weights 2 and 1 give worker-a two thirds of the traffic.
OME leaves out allocated and ready when they're 0, so a missing number means 0. Traffic map describes the fields.
While the poll is on, the gauge ome_trafficmap_capacity_fallback_homes, with the labels namespace and trafficmap, counts the homes without a ceiling. Alert when it stays above 0.
kubectl ome placement endpoint shows the condition and a code for each home's fallback reason and, with -o wide, its capacity source. The command reads alpha features.
Turn the poll off¶
To turn the poll off for one InferenceService, whatever the chart sets, replace the capacity block in isvc.yaml with disabled: true:
spec:
routing:
capacity:
disabled: trueApply the file again:
kubectl apply -f isvc.yamlinferenceservice.ome.io/qwen3-0-6b configuredOME stops polling the service's homes, and CapacityFallback turns False with the reason CapacityPollingDisabled. Don't use spec.routing.enabled: false for this: it takes the service out of routing, and OME deletes its TrafficMap.
To stop the chart's poll, remove the capacity block from your values file, and run helm upgrade again. OME keeps polling for InferenceServices that set their own spec.routing.capacity.
The observer limits¶
One chart block, ome.multicluster.config.routing.observer, bounds the work that the capacity polls and the routing health probes create, across all routed InferenceServices. These are the chart's defaults:
ome:
multicluster:
config:
routing:
observer:
maxConcurrentReconciles: 8
maxConcurrentRequests: 16
maxResponseBytes: 65536
minPeriod: 1s
maxSamples: 100| Field | What it bounds |
|---|---|
maxConcurrentReconciles |
How many TrafficMaps OME updates at once. |
maxConcurrentRequests |
How many poll and probe requests OME has underway at once. The two kinds share the budget. |
maxResponseBytes |
How many bytes OME reads from an answer. A longer report fails its poll with reading capacity response: response exceeds configured limit of 65536 bytes. A probe stops reading there, and passes or fails on the status alone. |
minPeriod |
The shortest period a poll or a probe can set. |
maxSamples |
The most readings a poll's samples can keep. |
With routing.enabled: true, all five limits are required, even when the chart sets no poll or probe. A limit that you leave out of your values file keeps its default, but one set to 0, or removed with null, stops the controller from starting, like a bad poll: see The controller rollout doesn't finish. OME checks the chart's poll and probe against minPeriod and maxSamples when the controller starts, and a spec.routing block when it picks the block up, where a broken bound freezes the TrafficMap like any other mistake in the block.
A poll or probe waits for one of the maxConcurrentRequests slots before it's sent. The wait can't fail it, since its timeout starts with the request. But when many homes share the budget, the time between a home's attempts grows past period, which shows as a probe's lastProbeTime falling behind. Raise maxConcurrentRequests in your values file when that happens.
Troubleshooting¶
The controller rollout doesn't finish¶
With an unusable multi-cluster setting, the new controller pods exit when they start, and kubectl rollout status fails with deployment "ome-controller-manager" exceeded its progress deadline. Find the setting in the controller's log:
kubectl logs -n ome -l control-plane=ome-controller-manager --tail=-1 | grep 'invalid multi-cluster configuration'The error names the setting. For example:
- A period without a unit gives
routing.capacity.period: "10" is not a duration (use forms like "30s", "5m"). - A timeout as long as the period gives
routing.capacity.timeout (10s) must be shorter than routing.capacity.period (10s). - A period under the observer's floor gives
routing.capacity.period (500ms) must be at least routing.observer.minPeriod (1s). - An observer limit set to 0 or
nullgivesrouting.observer.maxSamples must be positive when routing is enabled.
Fix the value in your values file, and run helm upgrade again.
The TrafficMap doesn't pick up the poll¶
kubectl apply accepts some mistakes in spec.routing.capacity that OME refuses later. Until you fix them, OME stops polling and probing the service's homes, and its TrafficMap stops updating: it keeps its last weights, and Published turns False with the reason InvalidOwner. A new InferenceService gets no TrafficMap. A probe error has the same effect. Find the error in the controller's log:
kubectl logs -n ome -l control-plane=ome-controller-manager --tail=-1 | grep -E 'validate routing policy|resolve (capacity|probe) policy'For example:
| Problem | Error |
|---|---|
maxAge is shorter than quorum times period |
validate routing policy: spec.routing.capacity.maxAge (20s) must span at least quorum (3) period intervals |
A format other than Report, such as report |
resolve capacity policy: spec.routing.capacity.format "report" is not registered in this build (available: [Report]) |
period is under the chart's routing.observer.minPeriod |
resolve capacity policy: spec.routing.capacity.period (500ms) must be at least routing.observer.minPeriod (1s) |
samples is over the chart's routing.observer.maxSamples |
resolve capacity policy: spec.routing.capacity.samples (200) must not exceed routing.observer.maxSamples (100) |
Fix the field, and apply isvc.yaml again.
A home falls back to its admitted replicas¶
When the CapacityFallback condition is True, its message counts the homes without a ceiling, as in 1 of 2 home(s) are using control-plane capacity; inspect spec.entries[].capacity.fallbackReason. List each home's reason:
kubectl get trafficmap qwen3-0-6b -n qwen3-0-6b \
-o jsonpath='{range .spec.entries[*]}{.cluster}{": "}{.capacity.fallbackReason}{"\n"}{end}'When worker-b's poll reaches the model server instead of your reporter, and worker-a has its ceiling:
worker-a:
worker-b: capacity endpoint returned HTTP 404The reason says what to fix:
fallbackReason |
What it means |
|---|---|
no capacity report yet, or capacity report awaiting quorum (1/3 accepted samples) |
Nothing's wrong. The home gets its ceiling once quorum polls in a row succeed. |
capacity report is stale (at least configured maximum age 40s), or capacity report awaiting quorum after stale samples expired (2/3 accepted samples) |
The reporter updates less often than maxAge, has frozen, or has a clock that runs behind. Fix it, or raise maxAge. |
capacity report timestamp is in the future |
The reporter's clock runs ahead of the controller's. |
capacity endpoint unreachable:, then the error |
A DNS, connection or TLS error, or no answer within timeout. Check the network path from the controller's pods, or raise timeout. |
capacity endpoint returned HTTP, then the status |
404: the path doesn't reach your reporter, as The report format explains. 3xx: OME doesn't follow redirects. 401 or 403: the poll sends no credentials. |
capacity response is not a valid report:, then the error, capacity report has no servable count, capacity report has no observedAt stamp, or capacity report is negative (-1) |
The body isn't the report that The report format describes, or is empty, as with method: HEAD. A negative count isn't read as 0. |
reading capacity response: or capacity request did not complete:, then the error |
The body is longer than the chart's routing.observer.maxResponseBytes, or the poll ran out of time. |
capacity target is invalid:, capacity request URL is invalid: or capacity request could not be built:, then the error |
OME can't build a request from the entry's endpoint and the path. |
capacity observation could not be submitted:, then the error |
The controller was starting or stopping, as in a restart. It clears on its own. |
Next steps¶
- Traffic map: how OME builds the TrafficMap and works out each home's weight.
- Configure routing health probes: set a cluster's weight to zero when its endpoint stops answering.
- Weight traffic for heterogeneous clusters: count one cluster's replicas for more than another's.
- Drain a workload cluster: hold a workload cluster at zero weight with
kubectl ome traffic drain, whatever its capacity. - Publish a global endpoint: serve the InferenceService on one hostname that splits its traffic by the TrafficMap's weights.