--- title: Drain a workload cluster description: Use the alpha kubectl ome traffic drain to take a workload cluster out of an InferenceService's traffic for maintenance, and undrain to give it back. status: preview since: v1.3 --- Drain a workload cluster to move an [InferenceService](../../concepts/serving/inference-services.md)'s traffic off it, for example during maintenance, while its replicas there keep running. OME holds the drained cluster at weight 0 in the service's [TrafficMap](../../concepts/rollouts-and-traffic/traffic-map.md), and the other clusters take its share. Start a drain with `kubectl ome traffic drain`, and end it with `kubectl ome traffic undrain`. A drain applies to one InferenceService: to take a cluster out of every service's traffic, drain each service. Multi-cluster routing is alpha and still in development, and so are both commands. Routing is off by default.
- A control-plane cluster with routing on, and `kubectl` access to it. Routing needs the `ome-resources` values `ome.multicluster.enabled=true` and `ome.multicluster.role=control-plane`, which [Controller manager flags](../../reference/operate-ome/controller-manager-flags.md#multi-cluster) describes, and `ome.multicluster.config.routing.enabled=true`, which [Turn on routing](../../concepts/rollouts-and-traffic/traffic-map.md#turn-on-routing) shows. The examples use its kubeconfig context, `hub`, as the current context. - The InferenceService `chat` in the namespace `prod`, placed on the WorkloadClusters `worker-a` and `worker-b`: its `spec.placement.mode` is `All` or `Split`, not the default `Single`. Check that it has a TrafficMap with `kubectl get trafficmap chat -n prod`. If not, routing is off or skips the service: see [Turn on routing](../../concepts/rollouts-and-traffic/traffic-map.md#turn-on-routing). - A global endpoint that publishes `chat`'s TrafficMap, as [Publish a global endpoint](publish-a-global-endpoint.md) sets up. Without one, a drain changes only the TrafficMap. - The `kubectl ome` plugin on your `PATH`. See [kubectl-ome overview and install](../../reference/kubectl-ome/overview.md). - Permission to `get` and `patch` `inferenceservices.ome.io` in `prod`, and to `get` `trafficmaps.ome.io`. Check with `kubectl auth can-i patch inferenceservices.ome.io -n prod`.
## How a drain is recorded `kubectl ome traffic drain` adds an entry, keyed by a drain ID that you choose, to the `ome.io/traffic-drain` annotation on the InferenceService in the control-plane cluster: ```yaml metadata: annotations: ome.io/traffic-drain: '{"maintenance-a":{"cluster":"worker-a","reason":"planned maintenance"}}' ``` OME gives the drained cluster's [TrafficMap entry](../../reference/api/ome.v1beta1.md#ome-io-v1beta1-TrafficMapEntry) weight 0, and lists the drain's ID in its `drainRefs`. The other clusters keep their weights, and share the traffic in the same ratio as before. Several drains can hold one cluster, and it gets its traffic back when the last one is removed. A drain stays until you undrain it. ## Step 1: Drain the cluster First check that another cluster can take the traffic: ```bash kubectl get trafficmap chat -n prod \ -o jsonpath='{range .spec.entries[*]}{.cluster}{" weight="}{.weight}{" drainRefs="}{.drainRefs}{"\n"}{end}' ``` Both clusters have the same capacity, so each has weight 1: ```output worker-a weight=1 drainRefs= worker-b weight=1 drainRefs= ``` !!! danger "Draining the last serving cluster cuts off traffic" If `worker-a` is the only entry with a positive weight, the drain takes all of `chat`'s traffic away. See [Routable is False with reason TrafficDrain](#routable-is-false-with-reason-trafficdrain). `kubectl ome traffic drain` takes three required flags: | Flag | What to pass | | --- | --- | | `--workload-cluster` | The WorkloadCluster to drain. The global `--cluster` flag picks a cluster from your kubeconfig instead. | | `--id` | A name for the drain, in lowercase letters, digits and hyphens. `undrain` takes it later. | | `--reason` | Why you drain the cluster. Anyone who can read the service sees it, so keep secrets out of it. | Drain `worker-a`. To try it first, add `--dry-run=server`: the API server checks the change and your permission, and stores nothing. ```bash kubectl ome traffic drain chat -n prod \ --workload-cluster worker-a \ --id maintenance-a \ --reason "planned maintenance" ``` The command prints a preview to stderr. Check that its `Cluster` row names one of the entries you listed, since the command accepts any valid name, then type `y` and press Enter: ```output ALPHA guarded traffic action (not TrafficMap convergence) FIELD VALUE Action traffic drain Context hub Workload NS prod Target InferenceService/chat UID 2f6c1d9e-4b7a-4c1e-9f3d-8a5b6c7d0e12 ResourceVersion 1284711 Dry-run none Override ID maintenance-a Cluster worker-a Reason "planned maintenance" Override count 0 -> 1 Annotation ome.io/traffic-drain Follow-up kubectl ome traffic status chat -n prod --context=hub API acceptance is not TrafficMap convergence or data-plane realization. The exact InferenceService UID/resourceVersion is tested atomically. Other annotations and traffic-drain override IDs are preserved. Run the previewed traffic status command to observe controller evidence. Confirm this exact action? [y/N] y ``` In a script, pass `--yes` to skip the prompt. The result goes to stdout: ```output FIELD VALUE action traffic drain target InferenceService/prod/chat dry-run none accepted Yes applied Yes override-id maintenance-a cluster worker-a overrides 0 -> 1 message API accepted traffic annotation request; not TrafficM... follow-up kubectl ome traffic status chat -n prod --context=hub hint Use -o json or -o yaml for full values. ``` ## Step 2: Check the drain `applied` `Yes` means that the API server stored the drain, and OME then applies it to the TrafficMap. The result suggests `kubectl ome traffic status`, but that command doesn't show drains, so list the TrafficMap's entries: ```bash kubectl get trafficmap chat -n prod \ -o jsonpath='{range .spec.entries[*]}{.cluster}{" weight="}{.weight}{" drainRefs="}{.drainRefs}{"\n"}{end}' ``` ```output worker-a weight=0 drainRefs=["maintenance-a"] worker-b weight=1 drainRefs= ``` `worker-a` is held at 0, and `worker-b` gets all the traffic. Then check the TrafficMap's [conditions](../../concepts/rollouts-and-traffic/traffic-map.md#conditions): ```bash kubectl get trafficmap chat -n prod ``` ```output NAME MODE PUBLISHED ROUTABLE OVERRIDE REASON AGE chat All True True True Routable 3h ``` `OVERRIDE` is `True` because the drain holds an entry, and `ROUTABLE` is `True` because `worker-b` can still serve. This `PUBLISHED` can still be `True` from before the drain. Before you start the maintenance, wait until `kubectl ome get trafficmap chat -n prod` shows `PUBLISHED` `True`. That command shows `True` only once the publisher has written the new weights to your gateway's route. If it stays `Unknown` or `False`, see [Check the publication](publish-a-global-endpoint.md#step-2-check-the-publication). Clients that call `worker-a`'s own endpoint, not the global one, still reach it. To see every drain on the service, with its reason, read the annotation: ```bash kubectl get inferenceservice chat -n prod -o jsonpath='{.metadata.annotations.ome\.io/traffic-drain}{"\n"}' ``` ```output {"maintenance-a":{"cluster":"worker-a","reason":"planned maintenance"}} ``` ## Step 3: Undrain the cluster When the maintenance is done, remove the drain by its ID: ```bash kubectl ome traffic undrain chat -n prod --id maintenance-a ``` Check the drain's cluster and reason in the preview, and type `y`. The result shows `applied` `Yes` and `overrides` `1 -> 0`: ```output FIELD VALUE action traffic undrain target InferenceService/prod/chat dry-run none accepted Yes applied Yes override-id maintenance-a cluster worker-a overrides 1 -> 0 message API accepted traffic annotation request; not TrafficM... follow-up kubectl ome traffic status chat -n prod --context=hub hint Use -o json or -o yaml for full values. ``` OME then sets `worker-a`'s weight from its capacity and health again. If the maintenance left it with fewer ready replicas, its weight stays lower, or at 0, until they're ready: see [Why a cluster's weight is zero](../../concepts/rollouts-and-traffic/traffic-map.md#why-a-clusters-weight-is-zero). List the entries again: ```bash kubectl get trafficmap chat -n prod \ -o jsonpath='{range .spec.entries[*]}{.cluster}{" weight="}{.weight}{" drainRefs="}{.drainRefs}{"\n"}{end}' ``` ```output worker-a weight=1 drainRefs= worker-b weight=1 drainRefs= ``` ## Troubleshooting The commands print errors to stderr as `error: `. The reference lists their [exit codes](../../reference/kubectl-ome/traffic.md#exit-codes). ### The command refuses to run These are the messages you're most likely to see. [Refusals](../../reference/kubectl-ome/traffic.md#drain-and-undrain-refusals) lists the rest. | Message | Cause and fix | | --- | --- | | `traffic drain requires --workload-cluster; --cluster selects the Kubernetes API cluster` | Name the cluster to drain with `--workload-cluster`, not `--cluster`. | | `required Kubernetes API request failed; check access and connectivity` | The service isn't in that namespace, you lack permission, or the API server is unreachable. After the preview, see [The command fails after you confirm](#the-outcome-is-unknown). | | `action refused: placement sources and derived services cannot be mutated` | The target is placement's copy on a workload cluster. Switch to the control-plane cluster's context. | | `traffic action refused: target is not eligible for cross-cluster traffic routing` | The service has no `spec.placement` block, and no obsolete `ome.io/accelerator-requirements` or `ome.io/cluster-selector` annotation, so routing ignores it. Set `spec.placement`, as the prerequisites describe. | | `traffic drain refused: override ID already exists` | The service already has a drain with this ID. Choose another ID, or undrain it first. | | `traffic undrain refused: override ID does not exist` | The service has no drain with this ID. List its drains as in [Step 2](#step-2-check-the-drain). | | `action not confirmed; noninteractive input requires --yes` | Answer `y` at the prompt. In a script, pass `--yes`. | | `context deadline exceeded` | The command has 45 seconds in all, including the prompt. Run it again, or, if it came after you confirmed, see [The command fails after you confirm](#the-outcome-is-unknown). | ### The patch is rejected as stale The command fails with this message and exit code `3`: ```output error: guarded annotation patch rejected; refresh traffic status and retry explicitly ``` `chat` changed after the command read it, often through a status update while the prompt waited, so the API server applied nothing. Run the command again to see a fresh preview. ### The command fails after you confirm {#the-outcome-is-unknown} When the command fails after you confirm, or after the preview when you pass `--yes`, the API server may still have stored the change. This applies to a message that ends in `check traffic status`, and to `required Kubernetes API request failed; check access and connectivity` or `context deadline exceeded`. To see whether it did, list the service's drains, as in [Step 2](#step-2-check-the-drain). Running the command again is also safe. If the first one went through, a repeated drain fails with `traffic drain refused: override ID already exists`. A repeated undrain fails with `traffic undrain refused: override ID does not exist`. ### The drain stays pending In Step 2, no entry lists the drain in `drainRefs`. `OVERRIDE` is `False` too, unless another drain holds an entry. The drain names a cluster that has no entry in the TrafficMap, usually because of a typo. Undrain it, then drain the right cluster: until you do, it holds any cluster with that name that later serves the service. ### Routable is False with reason TrafficDrain The drains hold every cluster that had a positive weight, so the gateway has nowhere to send `chat`'s requests. You drained them all, or the others were already at 0, for example with no ready replicas. Undrain a cluster that can serve, or fix the others. ### The TrafficMap stops changing after a hand edit Nothing validates a hand edit of `ome.io/traffic-drain`. When the value is invalid, OME keeps the last valid TrafficMap, so drained clusters stay drained. Capacity and health changes stop reaching the TrafficMap too, and `kubectl ome traffic drain` and `undrain` refuse with `traffic action refused: existing traffic-drain annotation is malformed or unsafe`. OME logs the error: ```bash kubectl logs -n ome -l control-plane=ome-controller-manager --tail=-1 | grep 'parse ome.io/traffic-drain' ``` Each line holds the parser's error after `parse ome.io/traffic-drain:`. Fix the value with `kubectl annotate --overwrite`, in the form that [How a drain is recorded](#how-a-drain-is-recorded) shows, with IDs, clusters and reasons that `kubectl ome traffic drain` accepts. Removing the annotation also works, but it ends every drain on the service. ## Next steps - [Traffic map](../../concepts/rollouts-and-traffic/traffic-map.md): how OME sets each cluster's weight, and what the conditions mean. - [kubectl ome traffic](../../reference/kubectl-ome/traffic.md): every flag of `drain`, `undrain`, `status` and `explain`. - [Guarded actions](../../reference/kubectl-ome/guarded-actions.md): the preview and confirmation that mutating `kubectl ome` commands share.