chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,91 @@
import os
import subprocess
from typing import Dict, List, Tuple
from ray.autoscaler._private.docker import with_docker_exec
from ray.autoscaler.command_runner import CommandRunnerInterface
class FakeDockerCommandRunner(CommandRunnerInterface):
"""Command runner for the fke docker multinode cluster.
This command runner uses ``docker exec`` and ``docker cp`` to
run commands and copy files, respectively.
The regular ``DockerCommandRunner`` is made for use in SSH settings
where Docker runs on a remote hose. In contrast, this command runner
does not wrap the docker commands in ssh calls.
"""
def __init__(self, docker_config, **common_args):
self.container_name = docker_config["container_name"]
self.docker_config = docker_config
self.home_dir = None
self.initialized = False
# Optionally use 'podman' instead of 'docker'
use_podman = docker_config.get("use_podman", False)
self.docker_cmd = "podman" if use_podman else "docker"
def _run_shell(self, cmd: str, timeout: int = 120) -> str:
return subprocess.check_output(
cmd, shell=True, timeout=timeout, encoding="utf-8"
)
def run(
self,
cmd: str = None,
timeout: int = 120,
exit_on_fail: bool = False,
port_forward: List[Tuple[int, int]] = None,
with_output: bool = False,
environment_variables: Dict[str, object] = None,
run_env: str = "auto",
ssh_options_override_ssh_key: str = "",
shutdown_after_run: bool = False,
) -> str:
prefix = with_docker_exec(
[cmd],
container_name=self.container_name,
with_interactive=False,
docker_cmd=self.docker_cmd,
)[0]
return self._run_shell(prefix)
def run_init(
self, *, as_head: bool, file_mounts: Dict[str, str], sync_run_yet: bool
):
pass
def remote_shell_command_str(self):
return "{} exec -it {} bash".format(self.docker_cmd, self.container_name)
def run_rsync_down(self, source, target, options=None):
docker_dir = os.path.dirname(self._docker_expand_user(source))
self._run_shell(f"docker cp {self.container_name}:{docker_dir} {target}")
def run_rsync_up(self, source, target, options=None):
docker_dir = os.path.dirname(self._docker_expand_user(target))
self.run(cmd=f"mkdir -p {docker_dir}")
self._run_shell(f"docker cp {source} {self.container_name}:{docker_dir}")
def _docker_expand_user(self, string, any_char=False):
user_pos = string.find("~")
if user_pos > -1:
if self.home_dir is None:
self.home_dir = self._run_shell(
with_docker_exec(
["printenv HOME"],
container_name=self.container_name,
docker_cmd=self.docker_cmd,
)
).strip()
if any_char:
return string.replace("~/", self.home_dir + "/")
elif not any_char and user_pos == 0:
return string.replace("~", self.home_dir, 1)
return string
@@ -0,0 +1,246 @@
"""Fake multinode docker monitoring script.
This script is the "docker compose server" for the fake_multinode
provider using Docker compose. It should be started before running
`RAY_FAKE_CLUSTER=1 ray up <cluster_config>`.
This script reads the volume directory from a supplied fake multinode
docker cluster config file.
It then waits until a docker-compose.yaml file is created in the same
directory, which is done by the `ray up` command.
It then watches for changes in the docker-compose.yaml file and runs
`docker compose up` whenever changes are detected. This will start docker
containers as requested by the autoscaler.
Generally, the docker-compose.yaml will be mounted in the head node of the
cluster, which will then continue to change it according to the autoscaler
requirements.
Additionally, this script monitors the docker container status using
`docker status` and writes it into a `status.json`. This information is
again used by the autoscaler to determine if any nodes have died.
"""
import argparse
import json
import os
import shutil
import subprocess
import time
from typing import Any, Dict, List, Optional
import yaml
def _read_yaml(path: str):
with open(path, "rt") as f:
return yaml.safe_load(f)
def _update_docker_compose(
docker_compose_path: str, project_name: str, status: Optional[Dict[str, Any]]
) -> bool:
docker_compose_config = _read_yaml(docker_compose_path)
if not docker_compose_config:
print("Docker compose currently empty")
return False
cmd = ["up", "-d"]
if status and len(status) > 0:
cmd += ["--no-recreate"]
shutdown = False
if not docker_compose_config["services"]:
# If no more nodes, run `down` instead of `up`
print("Shutting down nodes")
cmd = ["down"]
shutdown = True
try:
subprocess.check_call(
["docker", "compose", "-f", docker_compose_path, "-p", project_name]
+ cmd
+ [
"--remove-orphans",
]
)
except Exception as e:
print(f"Ran into error when updating docker compose: {e}")
# Ignore error
return shutdown
def _get_ip(
project_name: str,
container_name: str,
override_network: Optional[str] = None,
retry_times: int = 3,
) -> Optional[str]:
network = override_network or f"{project_name}_ray_local"
cmd = [
"docker",
"inspect",
"-f",
'"{{ .NetworkSettings.Networks' f".{network}.IPAddress" ' }}"',
f"{container_name}",
]
for i in range(retry_times):
try:
ip_address = subprocess.check_output(cmd, encoding="utf-8")
except Exception:
time.sleep(1)
else:
return ip_address.strip().strip('"').strip('\\"')
return None
def _update_docker_status(
docker_compose_path: str, project_name: str, docker_status_path: str
):
data_str = ""
try:
data_str = (
subprocess.check_output(
[
"docker",
"compose",
"-f",
docker_compose_path,
"-p",
project_name,
"ps",
"--format",
"json",
]
)
.decode("utf-8")
.strip()
.split("\n")
)
data: List[Dict[str, str]] = []
for line in data_str:
line = line.strip()
if line:
data.append(json.loads(line))
except Exception as e:
print(f"Ran into error when fetching status: {e}")
print(f"docker compose ps output: {data_str}")
return None
status = {}
for container in data:
node_id = container["Service"]
container_name = container["Name"]
if container["State"] == "running":
ip = _get_ip(project_name, container_name)
else:
ip = ""
container["IP"] = ip
status[node_id] = container
with open(docker_status_path, "wt") as f:
json.dump(status, f)
return status
def monitor_docker(
docker_compose_path: str,
status_path: str,
project_name: str,
update_interval: float = 1.0,
):
while not os.path.exists(docker_compose_path):
# Wait until cluster is created
time.sleep(0.5)
print("Docker compose config detected, starting status monitoring")
# Make sure this is always writeable from inside the containers
os.chmod(docker_compose_path, 0o777)
docker_config = {"force_update": True}
# Force update
next_update = time.monotonic() - 1.0
shutdown = False
status = None
# Loop:
# If the config changed, update cluster.
# Every `update_interval` seconds, update docker status.
while not shutdown:
new_docker_config = _read_yaml(docker_compose_path)
if new_docker_config != docker_config:
# Update cluster
shutdown = _update_docker_compose(docker_compose_path, project_name, status)
# Force status update
next_update = time.monotonic() - 1.0
if time.monotonic() > next_update:
# Update docker status
status = _update_docker_status(
docker_compose_path, project_name, status_path
)
next_update = time.monotonic() + update_interval
docker_config = new_docker_config
time.sleep(0.1)
print("Cluster shut down, terminating monitoring script.")
def start_monitor(config_file: str):
cluster_config = _read_yaml(config_file)
provider_config = cluster_config["provider"]
assert provider_config["type"] == "fake_multinode_docker", (
f"The docker monitor only works with providers of type "
f"`fake_multinode_docker`, got `{provider_config['type']}`"
)
project_name = provider_config["project_name"]
volume_dir = provider_config["shared_volume_dir"]
os.makedirs(volume_dir, mode=0o755, exist_ok=True)
# Create bootstrap config
bootstrap_config_path = os.path.join(volume_dir, "bootstrap_config.yaml")
shutil.copy(config_file, bootstrap_config_path)
# These two files usually don't exist, yet
docker_compose_config_path = os.path.join(volume_dir, "docker-compose.yaml")
docker_status_path = os.path.join(volume_dir, "status.json")
if os.path.exists(docker_compose_config_path):
# We wait until this file exists, so remove it if it exists
# from a previous run.
os.remove(docker_compose_config_path)
if os.path.exists(docker_status_path):
os.remove(docker_status_path)
# Create empty file so it can be mounted
with open(docker_status_path, "wt") as f:
f.write("{}")
print(
f"Starting monitor process. Please start Ray cluster with:\n"
f" RAY_FAKE_CLUSTER=1 ray up {config_file}"
)
monitor_docker(docker_compose_config_path, docker_status_path, project_name)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"config_file",
help="Path to cluster config file containing a fake docker "
"cluster configuration.",
)
args = parser.parse_args()
start_monitor(args.config_file)
@@ -0,0 +1,59 @@
# Example command to start a cluster with this config:
#
# RAY_FAKE_CLUSTER=1 ray start --autoscaling-config=example.yaml --head --block
#
# Alternatively, you can programmatically create a fake autoscaling cluster
# using ray.cluster_utils.AutoscalingCluster.
cluster_name: fake_multinode
max_workers: 8
provider:
type: fake_multinode
# This must be true since the nodes share the same ip!
use_node_id_as_ip: True
disable_node_updaters: True
disable_launch_config_check: True
available_node_types:
ray.head.default:
# You must set this manually to your "head" node resources!! The head
# node is launched via `ray start` and hence the autoscaler cannot
# configure its resources. The resources specified for its node type
# must line up with what Ray detects/is configured with on start.
resources:
CPU: 8 # <-- set this to num CPUs used/detected in `ray start`
GPU: 0 # <-- set this to num GPUs used/detected in `ray start`
node_config: {}
max_workers: 0
ray.worker.cpu:
resources:
CPU: 1
object_store_memory: 1000000000
node_config: {}
min_workers: 0
max_workers: 4
ray.worker.gpu:
resources:
CPU: 4
GPU: 1
object_store_memory: 1000000000
node_config: {}
min_workers: 0
max_workers: 2
head_node_type: ray.head.default
upscaling_speed: 1.0
idle_timeout_minutes: 0.1
#
# !!! Configurations below are not supported in fake cluster mode !!!
#
auth: {}
docker: {}
initialization_commands: []
setup_commands: []
head_setup_commands: []
worker_setup_commands: []
head_start_ray_commands: []
worker_start_ray_commands: []
file_mounts: {}
cluster_synced_files: []
file_mounts_sync_continuously: false
rsync_exclude: []
rsync_filter: []
@@ -0,0 +1,85 @@
# This is an example config file to start a local
# multi-node cluster using Docker compose.
# It requires the ``docker compose`` plugin to be installed:
# https://docs.docker.com/compose/cli-command/#installing-compose-v2
# The resulting cluster will consist of docker containers
# scheduled via docker compose. These containers behave just like
# regular Ray nodes, have their own IPs, and can SSH into each other.
# They are mostly used to test multi-node setups and autoscaling on
# a single node.
# Example command to start a cluster with this config:
#
# python docker_monitor.py example_docker.yaml &
# RAY_FAKE_DOCKER=1 ray up -y example_docker.yaml
cluster_name: fake_docker
max_workers: 8
provider:
type: fake_multinode_docker
disable_launch_config_check: True
disable_node_updaters: True
# Docker-compose config
project_name: fake_docker
image: rayproject/ray:nightly
shared_volume_dir: /tmp/fake_docker
# For now, this has to be set here separately again:
head_resources:
CPU: 4
GPU: 0
auth:
ssh_user: ubuntu
available_node_types:
ray.head.default:
# You must set this manually to your "head" node resources!! The head
# node is launched via `ray start` and hence the autoscaler cannot
# configure its resources. The resources specified for its node type
# must line up with what Ray detects/is configured with on start.
resources:
CPU: 4
GPU: 0
node_config: {}
max_workers: 0
ray.worker.cpu:
resources:
CPU: 2
object_store_memory: 1000000000
node_config: {}
min_workers: 1
max_workers: 4
ray.worker.gpu:
resources:
CPU: 4
GPU: 1
object_store_memory: 1000000000
node_config: {}
min_workers: 1
max_workers: 2
head_node_type: ray.head.default
upscaling_speed: 1.0
idle_timeout_minutes: 0.1
# The start commands currently don't work - docker doesn't seem to like docker exec
# and Ray only works when including it in the docker-compose command
head_start_ray_commands: []
worker_start_ray_commands: []
# The docker config is currently not propagated to the node provider config.
# Thus, docker-specific configuration is expected to go into the provider part
# as demonstrated above.
docker: {}
#
# !!! Configurations below are not supported in fake cluster mode !!!
#
initialization_commands: []
setup_commands: []
head_setup_commands: []
worker_setup_commands: []
file_mounts: {}
cluster_synced_files: []
file_mounts_sync_continuously: false
rsync_exclude: []
rsync_filter: []
@@ -0,0 +1,730 @@
import copy
import json
import logging
import os
import subprocess
import sys
import time
from threading import RLock
from types import ModuleType
from typing import Any, Dict, Optional
import yaml
import ray
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import build_address
from ray.autoscaler._private.fake_multi_node.command_runner import (
FakeDockerCommandRunner,
)
from ray.autoscaler.command_runner import CommandRunnerInterface
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__)
# We generate the node ids deterministically in the fake node provider, so that
# we can associate launched nodes with their resource reports. IDs increment
# starting with fffff*00000 for the head node, fffff*00001, etc. for workers.
FAKE_HEAD_NODE_ID = "fffffffffffffffffffffffffffffffffffffffffffffffffff00000"
FAKE_HEAD_NODE_TYPE = "ray.head.default"
FAKE_DOCKER_DEFAULT_GCS_PORT = 16379
FAKE_DOCKER_DEFAULT_OBJECT_MANAGER_PORT = 18076
FAKE_DOCKER_DEFAULT_CLIENT_PORT = 10002
DOCKER_COMPOSE_SKELETON = {
"services": {},
"networks": {"ray_local": {}},
}
DOCKER_NODE_SKELETON = {
"networks": ["ray_local"],
"mem_limit": "3000m",
"mem_reservation": "3000m",
"shm_size": "1200m",
"volumes": [],
}
DOCKER_HEAD_CMD = (
'bash -c "'
"sudo mkdir -p {volume_dir} && "
"sudo chmod 777 {volume_dir} && "
"touch {volume_dir}/.in_docker && "
"sudo chown -R ray:users /cluster/node && "
"sudo chmod -R 777 /cluster/node && "
"sudo chown -R ray:users /cluster/shared && "
"sudo chmod -R 777 /cluster/shared && "
"sudo chmod 700 ~/.ssh && "
"sudo chmod 600 ~/.ssh/authorized_keys && "
"sudo chmod 600 ~/ray_bootstrap_key.pem && "
"sudo chown ray:users "
"~/.ssh ~/.ssh/authorized_keys ~/ray_bootstrap_key.pem && "
"{ensure_ssh} && "
"sleep 1 && "
"RAY_FAKE_CLUSTER=1 ray start --head "
"--autoscaling-config=~/ray_bootstrap_config.yaml "
"--object-manager-port=8076 "
"--num-cpus {num_cpus} "
"--num-gpus {num_gpus} "
# "--resources='{resources}' "
'--block"'
)
DOCKER_WORKER_CMD = (
'bash -c "'
"sudo mkdir -p {volume_dir} && "
"sudo chmod 777 {volume_dir} && "
"touch {volume_dir}/.in_docker && "
"sudo chown -R ray:users /cluster/node && "
"sudo chmod -R 777 /cluster/node && "
"sudo chmod 700 ~/.ssh && "
"sudo chmod 600 ~/.ssh/authorized_keys && "
"sudo chown ray:users ~/.ssh ~/.ssh/authorized_keys && "
"{ensure_ssh} && "
"sleep 1 && "
f"ray start --address={FAKE_HEAD_NODE_ID}:6379 "
"--object-manager-port=8076 "
"--num-cpus {num_cpus} "
"--num-gpus {num_gpus} "
# "--resources='{resources}' "
'--block"'
)
def host_dir(container_dir: str):
"""Replace local dir with potentially different host dir.
E.g. in docker-in-docker environments, the host dir might be
different to the mounted directory in the container.
This method will do a simple global replace to adjust the paths.
"""
ray_tempdir = os.environ.get("RAY_TEMPDIR", None)
ray_hostdir = os.environ.get("RAY_HOSTDIR", None)
if not ray_tempdir or not ray_hostdir:
return container_dir
return container_dir.replace(ray_tempdir, ray_hostdir)
def create_node_spec(
head: bool,
docker_image: str,
mounted_cluster_dir: str,
mounted_node_dir: str,
num_cpus: int = 2,
num_gpus: int = 0,
resources: Optional[Dict] = None,
env_vars: Optional[Dict] = None,
host_gcs_port: int = 16379,
host_object_manager_port: int = 18076,
host_client_port: int = 10002,
volume_dir: Optional[str] = None,
node_state_path: Optional[str] = None,
docker_status_path: Optional[str] = None,
docker_compose_path: Optional[str] = None,
bootstrap_config_path: Optional[str] = None,
private_key_path: Optional[str] = None,
public_key_path: Optional[str] = None,
):
node_spec = copy.deepcopy(DOCKER_NODE_SKELETON)
node_spec["image"] = docker_image
bootstrap_cfg_path_on_container = "/home/ray/ray_bootstrap_config.yaml"
bootstrap_key_path_on_container = "/home/ray/ray_bootstrap_key.pem"
resources = resources or {}
ensure_ssh = (
(
"((sudo apt update && sudo apt install -y openssh-server && "
"sudo service ssh start) || true)"
)
if not bool(int(os.environ.get("RAY_HAS_SSH", "0") or "0"))
else "sudo service ssh start"
)
cmd_kwargs = dict(
ensure_ssh=ensure_ssh,
num_cpus=num_cpus,
num_gpus=num_gpus,
resources=json.dumps(resources, indent=None),
volume_dir=volume_dir,
autoscaling_config=bootstrap_cfg_path_on_container,
)
env_vars = env_vars or {}
# Set to "auto" to mount current autoscaler directory to nodes for dev
fake_cluster_dev_dir = os.environ.get("FAKE_CLUSTER_DEV", "")
if fake_cluster_dev_dir:
if fake_cluster_dev_dir == "auto":
local_ray_dir = os.path.dirname(ray.__file__)
else:
local_ray_dir = fake_cluster_dev_dir
os.environ["FAKE_CLUSTER_DEV"] = local_ray_dir
mj = sys.version_info.major
mi = sys.version_info.minor
fake_modules_str = os.environ.get("FAKE_CLUSTER_DEV_MODULES", "autoscaler")
fake_modules = fake_modules_str.split(",")
docker_ray_dir = f"/home/ray/anaconda3/lib/python{mj}.{mi}/site-packages/ray"
node_spec["volumes"] += [
f"{local_ray_dir}/{module}:{docker_ray_dir}/{module}:ro"
for module in fake_modules
]
env_vars["FAKE_CLUSTER_DEV"] = local_ray_dir
env_vars["FAKE_CLUSTER_DEV_MODULES"] = fake_modules_str
os.environ["FAKE_CLUSTER_DEV_MODULES"] = fake_modules_str
if head:
node_spec["command"] = DOCKER_HEAD_CMD.format(**cmd_kwargs)
# Expose ports so we can connect to the cluster from outside
node_spec["ports"] = [
f"{host_gcs_port}:{ray_constants.DEFAULT_PORT}",
f"{host_object_manager_port}:8076",
f"{host_client_port}:10001",
]
# Mount status and config files for the head node
node_spec["volumes"] += [
f"{host_dir(node_state_path)}:{node_state_path}",
f"{host_dir(docker_status_path)}:{docker_status_path}",
f"{host_dir(docker_compose_path)}:{docker_compose_path}",
f"{host_dir(bootstrap_config_path)}:" f"{bootstrap_cfg_path_on_container}",
f"{host_dir(private_key_path)}:{bootstrap_key_path_on_container}",
]
# Create file if it does not exist on local filesystem
for filename in [node_state_path, docker_status_path, bootstrap_config_path]:
if not os.path.exists(filename):
with open(filename, "wt") as f:
f.write("{}")
else:
node_spec["command"] = DOCKER_WORKER_CMD.format(**cmd_kwargs)
node_spec["depends_on"] = [FAKE_HEAD_NODE_ID]
# Mount shared directories and ssh access keys
node_spec["volumes"] += [
f"{host_dir(mounted_cluster_dir)}:/cluster/shared",
f"{host_dir(mounted_node_dir)}:/cluster/node",
f"{host_dir(public_key_path)}:/home/ray/.ssh/authorized_keys",
]
# Pass these environment variables (to the head node)
# These variables are propagated by the `docker compose` command.
env_vars.setdefault("RAY_HAS_SSH", os.environ.get("RAY_HAS_SSH", ""))
env_vars.setdefault("RAY_TEMPDIR", os.environ.get("RAY_TEMPDIR", ""))
env_vars.setdefault("RAY_HOSTDIR", os.environ.get("RAY_HOSTDIR", ""))
node_spec["environment"] = [f"{k}={v}" for k, v in env_vars.items()]
return node_spec
class FakeMultiNodeProvider(NodeProvider):
"""A node provider that implements multi-node on a single machine.
This is used for laptop mode testing of autoscaling functionality."""
def __init__(
self,
provider_config: Dict[str, Any],
cluster_name: str,
):
"""Initialize the fake multi-node provider.
Args:
provider_config: Configuration for the provider.
cluster_name: Name of the cluster.
"""
NodeProvider.__init__(self, provider_config, cluster_name)
self.lock = RLock()
if "RAY_FAKE_CLUSTER" not in os.environ:
raise RuntimeError(
"FakeMultiNodeProvider requires ray to be started with "
"RAY_FAKE_CLUSTER=1 ray start ..."
)
# GCS address to use for the cluster
self._gcs_address = provider_config.get("gcs_address", None)
# Head node id
self._head_node_id = provider_config.get("head_node_id", FAKE_HEAD_NODE_ID)
# Whether to launch multiple nodes at once, or one by one regardless of
# the count (default)
self._launch_multiple = provider_config.get("launch_multiple", False)
# These are injected errors for testing purposes. If not None,
# these will be raised on `create_node_with_resources_and_labels`` and
# `terminate_node``, respectively.
self._creation_error = None
self._termination_errors = None
self._nodes = {
self._head_node_id: {
"tags": {
TAG_RAY_NODE_KIND: NODE_KIND_HEAD,
TAG_RAY_USER_NODE_TYPE: FAKE_HEAD_NODE_TYPE,
TAG_RAY_NODE_NAME: self._head_node_id,
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
},
}
self._next_node_id = 0
def _next_hex_node_id(self):
self._next_node_id += 1
base = "fffffffffffffffffffffffffffffffffffffffffffffffffff"
return base + str(self._next_node_id).zfill(5)
def non_terminated_nodes(self, tag_filters):
with self.lock:
nodes = []
for node_id in self._nodes:
tags = self.node_tags(node_id)
ok = True
for k, v in tag_filters.items():
if tags.get(k) != v:
ok = False
if ok:
nodes.append(node_id)
return nodes
def is_running(self, node_id):
with self.lock:
return node_id in self._nodes
def is_terminated(self, node_id):
with self.lock:
return node_id not in self._nodes
def node_tags(self, node_id):
with self.lock:
return self._nodes[node_id]["tags"]
def _get_ip(self, node_id: str) -> Optional[str]:
return node_id
def external_ip(self, node_id):
return self._get_ip(node_id)
def internal_ip(self, node_id):
return self._get_ip(node_id)
def set_node_tags(self, node_id, tags):
raise AssertionError("Readonly node provider cannot be updated")
def create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
if self._creation_error:
raise self._creation_error
if self._launch_multiple:
for _ in range(count):
self._create_node_with_resources_and_labels(
node_config, tags, count, resources, labels
)
else:
self._create_node_with_resources_and_labels(
node_config, tags, count, resources, labels
)
def _create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
# This function calls `pop`. To avoid side effects, we make a
# copy of `resources`.
resources = copy.deepcopy(resources)
with self.lock:
node_type = tags[TAG_RAY_USER_NODE_TYPE]
next_id = self._next_hex_node_id()
ray_params = ray._private.parameter.RayParams(
min_worker_port=0,
max_worker_port=0,
dashboard_port=None,
dashboard_agent_listen_port=0,
num_cpus=resources.pop("CPU", 0),
num_gpus=resources.pop("GPU", 0),
object_store_memory=resources.pop("object_store_memory", None),
resources=resources,
labels=labels,
redis_address=build_address(
ray._private.services.get_node_ip_address(), 6379
)
if not self._gcs_address
else self._gcs_address,
gcs_address=build_address(
ray._private.services.get_node_ip_address(), 6379
)
if not self._gcs_address
else self._gcs_address,
env_vars={
"RAY_OVERRIDE_NODE_ID_FOR_TESTING": next_id,
"RAY_CLOUD_INSTANCE_ID": next_id,
"RAY_NODE_TYPE_NAME": node_type,
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE: json.dumps(resources),
ray_constants.LABELS_ENVIRONMENT_VARIABLE: json.dumps(labels),
},
)
node = ray._private.node.Node(
ray_params, head=False, shutdown_at_exit=False, spawn_reaper=False
)
all_tags = {
TAG_RAY_NODE_KIND: NODE_KIND_WORKER,
TAG_RAY_USER_NODE_TYPE: node_type,
TAG_RAY_NODE_NAME: next_id,
TAG_RAY_NODE_STATUS: STATUS_UP_TO_DATE,
}
all_tags.update(tags)
self._nodes[next_id] = {
"tags": all_tags,
"node": node,
}
def terminate_node(self, node_id):
with self.lock:
if self._termination_errors:
raise self._termination_errors
try:
node = self._nodes.pop(node_id)
except Exception as e:
raise e
self._terminate_node(node)
def _terminate_node(self, node):
node["node"].kill_all_processes(check_alive=False, allow_graceful=True)
@staticmethod
def bootstrap_config(cluster_config):
return cluster_config
############################
# Test only methods
############################
def _test_set_creation_error(self, e: Exception):
"""Set an error that will be raised on
create_node_with_resources_and_labels."""
self._creation_error = e
def _test_add_termination_errors(self, e: Exception):
"""Set an error that will be raised on terminate_node."""
self._termination_errors = e
class FakeMultiNodeDockerProvider(FakeMultiNodeProvider):
"""A node provider that implements multi-node on a single machine.
This is used for laptop mode testing of multi node functionality
where each node has their own FS and IP."""
def __init__(self, provider_config, cluster_name):
super(FakeMultiNodeDockerProvider, self).__init__(provider_config, cluster_name)
fake_head = copy.deepcopy(self._nodes)
self._project_name = self.provider_config["project_name"]
self._docker_image = self.provider_config["image"]
self._host_gcs_port = self.provider_config.get(
"host_gcs_port", FAKE_DOCKER_DEFAULT_GCS_PORT
)
self._host_object_manager_port = self.provider_config.get(
"host_object_manager_port", FAKE_DOCKER_DEFAULT_OBJECT_MANAGER_PORT
)
self._host_client_port = self.provider_config.get(
"host_client_port", FAKE_DOCKER_DEFAULT_CLIENT_PORT
)
self._head_resources = self.provider_config["head_resources"]
# subdirs:
# - ./shared (shared filesystem)
# - ./nodes/<node_id> (node-specific mounted filesystem)
self._volume_dir = self.provider_config["shared_volume_dir"]
self._mounted_cluster_dir = os.path.join(self._volume_dir, "shared")
if not self.in_docker_container:
# Only needed on host
os.makedirs(self._mounted_cluster_dir, mode=0o755, exist_ok=True)
self._boostrap_config_path = os.path.join(
self._volume_dir, "bootstrap_config.yaml"
)
self._private_key_path = os.path.join(self._volume_dir, "bootstrap_key.pem")
self._public_key_path = os.path.join(self._volume_dir, "bootstrap_key.pem.pub")
if not self.in_docker_container:
# Create private key
if not os.path.exists(self._private_key_path):
subprocess.check_call(
f'ssh-keygen -b 2048 -t rsa -q -N "" '
f"-f {self._private_key_path}",
shell=True,
)
# Create public key
if not os.path.exists(self._public_key_path):
subprocess.check_call(
f"ssh-keygen -y "
f"-f {self._private_key_path} "
f"> {self._public_key_path}",
shell=True,
)
self._docker_compose_config_path = os.path.join(
self._volume_dir, "docker-compose.yaml"
)
self._docker_compose_config = None
self._node_state_path = os.path.join(self._volume_dir, "nodes.json")
self._docker_status_path = os.path.join(self._volume_dir, "status.json")
self._load_node_state()
if FAKE_HEAD_NODE_ID not in self._nodes:
# Reset
self._nodes = copy.deepcopy(fake_head)
self._nodes[FAKE_HEAD_NODE_ID][
"node_spec"
] = self._create_node_spec_with_resources(
head=True, node_id=FAKE_HEAD_NODE_ID, resources=self._head_resources
)
self._possibly_terminated_nodes = dict()
self._cleanup_interval = provider_config.get("cleanup_interval", 9.5)
self._docker_status = {}
self._update_docker_compose_config()
self._update_docker_status()
self._save_node_state()
@property
def in_docker_container(self):
return os.path.exists(os.path.join(self._volume_dir, ".in_docker"))
def _create_node_spec_with_resources(
self, head: bool, node_id: str, resources: Dict[str, Any]
):
resources = resources.copy()
# Create shared directory
node_dir = os.path.join(self._volume_dir, "nodes", node_id)
os.makedirs(node_dir, mode=0o777, exist_ok=True)
resource_str = json.dumps(resources, indent=None)
return create_node_spec(
head=head,
docker_image=self._docker_image,
mounted_cluster_dir=self._mounted_cluster_dir,
mounted_node_dir=node_dir,
num_cpus=resources.pop("CPU", 0),
num_gpus=resources.pop("GPU", 0),
host_gcs_port=self._host_gcs_port,
host_object_manager_port=self._host_object_manager_port,
host_client_port=self._host_client_port,
resources=resources,
env_vars={
"RAY_OVERRIDE_NODE_ID_FOR_TESTING": node_id,
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE: resource_str,
**self.provider_config.get("env_vars", {}),
},
volume_dir=self._volume_dir,
node_state_path=self._node_state_path,
docker_status_path=self._docker_status_path,
docker_compose_path=self._docker_compose_config_path,
bootstrap_config_path=self._boostrap_config_path,
public_key_path=self._public_key_path,
private_key_path=self._private_key_path,
)
def _load_node_state(self) -> bool:
if not os.path.exists(self._node_state_path):
return False
try:
with open(self._node_state_path, "rt") as f:
nodes = json.load(f)
except Exception:
return False
if not nodes:
return False
self._nodes = nodes
return True
def _save_node_state(self):
with open(self._node_state_path, "wt") as f:
json.dump(self._nodes, f)
# Make sure this is always writeable from inside the containers
if not self.in_docker_container:
# Only chmod from the outer container
os.chmod(self._node_state_path, 0o777)
def _update_docker_compose_config(self):
config = copy.deepcopy(DOCKER_COMPOSE_SKELETON)
config["services"] = {}
for node_id, node in self._nodes.items():
config["services"][node_id] = node["node_spec"]
with open(self._docker_compose_config_path, "wt") as f:
yaml.safe_dump(config, f)
def _update_docker_status(self):
if not os.path.exists(self._docker_status_path):
return
with open(self._docker_status_path, "rt") as f:
self._docker_status = json.load(f)
def _update_nodes(self):
for node_id in list(self._nodes):
if not self._is_docker_running(node_id):
self._possibly_terminated_nodes.setdefault(node_id, time.monotonic())
else:
self._possibly_terminated_nodes.pop(node_id, None)
self._cleanup_nodes()
def _cleanup_nodes(self):
for node_id, timestamp in list(self._possibly_terminated_nodes.items()):
if time.monotonic() > timestamp + self._cleanup_interval:
if not self._is_docker_running(node_id):
self._nodes.pop(node_id, None)
self._possibly_terminated_nodes.pop(node_id, None)
self._save_node_state()
def _container_name(self, node_id):
node_status = self._docker_status.get(node_id, {})
timeout = time.monotonic() + 60
while not node_status:
if time.monotonic() > timeout:
raise RuntimeError(f"Container for {node_id} never became available.")
time.sleep(1)
self._update_docker_status()
node_status = self._docker_status.get(node_id, {})
return node_status["Name"]
def _is_docker_running(self, node_id):
self._update_docker_status()
return self._docker_status.get(node_id, {}).get("State", None) == "running"
def non_terminated_nodes(self, tag_filters):
self._update_nodes()
return super(FakeMultiNodeDockerProvider, self).non_terminated_nodes(
tag_filters
)
def is_running(self, node_id):
with self.lock:
self._update_nodes()
return node_id in self._nodes and self._is_docker_running(node_id)
def is_terminated(self, node_id):
with self.lock:
self._update_nodes()
return node_id not in self._nodes and not self._is_docker_running(node_id)
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:
if self.in_docker_container:
return super(FakeMultiNodeProvider, self).get_command_runner(
log_prefix,
node_id,
auth_config,
cluster_name,
process_runner,
use_internal_ip,
)
# Else, host command runner:
common_args = {
"log_prefix": log_prefix,
"node_id": node_id,
"provider": self,
"auth_config": auth_config,
"cluster_name": cluster_name,
"process_runner": process_runner,
"use_internal_ip": use_internal_ip,
}
docker_config["container_name"] = self._container_name(node_id)
docker_config["image"] = self._docker_image
return FakeDockerCommandRunner(docker_config, **common_args)
def _get_ip(self, node_id: str) -> Optional[str]:
for i in range(3):
self._update_docker_status()
ip = self._docker_status.get(node_id, {}).get("IP", None)
if ip:
return ip
time.sleep(3)
return None
def set_node_tags(self, node_id, tags):
assert node_id in self._nodes
self._nodes[node_id]["tags"].update(tags)
def create_node_with_resources_and_labels(
self, node_config, tags, count, resources, labels
):
with self.lock:
is_head = tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
if is_head:
next_id = FAKE_HEAD_NODE_ID
else:
next_id = self._next_hex_node_id()
self._nodes[next_id] = {
"tags": tags,
"node_spec": self._create_node_spec_with_resources(
head=is_head, node_id=next_id, resources=resources
),
}
self._update_docker_compose_config()
self._save_node_state()
def create_node(
self, node_config: Dict[str, Any], tags: Dict[str, str], count: int
) -> Optional[Dict[str, Any]]:
resources = self._head_resources
return self.create_node_with_resources_and_labels(
node_config, tags, count, resources, {}
)
def _terminate_node(self, node):
self._update_docker_compose_config()
self._save_node_state()
@staticmethod
def bootstrap_config(cluster_config):
return cluster_config
@@ -0,0 +1,399 @@
import json
import logging
import os
import random
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from typing import Any, Dict, Optional
import yaml
import ray
from ray._common.network_utils import build_address
from ray._private.dict import deep_update
from ray.autoscaler._private.fake_multi_node.node_provider import (
FAKE_DOCKER_DEFAULT_CLIENT_PORT,
FAKE_DOCKER_DEFAULT_GCS_PORT,
)
from ray.util.queue import Empty, Queue
logger = logging.getLogger(__name__)
DEFAULT_DOCKER_IMAGE = "rayproject/ray:nightly-py{major}{minor}-cpu"
class ResourcesNotReadyError(RuntimeError):
pass
class DockerCluster:
"""Docker cluster wrapper.
Creates a directory for starting a fake multinode docker cluster.
Includes APIs to update the cluster config as needed in tests,
and to start and connect to the cluster.
"""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self._base_config_file = os.path.join(
os.path.dirname(__file__), "example_docker.yaml"
)
self._tempdir = None
self._config_file = None
self._nodes_file = None
self._nodes = {}
self._status_file = None
self._status = {}
self._partial_config = config
self._cluster_config = None
self._docker_image = None
self._monitor_script = os.path.join(
os.path.dirname(__file__), "docker_monitor.py"
)
self._monitor_process = None
self._execution_thread = None
self._execution_event = threading.Event()
self._execution_queue = None
@property
def config_file(self):
return self._config_file
@property
def cluster_config(self):
return self._cluster_config
@property
def cluster_dir(self):
return self._tempdir
@property
def gcs_port(self):
return self._cluster_config.get("provider", {}).get(
"host_gcs_port", FAKE_DOCKER_DEFAULT_GCS_PORT
)
@property
def client_port(self):
return self._cluster_config.get("provider", {}).get(
"host_client_port", FAKE_DOCKER_DEFAULT_CLIENT_PORT
)
def connect(self, client: bool = True, timeout: int = 120, **init_kwargs):
"""Connect to the docker-compose Ray cluster.
Assumes the cluster is at RAY_TESTHOST (defaults to
``127.0.0.1``).
Args:
client: If True, uses Ray client to connect to the
cluster. If False, uses GCS to connect to the cluster.
timeout: Connection timeout in seconds.
**init_kwargs: kwargs to pass to ``ray.init()``.
"""
host = os.environ.get("RAY_TESTHOST", "127.0.0.1")
if client:
port = self.client_port
address = f"ray://{build_address(host, port)}"
else:
port = self.gcs_port
address = build_address(host, port)
timeout_at = time.monotonic() + timeout
while time.monotonic() < timeout_at:
try:
ray.init(address, **init_kwargs)
self.wait_for_resources({"CPU": 1})
except ResourcesNotReadyError:
time.sleep(1)
continue
else:
break
try:
ray.cluster_resources()
except Exception as e:
raise RuntimeError(f"Timed out connecting to Ray: {e}")
def remote_execution_api(self) -> "RemoteAPI":
"""Create an object to control cluster state from within the cluster."""
self._execution_queue = Queue(actor_options={"num_cpus": 0})
stop_event = self._execution_event
def entrypoint():
while not stop_event.is_set():
try:
cmd, kwargs = self._execution_queue.get(timeout=1)
except Empty:
continue
if cmd == "kill_node":
self.kill_node(**kwargs)
self._execution_thread = threading.Thread(target=entrypoint)
self._execution_thread.start()
return RemoteAPI(self._execution_queue)
@staticmethod
def wait_for_resources(resources: Dict[str, float], timeout: int = 60):
"""Wait until Ray cluster resources are available
Args:
resources: Minimum resources needed before
this function returns.
timeout: Timeout in seconds.
"""
timeout = time.monotonic() + timeout
available = ray.cluster_resources()
while any(available.get(k, 0.0) < v for k, v in resources.items()):
if time.monotonic() > timeout:
raise ResourcesNotReadyError(
f"Timed out waiting for resources: {resources}"
)
time.sleep(1)
available = ray.cluster_resources()
def update_config(self, config: Optional[Dict[str, Any]] = None):
"""Update autoscaling config.
Does a deep update of the base config with a new configuration.
This can change autoscaling behavior.
Args:
config: Partial config to update current
config with.
"""
assert self._tempdir, "Call setup() first"
config = config or {}
if config:
self._partial_config = config
if not config.get("provider", {}).get("image"):
# No image specified, trying to parse from buildkite
docker_image = os.environ.get("RAY_DOCKER_IMAGE", None)
if not docker_image:
# If still no docker image, use one according to Python version
mj = sys.version_info.major
mi = sys.version_info.minor
docker_image = DEFAULT_DOCKER_IMAGE.format(major=mj, minor=mi)
self._docker_image = docker_image
with open(self._base_config_file, "rt") as f:
cluster_config = yaml.safe_load(f)
if self._partial_config:
deep_update(cluster_config, self._partial_config, new_keys_allowed=True)
if self._docker_image:
cluster_config["provider"]["image"] = self._docker_image
cluster_config["provider"]["shared_volume_dir"] = self._tempdir
self._cluster_config = cluster_config
with open(self._config_file, "wt") as f:
yaml.safe_dump(self._cluster_config, f)
logging.info(f"Updated cluster config to: {self._cluster_config}")
def maybe_pull_image(self):
if self._docker_image:
try:
images_str = subprocess.check_output(
f"docker image inspect {self._docker_image}", shell=True
)
images = json.loads(images_str)
except Exception as e:
logger.error(f"Error inspecting image {self._docker_image}: {e}")
return
if not images:
try:
subprocess.check_call(
f"docker pull {self._docker_image}", shell=True
)
except Exception as e:
logger.error(f"Error pulling image {self._docker_image}: {e}")
def setup(self):
"""Setup docker compose cluster environment.
Creates the temporary directory, writes the initial config file,
and pulls the docker image, if required.
"""
self._tempdir = tempfile.mkdtemp(dir=os.environ.get("RAY_TEMPDIR", None))
os.chmod(self._tempdir, 0o777)
self._config_file = os.path.join(self._tempdir, "cluster.yaml")
self._nodes_file = os.path.join(self._tempdir, "nodes.json")
self._status_file = os.path.join(self._tempdir, "status.json")
self.update_config()
self.maybe_pull_image()
def teardown(self, keep_dir: bool = False):
"""Tear down docker compose cluster environment.
Args:
keep_dir: If True, cluster directory
will not be removed after termination.
"""
if not keep_dir:
shutil.rmtree(self._tempdir)
self._tempdir = None
self._config_file = None
def _start_monitor(self):
self._monitor_process = subprocess.Popen(
[sys.executable, self._monitor_script, self.config_file]
)
time.sleep(2)
def _stop_monitor(self):
if self._monitor_process:
self._monitor_process.wait(timeout=30)
if self._monitor_process.poll() is None:
self._monitor_process.terminate()
self._monitor_process = None
def start(self):
"""Start docker compose cluster.
Starts the monitor process and runs ``ray up``.
"""
self._start_monitor()
subprocess.check_call(
f"RAY_FAKE_CLUSTER=1 ray up -y {self.config_file}", shell=True
)
def stop(self):
"""Stop docker compose cluster.
Runs ``ray down`` and stops the monitor process.
"""
if ray.is_initialized:
ray.shutdown()
subprocess.check_call(
f"RAY_FAKE_CLUSTER=1 ray down -y {self.config_file}", shell=True
)
self._stop_monitor()
self._execution_event.set()
def _update_nodes(self):
with open(self._nodes_file, "rt") as f:
self._nodes = json.load(f)
def _update_status(self):
with open(self._status_file, "rt") as f:
self._status = json.load(f)
def _get_node(
self,
node_id: Optional[str] = None,
num: Optional[int] = None,
rand: Optional[str] = None,
) -> str:
self._update_nodes()
if node_id:
assert (
not num and not rand
), "Only provide either `node_id`, `num`, or `random`."
elif num:
assert (
not node_id and not rand
), "Only provide either `node_id`, `num`, or `random`."
base = "fffffffffffffffffffffffffffffffffffffffffffffffffff"
node_id = base + str(num).zfill(5)
elif rand:
assert (
not node_id and not num
), "Only provide either `node_id`, `num`, or `random`."
assert rand in [
"worker",
"any",
], "`random` must be one of ['worker', 'any']"
choices = list(self._nodes.keys())
if rand == "worker":
choices.remove(
"fffffffffffffffffffffffffffffffffffffffffffffffffff00000"
)
# Else: any
node_id = random.choice(choices)
assert node_id in self._nodes, f"Node with ID {node_id} is not in active nodes."
return node_id
def _get_docker_container(self, node_id: str) -> Optional[str]:
self._update_status()
node_status = self._status.get(node_id)
if not node_status:
return None
return node_status["Name"]
def kill_node(
self,
node_id: Optional[str] = None,
num: Optional[int] = None,
rand: Optional[str] = None,
):
"""Kill node.
If ``node_id`` is given, kill that node.
If ``num`` is given, construct node_id from this number, and kill
that node.
If ``rand`` is given (as either ``worker`` or ``any``), kill a random
node.
"""
node_id = self._get_node(node_id=node_id, num=num, rand=rand)
container = self._get_docker_container(node_id=node_id)
subprocess.check_call(f"docker kill {container}", shell=True)
class RemoteAPI:
"""Remote API to control cluster state from within cluster tasks.
This API uses a Ray queue to interact with an execution thread on the
host machine that will execute commands passed to the queue.
Instances of this class can be serialized and passed to Ray remote actors
to interact with cluster state (but they can also be used outside actors).
The API subset is limited to specific commands.
Args:
queue: Ray queue to push command instructions to.
"""
def __init__(self, queue: Queue):
self._queue = queue
def kill_node(
self,
node_id: Optional[str] = None,
num: Optional[int] = None,
rand: Optional[str] = None,
):
self._queue.put(("kill_node", dict(node_id=node_id, num=num, rand=rand)))