chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,768 @@
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from ray._raylet import RAY_INTERNAL_NAMESPACE_PREFIX, GcsClient
|
||||
|
||||
# TODO(rickyx): We should eventually remove these imports
|
||||
# when we deprecate the v1 kuberay node provider.
|
||||
from ray.autoscaler._private.kuberay.node_provider import (
|
||||
KUBERAY_KIND_HEAD,
|
||||
KUBERAY_KIND_WORKER,
|
||||
KUBERAY_LABEL_KEY_KIND,
|
||||
KUBERAY_LABEL_KEY_TYPE,
|
||||
RAY_HEAD_POD_NAME,
|
||||
IKubernetesHttpApiClient,
|
||||
KubernetesHttpApiClient,
|
||||
_worker_group_index,
|
||||
_worker_group_max_replicas,
|
||||
_worker_group_num_of_hosts,
|
||||
_worker_group_replicas,
|
||||
worker_delete_patch,
|
||||
worker_replica_patch,
|
||||
)
|
||||
from ray.autoscaler.v2.instance_manager.cloud_providers.kuberay.ippr_provider import (
|
||||
KubeRayIPPRProvider,
|
||||
)
|
||||
from ray.autoscaler.v2.instance_manager.node_provider import (
|
||||
CloudInstance,
|
||||
CloudInstanceId,
|
||||
CloudInstanceProviderError,
|
||||
ICloudInstanceProvider,
|
||||
LaunchNodeError,
|
||||
NodeKind,
|
||||
TerminateNodeError,
|
||||
)
|
||||
from ray.autoscaler.v2.schema import IPPRSpecs, IPPRStatus, NodeType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Annotation the KubeRay operator acts on to terminate the cluster.
|
||||
NO_DRIVER_TTL_EXPIRED_ANNOTATION = "ray.io/no-driver-ttl-expired"
|
||||
|
||||
AUTOSCALER_OPTIONS_KEY = "autoscalerOptions"
|
||||
NO_DRIVER_TIMEOUT_SECONDS_KEY = "noDriverTimeoutSeconds"
|
||||
|
||||
|
||||
class KubeRayProvider(ICloudInstanceProvider):
|
||||
"""
|
||||
This class is a thin wrapper around the Kubernetes API client. It modifies
|
||||
the RayCluster resource spec on the Kubernetes API server to scale the cluster:
|
||||
|
||||
It launches new instances/nodes by submitting patches to the Kubernetes API
|
||||
to update the RayCluster CRD.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cluster_name: str,
|
||||
provider_config: Dict[str, Any],
|
||||
gcs_client: GcsClient,
|
||||
k8s_api_client: Optional[IKubernetesHttpApiClient] = None,
|
||||
):
|
||||
"""
|
||||
Initializes a new KubeRayProvider.
|
||||
|
||||
Args:
|
||||
cluster_name: The name of the RayCluster resource.
|
||||
provider_config: The configuration for the RayCluster.
|
||||
gcs_client: The client to the GCS server. Will be used for resizing raylets.
|
||||
k8s_api_client: The client to the Kubernetes
|
||||
API server. This can be used to mock the Kubernetes API server for testing.
|
||||
"""
|
||||
self._cluster_name = cluster_name
|
||||
self._namespace = provider_config["namespace"]
|
||||
|
||||
self._k8s_api_client = k8s_api_client or KubernetesHttpApiClient(
|
||||
namespace=self._namespace
|
||||
)
|
||||
self._gcs_client = gcs_client
|
||||
|
||||
# Below are states that are cached locally.
|
||||
self._requests = set()
|
||||
self._launch_errors_queue = []
|
||||
self._terminate_errors_queue = []
|
||||
|
||||
# Below are states for idle-cluster termination tracking.
|
||||
# Monotonic timestamp when no driver was first observed; None resets it.
|
||||
self._no_driver_observed_since: Optional[float] = None
|
||||
# Latest GCS job end time seen; a newer one means a driver came and went.
|
||||
self._last_seen_job_end_time = 0
|
||||
# No-driver timeout (seconds) from the CR; None disables the feature.
|
||||
self._no_driver_timeout_seconds: Optional[float] = None
|
||||
|
||||
# Below are states that are fetched from the Kubernetes API server.
|
||||
self._ray_cluster = None
|
||||
self._cached_instances: Dict[CloudInstanceId, CloudInstance]
|
||||
self._ippr_provider = KubeRayIPPRProvider(
|
||||
gcs_client=gcs_client, k8s_api_client=self._k8s_api_client
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class ScaleRequest:
|
||||
"""Represents a scale request that contains the current states and go-to states
|
||||
for the ray cluster.
|
||||
|
||||
This class will be converted to patches to be submitted to the Kubernetes API
|
||||
server:
|
||||
- For launching new instances, it will adjust the `replicas` field in the
|
||||
workerGroupSpecs.
|
||||
- For terminating instances, it will adjust the `workersToDelete` field in the
|
||||
workerGroupSpecs.
|
||||
|
||||
"""
|
||||
|
||||
# The desired number of workers for each node type.
|
||||
desired_num_workers: Dict[NodeType, int] = field(default_factory=dict)
|
||||
# The workers to delete for each node type.
|
||||
workers_to_delete: Dict[NodeType, List[CloudInstanceId]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
# The worker groups with empty workersToDelete field.
|
||||
# This is needed since we will also need to clear the workersToDelete field
|
||||
# for the worker groups that have finished deletes.
|
||||
worker_groups_without_pending_deletes: Set[NodeType] = field(
|
||||
default_factory=set
|
||||
)
|
||||
# The worker groups that still have workers to be deleted.
|
||||
worker_groups_with_pending_deletes: Set[NodeType] = field(default_factory=set)
|
||||
|
||||
################################
|
||||
# Interface for ICloudInstanceProvider
|
||||
################################
|
||||
|
||||
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
|
||||
self._sync_with_api_server()
|
||||
self._evaluate_no_driver_termination()
|
||||
return copy.deepcopy(dict(self._cached_instances))
|
||||
|
||||
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
||||
if request_id in self._requests:
|
||||
# This request is already processed.
|
||||
logger.warning(f"Request {request_id} is already processed for: {ids}")
|
||||
return
|
||||
|
||||
logger.info("Terminating worker pods: {}".format(ids))
|
||||
scale_request = None
|
||||
try:
|
||||
scale_request = self._initialize_scale_request(
|
||||
to_launch={}, to_delete_instances=ids
|
||||
)
|
||||
|
||||
if scale_request.worker_groups_with_pending_deletes:
|
||||
errors_msg = (
|
||||
"There are workers to be deleted from: "
|
||||
f"{scale_request.worker_groups_with_pending_deletes}. "
|
||||
"Waiting for them to be deleted before adding new workers "
|
||||
" to be deleted"
|
||||
)
|
||||
logger.warning(errors_msg)
|
||||
self._add_terminate_errors(
|
||||
ids,
|
||||
request_id,
|
||||
details=errors_msg,
|
||||
)
|
||||
return
|
||||
|
||||
self._submit_scale_request(scale_request)
|
||||
# Only add to processed requests if successful
|
||||
self._requests.add(request_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error terminating nodes: {scale_request or 'N/A'}")
|
||||
self._add_terminate_errors(ids, request_id, details=str(e), e=e)
|
||||
|
||||
def launch(self, shape: Dict[NodeType, int], request_id: str) -> None:
|
||||
if request_id in self._requests:
|
||||
# This request is already processed.
|
||||
return
|
||||
|
||||
scale_request = None
|
||||
try:
|
||||
scale_request = self._initialize_scale_request(
|
||||
to_launch=shape, to_delete_instances=[]
|
||||
)
|
||||
|
||||
if scale_request.worker_groups_with_pending_deletes:
|
||||
error_msg = (
|
||||
"There are workers to be deleted from: "
|
||||
f"{scale_request.worker_groups_with_pending_deletes}. "
|
||||
"Waiting for them to be deleted before creating new workers."
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
self._add_launch_errors(
|
||||
shape,
|
||||
request_id,
|
||||
details=error_msg,
|
||||
)
|
||||
return
|
||||
|
||||
self._submit_scale_request(scale_request)
|
||||
# Only add to processed requests if successful
|
||||
self._requests.add(request_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error launching nodes: {scale_request or 'N/A'}")
|
||||
self._add_launch_errors(shape, request_id, details=str(e), e=e)
|
||||
|
||||
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
||||
errors = []
|
||||
errors += self._launch_errors_queue
|
||||
errors += self._terminate_errors_queue
|
||||
self._launch_errors_queue = []
|
||||
self._terminate_errors_queue = []
|
||||
return errors
|
||||
|
||||
def get_ippr_specs(self) -> IPPRSpecs:
|
||||
"""Return the cached, validated IPPR specs for the cluster.
|
||||
|
||||
The IPPR specs are refreshed during the provider's periodic sync with the
|
||||
API server by reading the RayCluster annotation and validating it against
|
||||
the IPPR schema.
|
||||
"""
|
||||
return self._ippr_provider.get_ippr_specs()
|
||||
|
||||
def get_ippr_statuses(self) -> Dict[str, IPPRStatus]:
|
||||
"""Return the latest per-pod IPPR statuses keyed by pod name.
|
||||
|
||||
These statuses are refreshed from the current pod list during the provider's
|
||||
periodic sync with the API server.
|
||||
"""
|
||||
return self._ippr_provider.get_ippr_statuses()
|
||||
|
||||
def do_ippr_requests(self, resizes: List[IPPRStatus]) -> None:
|
||||
"""Execute IPPR resize requests via the underlying IPPR provider.
|
||||
|
||||
Args:
|
||||
resizes: The list of per-pod IPPR actions produced by the scheduler.
|
||||
"""
|
||||
self._ippr_provider.do_ippr_requests(resizes)
|
||||
|
||||
############################
|
||||
# Private
|
||||
############################
|
||||
|
||||
def _initialize_scale_request(
|
||||
self, to_launch: Dict[NodeType, int], to_delete_instances: List[CloudInstanceId]
|
||||
) -> "KubeRayProvider.ScaleRequest":
|
||||
"""
|
||||
Initialize the scale request based on the current state of the cluster and
|
||||
the desired state (to launch, to delete).
|
||||
|
||||
Args:
|
||||
to_launch: The desired number of workers to launch for each node type.
|
||||
to_delete_instances: The instances to delete.
|
||||
|
||||
Returns:
|
||||
The scale request.
|
||||
"""
|
||||
|
||||
# Update the cached states.
|
||||
self._sync_with_api_server()
|
||||
ray_cluster = self.ray_cluster
|
||||
cur_instances = self.instances
|
||||
|
||||
# Get the worker groups that have pending deletes and the worker groups that
|
||||
# have finished deletes, and the set of workers included in the workersToDelete
|
||||
# field of any worker group.
|
||||
(
|
||||
worker_groups_with_pending_deletes,
|
||||
worker_groups_without_pending_deletes,
|
||||
worker_to_delete_set,
|
||||
) = self._get_workers_delete_info(ray_cluster, set(cur_instances.keys()))
|
||||
|
||||
observed_workers_dict = defaultdict(int)
|
||||
for instance in cur_instances.values():
|
||||
if instance.node_kind != NodeKind.WORKER:
|
||||
continue
|
||||
if instance.cloud_instance_id in worker_to_delete_set:
|
||||
continue
|
||||
observed_workers_dict[instance.node_type] += 1
|
||||
|
||||
# Calculate the desired number of workers by type.
|
||||
num_workers_dict = defaultdict(int)
|
||||
worker_groups = ray_cluster["spec"].get("workerGroupSpecs", [])
|
||||
for worker_group in worker_groups:
|
||||
node_type = worker_group["groupName"]
|
||||
# Handle the case where users manually increase `minReplicas`
|
||||
# to scale up the number of worker Pods. In this scenario,
|
||||
# `replicas` will be smaller than `minReplicas`.
|
||||
# num_workers_dict should account for multi-host replicas when
|
||||
# `numOfHosts`` is set.
|
||||
num_of_hosts = worker_group.get("numOfHosts", 1)
|
||||
replicas = (
|
||||
max(worker_group["replicas"], worker_group["minReplicas"])
|
||||
* num_of_hosts
|
||||
)
|
||||
|
||||
# The `replicas` field in worker group specs can be updated by users at any time.
|
||||
# However, users should only increase the field (manually upscaling the worker group), not decrease it,
|
||||
# because downscaling the worker group requires specifying which workers to delete explicitly in the `workersToDelete` field.
|
||||
# Since we don't have a way to enforce this, we need to fix unexpected decreases on the `replicas` field by using actual observations.
|
||||
# For example, if the user manually decreases the `replicas` field to 0 without specifying which workers to delete,
|
||||
# we should fix the `replicas` field back to the number of observed workers excluding the workers to be deleted,
|
||||
# otherwise, we won't have a correct `replicas` matches the actual number of workers eventually.
|
||||
num_workers_dict[node_type] = max(
|
||||
replicas, observed_workers_dict[node_type]
|
||||
)
|
||||
|
||||
# Add to launch nodes.
|
||||
for node_type, count in to_launch.items():
|
||||
num_workers_dict[node_type] += count
|
||||
|
||||
to_delete_instances_by_type = defaultdict(list)
|
||||
# Update the number of workers with to_delete_instances
|
||||
# and group them by type.
|
||||
for to_delete_id in to_delete_instances:
|
||||
to_delete_instance = cur_instances.get(to_delete_id, None)
|
||||
if to_delete_instance is None:
|
||||
# This instance has already been deleted.
|
||||
continue
|
||||
|
||||
if to_delete_instance.node_kind == NodeKind.HEAD:
|
||||
# Not possible to delete head node.
|
||||
continue
|
||||
|
||||
if to_delete_instance.cloud_instance_id in worker_to_delete_set:
|
||||
# If the instance is already in the workersToDelete field of
|
||||
# any worker group, skip it.
|
||||
continue
|
||||
|
||||
num_workers_dict[to_delete_instance.node_type] -= 1
|
||||
assert num_workers_dict[to_delete_instance.node_type] >= 0
|
||||
to_delete_instances_by_type[to_delete_instance.node_type].append(
|
||||
to_delete_instance
|
||||
)
|
||||
|
||||
scale_request = KubeRayProvider.ScaleRequest(
|
||||
desired_num_workers=num_workers_dict,
|
||||
workers_to_delete=to_delete_instances_by_type,
|
||||
worker_groups_without_pending_deletes=worker_groups_without_pending_deletes,
|
||||
worker_groups_with_pending_deletes=worker_groups_with_pending_deletes,
|
||||
)
|
||||
|
||||
return scale_request
|
||||
|
||||
def _submit_scale_request(
|
||||
self, scale_request: "KubeRayProvider.ScaleRequest"
|
||||
) -> None:
|
||||
"""Submits a scale request to the Kubernetes API server.
|
||||
|
||||
This method will convert the scale request to patches and submit the patches
|
||||
to the Kubernetes API server.
|
||||
|
||||
Args:
|
||||
scale_request: The scale request.
|
||||
|
||||
Raises:
|
||||
Exception: An exception is raised if the Kubernetes API server returns an
|
||||
error.
|
||||
"""
|
||||
# Get the current ray cluster spec.
|
||||
patch_payload = []
|
||||
|
||||
raycluster = self.ray_cluster
|
||||
|
||||
# Collect patches for replica counts.
|
||||
for node_type, num_workers 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)
|
||||
group_num_of_hosts = _worker_group_num_of_hosts(raycluster, group_index)
|
||||
# the num_workers from the scale request is multiplied by numOfHosts, so we need to divide it back.
|
||||
target_replicas = num_workers // group_num_of_hosts
|
||||
# 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.
|
||||
for (
|
||||
node_type,
|
||||
workers_to_delete_of_type,
|
||||
) in scale_request.workers_to_delete.items():
|
||||
group_index = _worker_group_index(raycluster, node_type)
|
||||
worker_ids_to_delete = [
|
||||
worker.cloud_instance_id for worker in workers_to_delete_of_type
|
||||
]
|
||||
patch = worker_delete_patch(group_index, worker_ids_to_delete)
|
||||
patch_payload.append(patch)
|
||||
|
||||
# Clear the workersToDelete field for the worker groups that have been deleted.
|
||||
for node_type in scale_request.worker_groups_without_pending_deletes:
|
||||
if node_type in scale_request.workers_to_delete:
|
||||
# This node type is still being deleted.
|
||||
continue
|
||||
group_index = _worker_group_index(raycluster, node_type)
|
||||
patch = worker_delete_patch(group_index, [])
|
||||
patch_payload.append(patch)
|
||||
|
||||
if len(patch_payload) == 0:
|
||||
# No patch required.
|
||||
return
|
||||
|
||||
logger.info(f"Submitting a scale request: {scale_request}")
|
||||
self._patch(f"rayclusters/{self._cluster_name}", patch_payload)
|
||||
|
||||
def _add_launch_errors(
|
||||
self,
|
||||
shape: Dict[NodeType, int],
|
||||
request_id: str,
|
||||
details: str,
|
||||
e: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Adds launch errors to the error queue.
|
||||
|
||||
Args:
|
||||
shape: The shape of the nodes that failed to launch.
|
||||
request_id: The request id of the launch request.
|
||||
details: The details of the error.
|
||||
e: The exception that caused the error.
|
||||
"""
|
||||
for node_type, count in shape.items():
|
||||
self._launch_errors_queue.append(
|
||||
LaunchNodeError(
|
||||
node_type=node_type,
|
||||
timestamp_ns=time.time_ns(),
|
||||
count=count,
|
||||
request_id=request_id,
|
||||
details=details,
|
||||
cause=e,
|
||||
)
|
||||
)
|
||||
|
||||
def _add_terminate_errors(
|
||||
self,
|
||||
ids: List[CloudInstanceId],
|
||||
request_id: str,
|
||||
details: str,
|
||||
e: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Adds terminate errors to the error queue.
|
||||
|
||||
Args:
|
||||
ids: The ids of the nodes that failed to terminate.
|
||||
request_id: The request id of the terminate request.
|
||||
details: The details of the error.
|
||||
e: The exception that caused the error.
|
||||
"""
|
||||
for id in ids:
|
||||
self._terminate_errors_queue.append(
|
||||
TerminateNodeError(
|
||||
cloud_instance_id=id,
|
||||
timestamp_ns=time.time_ns(),
|
||||
request_id=request_id,
|
||||
details=details,
|
||||
cause=e,
|
||||
)
|
||||
)
|
||||
|
||||
def _sync_with_api_server(self) -> None:
|
||||
"""Fetches the RayCluster resource from the Kubernetes API server."""
|
||||
self._ray_cluster = self._get(f"rayclusters/{self._cluster_name}")
|
||||
self._refresh_no_driver_timeout_seconds()
|
||||
self._ippr_provider.validate_and_set_ippr_specs(self._ray_cluster)
|
||||
self._cached_instances = self._fetch_instances()
|
||||
self._ippr_provider.sync_with_raylets()
|
||||
|
||||
def _refresh_no_driver_timeout_seconds(self) -> None:
|
||||
"""Reads noDriverTimeoutSeconds from the RayCluster CR."""
|
||||
opts = self._ray_cluster["spec"].get(AUTOSCALER_OPTIONS_KEY, {})
|
||||
secs = opts.get(NO_DRIVER_TIMEOUT_SECONDS_KEY)
|
||||
self._no_driver_timeout_seconds = float(secs) if secs is not None else None
|
||||
|
||||
@property
|
||||
def ray_cluster(self) -> Dict[str, Any]:
|
||||
return copy.deepcopy(self._ray_cluster)
|
||||
|
||||
@property
|
||||
def instances(self) -> Dict[CloudInstanceId, CloudInstance]:
|
||||
return copy.deepcopy(self._cached_instances)
|
||||
|
||||
@staticmethod
|
||||
def _get_workers_delete_info(
|
||||
ray_cluster_spec: Dict[str, Any], node_set: Set[CloudInstanceId]
|
||||
) -> Tuple[Set[NodeType], Set[NodeType], Set[CloudInstanceId]]:
|
||||
"""
|
||||
Gets the worker groups that have pending deletes and the worker groups that
|
||||
have finished deletes.
|
||||
|
||||
Args:
|
||||
ray_cluster_spec: The RayCluster CR spec dict.
|
||||
node_set: The set of currently known cloud instance IDs.
|
||||
|
||||
Returns:
|
||||
A tuple of:
|
||||
|
||||
- worker_groups_with_pending_deletes: The worker groups that have pending
|
||||
deletes.
|
||||
- worker_groups_with_finished_deletes: The worker groups that have finished
|
||||
deletes.
|
||||
- worker_to_delete_set: A set of Pods that are included in the
|
||||
workersToDelete field of any worker group.
|
||||
"""
|
||||
|
||||
worker_groups_with_pending_deletes = set()
|
||||
worker_groups_with_deletes = set()
|
||||
worker_to_delete_set = set()
|
||||
|
||||
worker_groups = ray_cluster_spec["spec"].get("workerGroupSpecs", [])
|
||||
for worker_group in worker_groups:
|
||||
workersToDelete = worker_group.get("scaleStrategy", {}).get(
|
||||
"workersToDelete", []
|
||||
)
|
||||
if not workersToDelete:
|
||||
# No workers to delete in this group.
|
||||
continue
|
||||
|
||||
node_type = worker_group["groupName"]
|
||||
worker_groups_with_deletes.add(node_type)
|
||||
|
||||
for worker in workersToDelete:
|
||||
worker_to_delete_set.add(worker)
|
||||
if worker in node_set:
|
||||
worker_groups_with_pending_deletes.add(node_type)
|
||||
|
||||
worker_groups_with_finished_deletes = (
|
||||
worker_groups_with_deletes - worker_groups_with_pending_deletes
|
||||
)
|
||||
return (
|
||||
worker_groups_with_pending_deletes,
|
||||
worker_groups_with_finished_deletes,
|
||||
worker_to_delete_set,
|
||||
)
|
||||
|
||||
def _fetch_instances(self) -> Dict[CloudInstanceId, CloudInstance]:
|
||||
"""
|
||||
Fetches the pods from the Kubernetes API server and convert them to Ray
|
||||
CloudInstance.
|
||||
|
||||
Returns:
|
||||
A dict of CloudInstanceId to CloudInstance.
|
||||
"""
|
||||
# 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_head_pod_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.
|
||||
cloud_instances = {}
|
||||
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"]
|
||||
cloud_instance = self._cloud_instance_from_pod(pod)
|
||||
if cloud_instance:
|
||||
cloud_instances[pod_name] = cloud_instance
|
||||
|
||||
self._ippr_provider.sync_ippr_status_from_pods(pod_list["items"])
|
||||
|
||||
return cloud_instances
|
||||
|
||||
@staticmethod
|
||||
def _cloud_instance_from_pod(pod: Dict[str, Any]) -> Optional[CloudInstance]:
|
||||
"""
|
||||
Convert a pod to a Ray CloudInstance.
|
||||
|
||||
Args:
|
||||
pod: The pod resource dict.
|
||||
|
||||
Returns:
|
||||
The CloudInstance representing the pod, or None if the pod is not a
|
||||
tracked Ray node (e.g. a redis-cleanup pod).
|
||||
"""
|
||||
labels = pod["metadata"]["labels"]
|
||||
if labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_HEAD:
|
||||
kind = NodeKind.HEAD
|
||||
type = labels[KUBERAY_LABEL_KEY_TYPE]
|
||||
elif labels[KUBERAY_LABEL_KEY_KIND] == KUBERAY_KIND_WORKER:
|
||||
kind = NodeKind.WORKER
|
||||
type = labels[KUBERAY_LABEL_KEY_TYPE]
|
||||
else:
|
||||
# Other ray nodes types defined by KubeRay.
|
||||
# e.g. this could also be `redis-cleanup`
|
||||
# We will not track these nodes.
|
||||
return None
|
||||
|
||||
# TODO: we should prob get from the pod's env var (RAY_CLOUD_INSTANCE_ID)
|
||||
# directly.
|
||||
cloud_instance_id = pod["metadata"]["name"]
|
||||
return CloudInstance(
|
||||
cloud_instance_id=cloud_instance_id,
|
||||
node_type=type,
|
||||
node_kind=kind,
|
||||
is_running=KubeRayProvider._is_running(pod),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_running(pod) -> bool:
|
||||
"""Convert pod state to Ray NodeStatus
|
||||
|
||||
A cloud instance is considered running if the pod is in the running state,
|
||||
else it could be pending/containers-terminated.
|
||||
|
||||
When it disappears from the list, it is considered terminated.
|
||||
"""
|
||||
if (
|
||||
"containerStatuses" not in pod["status"]
|
||||
or not pod["status"]["containerStatuses"]
|
||||
):
|
||||
return False
|
||||
|
||||
state = pod["status"]["containerStatuses"][0]["state"]
|
||||
if "running" in state:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get(self, remote_path: str) -> Dict[str, Any]:
|
||||
"""Get a resource from the Kubernetes API server."""
|
||||
return self._k8s_api_client.get(remote_path)
|
||||
|
||||
def _patch(self, remote_path: str, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Patch a resource on the Kubernetes API server."""
|
||||
return self._k8s_api_client.patch(remote_path, payload)
|
||||
|
||||
def _evaluate_no_driver_termination(self) -> None:
|
||||
"""Patches the no-driver-TTL annotation once no driver held for the timeout.
|
||||
|
||||
Detached actors do not count as a driver.
|
||||
"""
|
||||
# Feature disabled or a driver is attached: reset the anchor.
|
||||
if self._no_driver_timeout_seconds is None:
|
||||
self._no_driver_observed_since = None
|
||||
return
|
||||
has_active_driver, latest_job_end_time = self._driver_status()
|
||||
if has_active_driver:
|
||||
self._no_driver_observed_since = None
|
||||
return
|
||||
|
||||
# A driver finished since the last check: it was attached during the
|
||||
# no-driver window, so restart the timer.
|
||||
if latest_job_end_time > self._last_seen_job_end_time:
|
||||
self._last_seen_job_end_time = latest_job_end_time
|
||||
self._no_driver_observed_since = None
|
||||
|
||||
# Anchor on the first loop with no driver, then dispatch once the
|
||||
# no-driver window reaches the timeout.
|
||||
now = time.monotonic()
|
||||
if self._no_driver_observed_since is None:
|
||||
self._no_driver_observed_since = now
|
||||
if now - self._no_driver_observed_since < self._no_driver_timeout_seconds:
|
||||
return
|
||||
self._set_no_driver_annotation()
|
||||
|
||||
def _driver_status(self) -> Tuple[bool, int]:
|
||||
"""Returns whether a non-internal driver is alive and the latest job end time.
|
||||
|
||||
Fails closed: a failed GCS query reports a driver as present.
|
||||
"""
|
||||
try:
|
||||
jobs = self._gcs_client.get_all_job_info(
|
||||
skip_submission_job_info_field=True,
|
||||
skip_is_running_tasks_field=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query GCS job table; treating as drivers attached."
|
||||
)
|
||||
return True, self._last_seen_job_end_time
|
||||
|
||||
has_active_driver = False
|
||||
latest_job_end_time = 0
|
||||
for job in jobs.values():
|
||||
# Ray-internal drivers (e.g. the dashboard) are not user activity.
|
||||
if job.config.ray_namespace.startswith(RAY_INTERNAL_NAMESPACE_PREFIX):
|
||||
continue
|
||||
if job.is_dead:
|
||||
latest_job_end_time = max(latest_job_end_time, job.end_time)
|
||||
else:
|
||||
has_active_driver = True
|
||||
return has_active_driver, latest_job_end_time
|
||||
|
||||
def _set_no_driver_annotation(self) -> None:
|
||||
"""Sets `ray.io/no-driver-ttl-expired=true` on the RayCluster CR.
|
||||
|
||||
Idempotent via the CR cached this reconcile loop; PATCH errors are swallowed.
|
||||
"""
|
||||
annotations = self._ray_cluster.get("metadata", {}).get("annotations", {})
|
||||
if annotations.get(NO_DRIVER_TTL_EXPIRED_ANNOTATION) == "true":
|
||||
return
|
||||
|
||||
path = f"rayclusters/{self._cluster_name}"
|
||||
# Merge patch covers missing and present annotations in one call.
|
||||
payload = {
|
||||
"metadata": {"annotations": {NO_DRIVER_TTL_EXPIRED_ANNOTATION: "true"}}
|
||||
}
|
||||
try:
|
||||
self._k8s_api_client.patch(
|
||||
path,
|
||||
payload,
|
||||
content_type="application/merge-patch+json",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to PATCH %s=true on RayCluster %s",
|
||||
NO_DRIVER_TTL_EXPIRED_ANNOTATION,
|
||||
self._cluster_name,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Set %s=true on RayCluster %s.",
|
||||
NO_DRIVER_TTL_EXPIRED_ANNOTATION,
|
||||
self._cluster_name,
|
||||
)
|
||||
|
||||
def _get_head_pod_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"]
|
||||
@@ -0,0 +1,736 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import jsonschema
|
||||
|
||||
from ray._raylet import GcsClient
|
||||
from ray.autoscaler._private.kuberay.node_provider import (
|
||||
KUBERAY_KIND_HEAD,
|
||||
KUBERAY_KIND_WORKER,
|
||||
KUBERAY_LABEL_KEY_KIND,
|
||||
KUBERAY_LABEL_KEY_TYPE,
|
||||
IKubernetesHttpApiClient,
|
||||
replace_patch,
|
||||
)
|
||||
from ray.autoscaler._private.kuberay.utils import parse_quantity
|
||||
from ray.autoscaler.v2.schema import (
|
||||
IPPRGroupSpec,
|
||||
IPPRSpecs,
|
||||
IPPRSpecsSchema,
|
||||
IPPRStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KubeRayIPPRProvider:
|
||||
"""Implements in-place pod resize (IPPR) operations for KubeRay pods.
|
||||
|
||||
This provider is responsible for:
|
||||
- Validating and materializing IPPR specs from the RayCluster annotation
|
||||
(``ray.io/ippr``) into typed structures (``IPPRSpecs``/``IPPRGroupSpec``).
|
||||
- Tracking per-pod resize status (``IPPRStatus``) from Kubernetes pods and
|
||||
computing the desired resize actions.
|
||||
- Issuing Kubernetes Pod Resize API requests and keeping a shadow annotation
|
||||
(``ray.io/ippr-status``) to track progress and temporary caps.
|
||||
- Synchronizing successful resource changes with the Raylet so Ray's local
|
||||
resource view matches Kubernetes.
|
||||
|
||||
Attributes:
|
||||
_gcs_client: Ray GCS client used to fetch Raylet node information.
|
||||
_k8s_api_client: Kubernetes HTTP client for patching pods.
|
||||
_ippr_specs: Validated per-group IPPR specs (limits and timeouts).
|
||||
_ippr_statuses: Latest per-pod IPPR statuses indexed by pod name.
|
||||
_container_resources: Snapshot of container resource requests/limits
|
||||
from both pod spec and pod status, per pod name, used to compute
|
||||
patch diffs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gcs_client: GcsClient,
|
||||
k8s_api_client: IKubernetesHttpApiClient,
|
||||
):
|
||||
"""Create a new IPPR provider.
|
||||
|
||||
Args:
|
||||
gcs_client: Ray GCS client for resolving Raylet addresses.
|
||||
k8s_api_client: Kubernetes HTTP client to issue patch requests.
|
||||
"""
|
||||
self._gcs_client = gcs_client
|
||||
self._k8s_api_client = k8s_api_client
|
||||
self._ippr_specs: IPPRSpecs = IPPRSpecs(groups={})
|
||||
self._ippr_statuses: Dict[str, IPPRStatus] = {}
|
||||
self._container_resources: Dict[str, Any] = {}
|
||||
|
||||
def validate_and_set_ippr_specs(
|
||||
self, ray_cluster: Optional[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Validate and load IPPR specs from a RayCluster CR.
|
||||
|
||||
Reads the ``ray.io/ippr`` annotation, validates it against
|
||||
``IPPRSpecsSchema``, and converts it to typed ``IPPRSpecs`` with per-group
|
||||
``IPPRGroupSpec`` entries. Minimal resources are derived from the group's
|
||||
pod template; maximums and timeout come from the annotation. If the
|
||||
annotation is removed, clear any previously loaded IPPR specs.
|
||||
|
||||
Args:
|
||||
ray_cluster: The RayCluster custom resource as a dict. If missing or
|
||||
lacking the annotation, this method is a no-op.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Ray pod template is incompatible with IPPR (e.g.,
|
||||
missing required requests, using unsupported resizePolicy restarts,
|
||||
or conflicting ``rayStartParams``).
|
||||
|
||||
Example:
|
||||
import json
|
||||
|
||||
ray_cluster = {
|
||||
"metadata": {
|
||||
"name": "example-raycluster",
|
||||
"annotations": {
|
||||
"ray.io/ippr": json.dumps(
|
||||
{
|
||||
"groups": {
|
||||
"headgroup": {
|
||||
"max-cpu": "4",
|
||||
"max-memory": "8Gi",
|
||||
"resize-timeout": 300,
|
||||
},
|
||||
"small-workers": {
|
||||
"max-cpu": 2,
|
||||
"max-memory": "4Gi",
|
||||
"resize-timeout": 120,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"headGroupSpec": {
|
||||
"rayStartParams": {},
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"name": "ray-head",
|
||||
"resources": {
|
||||
"requests": {
|
||||
"cpu": "1",
|
||||
"memory": "2Gi",
|
||||
},
|
||||
"limits": {
|
||||
"cpu": "2",
|
||||
"memory": "4Gi",
|
||||
},
|
||||
},
|
||||
"resizePolicy": [
|
||||
{
|
||||
"resourceName": "cpu",
|
||||
"restartPolicy": "NotRequired",
|
||||
},
|
||||
{
|
||||
"resourceName": "memory",
|
||||
"restartPolicy": "NotRequired",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"workerGroupSpecs": [
|
||||
{
|
||||
"groupName": "small-workers",
|
||||
"rayStartParams": {},
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"name": "ray-worker",
|
||||
"resources": {
|
||||
"requests": {
|
||||
"cpu": "500m",
|
||||
"memory": "1Gi",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
provider.validate_and_set_ippr_specs(ray_cluster)
|
||||
"""
|
||||
if not ray_cluster:
|
||||
return
|
||||
|
||||
specs_str = ray_cluster["metadata"].get("annotations", {}).get("ray.io/ippr")
|
||||
if not specs_str:
|
||||
self._ippr_specs = IPPRSpecs(groups={})
|
||||
return
|
||||
|
||||
ippr_specs_raw = json.loads(specs_str)
|
||||
jsonschema.validate(instance=ippr_specs_raw, schema=IPPRSpecsSchema)
|
||||
|
||||
# Validate and build typed spec per group
|
||||
worker_groups = {
|
||||
worker_group_spec["groupName"]: worker_group_spec
|
||||
for worker_group_spec in ray_cluster["spec"].get("workerGroupSpecs", [])
|
||||
}
|
||||
worker_groups["headgroup"] = ray_cluster["spec"]["headGroupSpec"]
|
||||
|
||||
groups = {
|
||||
group_name: _build_ippr_group_spec(group_spec, worker_groups[group_name])
|
||||
for group_name, group_spec in ippr_specs_raw.get("groups", {}).items()
|
||||
if group_name in worker_groups
|
||||
}
|
||||
|
||||
self._ippr_specs = IPPRSpecs(groups=groups)
|
||||
|
||||
def sync_with_raylets(self) -> None:
|
||||
"""Propagate completed K8s resizes to Raylets via GCS.
|
||||
|
||||
For any pod whose K8s resize has completed, update the corresponding Raylet's local resource
|
||||
instances via GCS gRPC and clear the pending timestamp on the pod's
|
||||
``ray.io/ippr-status`` annotation.
|
||||
|
||||
|
||||
Three situations we can have exceptions are:
|
||||
1. K8s API is not available.
|
||||
2. GCS is not available.
|
||||
3. Raylet is not available.
|
||||
If a raylet is truly dead, its pod will also be deleted eventually.
|
||||
All of the above exceptions can only be resolved in the future reconcile loops.
|
||||
"""
|
||||
for ippr_status in self._ippr_statuses.values():
|
||||
if not ippr_status.need_sync_with_raylet():
|
||||
continue
|
||||
try:
|
||||
self._gcs_client.resize_raylet_resource_instances(
|
||||
ippr_status.raylet_id,
|
||||
{
|
||||
"CPU": ippr_status.current_cpu,
|
||||
"memory": ippr_status.current_memory,
|
||||
},
|
||||
)
|
||||
self._patch_ippr_status(ippr_status, resizing_at=None)
|
||||
logger.info(f"Pod {ippr_status.cloud_instance_id} resized successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to resize pod {ippr_status.cloud_instance_id}: {e}. "
|
||||
"If this persists, check GCS (e.g. Head/GCS logs and Raylet reachability) "
|
||||
"and Kubernetes (e.g. API errors, pod events, ray.io/ippr-status) for "
|
||||
"related request failures."
|
||||
)
|
||||
|
||||
def sync_ippr_status_from_pods(self, pods: List[Dict[str, Any]]) -> None:
|
||||
"""Refresh internal IPPR statuses and container resources from pods.
|
||||
|
||||
Parses pods to produce up-to-date ``IPPRStatus`` objects and stores
|
||||
relevant request/limit snapshots for later patch computations.
|
||||
|
||||
Args:
|
||||
pods: List of Kubernetes Pod resources for the Ray cluster.
|
||||
"""
|
||||
self._ippr_statuses = {}
|
||||
self._container_resources = {}
|
||||
|
||||
if not self._ippr_specs.groups:
|
||||
return
|
||||
|
||||
for pod in pods:
|
||||
if "deletionTimestamp" in pod["metadata"]:
|
||||
continue
|
||||
|
||||
labels = pod["metadata"].get("labels", {})
|
||||
kind = labels.get(KUBERAY_LABEL_KEY_KIND)
|
||||
if kind not in (KUBERAY_KIND_HEAD, KUBERAY_KIND_WORKER):
|
||||
continue
|
||||
|
||||
ippr_group_spec = self._ippr_specs.groups.get(
|
||||
labels.get(KUBERAY_LABEL_KEY_TYPE)
|
||||
)
|
||||
ippr_status, container_resource = _get_ippr_status_from_pod(
|
||||
ippr_group_spec, pod
|
||||
)
|
||||
if ippr_status:
|
||||
self._ippr_statuses[ippr_status.cloud_instance_id] = ippr_status
|
||||
if ippr_status and container_resource:
|
||||
self._container_resources[
|
||||
ippr_status.cloud_instance_id
|
||||
] = container_resource
|
||||
|
||||
def do_ippr_requests(self, resizes: List[IPPRStatus]) -> None:
|
||||
"""Issue Kubernetes Pod Resize requests for the given targets.
|
||||
|
||||
If any dimension downsizes, attempt to first adjust the Raylet's local
|
||||
resources via gRPC to avoid temporary overcommit in Ray's scheduler.
|
||||
The raylet request uses ``min(desired, current)`` per resource so mixed
|
||||
resizes (e.g. CPU up and memory down) do not advertise increases before
|
||||
Kubernetes applies them; the reply is merged so upsizing targets are kept
|
||||
for the pod patch.
|
||||
|
||||
Args:
|
||||
resizes: List of IPPRStatus with desired resources and metadata.
|
||||
"""
|
||||
for resize in resizes:
|
||||
logger.info(
|
||||
f"Resizing pod {resize.cloud_instance_id} to cpu={resize.desired_cpu} memory={resize.desired_memory} from cpu={resize.current_cpu} memory={resize.current_memory}"
|
||||
)
|
||||
if resize.raylet_id and (
|
||||
resize.desired_cpu < resize.current_cpu
|
||||
or resize.desired_memory < resize.current_memory
|
||||
):
|
||||
# For any downsize, update Raylet first. Cap each resource at
|
||||
# current so we never tell the scheduler about an upsize until
|
||||
# sync_with_raylets runs after K8s applies the pod resize.
|
||||
try:
|
||||
updated_total_resources = (
|
||||
self._gcs_client.resize_raylet_resource_instances(
|
||||
resize.raylet_id,
|
||||
{
|
||||
"CPU": min(resize.desired_cpu, resize.current_cpu),
|
||||
"memory": min(
|
||||
resize.desired_memory, resize.current_memory
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
if (
|
||||
"CPU" not in updated_total_resources
|
||||
or "memory" not in updated_total_resources
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"CPU or memory not found in the response of resizing raylet resources: {updated_total_resources}"
|
||||
)
|
||||
if resize.desired_cpu < resize.current_cpu:
|
||||
resize.desired_cpu = float(updated_total_resources["CPU"])
|
||||
if resize.desired_memory < resize.current_memory:
|
||||
resize.desired_memory = int(updated_total_resources["memory"])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Skip failed downsizing on pod {resize.cloud_instance_id}: {e}"
|
||||
)
|
||||
continue
|
||||
self._patch_ippr_resize(resize)
|
||||
|
||||
def get_ippr_specs(self) -> IPPRSpecs:
|
||||
"""Return the current validated IPPR specs."""
|
||||
return self._ippr_specs
|
||||
|
||||
def get_ippr_statuses(self) -> Dict[str, IPPRStatus]:
|
||||
"""Return the latest per-pod IPPR statuses keyed by pod name."""
|
||||
return self._ippr_statuses
|
||||
|
||||
def _patch_ippr_resize(self, resize: IPPRStatus) -> None:
|
||||
patch = self._make_ippr_patch(resize)
|
||||
self._k8s_api_client.patch(
|
||||
"pods/{}/resize".format(resize.cloud_instance_id), patch
|
||||
)
|
||||
self._patch_ippr_status(resize, resizing_at=int(time.time()))
|
||||
|
||||
def _patch_ippr_status(
|
||||
self, resize: IPPRStatus, resizing_at: Optional[int]
|
||||
) -> None:
|
||||
"""Save the IPPR status to the pod annotation ``ray.io/ippr-status``.
|
||||
The annotation is used to track the IPPR status of the pod across reconcile loops.
|
||||
|
||||
Args:
|
||||
resize: The IPPR status to save.
|
||||
resizing_at: Timestamp while a resize is in progress; pass ``None``
|
||||
to clear after the resize completes (e.g. from ``sync_with_raylets``).
|
||||
"""
|
||||
self._k8s_api_client.patch(
|
||||
"pods/{}".format(resize.cloud_instance_id),
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"ray.io/ippr-status": json.dumps(
|
||||
{
|
||||
"raylet-id": resize.raylet_id,
|
||||
"resizing-at": resizing_at,
|
||||
"suggested-max-cpu": resize.suggested_max_cpu,
|
||||
"suggested-max-memory": resize.suggested_max_memory,
|
||||
"last-failed-at": resize.last_failed_at,
|
||||
"last-failed-reason": resize.last_failed_reason,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
content_type="application/strategic-merge-patch+json",
|
||||
)
|
||||
|
||||
def _make_ippr_patch(self, resize: IPPRStatus) -> List[Dict[str, Any]]:
|
||||
patch = []
|
||||
path_prefix = "/spec/containers/0/resources"
|
||||
container_resource = self._container_resources[resize.cloud_instance_id]
|
||||
# When limits are present, preserve the existing gap (limits - requests)
|
||||
# by adjusting requests proportionally so QoS doesn't change.
|
||||
for resource_name, desired in (
|
||||
("cpu", resize.desired_cpu),
|
||||
("memory", resize.desired_memory),
|
||||
):
|
||||
if container_resource["status"]["limits"].get(resource_name):
|
||||
# Gap between status limits and requests for each resource.
|
||||
diff = _resource_gap(
|
||||
container_resource["status"]["limits"],
|
||||
container_resource["status"]["requests"],
|
||||
resource_name,
|
||||
)
|
||||
patch.append(
|
||||
replace_patch(f"{path_prefix}/limits/{resource_name}", desired)
|
||||
)
|
||||
patch.append(
|
||||
replace_patch(
|
||||
f"{path_prefix}/requests/{resource_name}",
|
||||
_request_from_desired(desired, diff),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No limits configured: adjust requests only.
|
||||
patch.append(
|
||||
replace_patch(f"{path_prefix}/requests/{resource_name}", desired)
|
||||
)
|
||||
return patch
|
||||
|
||||
|
||||
def _build_ippr_group_spec(
|
||||
group_spec: Dict[str, Any], worker_group_spec: Dict[str, Any]
|
||||
) -> IPPRGroupSpec:
|
||||
# Disallow per-pod overrides that conflict with IPPR's dynamic sizing.
|
||||
ray_start_params = worker_group_spec.get("rayStartParams", {})
|
||||
if "num-cpus" in ray_start_params or "memory" in ray_start_params:
|
||||
raise ValueError(
|
||||
"should not have 'num-cpus' or 'memory' in rayStartParams if IPPR is used"
|
||||
)
|
||||
|
||||
container_spec = worker_group_spec["template"]["spec"]["containers"][0]
|
||||
pod_spec_requests = container_spec.get("resources", {}).get("requests", {})
|
||||
# Pod template must declare baseline CPU/memory requests for IPPR.
|
||||
if "cpu" not in pod_spec_requests or "memory" not in pod_spec_requests:
|
||||
raise ValueError(
|
||||
"should have 'cpu' and 'memory' in resource requests as the resources lower bounds if IPPR is used"
|
||||
)
|
||||
|
||||
for policy in container_spec.get("resizePolicy", []):
|
||||
resource_name = policy.get("resourceName")
|
||||
if resource_name != "cpu" and resource_name != "memory":
|
||||
continue
|
||||
restart = policy.get("restartPolicy")
|
||||
# IPPR requires NotRequired so that K8s won't restart the container
|
||||
# during in-place resource updates.
|
||||
if restart is not None and restart != "NotRequired":
|
||||
raise ValueError("IPPR only supports restartPolicy=NotRequired")
|
||||
|
||||
# pod_spec_limits are the initial resource limits specified for the pod.
|
||||
# we use it together with pod_spec_requests to derive the lower bounds for IPPR.
|
||||
pod_spec_limits = container_spec.get("resources", {}).get("limits", {})
|
||||
return IPPRGroupSpec(
|
||||
min_cpu=_resource_value(pod_spec_requests, pod_spec_limits, "cpu", float),
|
||||
min_memory=_resource_value(pod_spec_requests, pod_spec_limits, "memory", int),
|
||||
max_cpu=float(parse_quantity(group_spec.get("max-cpu"))),
|
||||
max_memory=int(parse_quantity(group_spec.get("max-memory"))),
|
||||
resize_timeout=int(group_spec.get("resize-timeout")),
|
||||
)
|
||||
|
||||
|
||||
def _get_ippr_status_from_pod(
|
||||
ippr_group_spec: Optional[IPPRGroupSpec],
|
||||
pod: Dict[str, Any],
|
||||
) -> Tuple[Optional[IPPRStatus], Optional[Dict[str, Any]]]:
|
||||
"""Build IPPRStatus and container resource snapshots from a Pod.
|
||||
|
||||
Returns a tuple of (ippr_status, container_resource_snapshot). The snapshot
|
||||
contains both spec and status requests/limits used to construct resize
|
||||
patches that preserve the current QoS gap.
|
||||
|
||||
IPPRStatus includes the updated desired resources based on the failure messages of the previous resize request carried on the pod.
|
||||
This function doesn't have any external side effects.
|
||||
"""
|
||||
if not ippr_group_spec:
|
||||
return (None, None)
|
||||
|
||||
container_status = {}
|
||||
other_container_resources = []
|
||||
for status in pod.get("status", {}).get("containerStatuses", []):
|
||||
if status["name"] == pod["spec"]["containers"][0]["name"]:
|
||||
container_status = status
|
||||
else:
|
||||
# We need to substract other containers' resources when adjusting the
|
||||
# the new resource requests based on the capactity report from the Kubelet.
|
||||
requests = status.get("resources", {}).get("requests")
|
||||
if requests:
|
||||
other_container_resources.append(requests)
|
||||
|
||||
pod_spec_requests = (
|
||||
pod["spec"]["containers"][0].get("resources", {}).get("requests", {})
|
||||
)
|
||||
pod_spec_limits = (
|
||||
pod["spec"]["containers"][0].get("resources", {}).get("limits", {})
|
||||
)
|
||||
pod_status_requests = container_status.get("resources", {}).get(
|
||||
"requests", pod_spec_requests
|
||||
)
|
||||
pod_status_limits = container_status.get("resources", {}).get(
|
||||
"limits", pod_spec_limits
|
||||
)
|
||||
|
||||
ippr_status = IPPRStatus(
|
||||
cloud_instance_id=pod["metadata"]["name"],
|
||||
spec=ippr_group_spec,
|
||||
desired_cpu=_resource_value(pod_spec_requests, pod_spec_limits, "cpu", float),
|
||||
desired_memory=_resource_value(
|
||||
pod_spec_requests, pod_spec_limits, "memory", int
|
||||
),
|
||||
current_cpu=_resource_value(
|
||||
pod_status_requests, pod_status_limits, "cpu", float
|
||||
),
|
||||
current_memory=_resource_value(
|
||||
pod_status_requests, pod_status_limits, "memory", int
|
||||
),
|
||||
)
|
||||
|
||||
ippr_status = _restore_ippr_status_from_annotation(ippr_status, pod)
|
||||
ippr_status = _apply_resize_conditions(
|
||||
ippr_status=ippr_status,
|
||||
pod=pod,
|
||||
pod_status_requests=pod_status_requests,
|
||||
pod_status_limits=pod_status_limits,
|
||||
other_container_resources=other_container_resources,
|
||||
)
|
||||
ippr_status = _handle_failed_or_timed_out_ippr(ippr_status)
|
||||
|
||||
return (
|
||||
ippr_status,
|
||||
{
|
||||
"spec": {
|
||||
"requests": pod_spec_requests,
|
||||
"limits": pod_spec_limits,
|
||||
},
|
||||
"status": {
|
||||
"requests": pod_status_requests,
|
||||
"limits": pod_status_limits,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _restore_ippr_status_from_annotation(
|
||||
ippr_status: IPPRStatus, pod: Dict[str, Any]
|
||||
) -> IPPRStatus:
|
||||
"""Restore previously persisted IPPR status fields from pod annotations."""
|
||||
pod_ippr_status_json = (
|
||||
pod["metadata"].get("annotations", {}).get("ray.io/ippr-status")
|
||||
)
|
||||
if not pod_ippr_status_json:
|
||||
return ippr_status
|
||||
|
||||
pod_ippr_status = json.loads(pod_ippr_status_json)
|
||||
ippr_status.raylet_id = pod_ippr_status.get("raylet-id")
|
||||
ippr_status.resizing_at = pod_ippr_status.get("resizing-at")
|
||||
ippr_status.suggested_max_cpu = pod_ippr_status.get("suggested-max-cpu")
|
||||
ippr_status.suggested_max_memory = pod_ippr_status.get("suggested-max-memory")
|
||||
ippr_status.last_failed_at = pod_ippr_status.get("last-failed-at")
|
||||
ippr_status.last_failed_reason = pod_ippr_status.get("last-failed-reason")
|
||||
return ippr_status
|
||||
|
||||
|
||||
def _apply_resize_conditions(
|
||||
ippr_status: IPPRStatus,
|
||||
pod: Dict[str, Any],
|
||||
pod_status_requests: Dict[str, Any],
|
||||
pod_status_limits: Dict[str, Any],
|
||||
other_container_resources: List[Dict[str, Any]],
|
||||
) -> IPPRStatus:
|
||||
"""Parse pod resize conditions and queue any follow-up suggestions."""
|
||||
for condition in pod.get("status", {}).get("conditions", []):
|
||||
if condition["type"] == "PodResizePending" and condition["status"] == "True":
|
||||
ippr_status.k8s_resize_message = condition.get("message")
|
||||
ippr_status.k8s_resize_status = condition.get("reason", "").lower()
|
||||
ippr_status = _apply_resize_suggestion(
|
||||
ippr_status=ippr_status,
|
||||
pod_status_requests=pod_status_requests,
|
||||
pod_status_limits=pod_status_limits,
|
||||
other_container_resources=other_container_resources,
|
||||
)
|
||||
break
|
||||
|
||||
if condition["type"] == "PodResizeInProgress" and condition["status"] == "True":
|
||||
ippr_status.k8s_resize_message = condition.get("message")
|
||||
ippr_status.k8s_resize_status = "inprogress"
|
||||
if condition.get("reason") == "Error":
|
||||
ippr_status.k8s_resize_status = "error"
|
||||
break
|
||||
|
||||
return ippr_status
|
||||
|
||||
|
||||
def _apply_resize_suggestion(
|
||||
ippr_status: IPPRStatus,
|
||||
pod_status_requests: Dict[str, Any],
|
||||
pod_status_limits: Dict[str, Any],
|
||||
other_container_resources: List[Dict[str, Any]],
|
||||
) -> IPPRStatus:
|
||||
"""Parse Kubelet capacity reports and queue a suggested follow-up resize.
|
||||
|
||||
A suggested follow-up resize is taken from the failure message of the previous resize request on the pod.
|
||||
For example, a message, "Node didn't have enough resource: cpu, requested: 8000, used: 5000, capacity: 9000",
|
||||
means the pod can only have CPU request up to 4. For our suggested follow-up resize, we will set the suggested_max_cpu to
|
||||
4 + (orignal cpu limit - original cpu request) to keep the gap.
|
||||
"""
|
||||
report = None
|
||||
if ippr_status.k8s_resize_message and ippr_status.k8s_resize_status == "deferred":
|
||||
report = re.search(
|
||||
r"Node didn't have enough resource: (cpu|memory), requested: (\d+), used: (\d+), capacity: (\d+)",
|
||||
ippr_status.k8s_resize_message,
|
||||
)
|
||||
elif (
|
||||
ippr_status.k8s_resize_message and ippr_status.k8s_resize_status == "infeasible"
|
||||
):
|
||||
report = re.search(
|
||||
r"Node didn't have enough capacity: (cpu|memory), requested: (\d+), ()capacity: (\d+)",
|
||||
ippr_status.k8s_resize_message,
|
||||
)
|
||||
|
||||
if not report:
|
||||
return ippr_status
|
||||
|
||||
# Example (resize to max-cpu = 8, max-memory = 20Gi):
|
||||
# Initial pod status:
|
||||
# - CPU limit is 4 cores
|
||||
# - CPU request is 1 core (gap = 3 cores)
|
||||
# - Mem limit is 8Gi
|
||||
# - Mem request is 2Gi (gap = 6Gi)
|
||||
# Initial resize request will be:
|
||||
# - desired_cpu=8 cores
|
||||
# - desired_memory=20Gi
|
||||
# The actual resize patch will be:
|
||||
# - CPU limit is 8 cores (upsize from 4)
|
||||
# - CPU request is 8 - 3 = 5 cores (upsize from 1, keep the gap 3 cores)
|
||||
# - Mem limit is 20Gi (upsize from 8Gi)
|
||||
# - Mem request is 20 − 6 = 14Gi (upsize from 2Gi, keep the gap 6Gi)
|
||||
# If Kubelet reports (the deferred case):
|
||||
# - CPU: used=5, capacity=9 → remaining_cpu = 4 cores
|
||||
# - Mem: used=6Gi, capacity=10Gi → remaining_mem = 4Gi
|
||||
# The suggestions used in the next patch will be:
|
||||
# - suggested_max_cpu = remaining_cpu + cpu_gap = 4 + 3 = 7 cores
|
||||
# - suggested_max_memory = remaining_mem + mem_gap = 4Gi + 6Gi = 10Gi
|
||||
# The actual resize patch will be:
|
||||
# - CPU limit is 7 cores
|
||||
# - CPU request is 7 - 3 = 4 cores (aligned with the kubelet's report, and keep the gap 3 cores)
|
||||
# - Mem limit is 10Gi
|
||||
# - Mem request is 10Gi - 6Gi = 4Gi (aligned with the kubelet's report, and keep the gap 6Gi)
|
||||
|
||||
used = int(
|
||||
report.group(3) or "0"
|
||||
) # this field is the used resource request capacity of the k8s node excluding the current pod.
|
||||
capacity = int(
|
||||
report.group(4)
|
||||
) # this field is the total resource request capacity of the k8s node.
|
||||
max_request = (
|
||||
capacity - used
|
||||
) # so this max_request is the remaining resource request capacity that this pod can still request.
|
||||
resource_name = report.group(1)
|
||||
suggested = _suggested_resize_limit(
|
||||
resource_name,
|
||||
max_request,
|
||||
pod_status_requests,
|
||||
pod_status_limits,
|
||||
other_container_resources,
|
||||
)
|
||||
if resource_name == "cpu":
|
||||
ippr_status.suggested_max_cpu = float(suggested)
|
||||
if ippr_status.queue_resize_request(desired_cpu=ippr_status.suggested_max_cpu):
|
||||
logger.info(
|
||||
f"Apply resize suggestions for {ippr_status.cloud_instance_id} to cpu={ippr_status.suggested_max_cpu}"
|
||||
)
|
||||
else:
|
||||
ippr_status.suggested_max_memory = int(suggested)
|
||||
if ippr_status.queue_resize_request(
|
||||
desired_memory=ippr_status.suggested_max_memory,
|
||||
):
|
||||
logger.info(
|
||||
f"Apply resize suggestions for {ippr_status.cloud_instance_id} to memory={ippr_status.suggested_max_memory}"
|
||||
)
|
||||
return ippr_status
|
||||
|
||||
|
||||
def _suggested_resize_limit(
|
||||
resource_name: str,
|
||||
max_request: int,
|
||||
pod_status_requests: Dict[str, Any],
|
||||
pod_status_limits: Dict[str, Any],
|
||||
other_container_resources: List[Dict[str, Any]],
|
||||
) -> Union[float, int]:
|
||||
gap = _resource_gap(pod_status_limits, pod_status_requests, resource_name)
|
||||
other_requests = sum(
|
||||
parse_quantity(requests.get(resource_name, "0"))
|
||||
for requests in other_container_resources
|
||||
)
|
||||
if resource_name == "cpu":
|
||||
available = Decimal(str(max_request)) / 1000
|
||||
return float(available + gap - other_requests)
|
||||
else:
|
||||
available = Decimal(str(max_request))
|
||||
return int(available + gap - other_requests)
|
||||
|
||||
|
||||
def _resource_value(
|
||||
requests: Dict[str, Any],
|
||||
limits: Dict[str, Any],
|
||||
resource_name: str,
|
||||
value_type: Union[type[float], type[int]],
|
||||
) -> Union[float, int]:
|
||||
return value_type(
|
||||
parse_quantity(limits.get(resource_name) or requests.get(resource_name))
|
||||
)
|
||||
|
||||
|
||||
def _resource_gap(
|
||||
limits: Dict[str, Any],
|
||||
requests: Dict[str, Any],
|
||||
resource_name: str,
|
||||
) -> Decimal:
|
||||
return parse_quantity(
|
||||
limits.get(resource_name) or requests.get(resource_name)
|
||||
) - parse_quantity(requests.get(resource_name))
|
||||
|
||||
|
||||
def _request_from_desired(
|
||||
desired: Union[float, int], gap: Decimal
|
||||
) -> Union[float, int]:
|
||||
requested = Decimal(str(desired)) - gap
|
||||
return type(desired)(requested)
|
||||
|
||||
|
||||
def _handle_failed_or_timed_out_ippr(ippr_status: IPPRStatus) -> IPPRStatus:
|
||||
"""Record terminal IPPR failures and queue a revert to current resources."""
|
||||
if not (ippr_status.is_errored() or ippr_status.is_timeout()):
|
||||
return ippr_status
|
||||
|
||||
if ippr_status.last_failed_at is None:
|
||||
if ippr_status.is_errored():
|
||||
ippr_status.record_failure(
|
||||
reason=ippr_status.k8s_resize_message or "Pod resize failed"
|
||||
)
|
||||
else:
|
||||
ippr_status.record_failure(reason="Pod resize timed out")
|
||||
|
||||
if ippr_status.queue_resize_request(
|
||||
desired_cpu=ippr_status.current_cpu,
|
||||
desired_memory=ippr_status.current_memory,
|
||||
):
|
||||
logger.info(
|
||||
f"Revert failed or stuck IPPR for {ippr_status.cloud_instance_id} to cpu={ippr_status.current_cpu} memory={ippr_status.current_memory}"
|
||||
)
|
||||
return ippr_status
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from ray._common.utils import binary_to_hex
|
||||
from ray._raylet import GcsClient
|
||||
from ray.autoscaler._private.util import format_readonly_node_type
|
||||
from ray.autoscaler.v2.instance_manager.node_provider import (
|
||||
CloudInstance,
|
||||
CloudInstanceId,
|
||||
CloudInstanceProviderError,
|
||||
ICloudInstanceProvider,
|
||||
NodeKind,
|
||||
)
|
||||
from ray.autoscaler.v2.sdk import get_cluster_resource_state
|
||||
from ray.autoscaler.v2.utils import is_head_node
|
||||
from ray.core.generated.autoscaler_pb2 import NodeStatus
|
||||
|
||||
|
||||
class ReadOnlyProvider(ICloudInstanceProvider):
|
||||
"""
|
||||
A read only provider that use the ray node states from the GCS as the
|
||||
cloud instances.
|
||||
|
||||
This is used for laptop mode / manual cluster setup modes, in order to
|
||||
provide status reporting in the same way for users.
|
||||
"""
|
||||
|
||||
def __init__(self, provider_config: dict):
|
||||
self._provider_config = provider_config
|
||||
self._gcs_address = provider_config["gcs_address"]
|
||||
|
||||
self._gcs_client = GcsClient(address=self._gcs_address)
|
||||
|
||||
def get_non_terminated(self) -> Dict[str, CloudInstance]:
|
||||
cluster_resource_state = get_cluster_resource_state(self._gcs_client)
|
||||
cloud_instances = {}
|
||||
for gcs_node_state in cluster_resource_state.node_states:
|
||||
if gcs_node_state.status == NodeStatus.DEAD:
|
||||
# Skip dead nodes.
|
||||
continue
|
||||
|
||||
# Use node's node id if instance id is not available
|
||||
cloud_instance_id = (
|
||||
gcs_node_state.instance_id
|
||||
if gcs_node_state.instance_id
|
||||
else binary_to_hex(gcs_node_state.node_id)
|
||||
)
|
||||
|
||||
# TODO: we should add a field to the proto to indicate if the node is head
|
||||
# or not.
|
||||
is_head = is_head_node(gcs_node_state)
|
||||
|
||||
cloud_instances[cloud_instance_id] = CloudInstance(
|
||||
cloud_instance_id=cloud_instance_id,
|
||||
node_kind=NodeKind.HEAD if is_head else NodeKind.WORKER,
|
||||
node_type=format_readonly_node_type(
|
||||
binary_to_hex(gcs_node_state.node_id) # Legacy behavior.
|
||||
),
|
||||
is_running=True,
|
||||
request_id="",
|
||||
)
|
||||
|
||||
return cloud_instances
|
||||
|
||||
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
||||
raise NotImplementedError("Cannot terminate instances in read-only mode.")
|
||||
|
||||
def launch(
|
||||
self, shape: Dict[CloudInstanceId, int], request_id: CloudInstanceId
|
||||
) -> None:
|
||||
raise NotImplementedError("Cannot launch instances in read-only mode.")
|
||||
|
||||
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
||||
return []
|
||||
@@ -0,0 +1,511 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from ray.core.generated.instance_manager_pb2 import Instance, InstanceUpdateEvent
|
||||
|
||||
|
||||
class InstanceUtil:
|
||||
"""
|
||||
A helper class to group updates and operations on an Instance object defined
|
||||
in instance_manager.proto
|
||||
"""
|
||||
|
||||
# Memoized reachable from sets, where the key is the instance status, and
|
||||
# the value is the set of instance status that is reachable from the key
|
||||
# instance status.
|
||||
_reachable_from: Optional[
|
||||
Dict["Instance.InstanceStatus", Set["Instance.InstanceStatus"]]
|
||||
] = None
|
||||
|
||||
@staticmethod
|
||||
def new_instance(
|
||||
instance_id: str,
|
||||
instance_type: str,
|
||||
status: Instance.InstanceStatus,
|
||||
details: str = "",
|
||||
) -> Instance:
|
||||
"""
|
||||
Returns a new instance with the given status.
|
||||
|
||||
Args:
|
||||
instance_id: The instance id.
|
||||
instance_type: The instance type.
|
||||
status: The status of the new instance.
|
||||
details: The details of the status transition.
|
||||
|
||||
Returns:
|
||||
The newly-created instance.
|
||||
"""
|
||||
instance = Instance()
|
||||
instance.version = 0 # it will be populated by the underlying storage.
|
||||
instance.instance_id = instance_id
|
||||
instance.instance_type = instance_type
|
||||
instance.status = status
|
||||
InstanceUtil._record_status_transition(instance, status, details)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def random_instance_id() -> str:
|
||||
"""
|
||||
Returns a random instance id.
|
||||
"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@staticmethod
|
||||
def is_cloud_instance_allocated(instance_status: Instance.InstanceStatus) -> bool:
|
||||
"""
|
||||
Returns True if the instance is in a status where there could exist
|
||||
a cloud instance allocated by the cloud provider.
|
||||
"""
|
||||
assert instance_status != Instance.UNKNOWN
|
||||
return instance_status in {
|
||||
Instance.ALLOCATED,
|
||||
Instance.RAY_INSTALLING,
|
||||
Instance.RAY_RUNNING,
|
||||
Instance.RAY_STOPPING,
|
||||
Instance.RAY_STOP_REQUESTED,
|
||||
Instance.RAY_STOPPED,
|
||||
Instance.TERMINATING,
|
||||
Instance.RAY_INSTALL_FAILED,
|
||||
Instance.TERMINATION_FAILED,
|
||||
Instance.ALLOCATION_TIMEOUT,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_ray_running(instance_status: Instance.InstanceStatus) -> bool:
|
||||
"""
|
||||
Returns True if the instance is in a status where the ray process is
|
||||
running on the cloud instance.
|
||||
i.e. RAY_RUNNING, RAY_STOP_REQUESTED, RAY_STOPPING
|
||||
"""
|
||||
assert instance_status != Instance.UNKNOWN
|
||||
|
||||
if instance_status in InstanceUtil.get_reachable_statuses(
|
||||
Instance.RAY_STOPPING
|
||||
):
|
||||
return False
|
||||
|
||||
if instance_status in InstanceUtil.get_reachable_statuses(Instance.RAY_RUNNING):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_ray_pending(instance_status: Instance.InstanceStatus) -> bool:
|
||||
"""
|
||||
Returns True if the instance is in a status where the ray process is
|
||||
pending to be started on the cloud instance.
|
||||
|
||||
"""
|
||||
assert instance_status != Instance.UNKNOWN
|
||||
# Not gonna be in a RAY_RUNNING status.
|
||||
if Instance.RAY_RUNNING not in InstanceUtil.get_reachable_statuses(
|
||||
instance_status
|
||||
):
|
||||
return False
|
||||
|
||||
# Already running ray.
|
||||
if instance_status in InstanceUtil.get_reachable_statuses(Instance.RAY_RUNNING):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_ray_running_reachable(instance_status: Instance.InstanceStatus) -> bool:
|
||||
"""
|
||||
Returns True if the instance is in a status where it may transition
|
||||
to RAY_RUNNING status.
|
||||
"""
|
||||
return Instance.RAY_RUNNING in InstanceUtil.get_reachable_statuses(
|
||||
instance_status
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def set_status(
|
||||
instance: Instance,
|
||||
new_instance_status: Instance.InstanceStatus,
|
||||
details: str = "",
|
||||
) -> bool:
|
||||
"""Transitions the instance to the new state.
|
||||
|
||||
Args:
|
||||
instance: The instance to update.
|
||||
new_instance_status: The new status to transition to.
|
||||
details: The details of the transition.
|
||||
|
||||
Returns:
|
||||
True if the status transition is successful, False otherwise.
|
||||
"""
|
||||
if (
|
||||
new_instance_status
|
||||
not in InstanceUtil.get_valid_transitions()[instance.status]
|
||||
):
|
||||
return False
|
||||
|
||||
instance.status = new_instance_status
|
||||
InstanceUtil._record_status_transition(instance, new_instance_status, details)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _record_status_transition(
|
||||
instance: Instance, status: Instance.InstanceStatus, details: str
|
||||
):
|
||||
"""Records the status transition.
|
||||
|
||||
Args:
|
||||
instance: The instance to update.
|
||||
status: The new status to transition to.
|
||||
details: The details of the status transition.
|
||||
"""
|
||||
now_ns = time.time_ns()
|
||||
instance.status_history.append(
|
||||
Instance.StatusHistory(
|
||||
instance_status=status,
|
||||
timestamp_ns=now_ns,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def has_timeout(instance: Instance, timeout_s: int) -> bool:
|
||||
"""
|
||||
Returns True if the instance has been in the current status for more
|
||||
than the given timeout.
|
||||
|
||||
Args:
|
||||
instance: The instance to check.
|
||||
timeout_s: The timeout in seconds.
|
||||
|
||||
Returns:
|
||||
True if the instance has been in the current status for more than
|
||||
the timeout_s seconds.
|
||||
"""
|
||||
cur_status = instance.status
|
||||
|
||||
status_times_ns = InstanceUtil.get_status_transition_times_ns(
|
||||
instance, select_instance_status=cur_status
|
||||
)
|
||||
assert len(status_times_ns) >= 1, (
|
||||
f"instance {instance.instance_id} has {len(status_times_ns)} "
|
||||
f"{Instance.InstanceStatus.Name(cur_status)} status"
|
||||
)
|
||||
status_time_ns = sorted(status_times_ns)[-1]
|
||||
if time.time_ns() - status_time_ns <= (timeout_s * 1e9):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_valid_transitions() -> Dict[
|
||||
"Instance.InstanceStatus", Set["Instance.InstanceStatus"]
|
||||
]:
|
||||
return {
|
||||
# This is the initial status of a new instance.
|
||||
Instance.QUEUED: {
|
||||
# Cloud provider requested to launch a node for the instance.
|
||||
# This happens when the a launch request is made to the node provider.
|
||||
Instance.REQUESTED,
|
||||
# Allocation request canceled before being requested.
|
||||
# This happens when max_workers config is reduced or other termination
|
||||
# triggers occur while the instance is still queued.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# When in this status, a launch request to the node provider is made.
|
||||
Instance.REQUESTED: {
|
||||
# Cloud provider allocated a cloud instance for the instance.
|
||||
# This happens when the cloud instance first appears in the list of
|
||||
# running cloud instances from the cloud instance provider.
|
||||
Instance.ALLOCATED,
|
||||
# Retry the allocation, become queueing again.
|
||||
Instance.QUEUED,
|
||||
# Cloud provider fails to allocate one. Either as a timeout or
|
||||
# the launch request fails immediately.
|
||||
Instance.ALLOCATION_FAILED,
|
||||
},
|
||||
# When in this status, the cloud instance is allocated and running. This
|
||||
# happens when the cloud instance is present in node provider's list of
|
||||
# running cloud instances.
|
||||
Instance.ALLOCATED: {
|
||||
# Ray needs to be install and launch on the provisioned cloud instance.
|
||||
# This happens when the cloud instance is allocated, and the autoscaler
|
||||
# is responsible for installing and launching ray on the cloud instance.
|
||||
# For node provider that manages the ray installation and launching,
|
||||
# this state is skipped.
|
||||
Instance.RAY_INSTALLING,
|
||||
# Ray is already installed on the provisioned cloud
|
||||
# instance. It could be any valid ray status.
|
||||
Instance.RAY_RUNNING,
|
||||
# The cloud provider timed out for allocating running cloud instance.
|
||||
# The CloudResourceMonitor subscriber will lower this node-type's priority
|
||||
# in feature schedules.
|
||||
Instance.ALLOCATION_TIMEOUT,
|
||||
Instance.RAY_STOPPING,
|
||||
Instance.RAY_STOPPED,
|
||||
# Instance is requested to be stopped, e.g. instance leaked: no matching
|
||||
# Instance with the same type is found in the autoscaler's state.
|
||||
Instance.TERMINATING,
|
||||
# cloud instance somehow failed.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# Ray process is being installed and started on the cloud instance.
|
||||
# This status is skipped for node provider that manages the ray
|
||||
# installation and launching. (e.g. Ray-on-Spark)
|
||||
Instance.RAY_INSTALLING: {
|
||||
# Ray installed and launched successfully, reported by the ray cluster.
|
||||
# Similar to the Instance.ALLOCATED -> Instance.RAY_RUNNING transition,
|
||||
# where the ray process is managed by the node provider.
|
||||
Instance.RAY_RUNNING,
|
||||
# Ray installation failed. This happens when the ray process failed to
|
||||
# be installed and started on the cloud instance.
|
||||
Instance.RAY_INSTALL_FAILED,
|
||||
# Wen the ray node is reported as stopped by the ray cluster.
|
||||
# This could happen that the ray process was stopped quickly after start
|
||||
# such that a ray running node wasn't discovered and the RAY_RUNNING
|
||||
# transition was skipped.
|
||||
Instance.RAY_STOPPED,
|
||||
# A cloud instance is being terminated (when the instance itself is no
|
||||
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
||||
Instance.TERMINATING,
|
||||
# cloud instance somehow failed during the installation process.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# Ray process is installed and running on the cloud instance. When in this
|
||||
# status, a ray node must be present in the ray cluster.
|
||||
Instance.RAY_RUNNING: {
|
||||
# Ray is requested to be stopped.
|
||||
Instance.RAY_STOP_REQUESTED,
|
||||
# Ray is stopping (currently draining),
|
||||
# e.g. idle termination.
|
||||
Instance.RAY_STOPPING,
|
||||
# Ray is already stopped, as reported by the ray cluster.
|
||||
Instance.RAY_STOPPED,
|
||||
# A cloud instance is being terminated (when the instance itself is no
|
||||
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
||||
Instance.TERMINATING,
|
||||
# cloud instance somehow failed.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# Ray process should be stopped on the cloud instance. The RayStopper
|
||||
# subscriber will listen to this status and stop the ray process.
|
||||
Instance.RAY_STOP_REQUESTED: {
|
||||
# Ray is stopping on the cloud instance.
|
||||
Instance.RAY_STOPPING,
|
||||
# Ray stopped already.
|
||||
Instance.RAY_STOPPED,
|
||||
# Ray stop request failed (e.g. idle node no longer idle),
|
||||
# ray is still running.
|
||||
Instance.RAY_RUNNING,
|
||||
# cloud instance somehow failed.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# An instance has been allocated to a cloud instance, but the cloud
|
||||
# provider timed out for allocating running cloud instance, e.g. the
|
||||
# a kubernetes pod remains pending due to insufficient resources.
|
||||
Instance.ALLOCATION_TIMEOUT: {
|
||||
# Instance is requested to be stopped
|
||||
Instance.TERMINATING,
|
||||
# Cloud instance already disappeared; skip termination request.
|
||||
# This transition is allowed to avoid unnecessary termination attempts
|
||||
# when the cloud instance has already disappeared (e.g., manually deleted
|
||||
# or terminated by another process). While this helps avoid unnecessary
|
||||
# retries, it's important to monitor this transition as it may indicate
|
||||
# underlying issues with the allocation or termination process itself.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# When in this status, the ray process is requested to be stopped to the
|
||||
# ray cluster, but not yet present in the dead ray node list reported by
|
||||
# the ray cluster.
|
||||
Instance.RAY_STOPPING: {
|
||||
# Ray is stopped, and the ray node is present in the dead ray node list
|
||||
# reported by the ray cluster.
|
||||
Instance.RAY_STOPPED,
|
||||
# A cloud instance is being terminated (when the instance itself is no
|
||||
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
||||
Instance.TERMINATING,
|
||||
# cloud instance somehow failed.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# When in this status, the ray process is stopped, and the ray node is
|
||||
# present in the dead ray node list reported by the ray cluster.
|
||||
Instance.RAY_STOPPED: {
|
||||
# A cloud instance is being terminated (when the instance itself is no
|
||||
# longer needed, e.g. instance is outdated, autoscaler is scaling down)
|
||||
Instance.TERMINATING,
|
||||
# cloud instance somehow failed.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# When in this status, the cloud instance is requested to be stopped to
|
||||
# the node provider.
|
||||
Instance.TERMINATING: {
|
||||
# When a cloud instance no longer appears in the list of running cloud
|
||||
# instances from the node provider.
|
||||
Instance.TERMINATED,
|
||||
# When the cloud instance failed to be terminated.
|
||||
Instance.TERMINATION_FAILED,
|
||||
},
|
||||
# When in this status, the cloud instance failed to be terminated by the
|
||||
# node provider. We will keep retrying.
|
||||
Instance.TERMINATION_FAILED: {
|
||||
# Retry the termination, become terminating again.
|
||||
Instance.TERMINATING,
|
||||
# Cloud instance already disappeared; skip termination request.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# An instance is marked as terminated when:
|
||||
# 1. A cloud instance disappears from the list of running cloud instances
|
||||
# from the node provider (follows from TERMINATING or other running states).
|
||||
# 2. An allocation request is canceled before cloud resources are allocated
|
||||
# (follows from QUEUED).
|
||||
# This is a terminal state.
|
||||
Instance.TERMINATED: set(), # Terminal state.
|
||||
# When in this status, the cloud instance failed to be allocated by the
|
||||
# node provider.
|
||||
Instance.ALLOCATION_FAILED: set(), # Terminal state.
|
||||
Instance.RAY_INSTALL_FAILED: {
|
||||
# Autoscaler requests to shutdown the instance when ray install failed.
|
||||
Instance.TERMINATING,
|
||||
# cloud instance somehow failed.
|
||||
Instance.TERMINATED,
|
||||
},
|
||||
# Initial state before the instance is created. Should never be used.
|
||||
Instance.UNKNOWN: set(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_status_transitions(
|
||||
instance: Instance,
|
||||
select_instance_status: Optional["Instance.InstanceStatus"] = None,
|
||||
) -> List["Instance.StatusHistory"]:
|
||||
"""
|
||||
Returns the status history of the instance.
|
||||
|
||||
Args:
|
||||
instance: The instance.
|
||||
select_instance_status: The go-to status to search for, i.e. select
|
||||
only status history when the instance transitions into the status.
|
||||
If None, returns all status updates.
|
||||
|
||||
Returns:
|
||||
The list of status updates matching ``select_instance_status``,
|
||||
or all status updates when ``select_instance_status`` is None.
|
||||
"""
|
||||
history = []
|
||||
for status_update in instance.status_history:
|
||||
if (
|
||||
select_instance_status
|
||||
and status_update.instance_status != select_instance_status
|
||||
):
|
||||
continue
|
||||
history.append(status_update)
|
||||
return history
|
||||
|
||||
@staticmethod
|
||||
def get_last_status_transition(
|
||||
instance: Instance,
|
||||
select_instance_status: Optional["Instance.InstanceStatus"] = None,
|
||||
) -> Optional["Instance.StatusHistory"]:
|
||||
"""
|
||||
Returns the last status transition of the instance.
|
||||
|
||||
Args:
|
||||
instance: The instance.
|
||||
select_instance_status: The status to search for. If None, returns
|
||||
the last status update.
|
||||
|
||||
Returns:
|
||||
The last matching status update, or None if no status updates match.
|
||||
"""
|
||||
history = InstanceUtil.get_status_transitions(instance, select_instance_status)
|
||||
history.sort(key=lambda x: x.timestamp_ns)
|
||||
if history:
|
||||
return history[-1]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_status_transition_times_ns(
|
||||
instance: Instance,
|
||||
select_instance_status: Optional["Instance.InstanceStatus"] = None,
|
||||
) -> List[int]:
|
||||
"""
|
||||
Returns a list of timestamps of the instance status update.
|
||||
|
||||
Args:
|
||||
instance: The instance.
|
||||
select_instance_status: The status to search for. If None, returns
|
||||
all status update timestamps.
|
||||
|
||||
Returns:
|
||||
The list of timestamps of the instance status updates.
|
||||
"""
|
||||
return [
|
||||
e.timestamp_ns
|
||||
for e in InstanceUtil.get_status_transitions(
|
||||
instance, select_instance_status
|
||||
)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_reachable_statuses(
|
||||
cls,
|
||||
instance_status: Instance.InstanceStatus,
|
||||
) -> Set["Instance.InstanceStatus"]:
|
||||
"""
|
||||
Returns the set of instance status that is reachable from the given
|
||||
instance status following the status transitions.
|
||||
This method is memoized.
|
||||
Args:
|
||||
instance_status: The instance status to start from.
|
||||
Returns:
|
||||
The set of instance status that is reachable from the given instance
|
||||
status.
|
||||
"""
|
||||
if cls._reachable_from is None:
|
||||
cls._compute_reachable()
|
||||
return cls._reachable_from[instance_status]
|
||||
|
||||
@staticmethod
|
||||
def get_log_str_for_update(instance: Instance, update: InstanceUpdateEvent) -> str:
|
||||
"""Returns a log string for the given instance update."""
|
||||
if update.upsert:
|
||||
return (
|
||||
f"New instance "
|
||||
f"{Instance.InstanceStatus.Name(update.new_instance_status)} (id="
|
||||
f"{instance.instance_id}, type={instance.instance_type}, "
|
||||
f"cloud_instance_id={instance.cloud_instance_id}, "
|
||||
f"ray_id={instance.node_id}): {update.details}"
|
||||
)
|
||||
return (
|
||||
f"Update instance "
|
||||
f"{Instance.InstanceStatus.Name(instance.status)}->"
|
||||
f"{Instance.InstanceStatus.Name(update.new_instance_status)} (id="
|
||||
f"{instance.instance_id}, type={instance.instance_type}, "
|
||||
f"cloud_instance_id={instance.cloud_instance_id}, "
|
||||
f"ray_id={instance.node_id}): {update.details}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _compute_reachable(cls):
|
||||
"""
|
||||
Computes and memorize the from status sets for each status machine with
|
||||
a DFS search.
|
||||
"""
|
||||
valid_transitions = cls.get_valid_transitions()
|
||||
|
||||
def dfs(graph, start, visited):
|
||||
"""
|
||||
Regular DFS algorithm to find all reachable nodes from a given node.
|
||||
"""
|
||||
for next_node in graph[start]:
|
||||
if next_node not in visited:
|
||||
# We delay adding the visited set here so we could capture
|
||||
# the self loop.
|
||||
visited.add(next_node)
|
||||
dfs(graph, next_node, visited)
|
||||
return visited
|
||||
|
||||
# Initialize the graphs
|
||||
cls._reachable_from = {}
|
||||
for status in Instance.InstanceStatus.values():
|
||||
# All nodes reachable from 'start'
|
||||
visited = set()
|
||||
cls._reachable_from[status] = dfs(valid_transitions, status, visited)
|
||||
@@ -0,0 +1,561 @@
|
||||
import copy
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ray._common.utils import binary_to_hex
|
||||
from ray._private.ray_constants import env_integer
|
||||
from ray._raylet import GcsClient
|
||||
from ray.autoscaler._private.constants import (
|
||||
AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
||||
DEFAULT_UPSCALING_SPEED,
|
||||
DISABLE_LAUNCH_CONFIG_CHECK_KEY,
|
||||
DISABLE_NODE_UPDATERS_KEY,
|
||||
)
|
||||
from ray.autoscaler._private.kuberay.autoscaling_config import AutoscalingConfigProducer
|
||||
from ray.autoscaler._private.monitor import BASE_READONLY_CONFIG
|
||||
from ray.autoscaler._private.util import (
|
||||
format_readonly_node_type,
|
||||
hash_launch_conf,
|
||||
hash_runtime_conf,
|
||||
prepare_config,
|
||||
validate_config,
|
||||
)
|
||||
from ray.autoscaler.v2.schema import NodeType
|
||||
from ray.autoscaler.v2.sdk import get_cluster_resource_state
|
||||
from ray.autoscaler.v2.utils import is_head_node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Provider(Enum):
|
||||
UNKNOWN = 0
|
||||
ALIYUN = 1
|
||||
AWS = 2
|
||||
AZURE = 3
|
||||
GCP = 4
|
||||
KUBERAY = 5
|
||||
LOCAL = 6
|
||||
READ_ONLY = 7
|
||||
|
||||
|
||||
class IConfigReader(ABC):
|
||||
"""An interface for reading Autoscaling config.
|
||||
|
||||
A utility class that reads autoscaling configs from various sources:
|
||||
- File
|
||||
- In-memory dict
|
||||
- Remote config service (e.g. KubeRay's config)
|
||||
|
||||
Example:
|
||||
reader = FileConfigReader("path/to/config.yaml")
|
||||
# Get the recently cached config.
|
||||
config = reader.get_cached_autoscaling_config()
|
||||
|
||||
...
|
||||
# Refresh the cached config.
|
||||
reader.refresh_cached_autoscaling_config()
|
||||
config = reader.get_cached_autoscaling_config()
|
||||
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_cached_autoscaling_config(self) -> "AutoscalingConfig":
|
||||
"""Returns the recently read autoscaling config.
|
||||
|
||||
Returns:
|
||||
AutoscalingConfig: The recently read autoscaling config.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def refresh_cached_autoscaling_config(self):
|
||||
"""Read the config from the source."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstanceReconcileConfig:
|
||||
# The timeout for waiting for a REQUESTED instance to be ALLOCATED.
|
||||
request_status_timeout_s: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_REQUEST_STATUS_TIMEOUT_S", 10 * 60
|
||||
)
|
||||
# The timeout for waiting for a ALLOCATED instance to be RAY_RUNNING.
|
||||
allocate_status_timeout_s: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_ALLOCATE_STATUS_TIMEOUT_S", 60 * 60
|
||||
)
|
||||
# The timeout for waiting for a RAY_INSTALLING instance to be RAY_RUNNING.
|
||||
ray_install_status_timeout_s: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_RAY_INSTALL_STATUS_TIMEOUT_S", 30 * 60
|
||||
)
|
||||
# The timeout for waiting for a TERMINATING instance to be TERMINATED.
|
||||
terminating_status_timeout_s: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_TERMINATING_STATUS_TIMEOUT_S", 300
|
||||
)
|
||||
# The timeout for waiting for a RAY_STOP_REQUESTED instance
|
||||
# to be RAY_STOPPING or RAY_STOPPED.
|
||||
ray_stop_requested_status_timeout_s: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_RAY_STOP_REQUESTED_STATUS_TIMEOUT_S", 300
|
||||
)
|
||||
# The interval for raise a warning when an instance in transient status
|
||||
# is not updated for a long time.
|
||||
transient_status_warn_interval_s: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_TRANSIENT_STATUS_WARN_INTERVAL_S", 90
|
||||
)
|
||||
# The number of times to retry requesting to allocate an instance.
|
||||
max_num_retry_request_to_allocate: int = env_integer(
|
||||
"RAY_AUTOSCALER_RECONCILE_MAX_NUM_RETRY_REQUEST_TO_ALLOCATE", 3
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeTypeConfig:
|
||||
"""
|
||||
NodeTypeConfig is the helper class to provide node type specific configs.
|
||||
This maps to subset of the `available_node_types` field in the
|
||||
autoscaling config.
|
||||
"""
|
||||
|
||||
# Node type name
|
||||
name: NodeType
|
||||
# The minimal number of worker nodes to be launched for this node type.
|
||||
min_worker_nodes: int
|
||||
# The maximal number of worker nodes can be launched for this node type.
|
||||
max_worker_nodes: int
|
||||
# Idle timeout seconds for worker nodes of this node type.
|
||||
idle_timeout_s: Optional[float] = None
|
||||
# The priority of the worker group. Higher value means the group will be scaled up first if everything else is equal.
|
||||
priority: int = 0
|
||||
# The total resources on the node.
|
||||
resources: Dict[str, float] = field(default_factory=dict)
|
||||
# The labels on the node.
|
||||
labels: Dict[str, str] = field(default_factory=dict)
|
||||
# The node config's launch config hash. It's calculated from the auth
|
||||
# config, and the node's config in the `AutoscalingConfig` for the node
|
||||
# type when launching the node. It's used to detect config changes.
|
||||
launch_config_hash: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
assert self.min_worker_nodes <= self.max_worker_nodes
|
||||
assert self.min_worker_nodes >= 0
|
||||
|
||||
|
||||
class AutoscalingConfig:
|
||||
"""
|
||||
AutoscalingConfig is the helper class to provide autoscaling
|
||||
related configs.
|
||||
|
||||
# TODO(rickyx):
|
||||
1. Move the config validation logic here.
|
||||
2. Deprecate the ray-schema.json for validation because it's
|
||||
static thus not possible to validate the config with interdependency
|
||||
of each other.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
configs: Dict[str, Any],
|
||||
skip_content_hash: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the autoscaling config.
|
||||
|
||||
Args:
|
||||
configs: The raw configs dict.
|
||||
skip_content_hash: Whether to skip file mounts/ray command hash
|
||||
calculation.
|
||||
"""
|
||||
self._sync_continuously = False
|
||||
self.update_configs(configs, skip_content_hash)
|
||||
|
||||
def update_configs(self, configs: Dict[str, Any], skip_content_hash: bool) -> None:
|
||||
self._configs = prepare_config(configs)
|
||||
validate_config(self._configs)
|
||||
if skip_content_hash:
|
||||
return
|
||||
self._calculate_hashes()
|
||||
self._sync_continuously = self._configs.get(
|
||||
"generate_file_mounts_contents_hash", True
|
||||
)
|
||||
|
||||
def _calculate_hashes(self) -> None:
|
||||
logger.info("Calculating hashes for file mounts and ray commands.")
|
||||
self._runtime_hash, self._file_mounts_contents_hash = hash_runtime_conf(
|
||||
self._configs.get("file_mounts", {}),
|
||||
self._configs.get("cluster_synced_files", []),
|
||||
[
|
||||
self._configs.get("worker_setup_commands", []),
|
||||
self._configs.get("worker_start_ray_commands", []),
|
||||
],
|
||||
generate_file_mounts_contents_hash=self._configs.get(
|
||||
"generate_file_mounts_contents_hash", True
|
||||
),
|
||||
)
|
||||
|
||||
def get_cloud_node_config(self, ray_node_type: NodeType) -> Dict[str, Any]:
|
||||
return copy.deepcopy(
|
||||
self.get_node_type_specific_config(ray_node_type, "node_config") or {}
|
||||
)
|
||||
|
||||
def get_docker_config(self, ray_node_type: NodeType) -> Dict[str, Any]:
|
||||
"""
|
||||
Return the docker config for the specified node type.
|
||||
If it's a head node, the image will be chosen in the following order:
|
||||
1. Node specific docker image.
|
||||
2. The 'docker' config's 'head_image' field.
|
||||
3. The 'docker' config's 'image' field.
|
||||
If it's a worker node, the image will be chosen in the following order:
|
||||
1. Node specific docker image.
|
||||
2. The 'docker' config's 'worker_image' field.
|
||||
3. The 'docker' config's 'image' field.
|
||||
"""
|
||||
# TODO(rickyx): It's unfortunate we have multiple fields in ray-schema.json
|
||||
# that can specify docker images. We should consolidate them.
|
||||
docker_config = copy.deepcopy(self._configs.get("docker", {}))
|
||||
node_specific_docker_config = self._configs["available_node_types"][
|
||||
ray_node_type
|
||||
].get("docker", {})
|
||||
# Override the global docker config with node specific docker config.
|
||||
docker_config.update(node_specific_docker_config)
|
||||
|
||||
if self._configs.get("head_node_type") == ray_node_type:
|
||||
if "head_image" in docker_config:
|
||||
logger.info(
|
||||
"Overwriting image={} by head_image({}) for head node docker.".format( # noqa: E501
|
||||
docker_config["image"], docker_config["head_image"]
|
||||
)
|
||||
)
|
||||
docker_config["image"] = docker_config["head_image"]
|
||||
else:
|
||||
if "worker_image" in docker_config:
|
||||
logger.info(
|
||||
"Overwriting image={} by worker_image({}) for worker node docker.".format( # noqa: E501
|
||||
docker_config["image"], docker_config["worker_image"]
|
||||
)
|
||||
)
|
||||
docker_config["image"] = docker_config["worker_image"]
|
||||
|
||||
# These fields should be merged.
|
||||
docker_config.pop("head_image", None)
|
||||
docker_config.pop("worker_image", None)
|
||||
return docker_config
|
||||
|
||||
def get_worker_start_ray_commands(self) -> List[str]:
|
||||
return self._configs.get("worker_start_ray_commands", [])
|
||||
|
||||
def get_head_setup_commands(self) -> List[str]:
|
||||
return self._configs.get("head_setup_commands", [])
|
||||
|
||||
def get_head_start_ray_commands(self) -> List[str]:
|
||||
return self._configs.get("head_start_ray_commands", [])
|
||||
|
||||
def get_worker_setup_commands(self, ray_node_type: NodeType) -> List[str]:
|
||||
"""
|
||||
Return the worker setup commands for the specified node type.
|
||||
|
||||
If the node type specific worker setup commands are not specified,
|
||||
return the global worker setup commands.
|
||||
"""
|
||||
worker_setup_command = self.get_node_type_specific_config(
|
||||
ray_node_type, "worker_setup_commands"
|
||||
)
|
||||
if worker_setup_command is None:
|
||||
# Return global worker setup commands if node type specific
|
||||
# worker setup commands are not specified.
|
||||
logger.info(
|
||||
"Using global worker setup commands for {}".format(ray_node_type)
|
||||
)
|
||||
return self._configs.get("worker_setup_commands", [])
|
||||
return worker_setup_command
|
||||
|
||||
def get_initialization_commands(self, ray_node_type: NodeType) -> List[str]:
|
||||
"""
|
||||
Return the initialization commands for the specified node type.
|
||||
|
||||
If the node type specific initialization commands are not specified,
|
||||
return the global initialization commands.
|
||||
"""
|
||||
initialization_command = self.get_node_type_specific_config(
|
||||
ray_node_type, "initialization_commands"
|
||||
)
|
||||
if initialization_command is None:
|
||||
logger.info(
|
||||
"Using global initialization commands for {}".format(ray_node_type)
|
||||
)
|
||||
return self._configs.get("initialization_commands", [])
|
||||
return initialization_command
|
||||
|
||||
def get_node_type_specific_config(
|
||||
self, ray_node_type: NodeType, config_name: str
|
||||
) -> Optional[Any]:
|
||||
node_specific_config = self._configs["available_node_types"].get(
|
||||
ray_node_type, {}
|
||||
)
|
||||
return node_specific_config.get(config_name, None)
|
||||
|
||||
def get_node_resources(self, ray_node_type: NodeType) -> Dict[str, float]:
|
||||
return copy.deepcopy(
|
||||
self.get_node_type_specific_config(ray_node_type, "resources") or {}
|
||||
)
|
||||
|
||||
def get_node_labels(self, ray_node_type: NodeType) -> Dict[str, str]:
|
||||
return copy.deepcopy(
|
||||
self.get_node_type_specific_config(ray_node_type, "labels") or {}
|
||||
)
|
||||
|
||||
def get_config(self, config_name, default=None) -> Any:
|
||||
return self._configs.get(config_name, default)
|
||||
|
||||
def get_provider_instance_type(self, ray_node_type: NodeType) -> str:
|
||||
provider = self.provider
|
||||
node_config = (
|
||||
self.get_node_type_specific_config(ray_node_type, "node_config") or {}
|
||||
)
|
||||
if provider in [Provider.AWS, Provider.ALIYUN]:
|
||||
return node_config.get("InstanceType", "")
|
||||
elif provider == Provider.AZURE:
|
||||
return node_config.get("azure_arm_parameters", {}).get("vmSize", "")
|
||||
elif provider == Provider.GCP:
|
||||
return node_config.get("machineType", "")
|
||||
elif provider in [Provider.KUBERAY, Provider.LOCAL, Provider.UNKNOWN]:
|
||||
return ""
|
||||
else:
|
||||
raise ValueError(f"Unknown provider {provider}")
|
||||
|
||||
def get_node_type_configs(self) -> Dict[NodeType, NodeTypeConfig]:
|
||||
"""
|
||||
Returns the node type configs from the `available_node_types` field.
|
||||
|
||||
Returns:
|
||||
Dict[NodeType, NodeTypeConfig]: The node type configs.
|
||||
"""
|
||||
available_node_types = self._configs.get("available_node_types", {})
|
||||
if not available_node_types:
|
||||
return None
|
||||
node_type_configs = {}
|
||||
auth_config = self._configs.get("auth", {})
|
||||
head_node_type = self.get_head_node_type()
|
||||
assert head_node_type
|
||||
for node_type, node_config in available_node_types.items():
|
||||
launch_config_hash = hash_launch_conf(
|
||||
node_config.get("node_config", {}), auth_config
|
||||
)
|
||||
max_workers_nodes = node_config.get("max_workers", 0)
|
||||
if head_node_type == node_type:
|
||||
max_workers_nodes += 1
|
||||
|
||||
node_type_configs[node_type] = NodeTypeConfig(
|
||||
name=node_type,
|
||||
min_worker_nodes=node_config.get("min_workers", 0),
|
||||
max_worker_nodes=max_workers_nodes,
|
||||
idle_timeout_s=node_config.get("idle_timeout_s", None),
|
||||
priority=node_config.get("priority", 0),
|
||||
resources=node_config.get("resources", {}),
|
||||
labels=node_config.get("labels", {}),
|
||||
launch_config_hash=launch_config_hash,
|
||||
)
|
||||
return node_type_configs
|
||||
|
||||
def get_head_node_type(self) -> NodeType:
|
||||
"""
|
||||
Returns the head node type.
|
||||
|
||||
If there is only one node type, return the only node type as the head
|
||||
node type.
|
||||
If there are multiple node types, return the head node type specified
|
||||
in the config.
|
||||
"""
|
||||
available_node_types = self._configs.get("available_node_types", {})
|
||||
if len(available_node_types) == 1:
|
||||
return list(available_node_types.keys())[0]
|
||||
return self._configs.get("head_node_type")
|
||||
|
||||
def get_max_num_worker_nodes(self) -> Optional[int]:
|
||||
return self.get_config("max_workers", None)
|
||||
|
||||
def get_max_num_nodes(self) -> Optional[int]:
|
||||
max_num_workers = self.get_max_num_worker_nodes()
|
||||
if max_num_workers is not None:
|
||||
return max_num_workers + 1 # For head node
|
||||
return None
|
||||
|
||||
def get_raw_config_mutable(self) -> Dict[str, Any]:
|
||||
return self._configs
|
||||
|
||||
def get_upscaling_speed(self) -> float:
|
||||
return self.get_config("upscaling_speed", DEFAULT_UPSCALING_SPEED)
|
||||
|
||||
def get_max_concurrent_launches(self) -> int:
|
||||
return AUTOSCALER_MAX_CONCURRENT_LAUNCHES
|
||||
|
||||
def disable_node_updaters(self) -> bool:
|
||||
provider_config = self._configs.get("provider", {})
|
||||
return provider_config.get(DISABLE_NODE_UPDATERS_KEY, False)
|
||||
|
||||
def get_idle_timeout_s(self) -> Optional[float]:
|
||||
"""
|
||||
Returns the idle timeout in seconds if present in config, otherwise None.
|
||||
"""
|
||||
idle_timeout_s = self.get_config("idle_timeout_minutes", None)
|
||||
return idle_timeout_s * 60 if idle_timeout_s is not None else None
|
||||
|
||||
def disable_launch_config_check(self) -> bool:
|
||||
provider_config = self.get_provider_config()
|
||||
return provider_config.get(DISABLE_LAUNCH_CONFIG_CHECK_KEY, True)
|
||||
|
||||
def get_instance_reconcile_config(self) -> InstanceReconcileConfig:
|
||||
# TODO(rickyx): we need a way to customize these configs,
|
||||
# either extending the current ray-schema.json, or just use another
|
||||
# schema validation paths.
|
||||
return InstanceReconcileConfig()
|
||||
|
||||
def get_provider_config(self) -> Dict[str, Any]:
|
||||
return self._configs.get("provider", {})
|
||||
|
||||
def dump(self) -> str:
|
||||
return yaml.safe_dump(self._configs)
|
||||
|
||||
@property
|
||||
def provider(self) -> Provider:
|
||||
provider_str = self._configs.get("provider", {}).get("type", "")
|
||||
if provider_str == "local":
|
||||
return Provider.LOCAL
|
||||
elif provider_str == "aws":
|
||||
return Provider.AWS
|
||||
elif provider_str == "azure":
|
||||
return Provider.AZURE
|
||||
elif provider_str == "gcp":
|
||||
return Provider.GCP
|
||||
elif provider_str == "aliyun":
|
||||
return Provider.ALIYUN
|
||||
elif provider_str == "kuberay":
|
||||
return Provider.KUBERAY
|
||||
elif provider_str == "readonly":
|
||||
return Provider.READ_ONLY
|
||||
else:
|
||||
return Provider.UNKNOWN
|
||||
|
||||
@property
|
||||
def runtime_hash(self) -> str:
|
||||
if not hasattr(self, "_runtime_hash"):
|
||||
self._calculate_hashes()
|
||||
return self._runtime_hash
|
||||
|
||||
@property
|
||||
def file_mounts_contents_hash(self) -> str:
|
||||
if not hasattr(self, "_file_mounts_contents_hash"):
|
||||
self._calculate_hashes()
|
||||
return self._file_mounts_contents_hash
|
||||
|
||||
|
||||
class FileConfigReader(IConfigReader):
|
||||
"""A class that reads cluster config from a yaml file."""
|
||||
|
||||
def __init__(self, config_file: str, skip_content_hash: bool = True) -> None:
|
||||
"""Initialize the file config reader.
|
||||
|
||||
Args:
|
||||
config_file: The path to the config file.
|
||||
skip_content_hash: Whether to skip file mounts/ray command
|
||||
hash calculation. Default to True.
|
||||
"""
|
||||
self._config_file_path = Path(config_file).resolve()
|
||||
self._skip_content_hash = skip_content_hash
|
||||
self._cached_config = self._read()
|
||||
|
||||
def _read(self) -> AutoscalingConfig:
|
||||
with open(self._config_file_path) as f:
|
||||
config = yaml.safe_load(f.read())
|
||||
return AutoscalingConfig(config, skip_content_hash=self._skip_content_hash)
|
||||
|
||||
def get_cached_autoscaling_config(self) -> AutoscalingConfig:
|
||||
"""
|
||||
Returns:
|
||||
AutoscalingConfig: The autoscaling config.
|
||||
"""
|
||||
|
||||
return self._cached_config
|
||||
|
||||
def refresh_cached_autoscaling_config(self):
|
||||
self._cached_config = self._read()
|
||||
|
||||
|
||||
class KubeRayConfigReader(IConfigReader):
|
||||
"""A class that reads cluster config from a K8s RayCluster CR."""
|
||||
|
||||
def __init__(self, config_producer: AutoscalingConfigProducer):
|
||||
self._config_producer = config_producer
|
||||
self._cached_config = self._generate_configs_from_k8s()
|
||||
|
||||
def _generate_configs_from_k8s(self) -> AutoscalingConfig:
|
||||
return AutoscalingConfig(self._config_producer())
|
||||
|
||||
def get_cached_autoscaling_config(self) -> AutoscalingConfig:
|
||||
"""
|
||||
Returns:
|
||||
AutoscalingConfig: The autoscaling config.
|
||||
"""
|
||||
return self._cached_config
|
||||
|
||||
def refresh_cached_autoscaling_config(self):
|
||||
"""
|
||||
Reads the configs from the K8s RayCluster CR.
|
||||
|
||||
This reads from the K8s API server every time to pick up changes.
|
||||
"""
|
||||
self._cached_config = self._generate_configs_from_k8s()
|
||||
|
||||
|
||||
class ReadOnlyProviderConfigReader(IConfigReader):
|
||||
"""A class that reads cluster config for a read-only provider.
|
||||
|
||||
This is used for laptop mode / manual cluster setup modes, in order to
|
||||
provide status reporting in the same way for users."""
|
||||
|
||||
def __init__(self, gcs_address: str):
|
||||
self._configs = BASE_READONLY_CONFIG
|
||||
self._gcs_client = GcsClient(address=gcs_address)
|
||||
|
||||
def refresh_cached_autoscaling_config(self) -> AutoscalingConfig:
|
||||
# Update the config with node types from GCS.
|
||||
ray_cluster_resource_state = get_cluster_resource_state(self._gcs_client)
|
||||
|
||||
# Format each node type's config from the running nodes.
|
||||
available_node_types = {}
|
||||
|
||||
head_node_type = None
|
||||
for node_state in ray_cluster_resource_state.node_states:
|
||||
node_type = node_state.ray_node_type_name
|
||||
if not node_type:
|
||||
node_type = format_readonly_node_type(binary_to_hex(node_state.node_id))
|
||||
|
||||
if is_head_node(node_state):
|
||||
head_node_type = node_type
|
||||
|
||||
if node_type not in available_node_types:
|
||||
available_node_types[node_type] = {
|
||||
"resources": dict(node_state.total_resources),
|
||||
"min_workers": 0,
|
||||
"max_workers": 0 if is_head_node(node_state) else 1,
|
||||
"node_config": {},
|
||||
}
|
||||
elif not is_head_node(node_state):
|
||||
available_node_types[node_type]["max_workers"] += 1
|
||||
|
||||
if available_node_types:
|
||||
self._configs["available_node_types"].update(available_node_types)
|
||||
self._configs["max_workers"] = sum(
|
||||
v["max_workers"] for v in available_node_types.values()
|
||||
)
|
||||
assert head_node_type, "Head node type should be found."
|
||||
self._configs["head_node_type"] = head_node_type
|
||||
|
||||
# Don't idle terminated nodes in read-only mode.
|
||||
self._configs.pop("idle_timeout_minutes", None)
|
||||
|
||||
def get_cached_autoscaling_config(self) -> AutoscalingConfig:
|
||||
return AutoscalingConfig(self._configs, skip_content_hash=True)
|
||||
@@ -0,0 +1,271 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
from ray.autoscaler.v2.instance_manager.common import InstanceUtil
|
||||
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
|
||||
from ray.core.generated.instance_manager_pb2 import (
|
||||
GetInstanceManagerStateReply,
|
||||
GetInstanceManagerStateRequest,
|
||||
Instance,
|
||||
InstanceUpdateEvent,
|
||||
NodeKind,
|
||||
StatusCode,
|
||||
UpdateInstanceManagerStateReply,
|
||||
UpdateInstanceManagerStateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstanceUpdatedSubscriber(ABC):
|
||||
"""Subscribers to instance status changes."""
|
||||
|
||||
@abstractmethod
|
||||
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class InstanceManager:
|
||||
"""
|
||||
See `InstanceManagerService` in instance_manager.proto
|
||||
|
||||
This handles updates to an instance, or inserts a new instance if
|
||||
it's an insert update. We should only be inserting new instances
|
||||
of the below statuses:
|
||||
1. ALLOCATED: For unmanaged instance not initialized by InstanceManager,
|
||||
e.g. head node
|
||||
2. QUEUED: For new instance being queued to launch.
|
||||
3. TERMINATING: For leaked cloud instance that needs to be terminated.
|
||||
|
||||
For full status transitions, see:
|
||||
https://docs.google.com/document/d/1NzQjA8Mh-oMc-QxXOa529oneWCoA8sDiVoNkBqqDb4U/edit#heading=h.k9a1sp4qpqj4
|
||||
|
||||
Not thread safe, should be used as a singleton.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instance_storage: InstanceStorage,
|
||||
instance_status_update_subscribers: Optional[List[InstanceUpdatedSubscriber]],
|
||||
):
|
||||
self._instance_storage = instance_storage
|
||||
self._status_update_subscribers = instance_status_update_subscribers or []
|
||||
|
||||
def update_instance_manager_state(
|
||||
self, request: UpdateInstanceManagerStateRequest
|
||||
) -> UpdateInstanceManagerStateReply:
|
||||
"""
|
||||
Updates the instance manager state.
|
||||
|
||||
If there's any failure, no updates would be made and the reply
|
||||
would contain the latest version of the instance manager state,
|
||||
and the error info.
|
||||
|
||||
Args:
|
||||
request: The request to update the instance manager state.
|
||||
|
||||
Returns:
|
||||
The reply to the request.
|
||||
"""
|
||||
|
||||
# Handle updates
|
||||
ids_to_updates = {update.instance_id: update for update in request.updates}
|
||||
to_update_instances, version = self._instance_storage.get_instances(
|
||||
instance_ids=ids_to_updates.keys()
|
||||
)
|
||||
|
||||
if request.expected_version >= 0 and request.expected_version != version:
|
||||
err_str = (
|
||||
f"Version mismatch: expected: {request.expected_version}, "
|
||||
f"actual: {version}"
|
||||
)
|
||||
logger.warning(err_str)
|
||||
return self._get_update_im_state_reply(
|
||||
StatusCode.VERSION_MISMATCH,
|
||||
version,
|
||||
err_str,
|
||||
)
|
||||
|
||||
# Handle instances states update.
|
||||
to_upsert_instances = []
|
||||
for instance_id, update in ids_to_updates.items():
|
||||
if instance_id in to_update_instances:
|
||||
instance = self._update_instance(
|
||||
to_update_instances[instance_id], update
|
||||
)
|
||||
else:
|
||||
instance = self._create_instance(update)
|
||||
|
||||
to_upsert_instances.append(instance)
|
||||
|
||||
# Updates the instance storage.
|
||||
result = self._instance_storage.batch_upsert_instances(
|
||||
updates=to_upsert_instances,
|
||||
expected_storage_version=version,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
if result.version != version:
|
||||
err_str = (
|
||||
f"Version mismatch: expected: {version}, actual: {result.version}"
|
||||
)
|
||||
logger.warning(err_str)
|
||||
return self._get_update_im_state_reply(
|
||||
StatusCode.VERSION_MISMATCH, result.version, err_str
|
||||
)
|
||||
else:
|
||||
err_str = "Failed to update instance storage."
|
||||
logger.error(err_str)
|
||||
return self._get_update_im_state_reply(
|
||||
StatusCode.UNKNOWN_ERRORS, result.version, err_str
|
||||
)
|
||||
|
||||
# Successful updates.
|
||||
for subscriber in self._status_update_subscribers:
|
||||
subscriber.notify(request.updates)
|
||||
|
||||
return self._get_update_im_state_reply(StatusCode.OK, result.version)
|
||||
|
||||
def get_instance_manager_state(
|
||||
self, request: GetInstanceManagerStateRequest
|
||||
) -> GetInstanceManagerStateReply:
|
||||
"""
|
||||
Gets the instance manager state.
|
||||
|
||||
Args:
|
||||
request: The request to get the instance manager state.
|
||||
|
||||
Returns:
|
||||
The reply to the request.
|
||||
"""
|
||||
reply = GetInstanceManagerStateReply()
|
||||
instances, version = self._instance_storage.get_instances()
|
||||
reply.state.instances.extend(instances.values())
|
||||
reply.state.version = version
|
||||
reply.status.code = StatusCode.OK
|
||||
|
||||
return reply
|
||||
|
||||
#########################################
|
||||
# Private methods
|
||||
#########################################
|
||||
|
||||
@staticmethod
|
||||
def _get_update_im_state_reply(
|
||||
status_code: StatusCode, version: int, error_message: str = ""
|
||||
) -> UpdateInstanceManagerStateReply:
|
||||
"""
|
||||
Returns a UpdateInstanceManagerStateReply with the given status code and
|
||||
version.
|
||||
|
||||
Args:
|
||||
status_code: The status code.
|
||||
version: The version.
|
||||
error_message: The error message if any.
|
||||
|
||||
Returns:
|
||||
The reply.
|
||||
"""
|
||||
reply = UpdateInstanceManagerStateReply()
|
||||
reply.status.code = status_code
|
||||
reply.version = version
|
||||
if error_message:
|
||||
reply.status.message = error_message
|
||||
return reply
|
||||
|
||||
@staticmethod
|
||||
def _apply_update(instance: Instance, update: InstanceUpdateEvent):
|
||||
"""
|
||||
Apply status specific update to the instance.
|
||||
|
||||
Args:
|
||||
instance: The instance to update.
|
||||
update: The update to apply.
|
||||
"""
|
||||
if update.new_instance_status == Instance.ALLOCATED:
|
||||
assert (
|
||||
update.cloud_instance_id
|
||||
), "ALLOCATED update must have cloud_instance_id"
|
||||
assert update.node_kind in [
|
||||
NodeKind.WORKER,
|
||||
NodeKind.HEAD,
|
||||
], "ALLOCATED update must have node_kind as WORKER or HEAD"
|
||||
assert update.instance_type, "ALLOCATED update must have instance_type"
|
||||
assert (
|
||||
update.cloud_instance_id
|
||||
), "ALLOCATED update must have cloud_instance_id"
|
||||
instance.cloud_instance_id = update.cloud_instance_id
|
||||
instance.node_kind = update.node_kind
|
||||
instance.instance_type = update.instance_type
|
||||
instance.node_id = update.ray_node_id
|
||||
elif update.new_instance_status == Instance.RAY_RUNNING:
|
||||
assert update.ray_node_id, "RAY_RUNNING update must have ray_node_id"
|
||||
instance.node_id = update.ray_node_id
|
||||
elif update.new_instance_status == Instance.REQUESTED:
|
||||
assert (
|
||||
update.launch_request_id
|
||||
), "REQUESTED update must have launch_request_id"
|
||||
assert update.instance_type, "REQUESTED update must have instance_type"
|
||||
instance.launch_request_id = update.launch_request_id
|
||||
instance.instance_type = update.instance_type
|
||||
elif update.new_instance_status == Instance.TERMINATING:
|
||||
assert (
|
||||
update.cloud_instance_id
|
||||
), "TERMINATING update must have cloud instance id"
|
||||
|
||||
@staticmethod
|
||||
def _create_instance(update: InstanceUpdateEvent) -> Instance:
|
||||
"""
|
||||
Create a new instance from the given update.
|
||||
"""
|
||||
|
||||
assert update.upsert, "upsert must be true for creating new instance."
|
||||
|
||||
assert update.new_instance_status in [
|
||||
# For unmanaged instance not initialized by InstanceManager,
|
||||
# e.g. head node
|
||||
Instance.ALLOCATED,
|
||||
# For new instance being queued to launch.
|
||||
Instance.QUEUED,
|
||||
# For leaked cloud instance that needs to be terminated.
|
||||
Instance.TERMINATING,
|
||||
], (
|
||||
"Invalid status for new instance, must be one of "
|
||||
"[ALLOCATED, QUEUED, TERMINATING]"
|
||||
)
|
||||
|
||||
# Create a new instance first for common fields.
|
||||
instance = InstanceUtil.new_instance(
|
||||
instance_id=update.instance_id,
|
||||
instance_type=update.instance_type,
|
||||
status=update.new_instance_status,
|
||||
details=update.details,
|
||||
)
|
||||
|
||||
# Apply the status specific updates.
|
||||
logger.info(InstanceUtil.get_log_str_for_update(instance, update))
|
||||
InstanceManager._apply_update(instance, update)
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def _update_instance(instance: Instance, update: InstanceUpdateEvent) -> Instance:
|
||||
"""
|
||||
Update the instance with the given update.
|
||||
|
||||
Args:
|
||||
instance: The instance to update.
|
||||
update: The update to apply.
|
||||
|
||||
Returns:
|
||||
The updated instance.
|
||||
"""
|
||||
logger.info(InstanceUtil.get_log_str_for_update(instance, update))
|
||||
assert InstanceUtil.set_status(instance, update.new_instance_status), (
|
||||
"Invalid status transition from "
|
||||
f"{Instance.InstanceStatus.Name(instance.status)} to "
|
||||
f"{Instance.InstanceStatus.Name(update.new_instance_status)}"
|
||||
)
|
||||
InstanceManager._apply_update(instance, update)
|
||||
|
||||
return instance
|
||||
@@ -0,0 +1,151 @@
|
||||
import copy
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ray.autoscaler.v2.instance_manager.storage import Storage, StoreStatus
|
||||
from ray.core.generated.instance_manager_pb2 import Instance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstanceStorage:
|
||||
"""Instance storage stores the states of instances in the storage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cluster_id: str,
|
||||
storage: Storage,
|
||||
) -> None:
|
||||
self._storage = storage
|
||||
self._cluster_id = cluster_id
|
||||
self._table_name = f"instance_table@{cluster_id}"
|
||||
|
||||
def batch_upsert_instances(
|
||||
self,
|
||||
updates: List[Instance],
|
||||
expected_storage_version: Optional[int] = None,
|
||||
) -> StoreStatus:
|
||||
"""Upsert instances into the storage. If the instance already exists,
|
||||
it will be updated. Otherwise, it will be inserted. If the
|
||||
expected_storage_version is specified, the update will fail if the
|
||||
current storage version does not match the expected version.
|
||||
|
||||
Note the version of the upserted instances will be set to the current
|
||||
storage version.
|
||||
|
||||
Args:
|
||||
updates: A list of instances to be upserted.
|
||||
expected_storage_version: The expected storage version.
|
||||
|
||||
Returns:
|
||||
StoreStatus: A tuple of (success, storage_version).
|
||||
"""
|
||||
mutations = {}
|
||||
version = self._storage.get_version()
|
||||
# handle version mismatch
|
||||
if expected_storage_version and expected_storage_version != version:
|
||||
return StoreStatus(False, version)
|
||||
|
||||
for instance in updates:
|
||||
instance = copy.deepcopy(instance)
|
||||
# the instance version is set to 0, it will be
|
||||
# populated by the storage entry's verion on read
|
||||
instance.version = 0
|
||||
mutations[instance.instance_id] = instance.SerializeToString()
|
||||
|
||||
result, version = self._storage.batch_update(
|
||||
self._table_name, mutations, {}, expected_storage_version
|
||||
)
|
||||
|
||||
return StoreStatus(result, version)
|
||||
|
||||
def upsert_instance(
|
||||
self,
|
||||
instance: Instance,
|
||||
expected_instance_version: Optional[int] = None,
|
||||
expected_storage_version: Optional[int] = None,
|
||||
) -> StoreStatus:
|
||||
"""Upsert an instance in the storage.
|
||||
If the expected_instance_version is specified, the update will fail
|
||||
if the current instance version does not match the expected version.
|
||||
Similarly, if the expected_storage_version is
|
||||
specified, the update will fail if the current storage version does not
|
||||
match the expected version.
|
||||
|
||||
Note the version of the upserted instances will be set to the current
|
||||
storage version.
|
||||
|
||||
Args:
|
||||
instance: The instance to be updated.
|
||||
expected_instance_version: The expected instance version.
|
||||
expected_storage_version: The expected storage version.
|
||||
|
||||
Returns:
|
||||
StoreStatus: A tuple of (success, storage_version).
|
||||
"""
|
||||
instance = copy.deepcopy(instance)
|
||||
# the instance version is set to 0, it will be
|
||||
# populated by the storage entry's verion on read
|
||||
instance.version = 0
|
||||
result, version = self._storage.update(
|
||||
self._table_name,
|
||||
key=instance.instance_id,
|
||||
value=instance.SerializeToString(),
|
||||
expected_entry_version=expected_instance_version,
|
||||
expected_storage_version=expected_storage_version,
|
||||
insert_only=False,
|
||||
)
|
||||
|
||||
return StoreStatus(result, version)
|
||||
|
||||
def get_instances(
|
||||
self,
|
||||
instance_ids: List[str] = None,
|
||||
status_filter: Set[int] = None,
|
||||
) -> Tuple[Dict[str, Instance], int]:
|
||||
"""Get instances from the storage.
|
||||
|
||||
Args:
|
||||
instance_ids: A list of instance ids to be retrieved. If empty, all
|
||||
instances will be retrieved.
|
||||
status_filter: Only instances with the specified status will be returned.
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[str, Instance], int]: A tuple of (instances, version).
|
||||
The instances is a dictionary of (instance_id, instance) pairs.
|
||||
"""
|
||||
instance_ids = instance_ids or []
|
||||
status_filter = status_filter or set()
|
||||
pairs, version = self._storage.get(self._table_name, instance_ids)
|
||||
instances = {}
|
||||
for instance_id, (instance_data, entry_version) in pairs.items():
|
||||
instance = Instance()
|
||||
instance.ParseFromString(instance_data)
|
||||
instance.version = entry_version
|
||||
if status_filter and instance.status not in status_filter:
|
||||
continue
|
||||
instances[instance_id] = instance
|
||||
return instances, version
|
||||
|
||||
def batch_delete_instances(
|
||||
self, instance_ids: List[str], expected_storage_version: Optional[int] = None
|
||||
) -> StoreStatus:
|
||||
"""Delete instances from the storage. If the expected_storage_version
|
||||
is specified, the update will fail if the current storage version does
|
||||
not match the expected version.
|
||||
|
||||
Args:
|
||||
instance_ids: A list of instance ids to be deleted.
|
||||
expected_storage_version: The expected storage version.
|
||||
|
||||
Returns:
|
||||
StoreStatus: A tuple of (success, storage_version).
|
||||
"""
|
||||
version = self._storage.get_version()
|
||||
if expected_storage_version and expected_storage_version != version:
|
||||
return StoreStatus(False, version)
|
||||
|
||||
result = self._storage.batch_update(
|
||||
self._table_name, {}, instance_ids, expected_storage_version
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,530 @@
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from queue import Queue
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ray.autoscaler._private.constants import (
|
||||
AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
||||
AUTOSCALER_MAX_LAUNCH_BATCH,
|
||||
)
|
||||
from ray.autoscaler._private.util import hash_launch_conf
|
||||
from ray.autoscaler.node_provider import NodeProvider as NodeProviderV1
|
||||
from ray.autoscaler.tags import (
|
||||
NODE_KIND_HEAD,
|
||||
NODE_KIND_UNMANAGED,
|
||||
NODE_KIND_WORKER,
|
||||
STATUS_UNINITIALIZED,
|
||||
TAG_RAY_LAUNCH_CONFIG,
|
||||
TAG_RAY_LAUNCH_REQUEST,
|
||||
TAG_RAY_NODE_KIND,
|
||||
TAG_RAY_NODE_NAME,
|
||||
TAG_RAY_NODE_STATUS,
|
||||
TAG_RAY_USER_NODE_TYPE,
|
||||
)
|
||||
from ray.autoscaler.v2.instance_manager.config import IConfigReader
|
||||
from ray.autoscaler.v2.schema import NodeType
|
||||
from ray.core.generated.instance_manager_pb2 import NodeKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type Alias. This is a **unique identifier** for a cloud instance in the cluster.
|
||||
# The provider should guarantee that this id is unique across the cluster,
|
||||
# such that:
|
||||
# - When a cloud instance is created and running, no other cloud instance in the
|
||||
# cluster has the same id.
|
||||
# - When a cloud instance is terminated, no other cloud instance in the cluster will
|
||||
# be assigned the same id later.
|
||||
CloudInstanceId = str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CloudInstance:
|
||||
"""
|
||||
A class that represents a cloud instance in the cluster, with necessary metadata
|
||||
of the cloud instance.
|
||||
"""
|
||||
|
||||
# The cloud instance id.
|
||||
cloud_instance_id: CloudInstanceId
|
||||
# The node type of the cloud instance.
|
||||
node_type: NodeType
|
||||
# The node kind, i.e head or worker.
|
||||
node_kind: NodeKind
|
||||
# If the cloud instance is already running.
|
||||
is_running: bool
|
||||
# Update request id from which the cloud instance is launched.
|
||||
# This could be None if the cloud instance couldn't be associated with requests
|
||||
# by the cloud provider: e.g. cloud provider doesn't support per-instance
|
||||
# extra metadata.
|
||||
# This is fine for now since the reconciler should be able to know how
|
||||
# to handle cloud instances w/o request ids.
|
||||
# TODO: make this a required field.
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class CloudInstanceProviderError(Exception):
|
||||
"""
|
||||
An base error class that represents an error that happened in the cloud instance
|
||||
provider.
|
||||
"""
|
||||
|
||||
# The timestamp of the error occurred in nanoseconds.
|
||||
timestamp_ns: int
|
||||
|
||||
def __init__(self, msg, timestamp_ns) -> None:
|
||||
super().__init__(msg)
|
||||
self.timestamp_ns = timestamp_ns
|
||||
|
||||
|
||||
class LaunchNodeError(CloudInstanceProviderError):
|
||||
# The node type that failed to launch.
|
||||
node_type: NodeType
|
||||
# Number of nodes that failed to launch.
|
||||
count: int
|
||||
# A unique id that identifies from which update request the error originates.
|
||||
request_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_type: NodeType,
|
||||
count: int,
|
||||
request_id: str,
|
||||
timestamp_ns: int,
|
||||
details: str = "",
|
||||
cause: Optional[Exception] = None,
|
||||
) -> None:
|
||||
msg = (
|
||||
f"Failed to launch {count} nodes of type {node_type} with "
|
||||
f"request id {request_id}: {details}"
|
||||
)
|
||||
super().__init__(msg, timestamp_ns=timestamp_ns)
|
||||
self.node_type = node_type
|
||||
self.count = count
|
||||
self.request_id = request_id
|
||||
if cause:
|
||||
self.__cause__ = cause
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"LaunchNodeError(node_type={self.node_type}, count={self.count}, "
|
||||
f"request_id={self.request_id}): {self.__cause__}"
|
||||
)
|
||||
|
||||
|
||||
class TerminateNodeError(CloudInstanceProviderError):
|
||||
# The cloud instance id of the node that failed to terminate.
|
||||
cloud_instance_id: CloudInstanceId
|
||||
# A unique id that identifies from which update request the error originates.
|
||||
request_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cloud_instance_id: CloudInstanceId,
|
||||
request_id: str,
|
||||
timestamp_ns: int,
|
||||
details: str = "",
|
||||
cause: Optional[Exception] = None,
|
||||
) -> None:
|
||||
msg = (
|
||||
f"Failed to terminate node {cloud_instance_id} with "
|
||||
f"request id {request_id}: {details}"
|
||||
)
|
||||
super().__init__(msg, timestamp_ns=timestamp_ns)
|
||||
self.cloud_instance_id = cloud_instance_id
|
||||
self.request_id = request_id
|
||||
if cause:
|
||||
self.__cause__ = cause
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"TerminateNodeError(cloud_instance_id={self.cloud_instance_id}, "
|
||||
f"request_id={self.request_id}): {self.__cause__}"
|
||||
)
|
||||
|
||||
|
||||
class ICloudInstanceProvider(ABC):
|
||||
"""
|
||||
The interface for a cloud instance provider.
|
||||
|
||||
This interface is a minimal interface that should be implemented by the
|
||||
various cloud instance providers (e.g. AWS, and etc).
|
||||
|
||||
The cloud instance provider is responsible for managing the cloud instances in the
|
||||
cluster. It provides the following main functionalities:
|
||||
- Launch new cloud instances.
|
||||
- Terminate existing running instances.
|
||||
- Get the non-terminated cloud instances in the cluster.
|
||||
- Poll the errors that happened for the updates to the cloud instance provider.
|
||||
|
||||
Below properties of the cloud instance provider are assumed with this interface:
|
||||
|
||||
1. Eventually consistent
|
||||
The cloud instance provider is expected to be eventually consistent with the
|
||||
cluster state. For example, when a cloud instance is request to be terminated
|
||||
or launched, the provider may not immediately reflect the change in its state.
|
||||
However, the provider is expected to eventually reflect the change in its state.
|
||||
|
||||
2. Asynchronous
|
||||
The provider could also be asynchronous, where the termination/launch
|
||||
request may not immediately return the result of the request.
|
||||
|
||||
3. Unique cloud instance ids
|
||||
Cloud instance ids are expected to be unique across the cluster.
|
||||
|
||||
4. Idempotent updates
|
||||
For the update APIs (e.g. ensure_min_nodes, terminate), the provider may use the
|
||||
request ids to provide idempotency.
|
||||
|
||||
Usage:
|
||||
```
|
||||
provider: ICloudInstanceProvider = ...
|
||||
|
||||
# Update the cluster with a desired shape.
|
||||
provider.launch(
|
||||
shape={
|
||||
"worker_nodes": 10,
|
||||
"ray_head": 1,
|
||||
},
|
||||
request_id="1",
|
||||
)
|
||||
|
||||
# Get the non-terminated nodes of the cloud instance provider.
|
||||
running = provider.get_non_terminated()
|
||||
|
||||
# Poll the errors
|
||||
errors = provider.poll_errors()
|
||||
|
||||
# Terminate nodes.
|
||||
provider.terminate(
|
||||
ids=["cloud_instance_id_1", "cloud_instance_id_2"],
|
||||
request_id="2",
|
||||
)
|
||||
|
||||
# Process the state of the provider.
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
|
||||
"""Get the non-terminated cloud instances in the cluster.
|
||||
|
||||
Returns:
|
||||
A dictionary of the non-terminated cloud instances in the cluster.
|
||||
The key is the cloud instance id, and the value is the cloud instance.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
||||
"""
|
||||
Terminate the cloud instances asynchronously.
|
||||
|
||||
This method is expected to be idempotent, i.e. if the same request id is used
|
||||
to terminate the same cloud instances, this should be a no-op if
|
||||
the cloud instances are already terminated or being terminated.
|
||||
|
||||
Args:
|
||||
ids: the cloud instance ids to terminate.
|
||||
request_id: a unique id that identifies the request.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def launch(
|
||||
self,
|
||||
shape: Dict[NodeType, int],
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""Launch the cloud instances asynchronously.
|
||||
|
||||
Args:
|
||||
shape: A map from node type to number of nodes to launch.
|
||||
request_id: a unique id that identifies the update request.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
||||
"""
|
||||
Poll the errors that happened since the last poll.
|
||||
|
||||
This method would also clear the errors that happened since the last poll.
|
||||
|
||||
Returns:
|
||||
The errors that happened since the last poll.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloudInstanceLaunchRequest:
|
||||
"""
|
||||
The arguments to launch a node.
|
||||
"""
|
||||
|
||||
# The node type to launch.
|
||||
node_type: NodeType
|
||||
# Number of nodes to launch.
|
||||
count: int
|
||||
# A unique id that identifies the request.
|
||||
request_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloudInstanceTerminateRequest:
|
||||
"""
|
||||
The arguments to terminate a node.
|
||||
"""
|
||||
|
||||
# The cloud instance id of the node to terminate.
|
||||
cloud_instance_id: CloudInstanceId
|
||||
# A unique id that identifies the request.
|
||||
request_id: str
|
||||
|
||||
|
||||
class NodeProviderAdapter(ICloudInstanceProvider):
|
||||
"""
|
||||
Warps a NodeProviderV1 to a ICloudInstanceProvider.
|
||||
|
||||
TODO(rickyx):
|
||||
The current adapter right now consists of two sets of APIs:
|
||||
- v1: the old APIs that are used by the autoscaler, where
|
||||
we forward the calls to the NodeProviderV1.
|
||||
- v2: the new APIs that are used by the autoscaler v2, this is
|
||||
defined in the ICloudInstanceProvider interface.
|
||||
|
||||
We should eventually remove the v1 APIs and only use the v2 APIs.
|
||||
It's currently left as a TODO since changing the v1 APIs would
|
||||
requires a lot of changes in the cluster launcher codebase.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
v1_provider: NodeProviderV1,
|
||||
config_reader: IConfigReader,
|
||||
max_launch_batch_per_type: int = AUTOSCALER_MAX_LAUNCH_BATCH,
|
||||
max_concurrent_launches: int = AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
|
||||
) -> None:
|
||||
"""Initialize the node provider adapter.
|
||||
|
||||
Args:
|
||||
v1_provider: The v1 node provider to wrap.
|
||||
config_reader: The config reader to read the autoscaling config.
|
||||
max_launch_batch_per_type: The maximum number of nodes to launch per
|
||||
node type in a single batch.
|
||||
max_concurrent_launches: The maximum number of concurrent launches.
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
self._v1_provider = v1_provider
|
||||
self._config_reader = config_reader
|
||||
# Executor to async launching and terminating nodes.
|
||||
self._main_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="ray::NodeProviderAdapter"
|
||||
)
|
||||
|
||||
# v1 legacy rate limiting on the node provider launch calls.
|
||||
self._max_launch_batch_per_type = max_launch_batch_per_type
|
||||
max_batches = math.ceil(
|
||||
max_concurrent_launches / float(max_launch_batch_per_type)
|
||||
)
|
||||
self._node_launcher_executors = ThreadPoolExecutor(
|
||||
max_workers=max_batches,
|
||||
thread_name_prefix="ray::NodeLauncherPool",
|
||||
)
|
||||
|
||||
# Queue to retrieve new errors occur in the multi-thread executors
|
||||
# temporarily.
|
||||
self._errors_queue = Queue()
|
||||
|
||||
@property
|
||||
def v1_provider(self) -> NodeProviderV1:
|
||||
return self._v1_provider
|
||||
|
||||
def get_non_terminated(self) -> Dict[CloudInstanceId, CloudInstance]:
|
||||
nodes = {}
|
||||
|
||||
cloud_instance_ids = self._v1_non_terminated_nodes({})
|
||||
# Filter out nodes that are not running.
|
||||
# This is efficient since the provider is expected to cache the
|
||||
# running status of the nodes.
|
||||
for cloud_instance_id in cloud_instance_ids:
|
||||
node_tags = self._v1_node_tags(cloud_instance_id)
|
||||
node_kind_tag = node_tags.get(TAG_RAY_NODE_KIND, NODE_KIND_UNMANAGED)
|
||||
if node_kind_tag == NODE_KIND_UNMANAGED:
|
||||
# Filter out unmanaged nodes.
|
||||
continue
|
||||
elif node_kind_tag == NODE_KIND_WORKER:
|
||||
node_kind = NodeKind.WORKER
|
||||
elif node_kind_tag == NODE_KIND_HEAD:
|
||||
node_kind = NodeKind.HEAD
|
||||
else:
|
||||
raise ValueError(f"Invalid node kind: {node_kind_tag}")
|
||||
|
||||
nodes[cloud_instance_id] = CloudInstance(
|
||||
cloud_instance_id=cloud_instance_id,
|
||||
node_type=node_tags.get(TAG_RAY_USER_NODE_TYPE, ""),
|
||||
is_running=self._v1_is_running(cloud_instance_id),
|
||||
request_id=node_tags.get(TAG_RAY_LAUNCH_REQUEST, ""),
|
||||
node_kind=node_kind,
|
||||
)
|
||||
|
||||
return nodes
|
||||
|
||||
def poll_errors(self) -> List[CloudInstanceProviderError]:
|
||||
errors = []
|
||||
while not self._errors_queue.empty():
|
||||
errors.append(self._errors_queue.get_nowait())
|
||||
return errors
|
||||
|
||||
def launch(
|
||||
self,
|
||||
shape: Dict[NodeType, int],
|
||||
request_id: str,
|
||||
) -> None:
|
||||
self._main_executor.submit(self._do_launch, shape, request_id)
|
||||
|
||||
def terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
||||
self._main_executor.submit(self._do_terminate, ids, request_id)
|
||||
|
||||
###########################################
|
||||
# Private APIs
|
||||
###########################################
|
||||
|
||||
def _do_launch(
|
||||
self,
|
||||
shape: Dict[NodeType, int],
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Launch the cloud instances by calling into the v1 base node provider.
|
||||
|
||||
Args:
|
||||
shape: The requested to launch node type and number of nodes.
|
||||
request_id: The request id that identifies the request.
|
||||
"""
|
||||
for node_type, count in shape.items():
|
||||
# Keep submitting the launch requests to the launch pool in batches.
|
||||
while count > 0:
|
||||
to_launch = min(count, self._max_launch_batch_per_type)
|
||||
self._node_launcher_executors.submit(
|
||||
self._launch_nodes_by_type,
|
||||
node_type,
|
||||
to_launch,
|
||||
request_id,
|
||||
)
|
||||
count -= to_launch
|
||||
|
||||
def _do_terminate(self, ids: List[CloudInstanceId], request_id: str) -> None:
|
||||
"""
|
||||
Terminate the cloud instances by calling into the v1 base node provider.
|
||||
|
||||
If errors happen during the termination, the errors will be put into the
|
||||
errors queue.
|
||||
|
||||
Args:
|
||||
ids: The cloud instance ids to terminate.
|
||||
request_id: The request id that identifies the request.
|
||||
"""
|
||||
|
||||
try:
|
||||
self._v1_terminate_nodes(ids)
|
||||
except Exception as e:
|
||||
for id in ids:
|
||||
error = TerminateNodeError(id, request_id, int(time.time_ns()))
|
||||
error.__cause__ = e
|
||||
self._errors_queue.put(error)
|
||||
|
||||
def _launch_nodes_by_type(
|
||||
self,
|
||||
node_type: NodeType,
|
||||
count: int,
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Launch nodes of the given node type.
|
||||
|
||||
Args:
|
||||
node_type: The node type to launch.
|
||||
count: Number of nodes to launch.
|
||||
request_id: A unique id that identifies the request.
|
||||
|
||||
Raises:
|
||||
ValueError: If the node type is invalid.
|
||||
LaunchNodeError: If the launch failed and raised by the underlying provider.
|
||||
"""
|
||||
# Check node type is valid.
|
||||
try:
|
||||
config = self._config_reader.get_cached_autoscaling_config()
|
||||
launch_config = config.get_cloud_node_config(node_type)
|
||||
resources = config.get_node_resources(node_type)
|
||||
labels = config.get_node_labels(node_type)
|
||||
|
||||
# This is to be compatible with the v1 node launcher.
|
||||
# See more in https://github.com/ray-project/ray/blob/6f5a189bc463e52c51a70f8aea41fb2950b443e8/python/ray/autoscaler/_private/node_launcher.py#L78-L85 # noqa
|
||||
# TODO: this should be synced with what's stored in the IM, it should
|
||||
# probably be made as a metadata field in the cloud instance. This is
|
||||
# another incompatibility with KubeRay.
|
||||
launch_hash = hash_launch_conf(launch_config, config.get_config("auth", {}))
|
||||
node_tags = {
|
||||
TAG_RAY_NODE_NAME: "ray-{}-worker".format(
|
||||
config.get_config("cluster_name", "")
|
||||
),
|
||||
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
|
||||
TAG_RAY_NODE_STATUS: STATUS_UNINITIALIZED,
|
||||
TAG_RAY_LAUNCH_CONFIG: launch_hash,
|
||||
TAG_RAY_LAUNCH_REQUEST: request_id,
|
||||
TAG_RAY_USER_NODE_TYPE: node_type,
|
||||
}
|
||||
|
||||
logger.info("Launching {} nodes of type {}.".format(count, node_type))
|
||||
self._v1_provider.create_node_with_resources_and_labels(
|
||||
launch_config, node_tags, count, resources, labels
|
||||
)
|
||||
logger.info("Launched {} nodes of type {}.".format(count, node_type))
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"Failed to launch {} nodes of type {}: {}".format(count, node_type, e)
|
||||
)
|
||||
error = LaunchNodeError(node_type, count, request_id, int(time.time_ns()))
|
||||
error.__cause__ = e
|
||||
self._errors_queue.put(error)
|
||||
|
||||
###########################################
|
||||
# V1 Legacy APIs
|
||||
###########################################
|
||||
"""
|
||||
Below are the necessary legacy APIs from the V1 node provider.
|
||||
These are needed as of now to provide the needed features
|
||||
for V2 node provider.
|
||||
The goal is to eventually remove these APIs and only use the
|
||||
V2 APIs by modifying the individual node provider to inherit
|
||||
from ICloudInstanceProvider.
|
||||
"""
|
||||
|
||||
def _v1_terminate_nodes(
|
||||
self, ids: List[CloudInstanceId]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return self._v1_provider.terminate_nodes(ids)
|
||||
|
||||
def _v1_non_terminated_nodes(
|
||||
self, tag_filters: Dict[str, str]
|
||||
) -> List[CloudInstanceId]:
|
||||
return self._v1_provider.non_terminated_nodes(tag_filters)
|
||||
|
||||
def _v1_is_running(self, node_id: CloudInstanceId) -> bool:
|
||||
return self._v1_provider.is_running(node_id)
|
||||
|
||||
def _v1_post_process(self) -> None:
|
||||
self._v1_provider.post_process()
|
||||
|
||||
def _v1_node_tags(self, node_id: CloudInstanceId) -> Dict[str, str]:
|
||||
return self._v1_provider.node_tags(node_id)
|
||||
|
||||
def _v1_safe_to_scale(self) -> bool:
|
||||
return self._v1_provider.safe_to_scale()
|
||||
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from ray.autoscaler._private.updater import (
|
||||
STATUS_UP_TO_DATE,
|
||||
TAG_RAY_NODE_STATUS,
|
||||
NodeUpdater,
|
||||
)
|
||||
from ray.autoscaler._private.util import with_envs, with_head_node_ip
|
||||
from ray.autoscaler.node_provider import NodeProvider as NodeProviderV1
|
||||
from ray.autoscaler.v2.instance_manager.config import AutoscalingConfig
|
||||
from ray.core.generated.instance_manager_pb2 import Instance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RayInstaller(object):
|
||||
"""
|
||||
RayInstaller is responsible for installing ray on the target instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: NodeProviderV1,
|
||||
config: AutoscalingConfig,
|
||||
process_runner=subprocess,
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
self._config = config
|
||||
self._process_runner = process_runner
|
||||
|
||||
def install_ray(self, instance: Instance, head_node_ip: str) -> None:
|
||||
"""
|
||||
Install ray on the target instance synchronously.
|
||||
TODO:(rickyx): This runs in another thread, and errors are silently
|
||||
ignored. We should propagate the error to the main thread.
|
||||
"""
|
||||
setup_commands = self._config.get_worker_setup_commands(instance.instance_type)
|
||||
ray_start_commands = self._config.get_worker_start_ray_commands()
|
||||
docker_config = self._config.get_docker_config(instance.instance_type)
|
||||
|
||||
logger.info(
|
||||
f"Creating new (spawn_updater) updater thread for node"
|
||||
f" {instance.cloud_instance_id}."
|
||||
)
|
||||
provider_instance_type_name = self._config.get_provider_instance_type(
|
||||
instance.instance_type
|
||||
)
|
||||
updater = NodeUpdater(
|
||||
node_id=instance.cloud_instance_id,
|
||||
provider_config=self._config.get_config("provider"),
|
||||
provider=self._provider,
|
||||
auth_config=self._config.get_config("auth"),
|
||||
cluster_name=self._config.get_config("cluster_name"),
|
||||
file_mounts=self._config.get_config("file_mounts"),
|
||||
initialization_commands=with_head_node_ip(
|
||||
self._config.get_initialization_commands(instance.instance_type),
|
||||
head_node_ip,
|
||||
),
|
||||
setup_commands=with_head_node_ip(setup_commands, head_node_ip),
|
||||
# This will prepend envs to the begin of the ray start commands, e.g.
|
||||
# `RAY_HEAD_IP=<head_node_ip> \
|
||||
# RAY_CLOUD_INSTANCE_ID=<instance_id> \
|
||||
# ray start --head ...`
|
||||
# See src/ray/common/constants.h for ENV name definitions.
|
||||
ray_start_commands=with_envs(
|
||||
ray_start_commands,
|
||||
{
|
||||
"RAY_HEAD_IP": head_node_ip,
|
||||
"RAY_CLOUD_INSTANCE_ID": instance.cloud_instance_id,
|
||||
"RAY_NODE_TYPE_NAME": instance.instance_type,
|
||||
"RAY_CLOUD_INSTANCE_TYPE_NAME": provider_instance_type_name,
|
||||
},
|
||||
),
|
||||
runtime_hash=self._config.runtime_hash,
|
||||
file_mounts_contents_hash=self._config.file_mounts_contents_hash,
|
||||
is_head_node=False,
|
||||
cluster_synced_files=self._config.get_config("cluster_synced_files"),
|
||||
rsync_options={
|
||||
"rsync_exclude": self._config.get_config("rsync_exclude"),
|
||||
"rsync_filter": self._config.get_config("rsync_filter"),
|
||||
},
|
||||
use_internal_ip=True,
|
||||
docker_config=docker_config,
|
||||
node_resources=self._config.get_node_resources(instance.instance_type),
|
||||
node_labels=self._config.get_node_labels(instance.instance_type),
|
||||
process_runner=self._process_runner,
|
||||
)
|
||||
updater.run()
|
||||
# check if the updater was successful by checking the node tags
|
||||
# since the updater could hide exceptions and just set the status tag
|
||||
tags = self._provider.node_tags(instance.cloud_instance_id)
|
||||
if tags.get(TAG_RAY_NODE_STATUS) != STATUS_UP_TO_DATE:
|
||||
raise Exception(
|
||||
f"Ray installation failed with unexpected status: {tags.get(TAG_RAY_NODE_STATUS)}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
import copy
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import defaultdict, namedtuple
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
StoreStatus = namedtuple("StoreStatus", ["success", "version"])
|
||||
VersionedValue = namedtuple("VersionedValue", ["value", "version"])
|
||||
|
||||
|
||||
class Storage(metaclass=ABCMeta):
|
||||
"""Interface for a storage backend that stores the state of nodes in the cluster.
|
||||
|
||||
The storage is thread-safe.
|
||||
|
||||
The storage is versioned, which means that each successful stage change to the
|
||||
storage will bump the version number. The version number can be used to
|
||||
implement optimistic concurrency control.
|
||||
|
||||
Each entry in the storage table is also versioned. The version number of an entry
|
||||
is the last version number when the entry is updated.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def batch_update(
|
||||
self,
|
||||
table: str,
|
||||
mutation: Optional[Dict[str, str]] = None,
|
||||
deletion: Optional[List[str]] = None,
|
||||
expected_storage_version: Optional[int] = None,
|
||||
) -> StoreStatus:
|
||||
"""Batch update the storage table. This method is atomic.
|
||||
|
||||
Args:
|
||||
table: The name of the table.
|
||||
mutation: A dictionary of key-value pairs to be updated.
|
||||
deletion: A list of keys to be deleted.
|
||||
expected_storage_version: The expected storage version. The
|
||||
update will fail if the version does not match the
|
||||
current storage version.
|
||||
|
||||
Returns:
|
||||
StoreStatus: A tuple of (success, version). If the update is
|
||||
successful, returns (True, new_version).
|
||||
Otherwise, returns (False, current_version).
|
||||
"""
|
||||
raise NotImplementedError("batch_update() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def update(
|
||||
self,
|
||||
table: str,
|
||||
key: str,
|
||||
value: str,
|
||||
expected_entry_version: Optional[int] = None,
|
||||
insert_only: bool = False,
|
||||
) -> StoreStatus:
|
||||
"""Update a single entry in the storage table.
|
||||
|
||||
Args:
|
||||
table: The name of the table.
|
||||
key: The key of the entry.
|
||||
value: The value of the entry.
|
||||
expected_entry_version: The expected version of the entry.
|
||||
The update will fail if the version does not match the current
|
||||
version of the entry.
|
||||
insert_only: If True, the update will
|
||||
fail if the entry already exists.
|
||||
Returns:
|
||||
StoreStatus: A tuple of (success, version). If the update is
|
||||
successful, returns (True, new_version). Otherwise,
|
||||
returns (False, current_version).
|
||||
"""
|
||||
raise NotImplementedError("update() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def get_all(self, table: str) -> Tuple[Dict[str, Tuple[str, int]], int]:
|
||||
raise NotImplementedError("get_all() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def get(
|
||||
self, table: str, keys: List[str]
|
||||
) -> Tuple[Dict[str, Tuple[str, int]], int]:
|
||||
"""Get a list of entries from the storage table.
|
||||
|
||||
Args:
|
||||
table: The name of the table.
|
||||
keys: A list of keys to be retrieved. If the list is empty,
|
||||
all entries in the table will be returned.
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[str, VersionedValue], int]: A tuple of
|
||||
(entries, storage_version). The entries is a dictionary of
|
||||
(key, (value, entry_version)) pairs. The entry_version is the
|
||||
version of the entry when it was last updated. The
|
||||
storage_version is the current storage version.
|
||||
"""
|
||||
raise NotImplementedError("get() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def get_version(self) -> int:
|
||||
"""Get the current storage version.
|
||||
|
||||
Returns:
|
||||
int: The current storage version.
|
||||
"""
|
||||
raise NotImplementedError("get_version() has to be implemented")
|
||||
|
||||
|
||||
class InMemoryStorage(Storage):
|
||||
"""An in-memory implementation of the Storage interface. This implementation
|
||||
is not durable"""
|
||||
|
||||
def __init__(self):
|
||||
self._version = 0
|
||||
self._tables = defaultdict(dict)
|
||||
self._lock = Lock()
|
||||
|
||||
def batch_update(
|
||||
self,
|
||||
table: str,
|
||||
mutation: Dict[str, str] = None,
|
||||
deletion: List[str] = None,
|
||||
expected_version: Optional[int] = None,
|
||||
) -> StoreStatus:
|
||||
mutation = mutation if mutation else {}
|
||||
deletion = deletion if deletion else []
|
||||
with self._lock:
|
||||
if expected_version is not None and expected_version != self._version:
|
||||
return StoreStatus(False, self._version)
|
||||
self._version += 1
|
||||
key_value_pairs_with_version = {
|
||||
key: VersionedValue(value, self._version)
|
||||
for key, value in mutation.items()
|
||||
}
|
||||
self._tables[table].update(key_value_pairs_with_version)
|
||||
for deleted_key in deletion:
|
||||
self._tables[table].pop(deleted_key, None)
|
||||
return StoreStatus(True, self._version)
|
||||
|
||||
def update(
|
||||
self,
|
||||
table: str,
|
||||
key: str,
|
||||
value: str,
|
||||
expected_entry_version: Optional[int] = None,
|
||||
expected_storage_version: Optional[int] = None,
|
||||
insert_only: bool = False,
|
||||
) -> StoreStatus:
|
||||
with self._lock:
|
||||
if (
|
||||
expected_storage_version is not None
|
||||
and expected_storage_version != self._version
|
||||
):
|
||||
return StoreStatus(False, self._version)
|
||||
if insert_only and key in self._tables[table]:
|
||||
return StoreStatus(False, self._version)
|
||||
_, version = self._tables[table].get(key, (None, -1))
|
||||
if expected_entry_version is not None and expected_entry_version != version:
|
||||
return StoreStatus(False, self._version)
|
||||
self._version += 1
|
||||
self._tables[table][key] = VersionedValue(value, self._version)
|
||||
return StoreStatus(True, self._version)
|
||||
|
||||
def get_all(self, table: str) -> Tuple[Dict[str, VersionedValue], int]:
|
||||
with self._lock:
|
||||
return (copy.deepcopy(self._tables[table]), self._version)
|
||||
|
||||
def get(self, table: str, keys: List[str]) -> Tuple[Dict[str, VersionedValue], int]:
|
||||
if not keys:
|
||||
return self.get_all(table)
|
||||
with self._lock:
|
||||
result = {}
|
||||
for key in keys:
|
||||
if key in self._tables.get(table, {}):
|
||||
result[key] = self._tables[table][key]
|
||||
return StoreStatus(result, self._version)
|
||||
|
||||
def get_version(self) -> int:
|
||||
return self._version
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional
|
||||
|
||||
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
||||
InstanceUpdatedSubscriber,
|
||||
)
|
||||
from ray.autoscaler.v2.instance_manager.node_provider import ICloudInstanceProvider
|
||||
from ray.autoscaler.v2.metrics_reporter import AutoscalerMetricsReporter
|
||||
from ray.core.generated.instance_manager_pb2 import Instance, InstanceUpdateEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudInstanceUpdater(InstanceUpdatedSubscriber):
|
||||
"""CloudInstanceUpdater is responsible for launching
|
||||
new instances and terminating cloud instances
|
||||
|
||||
It requests the cloud instance provider to launch new instances when
|
||||
there are new instance requests (with REQUESTED status change).
|
||||
|
||||
It requests the cloud instance provider to terminate instances when
|
||||
there are new instance terminations (with TERMINATING status change).
|
||||
|
||||
The cloud instance APIs are async and non-blocking.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cloud_provider: ICloudInstanceProvider,
|
||||
metrics_reporter: Optional[AutoscalerMetricsReporter] = None,
|
||||
) -> None:
|
||||
self._cloud_provider = cloud_provider
|
||||
self._metrics_reporter = metrics_reporter
|
||||
|
||||
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
||||
new_requests = [
|
||||
event for event in events if event.new_instance_status == Instance.REQUESTED
|
||||
]
|
||||
new_terminations = [
|
||||
event
|
||||
for event in events
|
||||
if event.new_instance_status == Instance.TERMINATING
|
||||
]
|
||||
terminated_instances = [
|
||||
event
|
||||
for event in events
|
||||
if event.new_instance_status == Instance.TERMINATED
|
||||
and event.cloud_instance_id
|
||||
]
|
||||
self._launch_new_instances(new_requests)
|
||||
self._terminate_instances(new_terminations)
|
||||
self._count_stopped_instances(terminated_instances)
|
||||
|
||||
def _terminate_instances(self, new_terminations: List[InstanceUpdateEvent]) -> None:
|
||||
"""
|
||||
Terminate cloud instances through cloud provider.
|
||||
|
||||
Args:
|
||||
new_terminations: List of new instance terminations.
|
||||
"""
|
||||
if not new_terminations:
|
||||
logger.debug("No instances to terminate.")
|
||||
return
|
||||
|
||||
# Terminate the instances.
|
||||
cloud_instance_ids = [event.cloud_instance_id for event in new_terminations]
|
||||
|
||||
# This is an async call.
|
||||
self._cloud_provider.terminate(
|
||||
ids=cloud_instance_ids, request_id=str(uuid.uuid4())
|
||||
)
|
||||
|
||||
def _count_stopped_instances(self, terminated_instances: List[InstanceUpdateEvent]):
|
||||
"""
|
||||
Record successfully terminated cloud instances.
|
||||
|
||||
Args:
|
||||
terminated_instances: List of terminated cloud instances.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
if not terminated_instances or not self._metrics_reporter:
|
||||
return
|
||||
|
||||
self._metrics_reporter.inc_stopped_nodes(len(terminated_instances))
|
||||
|
||||
def _launch_new_instances(self, new_requests: List[InstanceUpdateEvent]) -> None:
|
||||
"""
|
||||
Launches new instances by requesting the cloud provider.
|
||||
|
||||
Args:
|
||||
new_requests: List of new instance requests.
|
||||
|
||||
"""
|
||||
if not new_requests:
|
||||
logger.debug("No instances to launch.")
|
||||
return
|
||||
|
||||
# Group new requests by launch request id.
|
||||
requests_by_launch_request_id = defaultdict(list)
|
||||
|
||||
for event in new_requests:
|
||||
assert (
|
||||
event.launch_request_id
|
||||
), "Launch request id should have been set by the reconciler"
|
||||
requests_by_launch_request_id[event.launch_request_id].append(event)
|
||||
|
||||
for launch_request_id, events in requests_by_launch_request_id.items():
|
||||
request_shape = defaultdict(int)
|
||||
for event in events:
|
||||
request_shape[event.instance_type] += 1
|
||||
# Make requests to the cloud provider.
|
||||
self._cloud_provider.launch(
|
||||
shape=request_shape, request_id=launch_request_id
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List
|
||||
|
||||
from ray.autoscaler._private.constants import RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S
|
||||
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
||||
InstanceUpdatedSubscriber,
|
||||
)
|
||||
from ray.autoscaler.v2.schema import NodeType
|
||||
from ray.core.generated.instance_manager_pb2 import Instance, InstanceUpdateEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudResourceMonitor(InstanceUpdatedSubscriber):
|
||||
"""CloudResourceMonitor records the availability of all node types.
|
||||
|
||||
In the Spot scenario, the resources in the cluster change dynamically.
|
||||
When scaling up, it is necessary to know which node types are most
|
||||
likely to have resources, in order to decide which type of node to request.
|
||||
|
||||
During scaling up, if resource of a node type is requested but fail to
|
||||
allocate, that type is considered unavailable at that timestamp.This class
|
||||
records the last timestamp at which a node type is unavailable,allowing the
|
||||
autoscaler to skip such node types when making future scaling decisions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
self._last_unavailable_timestamp: Dict[NodeType, float] = {}
|
||||
|
||||
def allocation_timeout(self, failed_event: InstanceUpdateEvent):
|
||||
unavailable_timestamp = time.time()
|
||||
self._last_unavailable_timestamp[
|
||||
failed_event.instance_type
|
||||
] = unavailable_timestamp
|
||||
logger.info(
|
||||
f"Cloud Resource Type {failed_event.instance_type} is "
|
||||
f"unavailable at timestamp={unavailable_timestamp}. "
|
||||
f"We will lower its priority in feature schedules."
|
||||
)
|
||||
|
||||
def allocation_succeeded(self, succeeded_event: InstanceUpdateEvent):
|
||||
if succeeded_event.instance_type in self._last_unavailable_timestamp:
|
||||
self._last_unavailable_timestamp.pop(succeeded_event.instance_type)
|
||||
logger.info(
|
||||
f"Cloud Resource Type {succeeded_event.instance_type} is "
|
||||
f"available at timestamp={time.time()}. We will higher its priority in "
|
||||
f"feature schedules."
|
||||
)
|
||||
|
||||
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
||||
for event in events:
|
||||
if event.new_instance_status == Instance.ALLOCATION_TIMEOUT:
|
||||
self.allocation_timeout(event)
|
||||
elif (
|
||||
event.new_instance_status == Instance.RAY_RUNNING
|
||||
and event.instance_type
|
||||
):
|
||||
self.allocation_succeeded(event)
|
||||
|
||||
def get_resource_availabilities(self) -> Dict[NodeType, float]:
|
||||
"""Calculate the availability scores of node types.
|
||||
Higher values indicate a higher likelihood of resource allocation.
|
||||
"""
|
||||
resource_availability_scores: Dict[NodeType, float] = {}
|
||||
if self._last_unavailable_timestamp:
|
||||
max_ts = max(self._last_unavailable_timestamp.values())
|
||||
for node_type in self._last_unavailable_timestamp:
|
||||
resource_availability_scores[node_type] = (
|
||||
1 - self._last_unavailable_timestamp[node_type] / max_ts
|
||||
)
|
||||
return resource_availability_scores
|
||||
|
||||
def get_recoverable_resource_availabilities(self) -> Dict[NodeType, float]:
|
||||
"""Calculate a continuous recovery score from 0.0 to 1.0.
|
||||
|
||||
score = 0.0 if (current_time - last_unavailable_timestamp) < safety_floor
|
||||
else min(1.0, (current_time - last_unavailable_timestamp) /
|
||||
RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S)
|
||||
"""
|
||||
assert (
|
||||
RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S > 0
|
||||
), "RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S must be positive"
|
||||
recovery_scores: Dict[NodeType, float] = {}
|
||||
current_time = time.time()
|
||||
|
||||
# Safety floor is 10s or 10% of recovery window.
|
||||
# This ensures that we don't immediately retry a failed node type
|
||||
# and be stuck in a retry loop.
|
||||
safety_floor = min(10, RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S * 0.1)
|
||||
|
||||
for node_type, last_ts in self._last_unavailable_timestamp.items():
|
||||
diff = current_time - last_ts
|
||||
if diff < safety_floor:
|
||||
recovery_scores[node_type] = 0.0
|
||||
else:
|
||||
recovery_scores[node_type] = min(
|
||||
1.0, diff / RAY_AUTOSCALER_AVAILABILITY_RECOVERY_S
|
||||
)
|
||||
return recovery_scores
|
||||
@@ -0,0 +1,158 @@
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from queue import Queue
|
||||
from typing import List
|
||||
|
||||
from ray._common.utils import hex_to_binary
|
||||
from ray._raylet import GcsClient
|
||||
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
||||
InstanceUpdatedSubscriber,
|
||||
)
|
||||
from ray.core.generated.autoscaler_pb2 import DrainNodeReason
|
||||
from ray.core.generated.instance_manager_pb2 import (
|
||||
Instance,
|
||||
InstanceUpdateEvent,
|
||||
TerminationRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RayStopError:
|
||||
# Instance manager's instance id.
|
||||
im_instance_id: str
|
||||
|
||||
|
||||
class RayStopper(InstanceUpdatedSubscriber):
|
||||
"""RayStopper is responsible for stopping ray on instances.
|
||||
|
||||
It will drain the ray node if it's for idle termination.
|
||||
For other terminations, it will stop the ray node. (e.g. scale down, etc.)
|
||||
|
||||
If any failures happen when stopping/draining the node, we will not retry
|
||||
and rely on the reconciler to handle the failure.
|
||||
|
||||
TODO: we could also surface the errors back to the reconciler for
|
||||
quicker failure detection.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, gcs_client: GcsClient, error_queue: Queue) -> None:
|
||||
self._gcs_client = gcs_client
|
||||
self._error_queue = error_queue
|
||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
||||
for event in events:
|
||||
if event.new_instance_status == Instance.RAY_STOP_REQUESTED:
|
||||
fut = self._executor.submit(self._stop_or_drain_ray, event)
|
||||
|
||||
def _log_on_error(fut):
|
||||
try:
|
||||
fut.result()
|
||||
except Exception:
|
||||
logger.exception("Error stopping/drain ray.")
|
||||
|
||||
fut.add_done_callback(_log_on_error)
|
||||
|
||||
def _stop_or_drain_ray(self, event: InstanceUpdateEvent) -> None:
|
||||
"""
|
||||
Stops or drains the ray node based on the termination request.
|
||||
"""
|
||||
assert event.HasField("termination_request"), "Termination request is required."
|
||||
termination_request = event.termination_request
|
||||
ray_node_id = termination_request.ray_node_id
|
||||
instance_id = event.instance_id
|
||||
|
||||
if termination_request.cause == TerminationRequest.Cause.IDLE:
|
||||
reason = DrainNodeReason.DRAIN_NODE_REASON_IDLE_TERMINATION
|
||||
reason_str = "Termination of node that's idle for {} seconds.".format(
|
||||
termination_request.idle_duration_ms / 1000
|
||||
)
|
||||
self._drain_ray_node(
|
||||
self._gcs_client,
|
||||
self._error_queue,
|
||||
ray_node_id,
|
||||
instance_id,
|
||||
reason,
|
||||
reason_str,
|
||||
)
|
||||
return
|
||||
|
||||
# If it's not an idle termination, we stop the ray node.
|
||||
self._stop_ray_node(
|
||||
self._gcs_client, self._error_queue, ray_node_id, instance_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _drain_ray_node(
|
||||
gcs_client: GcsClient,
|
||||
error_queue: Queue,
|
||||
ray_node_id: str,
|
||||
instance_id: str,
|
||||
reason: DrainNodeReason,
|
||||
reason_str: str,
|
||||
):
|
||||
"""
|
||||
Drains the ray node.
|
||||
|
||||
Args:
|
||||
gcs_client: The gcs client to use.
|
||||
error_queue: Queue to put errors on when draining fails.
|
||||
ray_node_id: The ray node id to drain.
|
||||
instance_id: The instance id corresponding to the ray node.
|
||||
reason: The reason to drain the node.
|
||||
reason_str: The reason message to drain the node.
|
||||
"""
|
||||
try:
|
||||
accepted, reject_msg_str = gcs_client.drain_node(
|
||||
node_id=ray_node_id,
|
||||
reason=reason,
|
||||
reason_message=reason_str,
|
||||
# TODO: we could probably add a deadline here that's derived
|
||||
# from the stuck instance reconciliation configs.
|
||||
deadline_timestamp_ms=0,
|
||||
)
|
||||
logger.info(
|
||||
f"Drained ray on {ray_node_id}(success={accepted}, "
|
||||
f"msg={reject_msg_str})"
|
||||
)
|
||||
if not accepted:
|
||||
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
||||
except Exception:
|
||||
logger.exception(f"Error draining ray on {ray_node_id}")
|
||||
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
||||
|
||||
@staticmethod
|
||||
def _stop_ray_node(
|
||||
gcs_client: GcsClient,
|
||||
error_queue: Queue,
|
||||
ray_node_id: str,
|
||||
instance_id: str,
|
||||
):
|
||||
"""
|
||||
Stops the ray node.
|
||||
|
||||
Args:
|
||||
gcs_client: The gcs client to use.
|
||||
error_queue: Queue to put errors on when stopping fails.
|
||||
ray_node_id: The ray node id to stop.
|
||||
instance_id: The instance id corresponding to the ray node.
|
||||
"""
|
||||
try:
|
||||
drained = gcs_client.drain_nodes(node_ids=[hex_to_binary(ray_node_id)])
|
||||
success = len(drained) > 0
|
||||
logger.info(
|
||||
f"Stopping ray on {ray_node_id}(instance={instance_id}): "
|
||||
f"success={success})"
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Error stopping ray on {ray_node_id}(instance={instance_id})"
|
||||
)
|
||||
error_queue.put_nowait(RayStopError(im_instance_id=instance_id))
|
||||
@@ -0,0 +1,95 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from queue import Queue
|
||||
from typing import List
|
||||
|
||||
from ray.autoscaler.v2.instance_manager.instance_manager import (
|
||||
InstanceUpdatedSubscriber,
|
||||
)
|
||||
from ray.autoscaler.v2.instance_manager.instance_storage import InstanceStorage
|
||||
from ray.autoscaler.v2.instance_manager.ray_installer import RayInstaller
|
||||
from ray.core.generated.instance_manager_pb2 import (
|
||||
Instance,
|
||||
InstanceUpdateEvent,
|
||||
NodeKind,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RayInstallError:
|
||||
# Instance manager's instance id.
|
||||
im_instance_id: str
|
||||
# Error details.
|
||||
details: str
|
||||
|
||||
|
||||
class ThreadedRayInstaller(InstanceUpdatedSubscriber):
|
||||
"""ThreadedRayInstaller is responsible for install ray on new nodes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
head_node_ip: str,
|
||||
instance_storage: InstanceStorage,
|
||||
ray_installer: RayInstaller,
|
||||
error_queue: Queue,
|
||||
max_install_attempts: int = 3,
|
||||
install_retry_interval: int = 10,
|
||||
max_concurrent_installs: int = 50,
|
||||
) -> None:
|
||||
self._head_node_ip = head_node_ip
|
||||
self._instance_storage = instance_storage
|
||||
self._ray_installer = ray_installer
|
||||
self._max_concurrent_installs = max_concurrent_installs
|
||||
self._max_install_attempts = max_install_attempts
|
||||
self._install_retry_interval = install_retry_interval
|
||||
self._error_queue = error_queue
|
||||
self._ray_installation_executor = ThreadPoolExecutor(
|
||||
max_workers=self._max_concurrent_installs
|
||||
)
|
||||
|
||||
def notify(self, events: List[InstanceUpdateEvent]) -> None:
|
||||
for event in events:
|
||||
if event.new_instance_status == Instance.RAY_INSTALLING:
|
||||
self._install_ray_on_new_nodes(event.instance_id)
|
||||
|
||||
def _install_ray_on_new_nodes(self, instance_id: str) -> None:
|
||||
allocated_instance, _ = self._instance_storage.get_instances(
|
||||
instance_ids={instance_id},
|
||||
status_filter={Instance.RAY_INSTALLING},
|
||||
)
|
||||
for instance in allocated_instance.values():
|
||||
assert instance.node_kind == NodeKind.WORKER
|
||||
self._ray_installation_executor.submit(
|
||||
self._install_ray_on_single_node, instance
|
||||
)
|
||||
|
||||
def _install_ray_on_single_node(self, instance: Instance) -> None:
|
||||
assert instance.status == Instance.RAY_INSTALLING
|
||||
|
||||
# install with exponential backoff
|
||||
backoff_factor = 1
|
||||
last_exception = None
|
||||
for _ in range(self._max_install_attempts):
|
||||
try:
|
||||
self._ray_installer.install_ray(instance, self._head_node_ip)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f"Ray installation failed on instance {instance.cloud_instance_id}: {e}"
|
||||
)
|
||||
last_exception = e
|
||||
|
||||
logger.warning("Failed to install ray, retrying...")
|
||||
time.sleep(self._install_retry_interval * backoff_factor)
|
||||
backoff_factor *= 2
|
||||
|
||||
self._error_queue.put_nowait(
|
||||
RayInstallError(
|
||||
im_instance_id=instance.instance_id,
|
||||
details=str(last_exception),
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user