Files
2026-07-13 13:17:40 +08:00

351 lines
12 KiB
Python

import copy
import logging
import time
from functools import wraps
from threading import RLock
from types import ModuleType
from typing import Any, Dict, List, Optional, Tuple
import googleapiclient
from ray.autoscaler._private.gcp.config import (
bootstrap_gcp,
construct_clients_from_provider_config,
get_node_type,
tpu_accelerator_config_to_type,
)
# The logic has been abstracted away here to allow for different GCP resources
# (API endpoints), which can differ widely, making it impossible to use
# the same logic for everything.
from ray.autoscaler._private.gcp.node import (
GCPTPU, # noqa
GCPCompute,
GCPNode,
GCPNodeType,
GCPResource,
)
from ray.autoscaler._private.gcp.tpu_command_runner import TPUCommandRunner
from ray.autoscaler.command_runner import CommandRunnerInterface
from ray.autoscaler.node_provider import NodeProvider
logger = logging.getLogger(__name__)
def _retry(method, max_tries=5, backoff_s=1):
"""Retry decorator for methods of GCPNodeProvider.
Upon catching BrokenPipeError, API clients are rebuilt and
decorated methods are retried.
Work-around for https://github.com/ray-project/ray/issues/16072.
Based on https://github.com/kubeflow/pipelines/pull/5250/files.
"""
@wraps(method)
def method_with_retries(self, *args, **kwargs):
try_count = 0
while try_count < max_tries:
try:
return method(self, *args, **kwargs)
except BrokenPipeError:
logger.warning("Caught a BrokenPipeError. Retrying.")
try_count += 1
if try_count < max_tries:
self._construct_clients()
time.sleep(backoff_s)
else:
raise
return method_with_retries
class GCPNodeProvider(NodeProvider):
def __init__(self, provider_config: dict, cluster_name: str):
NodeProvider.__init__(self, provider_config, cluster_name)
self.lock = RLock()
self._construct_clients()
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", False)
# Cache of node objects from the last nodes() call. This avoids
# excessive DescribeInstances requests.
self.cached_nodes: Dict[str, GCPNode] = {}
def _construct_clients(self):
_, _, compute, tpu = construct_clients_from_provider_config(
self.provider_config
)
# Dict of different resources provided by GCP.
# At this moment - Compute and TPUs
self.resources: Dict[GCPNodeType, GCPResource] = {}
# Compute is always required
self.resources[GCPNodeType.COMPUTE] = GCPCompute(
compute,
self.provider_config["project_id"],
self.provider_config["availability_zone"],
self.cluster_name,
)
# if there are no TPU nodes defined in config, tpu will be None.
if tpu is not None:
self.resources[GCPNodeType.TPU] = GCPTPU(
tpu,
self.provider_config["project_id"],
self.provider_config["availability_zone"],
self.cluster_name,
)
def _get_resource_depending_on_node_name(self, node_name: str) -> GCPResource:
"""Return the resource responsible for the node, based on node_name.
This expects the name to be in format '[NAME]-[UUID]-[TYPE]',
where [TYPE] is either 'compute' or 'tpu' (see ``GCPNodeType``).
"""
return self.resources[GCPNodeType.name_to_type(node_name)]
@_retry
def non_terminated_nodes(self, tag_filters: dict):
with self.lock:
instances = []
for resource in self.resources.values():
node_instances = resource.list_instances(tag_filters)
instances += node_instances
# Note: All the operations use "name" as the unique instance id
self.cached_nodes = {i["name"]: i for i in instances}
return [i["name"] for i in instances]
def is_running(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
return node.is_running()
def is_terminated(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
return node.is_terminated()
def node_tags(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
return node.get_labels()
@_retry
def set_node_tags(self, node_id: str, tags: dict):
with self.lock:
labels = tags
node = self._get_node(node_id)
resource = self._get_resource_depending_on_node_name(node_id)
result = resource.set_labels(node=node, labels=labels)
return result
def external_ip(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
ip = node.get_external_ip()
if ip is None:
node = self._get_node(node_id)
ip = node.get_external_ip()
return ip
def internal_ip(self, node_id: str):
with self.lock:
node = self._get_cached_node(node_id)
ip = node.get_internal_ip()
if ip is None:
node = self._get_node(node_id)
ip = node.get_internal_ip()
return ip
@_retry
def create_node(self, base_config: dict, tags: dict, count: int) -> Dict[str, dict]:
"""Creates instances.
Returns dict mapping instance id to each create operation result for the created
instances.
"""
with self.lock:
labels = tags # gcp uses "labels" instead of aws "tags"
node_type = get_node_type(base_config)
resource = self.resources[node_type]
all_nodes = {}
if self.cache_stopped_nodes:
filters = {
"ray-node-name": labels["ray-node-name"],
"ray-node-type": labels["ray-node-type"],
"ray-user-node-type": labels["ray-user-node-type"],
}
reuse_nodes = resource.list_instances(filters, True)[:count]
if reuse_nodes:
reused_nodes_dict = {
n["name"]: resource.start_instance(n["name"])
for n in reuse_nodes
}
all_nodes.update(reused_nodes_dict)
count -= len(reuse_nodes)
if count > 0:
results: List[Tuple[dict, str]] = resource.create_instances(
base_config, labels, count
)
created_nodes_dict = {
instance_id: result for result, instance_id in results
}
all_nodes.update(created_nodes_dict)
return all_nodes
def _thread_unsafe_terminate_node(self, node_id: str):
# Assumes the global lock is held for the duration of this operation.
# The lock may be held by a different thread if in `terminate_nodes()` case.
logger.info("NodeProvider: {}: Terminating node".format(node_id))
resource = self._get_resource_depending_on_node_name(node_id)
try:
result = resource.delete_instance(
node_id=node_id,
)
except googleapiclient.errors.HttpError as http_error:
if http_error.resp.status == 404:
logger.warning(
f"Tried to delete the node with id {node_id} "
"but it was already gone."
)
result = None
else:
raise http_error from None
return result
@_retry
def terminate_node(self, node_id: str):
with self.lock:
resource = self._get_resource_depending_on_node_name(node_id)
try:
if self.cache_stopped_nodes:
node = self._get_cached_node(node_id)
if node.is_running():
result = resource.stop_instance(node_id=node_id)
else:
result = None
else:
result = resource.delete_instance(
node_id=node_id,
)
except googleapiclient.errors.HttpError as http_error:
if http_error.resp.status == 404:
logger.warning(
f"Tried to delete the node with id {node_id} "
"but it was already gone."
)
else:
raise http_error from None
return result
@_retry
def _get_node(self, node_id: str) -> GCPNode:
self.non_terminated_nodes({}) # Side effect: updates cache
with self.lock:
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
resource = self._get_resource_depending_on_node_name(node_id)
instance = resource.get_instance(node_id=node_id)
return instance
def _get_cached_node(self, node_id: str) -> GCPNode:
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
return self._get_node(node_id)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_gcp(cluster_config)
@staticmethod
def fillout_available_node_types_resources(
cluster_config: Dict[str, Any]
) -> Dict[str, Any]:
"""Fill out TPU resources to the cluster config.
To enable TPU pod autoscaling, we provide the TPU accelerator
type as a resource that only exists on worker 0 of the pod slice.
For instance, a v4-16 should have the resource labels:
worker 0: resources = {"TPU": 4, "TPU-v4-16-head": 1}
worker 1: resources = {"TPU": 4}
For the autoscaler to correctly process the demands of
creating a new TPU pod, then the autoscaler must know what
a TPU pod is in the form of the TPU accelerator resource.
Therefore we fill out TPU pods appropriately by providing the
expected resource which we can deduce from the cluster config.
"""
if "available_node_types" not in cluster_config:
return cluster_config
cluster_config = copy.deepcopy(cluster_config)
available_node_types = cluster_config["available_node_types"]
for node_type in available_node_types:
node_config = available_node_types[node_type]["node_config"]
if get_node_type(node_config) == GCPNodeType.TPU:
autodetected_resources = {}
accelerator_type = ""
if "acceleratorType" in node_config:
accelerator_type = node_config["acceleratorType"]
elif "acceleratorConfig" in node_config:
accelerator_type = tpu_accelerator_config_to_type(
node_config["acceleratorConfig"]
)
if not accelerator_type:
continue
autodetected_resources[f"TPU-{accelerator_type}-head"] = 1
available_node_types[node_type]["resources"].update(
autodetected_resources
)
return cluster_config
def get_command_runner(
self,
log_prefix: str,
node_id: str,
auth_config: Dict[str, Any],
cluster_name: str,
process_runner: ModuleType,
use_internal_ip: bool,
docker_config: Optional[Dict[str, Any]] = None,
) -> CommandRunnerInterface:
"""Returns a TPU command runner as applicable."""
resource = self._get_resource_depending_on_node_name(node_id)
instance = resource.get_instance(node_id)
common_args = {
"docker_config": docker_config,
"log_prefix": log_prefix,
"node_id": node_id,
"auth_config": auth_config,
"cluster_name": cluster_name,
"process_runner": process_runner,
"use_internal_ip": use_internal_ip,
}
if (
GCPNodeType.TPU in self.resources
and resource == self.resources[GCPNodeType.TPU]
):
return TPUCommandRunner(instance=instance, provider=self, **common_args)
else:
return super().get_command_runner(**common_args)