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

330 lines
12 KiB
Python

import json
import logging
import os
import socket
from threading import RLock
from filelock import FileLock
from ray.autoscaler._private.local.config import (
LOCAL_CLUSTER_NODE_TYPE,
bootstrap_local,
get_lock_path,
get_state_path,
)
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
NODE_KIND_HEAD,
NODE_KIND_WORKER,
STATUS_UP_TO_DATE,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_NODE_STATUS,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
filelock_logger = logging.getLogger("filelock")
filelock_logger.setLevel(logging.WARNING)
class ClusterState:
def __init__(self, lock_path, save_path, provider_config):
self.lock = RLock()
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
self.file_lock = FileLock(lock_path)
self.save_path = save_path
with self.lock:
with self.file_lock:
if os.path.exists(self.save_path):
workers = json.loads(open(self.save_path).read())
head_config = workers.get(provider_config["head_ip"])
if (
not head_config
or head_config.get("tags", {}).get(TAG_RAY_NODE_KIND)
!= NODE_KIND_HEAD
):
workers = {}
logger.info("Head IP changed - recreating cluster.")
else:
workers = {}
logger.info(
"ClusterState: Loaded cluster state: {}".format(list(workers))
)
for worker_ip in provider_config["worker_ips"]:
if worker_ip not in workers:
workers[worker_ip] = {
"tags": {TAG_RAY_NODE_KIND: NODE_KIND_WORKER},
"state": "terminated",
}
else:
assert (
workers[worker_ip]["tags"][TAG_RAY_NODE_KIND]
== NODE_KIND_WORKER
)
if provider_config["head_ip"] not in workers:
workers[provider_config["head_ip"]] = {
"tags": {TAG_RAY_NODE_KIND: NODE_KIND_HEAD},
"state": "terminated",
}
else:
assert (
workers[provider_config["head_ip"]]["tags"][TAG_RAY_NODE_KIND]
== NODE_KIND_HEAD
)
# Relevant when a user reduces the number of workers
# without changing the headnode.
list_of_node_ips = list(provider_config["worker_ips"])
list_of_node_ips.append(provider_config["head_ip"])
for worker_ip in list(workers):
if worker_ip not in list_of_node_ips:
del workers[worker_ip]
# Set external head ip, if provided by user.
# Necessary if calling `ray up` from outside the network.
# Refer to LocalNodeProvider.external_ip function.
external_head_ip = provider_config.get("external_head_ip")
if external_head_ip:
head = workers[provider_config["head_ip"]]
head["external_ip"] = external_head_ip
assert len(workers) == len(provider_config["worker_ips"]) + 1
with open(self.save_path, "w") as f:
logger.debug(
"ClusterState: Writing cluster state: {}".format(workers)
)
f.write(json.dumps(workers))
def get(self):
with self.lock:
with self.file_lock:
workers = json.loads(open(self.save_path).read())
return workers
def put(self, worker_id, info):
assert "tags" in info
assert "state" in info
with self.lock:
with self.file_lock:
workers = self.get()
workers[worker_id] = info
with open(self.save_path, "w") as f:
logger.info(
"ClusterState: "
"Writing cluster state: {}".format(list(workers))
)
f.write(json.dumps(workers))
class OnPremCoordinatorState(ClusterState):
"""Generates & updates the state file of CoordinatorSenderNodeProvider.
Unlike ClusterState, which generates a cluster specific file with
predefined head and worker ips, OnPremCoordinatorState overwrites
ClusterState's __init__ function to generate and manage a unified
file of the status of all the nodes for multiple clusters.
"""
def __init__(self, lock_path, save_path, list_of_node_ips):
self.lock = RLock()
self.file_lock = FileLock(lock_path)
self.save_path = save_path
with self.lock:
with self.file_lock:
if os.path.exists(self.save_path):
nodes = json.loads(open(self.save_path).read())
else:
nodes = {}
logger.info(
"OnPremCoordinatorState: "
"Loaded on prem coordinator state: {}".format(nodes)
)
# Filter removed node ips.
for node_ip in list(nodes):
if node_ip not in list_of_node_ips:
del nodes[node_ip]
for node_ip in list_of_node_ips:
if node_ip not in nodes:
nodes[node_ip] = {
"tags": {},
"state": "terminated",
}
assert len(nodes) == len(list_of_node_ips)
with open(self.save_path, "w") as f:
logger.info(
"OnPremCoordinatorState: "
"Writing on prem coordinator state: {}".format(nodes)
)
f.write(json.dumps(nodes))
class LocalNodeProvider(NodeProvider):
"""NodeProvider for private/local clusters.
`node_id` is overloaded to also be `node_ip` in this class.
When `cluster_name` is provided, it manages a single cluster in a cluster
specific state file. But when `cluster_name` is None, it manages multiple
clusters in a unified state file that requires each node to be tagged with
TAG_RAY_CLUSTER_NAME in create and non_terminated_nodes function calls to
associate each node with the right cluster.
The current use case of managing multiple clusters is by
OnPremCoordinatorServer which receives node provider HTTP requests
from CoordinatorSenderNodeProvider and uses LocalNodeProvider to get
the responses.
"""
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
if cluster_name:
lock_path = get_lock_path(cluster_name)
state_path = get_state_path(cluster_name)
self.state = ClusterState(
lock_path,
state_path,
provider_config,
)
self.use_coordinator = False
else:
# LocalNodeProvider with a coordinator server.
self.state = OnPremCoordinatorState(
"/tmp/coordinator.lock",
"/tmp/coordinator.state",
provider_config["list_of_node_ips"],
)
self.use_coordinator = True
def non_terminated_nodes(self, tag_filters):
workers = self.state.get()
matching_ips = []
for worker_ip, info in workers.items():
if info["state"] == "terminated":
continue
ok = True
for k, v in tag_filters.items():
if info["tags"].get(k) != v:
ok = False
break
if ok:
matching_ips.append(worker_ip)
return matching_ips
def nodes_for_teardown(self, tag_filters):
"""Return all known node ids matching tag_filters regardless of state.
The local state file on the machine invoking ``ray down`` may show
workers as terminated because only the head node's autoscaler updates
them to running. During teardown we still need to reach these nodes
to stop their Docker containers.
Nodes that have already been fully torn down (i.e. terminate_node was
called, setting teardown_complete) are excluded so they are not
targeted again on subsequent teardown passes.
"""
workers = self.state.get()
return [
worker_ip
for worker_ip, info in workers.items()
if all(info["tags"].get(k) == v for k, v in tag_filters.items())
and not info.get("teardown_complete", False)
]
def is_running(self, node_id):
return self.state.get()[node_id]["state"] == "running"
def is_terminated(self, node_id):
return not self.is_running(node_id)
def node_tags(self, node_id):
return self.state.get()[node_id]["tags"]
def external_ip(self, node_id):
"""Returns an external ip if the user has supplied one.
Otherwise, use the same logic as internal_ip below.
This can be used to call ray up from outside the network, for example
if the Ray cluster exists in an AWS VPC and we're interacting with
the cluster from a laptop (where using an internal_ip will not work).
Useful for debugging the local node provider with cloud VMs."""
node_state = self.state.get()[node_id]
ext_ip = node_state.get("external_ip")
if ext_ip:
return ext_ip
else:
return socket.gethostbyname(node_id)
def internal_ip(self, node_id):
return socket.gethostbyname(node_id)
def set_node_tags(self, node_id, tags):
with self.state.lock:
with self.state.file_lock:
info = self.state.get()[node_id]
info["tags"].update(tags)
self.state.put(node_id, info)
def create_node(self, node_config, tags, count):
"""Creates min(count, currently available) nodes."""
node_type = tags[TAG_RAY_NODE_KIND]
with self.state.lock:
with self.state.file_lock:
workers = self.state.get()
for node_id, info in workers.items():
if info["state"] == "terminated" and (
self.use_coordinator
or info["tags"][TAG_RAY_NODE_KIND] == node_type
):
info["tags"] = tags
info["state"] = "running"
info.pop("teardown_complete", None)
self.state.put(node_id, info)
count = count - 1
if count == 0:
return
def terminate_node(self, node_id):
workers = self.state.get()
info = workers[node_id]
info["state"] = "terminated"
info["teardown_complete"] = True
self.state.put(node_id, info)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_local(cluster_config)
def record_local_head_state_if_needed(local_provider: LocalNodeProvider) -> None:
"""This function is called on the Ray head from StandardAutoscaler.reset
to record the head node's own existence in the cluster state file.
This is necessary because `provider.create_node` in
`commands.get_or_create_head_node` records the head state on the
cluster-launching machine but not on the head.
"""
head_ip = local_provider.provider_config["head_ip"]
cluster_name = local_provider.cluster_name
# If the head node is not marked as created in the cluster state file,
if head_ip not in local_provider.non_terminated_nodes({}):
# These tags are based on the ones in commands.get_or_create_head_node;
# keep in sync.
head_tags = {
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
TAG_RAY_USER_NODE_TYPE: LOCAL_CLUSTER_NODE_TYPE,
TAG_RAY_NODE_NAME: "ray-{}-head".format(cluster_name),
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
# Mark the head node as created in the cluster state file.
local_provider.create_node(node_config={}, tags=head_tags, count=1)
assert head_ip in local_provider.non_terminated_nodes({})