chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,555 @@
import decimal
import json
import logging
import time
from itertools import chain
from typing import Any, Dict, Optional
import requests
from ray._private.label_utils import (
validate_node_label_syntax,
)
from ray.autoscaler._private.constants import (
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
DISABLE_NODE_UPDATERS_KEY,
FOREGROUND_NODE_LAUNCH_KEY,
WORKER_LIVENESS_CHECK_KEY,
)
from ray.autoscaler._private.kuberay import node_provider, utils
from ray.autoscaler._private.util import validate_config
from ray.util.debug import log_once
logger = logging.getLogger(__name__)
AUTOSCALER_OPTIONS_KEY = "autoscalerOptions"
IDLE_SECONDS_KEY = "idleTimeoutSeconds"
UPSCALING_KEY = "upscalingMode"
UPSCALING_VALUE_AGGRESSIVE = "Aggressive"
UPSCALING_VALUE_DEFAULT = "Default"
UPSCALING_VALUE_CONSERVATIVE = "Conservative"
MAX_RAYCLUSTER_FETCH_TRIES = 5
RAYCLUSTER_FETCH_RETRY_S = 5
GKE_TPU_TOPOLOGY_LABEL = "cloud.google.com/gke-tpu-topology"
GKE_TPU_ACCELERATOR_LABEL = "cloud.google.com/gke-tpu-accelerator"
# Logical group name for the KubeRay head group.
# Used as the name of the "head node type" by the autoscaler.
_HEAD_GROUP_NAME = "headgroup"
class AutoscalingConfigProducer:
"""Produces an autoscaling config by reading data from the RayCluster CR.
Used to fetch the autoscaling config at the beginning of each autoscaler iteration.
In the context of Ray deployment on Kubernetes, the autoscaling config is an
internal interface.
The autoscaling config carries the strict subset of RayCluster CR data required by
the autoscaler to make scaling decisions; in particular, the autoscaling config does
not carry pod configuration data.
This class is the only public object in this file.
"""
def __init__(self, ray_cluster_name, ray_cluster_namespace):
self.kubernetes_api_client = node_provider.KubernetesHttpApiClient(
namespace=ray_cluster_namespace
)
self._ray_cr_path = f"rayclusters/{ray_cluster_name}"
def __call__(self):
ray_cr = self._fetch_ray_cr_from_k8s_with_retries()
autoscaling_config = _derive_autoscaling_config_from_ray_cr(ray_cr)
return autoscaling_config
def _fetch_ray_cr_from_k8s_with_retries(self) -> Dict[str, Any]:
"""Fetch the RayCluster CR by querying the K8s API server.
Retry on HTTPError for robustness, in particular to protect autoscaler
initialization.
"""
for i in range(1, MAX_RAYCLUSTER_FETCH_TRIES + 1):
try:
return self.kubernetes_api_client.get(self._ray_cr_path)
except requests.HTTPError as e:
if i < MAX_RAYCLUSTER_FETCH_TRIES:
logger.exception(
"Failed to fetch RayCluster CR from K8s. Retrying."
)
time.sleep(RAYCLUSTER_FETCH_RETRY_S)
else:
raise e from None
# This branch is inaccessible. Raise to satisfy mypy.
raise AssertionError
def _derive_autoscaling_config_from_ray_cr(ray_cr: Dict[str, Any]) -> Dict[str, Any]:
provider_config = _generate_provider_config(ray_cr["metadata"]["namespace"])
available_node_types = _generate_available_node_types_from_ray_cr_spec(
ray_cr["spec"]
)
# The autoscaler expects a global max workers field. We set it to the sum of
# node type max workers.
global_max_workers = sum(
node_type["max_workers"] for node_type in available_node_types.values()
)
# Legacy autoscaling fields carry no information but are required for compatibility.
legacy_autoscaling_fields = _generate_legacy_autoscaling_config_fields()
# Process autoscaler options.
autoscaler_options = ray_cr["spec"].get(AUTOSCALER_OPTIONS_KEY, {})
if IDLE_SECONDS_KEY in autoscaler_options:
idle_timeout_minutes = autoscaler_options[IDLE_SECONDS_KEY] / 60.0
else:
idle_timeout_minutes = 1.0
if autoscaler_options.get(UPSCALING_KEY) == UPSCALING_VALUE_CONSERVATIVE:
upscaling_speed = 1 # Rate-limit upscaling if "Conservative" is set by user.
# This elif is redudant but included for clarity.
elif autoscaler_options.get(UPSCALING_KEY) == UPSCALING_VALUE_DEFAULT:
upscaling_speed = 1000 # i.e. big, no rate-limiting by default
# This elif is redudant but included for clarity.
elif autoscaler_options.get(UPSCALING_KEY) == UPSCALING_VALUE_AGGRESSIVE:
upscaling_speed = 1000
else:
upscaling_speed = 1000
autoscaling_config = {
"provider": provider_config,
"cluster_name": ray_cr["metadata"]["name"],
"head_node_type": _HEAD_GROUP_NAME,
"available_node_types": available_node_types,
"max_workers": global_max_workers,
# Should consider exposing `idleTimeoutMinutes` in the RayCluster CRD,
# under an `autoscaling` field.
"idle_timeout_minutes": idle_timeout_minutes,
# Should consider exposing `upscalingSpeed` in the RayCluster CRD,
# under an `autoscaling` field.
"upscaling_speed": upscaling_speed,
**legacy_autoscaling_fields,
}
# Make sure the config is readable by the autoscaler.
validate_config(autoscaling_config)
return autoscaling_config
def _generate_provider_config(ray_cluster_namespace: str) -> Dict[str, Any]:
"""Generates the `provider` field of the autoscaling config, which carries data
required to instantiate the KubeRay node provider.
"""
return {
"type": "kuberay",
"namespace": ray_cluster_namespace,
DISABLE_NODE_UPDATERS_KEY: True,
DISABLE_LAUNCH_CONFIG_CHECK_KEY: True,
FOREGROUND_NODE_LAUNCH_KEY: True,
WORKER_LIVENESS_CHECK_KEY: False,
}
def _generate_legacy_autoscaling_config_fields() -> Dict[str, Any]:
"""Generates legacy autoscaling config fields required for compatibiliy."""
return {
"file_mounts": {},
"cluster_synced_files": [],
"file_mounts_sync_continuously": False,
"initialization_commands": [],
"setup_commands": [],
"head_setup_commands": [],
"worker_setup_commands": [],
"head_start_ray_commands": [],
"worker_start_ray_commands": [],
"auth": {},
}
def _generate_available_node_types_from_ray_cr_spec(
ray_cr_spec: Dict[str, Any],
) -> Dict[str, Any]:
"""Formats autoscaler "available_node_types" field based on the Ray CR's group
specs.
"""
headGroupSpec = ray_cr_spec["headGroupSpec"]
return {
_HEAD_GROUP_NAME: _node_type_from_group_spec(headGroupSpec, is_head=True),
**{
worker_group_spec["groupName"]: _node_type_from_group_spec(
worker_group_spec, is_head=False
)
for worker_group_spec in ray_cr_spec["workerGroupSpecs"]
},
}
def _node_type_from_group_spec(
group_spec: Dict[str, Any], is_head: bool
) -> Dict[str, Any]:
"""Converts CR group spec to autoscaler node type."""
group_name = _HEAD_GROUP_NAME if is_head else group_spec["groupName"]
if is_head:
# The head node type has no workers because the head is not a worker.
min_workers = max_workers = 0
else:
# `minReplicas` and `maxReplicas` are required fields for each workerGroupSpec.
# numOfHosts specifies the number of workers per replica in KubeRay v1.1+.
min_workers = group_spec["minReplicas"] * group_spec.get("numOfHosts", 1)
max_workers = group_spec["maxReplicas"] * group_spec.get("numOfHosts", 1)
resources = _get_ray_resources_from_group_spec(group_spec, is_head)
labels = _get_labels_from_group_spec(group_spec, group_name)
node_type = {
"min_workers": min_workers,
"max_workers": max_workers,
# `node_config` is a legacy field required for compatibility.
# Pod config data is required by the operator but not by the autoscaler.
"node_config": {},
"resources": resources,
"labels": labels,
}
idle_timeout_s = group_spec.get(IDLE_SECONDS_KEY)
if idle_timeout_s is not None:
node_type["idle_timeout_s"] = float(idle_timeout_s)
return node_type
def _get_ray_resources_from_group_spec(
group_spec: Dict[str, Any], is_head: bool
) -> Dict[str, int]:
"""
Infers Ray resources from group `Resources` field, rayStartCommands, or K8s limits.
The resources extracted are used in autoscaling calculations.
"""
# Set resources from top-level group 'Resources' field if it exists.
group_resources = group_spec.get("resources", {})
ray_start_params = group_spec.get("rayStartParams", {})
# In KubeRay, Ray container is always the first application container of a Ray Pod.
k8s_resources = group_spec["template"]["spec"]["containers"][0].get("resources", {})
group_name = _HEAD_GROUP_NAME if is_head else group_spec["groupName"]
num_cpus = _get_num_cpus(
group_resources, ray_start_params, k8s_resources, group_name
)
num_gpus = _get_num_gpus(
group_resources, ray_start_params, k8s_resources, group_name
)
custom_resource_dict = _get_custom_resources(
group_resources, ray_start_params, group_name
)
num_tpus = _get_num_tpus(group_resources, custom_resource_dict, k8s_resources)
memory = _get_memory(group_resources, ray_start_params, k8s_resources)
# It's not allowed to use object store memory as a resource request, so we don't
# add that to the autoscaler's resources annotations.
resources = {}
assert isinstance(num_cpus, int)
resources["CPU"] = num_cpus
if num_gpus is not None:
resources["GPU"] = num_gpus
if num_tpus is not None:
# Add TPU Ray resource if not already added by ray_start_params,
# but specified in k8s_resource_limits.
if "TPU" not in custom_resource_dict:
resources["TPU"] = num_tpus
"""Add TPU head resource, similar to the GCP node_provider.
Sets the Ray resource TPU-{...}-head to ensure the Ray autoscaler
has sufficient resources to make scaling decisions.
TPU worker groups treat each TPU podslice as a replica, with `NumOfHosts`
specifying the number of workers per slice. Each replica of a TPU worker
group has one TPU head.
For example, a v4-16 worker group with 2 replicas should have the following
resource labels on worker 0 of each replica:
worker 0: resources = {"TPU": 4, "TPU-v4-16-head": 1}
"""
if (
"nodeSelector" in group_spec["template"]["spec"]
and GKE_TPU_TOPOLOGY_LABEL in group_spec["template"]["spec"]["nodeSelector"]
and GKE_TPU_ACCELERATOR_LABEL
in group_spec["template"]["spec"]["nodeSelector"]
):
topology = group_spec["template"]["spec"]["nodeSelector"][
GKE_TPU_TOPOLOGY_LABEL
]
accelerator = group_spec["template"]["spec"]["nodeSelector"][
GKE_TPU_ACCELERATOR_LABEL
]
accelerator_type = utils.tpu_node_selectors_to_type(topology, accelerator)
if accelerator_type:
resources[f"TPU-{accelerator_type}-head"] = 1
else:
logger.error(
f"Pods using TPUs require both `{GKE_TPU_TOPOLOGY_LABEL}` and `{GKE_TPU_ACCELERATOR_LABEL}` node selectors. "
"See https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/tpu.html#configuring-ray-pods-for-tpu-usage "
"and https://cloud.google.com/kubernetes-engine/docs/how-to/tpus."
)
if memory is not None:
resources["memory"] = memory
resources.update(custom_resource_dict)
return resources
def _get_labels_from_group_spec(
group_spec: Dict[str, Any], group_name: str = ""
) -> Dict[str, str]:
"""
Parses Ray node labels for the autoscaling config based on the following
priority:
1. Top-level `labels` field in the group spec.
2. `labels` field in `rayStartParams`.
Args:
group_spec: The group specification dictionary.
group_name: The name of the group (used in warning messages).
Returns:
A dictionary of labels for the node type.
"""
labels_dict = {}
ray_start_params = group_spec.get("rayStartParams", {})
labels_str = ray_start_params.get("labels")
# Use a unique log_once key per group to ensure each group's warning is shown.
log_once_key = (
f"raystartparams_labels_warning_{group_name}"
if group_name
else "raystartparams_labels_warning"
)
if labels_str and log_once(log_once_key):
if group_name:
logger.warning(
f"Ignoring labels: {labels_str} set in rayStartParams for group "
f"'{group_name}'. Group labels are supported in the top-level "
"Labels field starting in KubeRay v1.5"
)
else:
logger.warning(
f"Ignoring labels: {labels_str} set in rayStartParams. "
"Group labels are supported in the top-level Labels field "
"starting in KubeRay v1.5"
)
# Check for top-level structured Labels field.
if "labels" in group_spec and isinstance(group_spec.get("labels"), dict):
labels_dict = group_spec.get("labels")
# Validate node labels follow expected Kubernetes label syntax.
validate_node_label_syntax(labels_dict)
return labels_dict
def _get_num_cpus(
group_resources: Dict[str, str],
ray_start_params: Dict[str, str],
k8s_resources: Dict[str, Dict[str, str]],
group_name: str,
) -> int:
"""Get CPU annotation from `resources` field, ray_start_params or k8s_resources,
with priority for `resources` field.
"""
if "CPU" in group_resources:
if "num-cpus" in ray_start_params:
logger.warning(
f"'CPU' specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the value from 'resources': {group_resources['CPU']}."
)
return _round_up_k8s_quantity(group_resources["CPU"])
if "num-cpus" in ray_start_params:
return int(ray_start_params["num-cpus"])
elif "cpu" in k8s_resources.get("limits", {}):
cpu_quantity: str = k8s_resources["limits"]["cpu"]
return _round_up_k8s_quantity(cpu_quantity)
elif "cpu" in k8s_resources.get("requests", {}):
cpu_quantity: str = k8s_resources["requests"]["cpu"]
return _round_up_k8s_quantity(cpu_quantity)
else:
# Getting the number of CPUs is important, so raise an error if we can't do it.
raise ValueError(
f"Autoscaler failed to detect `CPU` resources for group {group_name}."
"\nSet the `--num-cpus` rayStartParam and/or "
"the CPU resource limit for the Ray container."
)
def _get_memory(
group_resources: Dict[str, str],
ray_start_params: Dict[str, str],
k8s_resources: Dict[str, Dict[str, str]],
) -> Optional[int]:
"""Get memory resource annotation from `resources` field, ray_start_params or k8s_resources,
with priority for `resources` field.
"""
if "memory" in group_resources:
if "memory" in ray_start_params:
logger.warning(
f"'memory' specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the value from 'resources': {group_resources['memory']}."
)
return _round_up_k8s_quantity(group_resources["memory"])
if "memory" in ray_start_params:
return int(ray_start_params["memory"])
elif "memory" in k8s_resources.get("limits", {}):
memory_quantity: str = k8s_resources["limits"]["memory"]
return _round_up_k8s_quantity(memory_quantity)
elif "memory" in k8s_resources.get("requests", {}):
memory_quantity: str = k8s_resources["requests"]["memory"]
return _round_up_k8s_quantity(memory_quantity)
return None
def _get_num_gpus(
group_resources: Dict[str, str],
ray_start_params: Dict[str, str],
k8s_resources: Dict[str, Dict[str, str]],
group_name: str,
) -> Optional[int]:
"""Get GPU resource annotation from `resources` field, ray_start_params or k8s_resources,
with priority for `resources` field.
"""
if "GPU" in group_resources:
if "num-gpus" in ray_start_params:
logger.warning(
f"'GPU' specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the value from 'resources': {group_resources['GPU']}."
)
return _round_up_k8s_quantity(group_resources["GPU"])
elif "num-gpus" in ray_start_params:
return int(ray_start_params["num-gpus"])
else:
for key, resource_quantity in chain(
k8s_resources.get("limits", {}).items(),
k8s_resources.get("requests", {}).items(),
):
# e.g. nvidia.com/gpu
if key.endswith("gpu"):
# Typically, this is a string representing an interger, e.g. "1".
# Convert to int, making no assumptions on the resource_quantity,
# besides that it's valid as a K8s resource quantity.
num_gpus = _round_up_k8s_quantity(resource_quantity)
if num_gpus > 0:
# Only one GPU type supported for now, break out on first
# "/gpu" match.
return num_gpus
return None
def _get_num_tpus(
group_resources: Dict[str, str],
custom_resource_dict: Dict[str, int],
k8s_resources: Dict[str, Dict[str, str]],
) -> Optional[int]:
"""Get TPU custom resource annotation from `resources` field, custom_resource_dict in ray_start_params,
or k8s_resources, with priority for `resources` field.
"""
if "TPU" in group_resources:
return _round_up_k8s_quantity(group_resources["TPU"])
elif "TPU" in custom_resource_dict:
return custom_resource_dict["TPU"]
else:
for typ in ["limits", "requests"]:
tpu_resource_quantity = k8s_resources.get(typ, {}).get("google.com/tpu")
if tpu_resource_quantity is not None:
# Typically, this is a string representing an integer, e.g. "1".
# Convert to int, making no assumptions on the tpu_resource_quantity,
# besides that it's valid as a K8s resource quantity.
num_tpus = _round_up_k8s_quantity(tpu_resource_quantity)
if num_tpus > 0:
return num_tpus
return None
def _round_up_k8s_quantity(quantity: str) -> int:
"""Rounds a Kubernetes resource quantity up to the nearest integer.
Args:
quantity: Resource quantity as a string in the canonical K8s form.
Returns:
The quantity, rounded up, as an integer.
"""
resource_decimal: decimal.Decimal = utils.parse_quantity(quantity)
rounded = resource_decimal.to_integral_value(rounding=decimal.ROUND_UP)
return int(rounded)
def _get_custom_resources(
group_resources: Dict[str, str], ray_start_params: Dict[str, Any], group_name: str
) -> Dict[str, int]:
"""Format custom resources based on the group `resources` field or `resources` Ray start param.
Currently, the value of the rayStartParam `resources` field must
be formatted as follows:
'"{\"Custom1\": 1, \"Custom2\": 5}"'.
This method first converts the input to a correctly formatted
json string and then loads that json string to a dict.
"""
# If the top-level `resources` field is defined, use it as the exclusive source.
if group_resources:
if "resources" in ray_start_params:
logger.warning(
f"custom resources specified in both the top-level 'resources' field and in 'rayStartParams'. "
f"Using the values from 'resources': {group_resources}."
)
standard_keys = {"CPU", "GPU", "TPU", "memory"}
try:
custom_resources = {
k: _round_up_k8s_quantity(v)
for k, v in group_resources.items()
if k not in standard_keys
}
except Exception as e:
logger.error(
f"Error reading `resource` for group {group_name}."
" For the correct format, refer to example configuration at "
"https://github.com/ray-project/ray/blob/master/python/"
"ray/autoscaler/kuberay/ray-cluster.complete.yaml."
)
raise e
return custom_resources
# Otherwise, check rayStartParams.
if "resources" not in ray_start_params:
return {}
resources_string = ray_start_params["resources"]
try:
# Drop the extra pair of quotes and remove the backslash escapes.
# resources_json should be a json string.
resources_json = resources_string[1:-1].replace("\\", "")
# Load a dict from the json string.
resources = json.loads(resources_json)
assert isinstance(resources, dict)
for key, value in resources.items():
assert isinstance(key, str)
assert isinstance(value, int)
except Exception as e:
logger.error(
f"Error reading `resource` rayStartParam for group {group_name}."
" For the correct format, refer to example configuration at "
"https://github.com/ray-project/ray/blob/master/python/"
"ray/autoscaler/kuberay/ray-cluster.complete.yaml."
)
raise e
return resources
@@ -0,0 +1,558 @@
import datetime
import json
import logging
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
from ray._common.network_utils import build_address
from ray.autoscaler._private.constants import WORKER_LIVENESS_CHECK_KEY
from ray.autoscaler._private.util import NodeID, NodeIP, NodeKind, NodeStatus, NodeType
from ray.autoscaler.batching_node_provider import (
BatchingNodeProvider,
NodeData,
ScaleRequest,
)
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_UP_TO_DATE,
STATUS_UPDATE_FAILED,
TAG_RAY_USER_NODE_TYPE,
)
# Key for KubeRay label that identifies a Ray pod as head or worker.
KUBERAY_LABEL_KEY_KIND = "ray.io/node-type"
# Key for KubeRay label that identifies the worker group (autoscaler node type) of a
# Ray pod.
KUBERAY_LABEL_KEY_TYPE = "ray.io/group"
# These should be synced with:
# https://github.com/ray-project/kuberay/blob/f2d94ffe213dd8f69481b09c474047cb899fa73b/ray-operator/apis/ray/v1/raycluster_types.go#L165-L171 # noqa
# Kind label value indicating the pod is the head.
KUBERAY_KIND_HEAD = "head"
# Kind label value indicating the pod is the worker.
KUBERAY_KIND_WORKER = "worker"
# KubeRay CRD version
KUBERAY_CRD_VER = os.getenv("KUBERAY_CRD_VER", "v1alpha1")
KUBERAY_REQUEST_TIMEOUT_S = int(os.getenv("KUBERAY_REQUEST_TIMEOUT_S", 60))
RAY_HEAD_POD_NAME = os.getenv("RAY_HEAD_POD_NAME")
# https://kubernetes.io/docs/tasks/run-application/access-api-from-pod
# While running in a Pod, your container can create an HTTPS URL for the
# Kubernetes API server by fetching the KUBERNETES_SERVICE_HOST and
# KUBERNETES_SERVICE_PORT_HTTPS environment variables.
KUBERNETES_SERVICE_HOST = os.getenv(
"KUBERNETES_SERVICE_HOST", "https://kubernetes.default"
)
KUBERNETES_SERVICE_PORT = os.getenv("KUBERNETES_SERVICE_PORT_HTTPS", "443")
KUBERNETES_HOST = build_address(KUBERNETES_SERVICE_HOST, KUBERNETES_SERVICE_PORT)
# Key for GKE label that identifies which multi-host replica a pod belongs to
REPLICA_INDEX_KEY = "replicaIndex"
TOKEN_REFRESH_PERIOD = datetime.timedelta(minutes=1)
# Design:
# Each modification the autoscaler wants to make is posted to the API server goal state
# (e.g. if the autoscaler wants to scale up, it increases the number of
# replicas of the worker group it wants to scale, if it wants to scale down
# it decreases the number of replicas and adds the exact pods that should be
# terminated to the scaleStrategy).
# KubeRayNodeProvider inherits from BatchingNodeProvider.
# Thus, the autoscaler's create and terminate requests are batched into a single
# Scale Request object which is submitted at the end of autoscaler update.
# KubeRay node provider converts the ScaleRequest into a RayCluster CR patch
# and applies the patch in the submit_scale_request method.
# To reduce potential for race conditions, KubeRayNodeProvider
# aborts the autoscaler update if the operator has not yet processed workersToDelete -
# see KubeRayNodeProvider.safe_to_scale().
# Once it is confirmed that workersToDelete have been cleaned up, KubeRayNodeProvider
# clears the workersToDelete list.
# Note: Log handlers set up in autoscaling monitor entrypoint.
logger = logging.getLogger(__name__)
def node_data_from_pod(pod: Dict[str, Any]) -> NodeData:
"""Converts a Ray pod extracted from K8s into Ray NodeData.
NodeData is processed by BatchingNodeProvider.
"""
kind, type = kind_and_type(pod)
status = status_tag(pod)
ip = pod_ip(pod)
replica_index = _replica_index_label(pod)
return NodeData(
kind=kind, type=type, replica_index=replica_index, status=status, ip=ip
)
def kind_and_type(pod: Dict[str, Any]) -> Tuple[NodeKind, NodeType]:
"""Determine Ray node kind (head or workers) and node type (worker group name)
from a Ray pod's labels.
"""
labels = pod["metadata"]["labels"]
kind = (
NODE_KIND_HEAD
if labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_HEAD
else NODE_KIND_WORKER
)
type = labels[KUBERAY_LABEL_KEY_TYPE]
return kind, type
def _replica_index_label(pod: Dict[str, Any]) -> Optional[str]:
"""Returns the replicaIndex label for a Pod in a multi-host TPU worker group.
The replicaIndex label is set by the GKE TPU Ray webhook and is of
the form {$WORKER_GROUP_NAME-$REPLICA_INDEX} where $REPLICA_INDEX
is an integer from 0 to Replicas-1.
"""
labels = pod["metadata"]["labels"]
return labels.get(REPLICA_INDEX_KEY, None)
def pod_ip(pod: Dict[str, Any]) -> NodeIP:
return pod["status"].get("podIP", "IP not yet assigned")
def status_tag(pod: Dict[str, Any]) -> NodeStatus:
"""Convert pod state to Ray autoscaler node status.
See the doc string of the class
batching_node_provider.NodeData for the semantics of node status.
"""
if (
"containerStatuses" not in pod["status"]
or not pod["status"]["containerStatuses"]
):
return "pending"
state = pod["status"]["containerStatuses"][0]["state"]
if "pending" in state:
return "pending"
if "running" in state:
return STATUS_UP_TO_DATE
if "waiting" in state:
return "waiting"
if "terminated" in state:
return STATUS_UPDATE_FAILED
raise ValueError("Unexpected container state.")
def worker_delete_patch(group_index: str, workers_to_delete: List[NodeID]):
path = f"/spec/workerGroupSpecs/{group_index}/scaleStrategy"
value = {"workersToDelete": workers_to_delete}
return replace_patch(path, value)
def worker_replica_patch(group_index: str, target_replicas: int):
path = f"/spec/workerGroupSpecs/{group_index}/replicas"
value = target_replicas
return replace_patch(path, value)
def replace_patch(path: str, value: Any) -> Dict[str, Any]:
return {"op": "replace", "path": path, "value": value}
def load_k8s_secrets() -> Tuple[Dict[str, str], str]:
"""
Loads secrets needed to access K8s resources.
Returns:
headers: Headers with K8s access token
verify: Path to certificate
"""
with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as secret:
token = secret.read()
headers = {
"Authorization": "Bearer " + token,
}
verify = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
return headers, verify
def url_from_resource(
namespace: str,
path: str,
kuberay_crd_version: str = KUBERAY_CRD_VER,
kubernetes_host: str = KUBERNETES_HOST,
) -> str:
"""Convert resource path to REST URL for Kubernetes API server.
Args:
namespace: The K8s namespace of the resource
path: The part of the resource path that starts with the resource type.
Supported resource types are "pods" and "rayclusters".
kuberay_crd_version: The API version of the KubeRay CRD.
Looks like "v1alpha1", "v1".
kubernetes_host: The host of the Kubernetes API server.
Uses $KUBERNETES_SERVICE_HOST and
$KUBERNETES_SERVICE_PORT to construct the kubernetes_host if not provided.
When set by Kubernetes,
$KUBERNETES_SERVICE_HOST could be an IP address. That's why the https
scheme is added here.
Defaults to "https://kubernetes.default:443".
Returns:
The REST URL for the resource.
"""
if kubernetes_host.startswith("http://"):
raise ValueError("Kubernetes host must be accessed over HTTPS.")
if not kubernetes_host.startswith("https://"):
kubernetes_host = "https://" + kubernetes_host
if path.startswith("pods"):
api_group = "/api/v1"
elif path.startswith("rayclusters"):
api_group = "/apis/ray.io/" + kuberay_crd_version
else:
raise NotImplementedError("Tried to access unknown entity at {}".format(path))
return kubernetes_host + api_group + "/namespaces/" + namespace + "/" + path
def _worker_group_index(raycluster: Dict[str, Any], group_name: str) -> int:
"""Extract worker group index from RayCluster."""
group_names = [
spec["groupName"] for spec in raycluster["spec"].get("workerGroupSpecs", [])
]
return group_names.index(group_name)
def _worker_group_max_replicas(
raycluster: Dict[str, Any], group_index: int
) -> Optional[int]:
"""Extract the maxReplicas of a worker group.
If maxReplicas is unset, return None, to be interpreted as "no constraint".
At time of writing, it should be impossible for maxReplicas to be unset, but it's
better to handle this anyway.
"""
return raycluster["spec"]["workerGroupSpecs"][group_index].get("maxReplicas")
def _worker_group_replicas(raycluster: Dict[str, Any], group_index: int):
# 1 is the default replicas value used by the KubeRay operator
return raycluster["spec"]["workerGroupSpecs"][group_index].get("replicas", 1)
def _worker_group_num_of_hosts(raycluster: Dict[str, Any], group_index: int):
# 1 is the default numOfHosts value used by the KubeRay operator
return raycluster["spec"]["workerGroupSpecs"][group_index].get("numOfHosts", 1)
class IKubernetesHttpApiClient(ABC):
"""
An interface for a Kubernetes HTTP API client.
This interface could be used to mock the Kubernetes API client in tests.
"""
@abstractmethod
def get(self, path: str) -> Dict[str, Any]:
"""Wrapper for REST GET of resource with proper headers."""
pass
@abstractmethod
def patch(
self,
path: str,
payload: Union[List[Dict[str, Any]], Dict[str, Any]],
content_type: str = "application/json-patch+json",
) -> Dict[str, Any]:
"""Wrapper for REST PATCH of resource with proper headers."""
pass
class KubernetesHttpApiClient(IKubernetesHttpApiClient):
def __init__(self, namespace: str, kuberay_crd_version: str = KUBERAY_CRD_VER):
self._kuberay_crd_version = kuberay_crd_version
self._namespace = namespace
self._token_expires_at = datetime.datetime.now() + TOKEN_REFRESH_PERIOD
self._headers, self._verify = None, None
def _get_refreshed_headers_and_verify(self):
if (datetime.datetime.now() >= self._token_expires_at) or (
self._headers is None or self._verify is None
):
logger.info("Refreshing K8s API client token and certs.")
self._headers, self._verify = load_k8s_secrets()
self._token_expires_at = datetime.datetime.now() + TOKEN_REFRESH_PERIOD
return self._headers, self._verify
else:
return self._headers, self._verify
def get(self, path: str) -> Dict[str, Any]:
"""Wrapper for REST GET of resource with proper headers.
Args:
path: The part of the resource path that starts with the resource type.
Returns:
The JSON response of the GET request.
Raises:
HTTPError: If the GET request fails.
"""
url = url_from_resource(
namespace=self._namespace,
path=path,
kuberay_crd_version=self._kuberay_crd_version,
)
headers, verify = self._get_refreshed_headers_and_verify()
result = requests.get(
url,
headers=headers,
timeout=KUBERAY_REQUEST_TIMEOUT_S,
verify=verify,
)
if not result.status_code == 200:
result.raise_for_status()
return result.json()
def patch(
self,
path: str,
payload: Union[List[Dict[str, Any]], Dict[str, Any]],
content_type: str = "application/json-patch+json",
) -> Dict[str, Any]:
"""Wrapper for REST PATCH of resource with proper headers
Args:
path: The part of the resource path that starts with the resource type.
payload: The patch payload, either a JSON Patch list or a
strategic-merge patch object.
content_type: The content type of the merge strategy.
Returns:
The JSON response of the PATCH request.
Raises:
HTTPError: If the PATCH request fails.
"""
url = url_from_resource(
namespace=self._namespace,
path=path,
kuberay_crd_version=self._kuberay_crd_version,
)
headers, verify = self._get_refreshed_headers_and_verify()
result = requests.patch(
url,
json.dumps(payload),
headers={**headers, "Content-type": content_type},
timeout=KUBERAY_REQUEST_TIMEOUT_S,
verify=verify,
)
if not result.status_code == 200:
result.raise_for_status()
return result.json()
class KubeRayNodeProvider(BatchingNodeProvider): # type: ignore
def __init__(
self,
provider_config: Dict[str, Any],
cluster_name: str,
):
logger.info("Creating KubeRayNodeProvider.")
self.namespace = provider_config["namespace"]
self.cluster_name = cluster_name
self.k8s_api_client = KubernetesHttpApiClient(self.namespace)
assert (
provider_config.get(WORKER_LIVENESS_CHECK_KEY, True) is False
), f"To use KubeRayNodeProvider, must set `{WORKER_LIVENESS_CHECK_KEY}:False`."
BatchingNodeProvider.__init__(self, provider_config, cluster_name)
def get_node_data(self) -> Dict[NodeID, NodeData]:
"""Queries K8s for pods in the RayCluster. Converts that pod data into a
map of pod name to Ray NodeData, as required by BatchingNodeProvider.
"""
# Store the raycluster CR
self._raycluster = self._get(f"rayclusters/{self.cluster_name}")
# Get the pods resource version.
# Specifying a resource version in list requests is important for scalability:
# https://kubernetes.io/docs/reference/using-api/api-concepts/#semantics-for-get-and-list
resource_version = self._get_pods_resource_version()
if resource_version:
logger.info(
f"Listing pods for RayCluster {self.cluster_name}"
f" in namespace {self.namespace}"
f" at pods resource version >= {resource_version}."
)
# Filter pods by cluster_name.
label_selector = requests.utils.quote(f"ray.io/cluster={self.cluster_name}")
resource_path = f"pods?labelSelector={label_selector}"
if resource_version:
resource_path += (
f"&resourceVersion={resource_version}"
+ "&resourceVersionMatch=NotOlderThan"
)
pod_list = self._get(resource_path)
fetched_resource_version = pod_list["metadata"]["resourceVersion"]
logger.info(
f"Fetched pod data at resource version" f" {fetched_resource_version}."
)
# Extract node data from the pod list.
node_data_dict = {}
for pod in pod_list["items"]:
# Kubernetes sets metadata.deletionTimestamp immediately after admitting a
# request to delete an object. Full removal of the object may take some time
# after the deletion timestamp is set. See link for details:
# https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-deletion
if "deletionTimestamp" in pod["metadata"]:
# Ignore pods marked for termination.
continue
pod_name = pod["metadata"]["name"]
node_data_dict[pod_name] = node_data_from_pod(pod)
return node_data_dict
def submit_scale_request(self, scale_request: ScaleRequest):
"""Converts the scale request generated by BatchingNodeProvider into
a patch that modifies the RayCluster CR's replicas and/or workersToDelete
fields. Then submits the patch to the K8s API server.
"""
# Transform the scale request into a patch payload.
patch_payload = self._scale_request_to_patch_payload(
scale_request, self._raycluster
)
# Submit the patch to K8s.
logger.info(
"Autoscaler is submitting the following patch to RayCluster "
f"{self.cluster_name} in namespace {self.namespace}."
)
logger.info(patch_payload)
self._submit_raycluster_patch(patch_payload)
def safe_to_scale(self) -> bool:
"""Returns False iff non_terminated_nodes contains any pods in the RayCluster's
workersToDelete lists.
Explanation:
If there are any workersToDelete which are non-terminated,
we should wait for the operator to do its job and delete those
pods. Therefore, we back off the autoscaler update.
If, on the other hand, all of the workersToDelete have already been cleaned up,
then we patch away the workersToDelete lists and return True.
In the future, we may consider having the operator clean up workersToDelete
on it own:
https://github.com/ray-project/kuberay/issues/733
Note (Dmitri):
It is stylistically bad that this function has a side effect.
"""
# Get the list of nodes.
node_set = set(self.node_data_dict.keys())
worker_groups = self._raycluster["spec"].get("workerGroupSpecs", [])
# Accumulates the indices of worker groups with non-empty workersToDelete
non_empty_worker_group_indices = []
for group_index, worker_group in enumerate(worker_groups):
workersToDelete = worker_group.get("scaleStrategy", {}).get(
"workersToDelete", []
)
if workersToDelete:
non_empty_worker_group_indices.append(group_index)
for worker in workersToDelete:
if worker in node_set:
# The operator hasn't removed this worker yet. Abort
# the autoscaler update.
logger.warning(f"Waiting for operator to remove worker {worker}.")
return False
# All required workersToDelete have been removed.
# Clean up the workersToDelete field.
patch_payload = []
for group_index in non_empty_worker_group_indices:
patch = worker_delete_patch(group_index, workers_to_delete=[])
patch_payload.append(patch)
if patch_payload:
logger.info("Cleaning up workers to delete.")
logger.info(f"Submitting patch {patch_payload}.")
self._submit_raycluster_patch(patch_payload)
# It's safe to proceed with the autoscaler update.
return True
def _get_pods_resource_version(self) -> str:
"""
Extract a recent pods resource version by reading the head pod's
metadata.resourceVersion of the response.
"""
if not RAY_HEAD_POD_NAME:
return None
pod_resp = self._get(f"pods/{RAY_HEAD_POD_NAME}")
return pod_resp["metadata"]["resourceVersion"]
def _scale_request_to_patch_payload(
self, scale_request: ScaleRequest, raycluster: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Converts autoscaler scale request into a RayCluster CR patch payload."""
patch_payload = []
# Collect patches for replica counts.
for node_type, target_replicas in scale_request.desired_num_workers.items():
group_index = _worker_group_index(raycluster, node_type)
group_max_replicas = _worker_group_max_replicas(raycluster, group_index)
# Cap the replica count to maxReplicas.
if group_max_replicas is not None and group_max_replicas < target_replicas:
logger.warning(
"Autoscaler attempted to create "
+ "more than maxReplicas pods of type {}.".format(node_type)
)
target_replicas = group_max_replicas
# Check if we need to change the target count.
if target_replicas == _worker_group_replicas(raycluster, group_index):
# No patch required.
continue
# Need to patch replica count. Format the patch and add it to the payload.
patch = worker_replica_patch(group_index, target_replicas)
patch_payload.append(patch)
# Maps node_type to nodes to delete for that group.
deletion_groups = defaultdict(list)
for worker in scale_request.workers_to_delete:
node_type = self.node_tags(worker)[TAG_RAY_USER_NODE_TYPE]
deletion_groups[node_type].append(worker)
for node_type, workers_to_delete in deletion_groups.items():
group_index = _worker_group_index(raycluster, node_type)
patch = worker_delete_patch(group_index, workers_to_delete)
patch_payload.append(patch)
return patch_payload
def _submit_raycluster_patch(self, patch_payload: List[Dict[str, Any]]):
"""Submits a patch to modify a RayCluster CR."""
path = "rayclusters/{}".format(self.cluster_name)
self._patch(path, patch_payload)
def _get(self, path: str) -> Dict[str, Any]:
"""Wrapper for REST GET of resource with proper headers."""
return self.k8s_api_client.get(path)
def _patch(self, path: str, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Wrapper for REST PATCH of resource with proper headers."""
return self.k8s_api_client.patch(path, payload)
@@ -0,0 +1,143 @@
import logging
import os
import subprocess
import time
import ray
from ray._common.network_utils import build_address
from ray._common.ray_constants import (
LOGGING_ROTATE_BACKUP_COUNT,
LOGGING_ROTATE_BYTES,
)
from ray._common.utils import env_integer, try_to_create_directory
from ray._private import ray_constants
from ray._private.ray_logging import setup_component_logger
from ray._private.services import get_node_ip_address
from ray._private.utils import get_all_node_info_until_retrieved
from ray._raylet import GcsClient
from ray.autoscaler._private.kuberay.autoscaling_config import AutoscalingConfigProducer
from ray.autoscaler._private.monitor import Monitor
from ray.autoscaler.v2.instance_manager.config import KubeRayConfigReader
from ray.autoscaler.v2.utils import is_autoscaler_v2
from ray.core.generated.gcs_service_pb2 import GetAllNodeInfoRequest
logger = logging.getLogger(__name__)
BACKOFF_S = 5
def _get_log_dir(gcs_client: GcsClient) -> str:
head_node_selector = GetAllNodeInfoRequest.NodeSelector()
head_node_selector.is_head_node = True
# We need to wait until head node's raylet is registered in GCS.
node_infos = get_all_node_info_until_retrieved(
gcs_client,
node_selectors=[head_node_selector],
)
node_info = next(iter(node_infos))
temp_dir = getattr(node_info, "temp_dir", None)
if temp_dir is None:
raise Exception(
"Node temp_dir was not found in NodeInfo. did the head node's raylet start successfully?"
)
return os.path.join(temp_dir, ray._private.ray_constants.SESSION_LATEST, "logs")
def run_kuberay_autoscaler(cluster_name: str, cluster_namespace: str):
"""Wait until the Ray head container is ready. Then start the autoscaler."""
head_ip = get_node_ip_address()
ray_address = build_address(head_ip, 6379)
while True:
try:
# Autoscaler Ray version might not exactly match GCS version, so skip the
# version check when checking GCS status.
subprocess.check_call(
[
"ray",
"health-check",
"--address",
ray_address,
"--skip-version-check",
]
)
logger.info("The Ray head is ready. Starting the autoscaler.")
break
except subprocess.CalledProcessError:
logger.warning(
f"The Ray head is not ready. Will check again in {BACKOFF_S} seconds."
)
time.sleep(BACKOFF_S)
gcs_client = GcsClient(ray_address)
log_dir = _get_log_dir(gcs_client)
# The Ray head container sets up the log directory. Thus, we set up logging
# only after the Ray head is ready.
_setup_logging(log_dir)
# autoscaling_config_producer reads the RayCluster CR from K8s and uses the CR
# to output an autoscaling config.
autoscaling_config_producer = AutoscalingConfigProducer(
cluster_name, cluster_namespace
)
if is_autoscaler_v2(fetch_from_server=True, gcs_client=gcs_client):
from ray.autoscaler.v2.monitor import AutoscalerMonitor as MonitorV2
MonitorV2(
address=gcs_client.address,
config_reader=KubeRayConfigReader(autoscaling_config_producer),
log_dir=log_dir,
monitor_ip=head_ip,
).run()
else:
Monitor(
address=gcs_client.address,
# The `autoscaling_config` arg can be a dict or a `Callable: () -> dict`.
# In this case, it's a callable.
autoscaling_config=autoscaling_config_producer,
monitor_ip=head_ip,
# Let the autoscaler process exit after it hits 5 exceptions.
# (See ray.autoscaler._private.constants.AUTOSCALER_MAX_NUM_FAILURES.)
# Kubernetes will then restart the autoscaler container.
retry_on_failure=False,
).run()
def _setup_logging(log_dir: str) -> None:
"""Log to autoscaler log file
(typically, /tmp/ray/session_latest/logs/monitor.*)
Also log to pod stdout (logs viewable with `kubectl logs <head-pod> -c autoscaler`).
Args:
log_dir: The path to the log directory.
"""
# The director should already exist, but try (safely) to create it just in case.
try_to_create_directory(log_dir)
# Write logs at info level to monitor.log.
max_bytes = env_integer("RAY_ROTATION_MAX_BYTES", LOGGING_ROTATE_BYTES)
backup_count = env_integer("RAY_ROTATION_BACKUP_COUNT", LOGGING_ROTATE_BACKUP_COUNT)
setup_component_logger(
logging_level=ray_constants.LOGGER_LEVEL,
logging_format=ray_constants.LOGGER_FORMAT,
log_dir=log_dir,
filename=ray_constants.MONITOR_LOG_FILE_NAME, # monitor.log
max_bytes=max_bytes,
backup_count=backup_count,
)
# For the autoscaler, the root logger _also_ needs to write to stderr, not just
# ray_constants.MONITOR_LOG_FILE_NAME.
level = logging.getLevelName(ray_constants.LOGGER_LEVEL.upper())
stderr_handler = logging._StderrHandler()
stderr_handler.setFormatter(logging.Formatter(ray_constants.LOGGER_FORMAT))
stderr_handler.setLevel(level)
logging.root.setLevel(level)
logging.root.addHandler(stderr_handler)
# The stdout handler was set up in the Ray CLI entry point.
# See ray.scripts.scripts::cli().
@@ -0,0 +1,112 @@
# Source:
# https://github.com/kubernetes-client/python/blob/master/kubernetes/utils/quantity.py
from decimal import Decimal, InvalidOperation
from typing import Optional, Union
from ray._private.accelerators.tpu import (
get_num_chips_from_topology,
get_tpu_cores_per_chip,
)
# Mapping used to get generation for TPU-{accelerator}-head resource
# https://cloud.google.com/kubernetes-engine/docs/how-to/tpus#run
gke_tpu_accelerator_to_generation = {
"tpu-v4-podslice": "v4",
"tpu-v5-lite-device": "v5litepod",
"tpu-v5-lite-podslice": "v5litepod",
"tpu-v5p-slice": "v5p",
"tpu-v6e-slice": "v6e",
"tpu7x": "v7x",
}
def parse_quantity(quantity: Union[str, int, float, Decimal]) -> Decimal:
"""Parse kubernetes canonical form quantity like 200Mi to a decimal number.
Supported SI suffixes:
base1024: Ki | Mi | Gi | Ti | Pi | Ei
base1000: n | u | m | "" | k | M | G | T | P | E
See
https://github.com/kubernetes/apimachinery/blob/master/pkg/api/resource/quantity.go
Args:
quantity: kubernetes canonical form quantity.
Returns:
The parsed quantity as a decimal number.
Raises:
ValueError: On invalid or unknown input
"""
if isinstance(quantity, (int, float, Decimal)):
return Decimal(quantity)
exponents = {
"n": -3,
"u": -2,
"m": -1,
"K": 1,
"k": 1,
"M": 2,
"G": 3,
"T": 4,
"P": 5,
"E": 6,
}
quantity = str(quantity)
number = quantity
suffix = None
if len(quantity) >= 2 and quantity[-1] == "i":
if quantity[-2] in exponents:
number = quantity[:-2]
suffix = quantity[-2:]
elif len(quantity) >= 1 and quantity[-1] in exponents:
number = quantity[:-1]
suffix = quantity[-1:]
try:
number = Decimal(number)
except InvalidOperation:
raise ValueError("Invalid number format: {}".format(number))
if suffix is None:
return number
if suffix.endswith("i"):
base = 1024
elif len(suffix) == 1:
base = 1000
else:
raise ValueError("{} has unknown suffix".format(quantity))
# handle SI inconsistency
if suffix == "ki":
raise ValueError("{} has unknown suffix".format(quantity))
if suffix[0] not in exponents:
raise ValueError("{} has unknown suffix".format(quantity))
exponent = Decimal(exponents[suffix[0]])
return number * (base**exponent)
def tpu_node_selectors_to_type(topology: str, accelerator: str) -> Optional[str]:
"""Convert Kubernetes gke-tpu nodeSelectors to TPU accelerator_type
for a kuberay TPU worker group.
Args:
topology: value of the cloud.google.com/gke-tpu-topology Kubernetes
nodeSelector, describes the physical topology of the TPU podslice.
accelerator: value of the cloud.google.com/gke-tpu-accelerator nodeSelector,
the name of the TPU accelerator, e.g. tpu-v4-podslice
Returns:
A string, accelerator_type, e.g. "v4-8".
"""
if topology and accelerator:
generation = gke_tpu_accelerator_to_generation.get(accelerator)
if not generation:
return None
num_chips = get_num_chips_from_topology(topology)
num_cores = num_chips * get_tpu_cores_per_chip(generation)
return f"{generation}-{num_cores}"
return None