2094 lines
84 KiB
Python
2094 lines
84 KiB
Python
import atexit
|
|
import collections
|
|
import datetime
|
|
import errno
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from typing import IO, TYPE_CHECKING, AnyStr, Optional, Tuple
|
|
|
|
import ray
|
|
import ray._private.ray_constants as ray_constants
|
|
import ray._private.services
|
|
from ray._common.network_utils import (
|
|
build_address,
|
|
get_localhost_ip,
|
|
is_ipv6,
|
|
parse_address,
|
|
)
|
|
from ray._common.ray_constants import LOGGING_ROTATE_BACKUP_COUNT, LOGGING_ROTATE_BYTES
|
|
from ray._common.utils import try_to_create_directory
|
|
from ray._private.resource_and_label_spec import ResourceAndLabelSpec
|
|
from ray._private.resource_isolation_config import ResourceIsolationConfig
|
|
from ray._private.services import get_address, serialize_config
|
|
from ray._private.utils import (
|
|
get_all_node_info_until_retrieved,
|
|
is_in_test,
|
|
open_log,
|
|
try_to_symlink,
|
|
validate_socket_filepath,
|
|
)
|
|
from ray._raylet import (
|
|
GCS_SERVER_PORT_NAME,
|
|
GcsClient,
|
|
get_port_filename,
|
|
get_session_key_from_storage,
|
|
wait_for_persisted_port,
|
|
)
|
|
from ray.core.generated.gcs_pb2 import GcsNodeInfo
|
|
from ray.core.generated.gcs_service_pb2 import GetAllNodeInfoRequest
|
|
|
|
import psutil
|
|
|
|
if TYPE_CHECKING:
|
|
from ray._private.parameter import RayParams
|
|
|
|
# Logger for this module. It should be configured at the entry point
|
|
# into the program using Ray. Ray configures it by default automatically
|
|
# using logging.basicConfig in its entry/init points.
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Node:
|
|
"""An encapsulation of the Ray processes on a single node.
|
|
|
|
This class is responsible for starting Ray processes and killing them,
|
|
and it also controls the temp file policy.
|
|
|
|
Attributes:
|
|
all_processes: A mapping from process type (str) to a list of
|
|
ProcessInfo objects. All lists have length one except for the Redis
|
|
server list, which has multiple.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
ray_params: "RayParams",
|
|
head: bool = False,
|
|
shutdown_at_exit: bool = True,
|
|
spawn_reaper: bool = True,
|
|
connect_only: bool = False,
|
|
default_worker: bool = False,
|
|
ray_init_cluster: bool = False,
|
|
):
|
|
"""Start a node.
|
|
|
|
Args:
|
|
ray_params: The RayParams to use to configure the node.
|
|
head: True if this is the head node, which means it will
|
|
start additional processes like the Redis servers, monitor
|
|
processes, and web UI.
|
|
shutdown_at_exit: If true, spawned processes will be cleaned
|
|
up if this process exits normally.
|
|
spawn_reaper: If true, spawns a process that will clean up
|
|
other spawned processes if this process dies unexpectedly.
|
|
connect_only: If true, connect to the node without starting
|
|
new processes.
|
|
default_worker: Whether it's running from a ray worker or not
|
|
ray_init_cluster: Whether it's a cluster created by ray.init()
|
|
"""
|
|
if shutdown_at_exit:
|
|
if connect_only:
|
|
raise ValueError(
|
|
"'shutdown_at_exit' and 'connect_only' cannot both be true."
|
|
)
|
|
self._register_shutdown_hooks()
|
|
self._default_worker = default_worker
|
|
self.head = head
|
|
self.kernel_fate_share = bool(
|
|
spawn_reaper and ray._private.utils.detect_fate_sharing_support()
|
|
)
|
|
self.resource_isolation_config: ResourceIsolationConfig = (
|
|
ray_params.resource_isolation_config
|
|
)
|
|
self.all_processes: dict = {}
|
|
self.removal_lock = threading.Lock()
|
|
|
|
self.ray_init_cluster = ray_init_cluster
|
|
if ray_init_cluster:
|
|
assert head, "ray.init() created cluster only has the head node"
|
|
|
|
# Set up external Redis when `RAY_REDIS_ADDRESS` is specified.
|
|
redis_address_env = os.environ.get("RAY_REDIS_ADDRESS")
|
|
if ray_params.external_addresses is None and redis_address_env is not None:
|
|
external_redis = redis_address_env.split(",")
|
|
|
|
# Reuse primary Redis as Redis shard when there's only one
|
|
# instance provided.
|
|
if len(external_redis) == 1:
|
|
external_redis.append(external_redis[0])
|
|
ray_params.external_addresses = external_redis
|
|
ray_params.num_redis_shards = len(external_redis) - 1
|
|
|
|
if (
|
|
ray_params._system_config
|
|
and len(ray_params._system_config) > 0
|
|
and (not head and not connect_only)
|
|
):
|
|
raise ValueError(
|
|
"System config parameters can only be set on the head node."
|
|
)
|
|
|
|
ray_params.update_if_absent(
|
|
include_log_monitor=True,
|
|
resources={},
|
|
worker_path=os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
"workers",
|
|
"default_worker.py",
|
|
),
|
|
setup_worker_path=os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
"workers",
|
|
ray_constants.SETUP_WORKER_FILENAME,
|
|
),
|
|
)
|
|
|
|
self._resource_and_label_spec = None
|
|
self._localhost = get_localhost_ip()
|
|
self._ray_params = ray_params
|
|
self._config = ray_params._system_config or {}
|
|
|
|
# Configure log rotation parameters.
|
|
self.max_bytes = int(os.getenv("RAY_ROTATION_MAX_BYTES", LOGGING_ROTATE_BYTES))
|
|
self.backup_count = int(
|
|
os.getenv("RAY_ROTATION_BACKUP_COUNT", LOGGING_ROTATE_BACKUP_COUNT)
|
|
)
|
|
|
|
assert self.max_bytes >= 0
|
|
assert self.backup_count >= 0
|
|
|
|
self._redis_address = ray_params.redis_address
|
|
if head:
|
|
ray_params.update_if_absent(num_redis_shards=1)
|
|
self._gcs_address = ray_params.gcs_address
|
|
self._gcs_client = None
|
|
|
|
if not self.head:
|
|
self.validate_ip_port(self.address)
|
|
self._init_gcs_client()
|
|
|
|
# Register the temp dir.
|
|
self._session_name = ray_params.session_name
|
|
if self._session_name is None:
|
|
if head:
|
|
# We expect this the first time we initialize a cluster, but not during
|
|
# subsequent restarts of the head node.
|
|
maybe_key = self.check_persisted_session_name()
|
|
if maybe_key is None:
|
|
# date including microsecond
|
|
date_str = datetime.datetime.today().strftime(
|
|
"%Y-%m-%d_%H-%M-%S_%f"
|
|
)
|
|
self._session_name = f"session_{date_str}_{os.getpid()}"
|
|
else:
|
|
self._session_name = ray._common.utils.decode(maybe_key)
|
|
else:
|
|
assert not self._default_worker
|
|
session_name = ray._private.utils.internal_kv_get_with_retry(
|
|
self.get_gcs_client(),
|
|
"session_name",
|
|
ray_constants.KV_NAMESPACE_SESSION,
|
|
num_retries=ray_constants.NUM_REDIS_GET_RETRIES,
|
|
)
|
|
self._session_name = ray._common.utils.decode(session_name)
|
|
|
|
# Initialize webui url
|
|
if head:
|
|
self._webui_url = None
|
|
else:
|
|
if ray_params.webui is None:
|
|
assert not self._default_worker
|
|
self._webui_url = ray._private.services.get_webui_url_from_internal_kv()
|
|
else:
|
|
self._webui_url = build_address(
|
|
ray_params.dashboard_host, ray_params.dashboard_port
|
|
)
|
|
|
|
# Resolve node to connect to
|
|
node_to_connect_info = None
|
|
if connect_only and not self._default_worker:
|
|
node_to_connect_info = ray._private.services.get_node_to_connect_for_driver(
|
|
self.get_gcs_client(),
|
|
node_ip_address=ray_params.node_ip_address,
|
|
node_name=ray_params.node_name,
|
|
temp_dir=ray_params.temp_dir,
|
|
)
|
|
|
|
# Resolve node ID
|
|
if connect_only:
|
|
self._node_id = ray_params.node_id
|
|
if self._node_id is None:
|
|
self._node_id = node_to_connect_info.node_id.hex()
|
|
else:
|
|
if (
|
|
self._ray_params.env_vars is not None
|
|
and "RAY_OVERRIDE_NODE_ID_FOR_TESTING" in self._ray_params.env_vars
|
|
):
|
|
node_id = self._ray_params.env_vars["RAY_OVERRIDE_NODE_ID_FOR_TESTING"]
|
|
logger.debug(
|
|
f"Setting node ID to {node_id} "
|
|
"based on ray_params.env_vars override"
|
|
)
|
|
self._node_id = node_id
|
|
elif os.environ.get("RAY_OVERRIDE_NODE_ID_FOR_TESTING"):
|
|
node_id = os.environ["RAY_OVERRIDE_NODE_ID_FOR_TESTING"]
|
|
logger.debug(f"Setting node ID to {node_id} based on env override")
|
|
self._node_id = node_id
|
|
else:
|
|
node_id = ray.NodeID.from_random().hex()
|
|
logger.debug(f"Setting node ID to {node_id}")
|
|
self._node_id = node_id
|
|
|
|
# Resolve node ip address
|
|
node_ip_address = ray_params.node_ip_address
|
|
if node_ip_address is None:
|
|
if connect_only:
|
|
assert node_to_connect_info is not None
|
|
node_ip_address = node_to_connect_info.node_manager_address
|
|
else:
|
|
node_ip_address = ray.util.get_node_ip_address()
|
|
assert node_ip_address is not None
|
|
ray_params.update_if_absent(node_ip_address=node_ip_address)
|
|
self._node_ip_address = node_ip_address
|
|
|
|
# Resolve head node directories for defaults
|
|
if not self.head and not connect_only:
|
|
try:
|
|
head_node_selector = GetAllNodeInfoRequest.NodeSelector()
|
|
head_node_selector.is_head_node = True
|
|
|
|
node_infos = get_all_node_info_until_retrieved(
|
|
self.get_gcs_client(),
|
|
timeout_per_retry=ray_constants.GCS_SERVER_REQUEST_TIMEOUT_SECONDS,
|
|
node_selectors=[head_node_selector],
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Failed to get head node info: {repr(e)}")
|
|
raise e
|
|
|
|
node_info = None
|
|
for info in node_infos:
|
|
node_info = info
|
|
if info.state == GcsNodeInfo.GcsNodeState.ALIVE:
|
|
break
|
|
self._head_temp_dir = getattr(node_info, "temp_dir", None)
|
|
if self._head_temp_dir is None:
|
|
raise Exception(
|
|
"Head node temp_dir not found in NodeInfo, "
|
|
"either GCS or head node's raylet may not have started successfully."
|
|
)
|
|
self._head_session_dir = getattr(node_info, "session_dir", None)
|
|
if self._head_session_dir is None:
|
|
raise Exception(
|
|
"Head node session dir not found in NodeInfo, "
|
|
"either GCS or head node's raylet may not have started successfully."
|
|
)
|
|
|
|
# It creates a session_dir.
|
|
self._init_temp(node_to_connect_info)
|
|
|
|
# Resolve socket and port names
|
|
if connect_only:
|
|
# Get socket names from the configuration.
|
|
self._plasma_store_socket_name = ray_params.plasma_store_socket_name
|
|
self._raylet_socket_name = ray_params.raylet_socket_name
|
|
|
|
# If user does not provide the socket name, get it from GCS.
|
|
if (
|
|
self._plasma_store_socket_name is None
|
|
or self._raylet_socket_name is None
|
|
or self._ray_params.node_manager_port is None
|
|
):
|
|
# Get the address info of the processes to connect to
|
|
# from Redis or GCS.
|
|
assert node_to_connect_info is not None
|
|
self._plasma_store_socket_name = (
|
|
node_to_connect_info.object_store_socket_name
|
|
)
|
|
self._raylet_socket_name = node_to_connect_info.raylet_socket_name
|
|
self._ray_params.node_manager_port = (
|
|
node_to_connect_info.node_manager_port
|
|
)
|
|
else:
|
|
# If the user specified a socket name, use it.
|
|
self._plasma_store_socket_name = self._prepare_socket_file(
|
|
self._ray_params.plasma_store_socket_name, default_prefix="plasma_store"
|
|
)
|
|
self._raylet_socket_name = self._prepare_socket_file(
|
|
self._ray_params.raylet_socket_name, default_prefix="raylet"
|
|
)
|
|
|
|
self._object_spilling_config = self._get_object_spilling_config()
|
|
logger.debug(
|
|
f"Starting node with object spilling config: {self._object_spilling_config}"
|
|
)
|
|
|
|
# Obtain the fallback directoy from the object spilling config
|
|
# Currently, we set the fallback directory to be the same as the object spilling
|
|
# path when the object spills to file system
|
|
self._fallback_directory = None
|
|
if self._object_spilling_config:
|
|
config = json.loads(self._object_spilling_config)
|
|
if config.get("type") == "filesystem":
|
|
directory_path = config.get("params", {}).get("directory_path")
|
|
if isinstance(directory_path, list):
|
|
self._fallback_directory = directory_path[0]
|
|
elif isinstance(directory_path, str):
|
|
self._fallback_directory = directory_path
|
|
if self._fallback_directory is None:
|
|
raise ValueError(
|
|
f"Failed to obtain fallback directory from "
|
|
f"object spilling config: {self._object_spilling_config}"
|
|
)
|
|
|
|
# If it is a head node, try validating if external storage is configurable.
|
|
if head:
|
|
self.validate_external_storage()
|
|
|
|
ray_params.update_if_absent(
|
|
metrics_agent_port=ray_params.metrics_agent_port or 0,
|
|
metrics_export_port=ray_params.metrics_export_port or 0,
|
|
dashboard_agent_listen_port=ray_params.dashboard_agent_listen_port or 0,
|
|
runtime_env_agent_port=ray_params.runtime_env_agent_port or 0,
|
|
)
|
|
|
|
# Pick a GCS server port.
|
|
if head:
|
|
# For GCS fault tolerance: if the port file already exists in the
|
|
# current session directory, this indicates a GCS restart scenario.
|
|
# We reuse the existing port so that other components can reconnect
|
|
# to GCS after it restarts.
|
|
gcs_port_filename = get_port_filename(self._node_id, GCS_SERVER_PORT_NAME)
|
|
gcs_port_file = os.path.join(self._session_dir, gcs_port_filename)
|
|
if os.path.exists(gcs_port_file):
|
|
gcs_port = wait_for_persisted_port(
|
|
self._session_dir,
|
|
self._node_id,
|
|
GCS_SERVER_PORT_NAME,
|
|
timeout_ms=0,
|
|
)
|
|
ray_params.update_if_absent(gcs_server_port=gcs_port)
|
|
|
|
else:
|
|
gcs_server_port = os.getenv(ray_constants.GCS_PORT_ENVIRONMENT_VARIABLE)
|
|
ray_params.update_if_absent(
|
|
gcs_server_port=int(gcs_server_port) if gcs_server_port else 0
|
|
)
|
|
|
|
# For worker nodes, check version compatibility before spawning
|
|
# any processes (including the reaper). If the check fails (e.g.
|
|
# Ray / Python version mismatch) we raise immediately rather than
|
|
# leave orphaned child processes behind.
|
|
if not head and not connect_only:
|
|
self.check_version_info()
|
|
|
|
if not connect_only and spawn_reaper and not self.kernel_fate_share:
|
|
self.start_reaper_process()
|
|
if not connect_only:
|
|
self._ray_params.update_pre_selected_port()
|
|
|
|
# Start processes.
|
|
if head:
|
|
self.start_head_processes()
|
|
|
|
node_info = None
|
|
if not connect_only:
|
|
self.start_ray_processes()
|
|
# Wait for the node info to be available in the GCS so that
|
|
# we know it's started up.
|
|
|
|
# Grace period to let the Raylet register with the GCS.
|
|
# We retry in a loop in case it takes longer than expected.
|
|
time.sleep(0.1)
|
|
start_time = time.monotonic()
|
|
raylet_start_wait_time_s = 30
|
|
while True:
|
|
try:
|
|
# Will raise a RuntimeError if the node info is not available.
|
|
node_info = ray._private.services.get_node(
|
|
self.gcs_address,
|
|
self._node_id,
|
|
)
|
|
break
|
|
except RuntimeError as e:
|
|
logger.info(f"Failed to get node info {e}")
|
|
if time.monotonic() - start_time > raylet_start_wait_time_s:
|
|
raise Exception(
|
|
"The current node timed out during startup. This "
|
|
"could happen because some of the raylet failed to "
|
|
"startup or the GCS has become overloaded."
|
|
)
|
|
|
|
if connect_only:
|
|
# Fetch node info to get labels.
|
|
node_info = ray._private.services.get_node(
|
|
self.gcs_address,
|
|
self._node_id,
|
|
)
|
|
# Set node labels from GCS
|
|
self._node_labels = node_info.get("labels", {}) if node_info else {}
|
|
|
|
# port can be 0 or None for two cases:
|
|
# 1. user is starting a new ray cluster and does not specify the port, components self-bind.
|
|
# 2. user is connecting to an existing ray cluster, no port info is provided.
|
|
# We always update port info from GCS to ensure consistency.
|
|
self._ray_params.node_manager_port = node_info["node_manager_port"]
|
|
self._ray_params.runtime_env_agent_port = node_info["runtime_env_agent_port"]
|
|
self._ray_params.metrics_agent_port = node_info["metrics_agent_port"]
|
|
self._ray_params.metrics_export_port = node_info["metrics_export_port"]
|
|
self._ray_params.dashboard_agent_listen_port = node_info[
|
|
"dashboard_agent_listen_port"
|
|
]
|
|
|
|
# Makes sure the Node object has valid addresses after setup.
|
|
self.validate_ip_port(self.address)
|
|
self.validate_ip_port(self.gcs_address)
|
|
|
|
if not connect_only:
|
|
self._record_stats()
|
|
|
|
def _resolve_ray_config(self, name, default):
|
|
"""Resolve a RAY_CONFIG value the same way the C++ GCS does.
|
|
|
|
``RayConfig::initialize`` (src/ray/common/ray_config.cc) first
|
|
reads the ``RAY_<name>`` env var, then overrides it from the
|
|
``--config_list`` JSON, which Python passes as ``_system_config``.
|
|
So ``_system_config`` wins over the env var, which wins over the
|
|
default. Checking only ``os.environ`` here would miss a backend
|
|
selected purely via ``_system_config`` and desync the Python-side
|
|
rocksdb startup logic (marker file, GCS port wait) from the
|
|
backend the GCS process actually picks.
|
|
"""
|
|
return self._config.get(name, os.environ.get("RAY_" + name, default))
|
|
|
|
def _is_rocksdb_gcs(self):
|
|
return self._resolve_ray_config("gcs_storage", "memory") == "rocksdb"
|
|
|
|
def _check_persisted_rocksdb_session_name(self):
|
|
# Read the session_name marker file written by the previous head
|
|
# process. GCS isn't up yet at this point in startup, so we can't
|
|
# query the rocksdb-backed internal_kv directly; the marker file
|
|
# bridges that gap. (This is a plain file on the GCS storage
|
|
# volume, not a Kubernetes sidecar container.) See
|
|
# src/ray/gcs/store_client/rocksdb_session_name_recovery.md for
|
|
# the full rationale and write-path invariants.
|
|
rocksdb_storage_path = self._resolve_ray_config("gcs_storage_path", "")
|
|
if not rocksdb_storage_path:
|
|
# Mirrors the C++ RAY_CHECK in
|
|
# gcs_server.cc::GetStorageType(); fail loudly rather
|
|
# than silently skipping recovery, which would
|
|
# generate a fresh session_name and trip the assert
|
|
# at the persisted-value check below.
|
|
raise ValueError(
|
|
"RAY_gcs_storage=rocksdb requires RAY_gcs_storage_path "
|
|
"to be set to a writable directory."
|
|
)
|
|
session_name_file = os.path.join(rocksdb_storage_path, "session_name")
|
|
try:
|
|
with open(session_name_file, "rb") as f:
|
|
persisted = f.read().strip()
|
|
return persisted if persisted else None
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
def check_persisted_session_name(self):
|
|
# For the rocksdb GCS backend the session_name lives in a marker
|
|
# file on the storage volume; delegate to the helper. Non-rocksdb
|
|
# deployments fall through to the Redis path below.
|
|
if self._is_rocksdb_gcs():
|
|
return self._check_persisted_rocksdb_session_name()
|
|
|
|
if self._ray_params.external_addresses is None:
|
|
return None
|
|
self._redis_address = self._ray_params.external_addresses[0]
|
|
redis_ip_address, redis_port, enable_redis_ssl = get_address(
|
|
self._redis_address,
|
|
)
|
|
# Address is ip:port or redis://ip:port
|
|
if int(redis_port) < 0:
|
|
raise ValueError(
|
|
f"Invalid Redis port provided: {redis_port}."
|
|
"The port must be a non-negative integer."
|
|
)
|
|
|
|
return get_session_key_from_storage(
|
|
redis_ip_address,
|
|
int(redis_port),
|
|
self._ray_params.redis_username,
|
|
self._ray_params.redis_password,
|
|
enable_redis_ssl,
|
|
serialize_config(self._config),
|
|
b"session_name",
|
|
)
|
|
|
|
def _persist_rocksdb_session_name_file(self):
|
|
"""Durably write session_name to the RocksDB GCS storage directory.
|
|
|
|
Companion to check_persisted_session_name(): on a head restart,
|
|
that method reads this marker file before GCS (and thus
|
|
internal_kv) is back up. The write is atomic and durable
|
|
(tmp + fsync + rename + dir fsync) so the file survives a
|
|
power-loss crash, matching the durability of the internal_kv_put
|
|
that follows it.
|
|
"""
|
|
rocksdb_storage_path = self._resolve_ray_config("gcs_storage_path", "")
|
|
if not rocksdb_storage_path:
|
|
# Symmetric with check_persisted_session_name(); see
|
|
# there for the rationale. Without the marker file, the
|
|
# internal_kv_put would persist a session_name in rocksdb
|
|
# with no companion file, breaking restart recovery.
|
|
raise ValueError(
|
|
"RAY_gcs_storage=rocksdb requires RAY_gcs_storage_path "
|
|
"to be set to a writable directory."
|
|
)
|
|
session_name_file = os.path.join(rocksdb_storage_path, "session_name")
|
|
os.makedirs(rocksdb_storage_path, exist_ok=True)
|
|
tmp_fd, tmp_path = tempfile.mkstemp(
|
|
dir=rocksdb_storage_path,
|
|
prefix="session_name.",
|
|
suffix=".tmp",
|
|
)
|
|
try:
|
|
with os.fdopen(tmp_fd, "wb") as f:
|
|
f.write(self._session_name.encode("utf-8"))
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp_path, session_name_file)
|
|
dir_fd = os.open(rocksdb_storage_path, os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(dir_fd)
|
|
finally:
|
|
os.close(dir_fd)
|
|
except BaseException:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
raise
|
|
|
|
@staticmethod
|
|
def validate_ip_port(ip_port):
|
|
"""Validates the address is in the ip:port format"""
|
|
parts = parse_address(ip_port)
|
|
if parts is None:
|
|
raise ValueError(f"Port is not specified for address {ip_port}")
|
|
try:
|
|
_ = int(parts[1])
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Unable to parse port number from {parts[1]} (full address = {ip_port})"
|
|
)
|
|
|
|
def check_version_info(self):
|
|
"""Check if the Python and Ray version of this process matches that in GCS.
|
|
|
|
This will be used to detect if workers or drivers are started using
|
|
different versions of Python, or Ray.
|
|
|
|
Returns:
|
|
None.
|
|
|
|
Raises:
|
|
Exception: An exception is raised if there is a version mismatch.
|
|
"""
|
|
import ray._common.usage.usage_lib as ray_usage_lib
|
|
|
|
cluster_metadata = ray_usage_lib.get_cluster_metadata(self.get_gcs_client())
|
|
if cluster_metadata is None:
|
|
cluster_metadata = ray_usage_lib.get_cluster_metadata(self.get_gcs_client())
|
|
|
|
if not cluster_metadata:
|
|
return
|
|
node_ip_address = ray._private.services.get_node_ip_address()
|
|
ray._private.utils.check_version_info(
|
|
cluster_metadata, f"node {node_ip_address}"
|
|
)
|
|
|
|
def _register_shutdown_hooks(self):
|
|
# Register the atexit handler. In this case, we shouldn't call sys.exit
|
|
# as we're already in the exit procedure.
|
|
def atexit_handler(*args):
|
|
self.kill_all_processes(check_alive=False, allow_graceful=True)
|
|
|
|
atexit.register(atexit_handler)
|
|
|
|
# Register the handler to be called if we get a SIGTERM.
|
|
# In this case, we want to exit with an error code (1) after
|
|
# cleaning up child processes.
|
|
def sigterm_handler(signum, frame):
|
|
self.kill_all_processes(check_alive=False, allow_graceful=True)
|
|
sys.exit(1)
|
|
|
|
ray._private.utils.set_sigterm_handler(sigterm_handler)
|
|
|
|
def _init_temp(self, node_to_connect_info: Optional[GcsNodeInfo]):
|
|
# Create a dictionary to store temp file index.
|
|
self._incremental_dict = collections.defaultdict(lambda: 0)
|
|
|
|
self.temp_dir = self._ray_params.temp_dir
|
|
if self.temp_dir is None:
|
|
if node_to_connect_info is not None:
|
|
self.temp_dir = node_to_connect_info.temp_dir
|
|
else:
|
|
if self.head:
|
|
self.temp_dir = ray._common.utils.get_default_ray_temp_dir()
|
|
else:
|
|
assert not self._default_worker
|
|
self.temp_dir = self._head_temp_dir
|
|
|
|
assert self._session_name is not None
|
|
self._session_dir = os.path.join(self.temp_dir, self._session_name)
|
|
session_symlink = os.path.join(self.temp_dir, ray_constants.SESSION_LATEST)
|
|
self._sockets_dir = os.path.join(self._session_dir, "sockets")
|
|
self._logs_dir = os.path.join(self._session_dir, "logs")
|
|
old_logs_dir = os.path.join(self._logs_dir, "old")
|
|
# Create a directory to be used for runtime environment.
|
|
self._runtime_env_dir = os.path.join(
|
|
self._session_dir, self._ray_params.runtime_env_dir_name
|
|
)
|
|
|
|
if node_to_connect_info is None:
|
|
# Only create the temp dir on node creation
|
|
try_to_create_directory(self.temp_dir)
|
|
# Send a warning message if the session exists.
|
|
try_to_create_directory(self._session_dir)
|
|
try_to_symlink(session_symlink, self._session_dir)
|
|
# Create a directory to be used for socket files.
|
|
try_to_create_directory(self._sockets_dir)
|
|
# Create a directory to be used for process log files.
|
|
try_to_create_directory(self._logs_dir)
|
|
try_to_create_directory(old_logs_dir)
|
|
try_to_create_directory(self._runtime_env_dir)
|
|
|
|
# Create a symlink to the libtpu tpu_logs directory if it exists.
|
|
if "TPU_LOG_DIR" in os.environ and os.path.isdir(os.environ["TPU_LOG_DIR"]):
|
|
tpu_log_dir = os.environ["TPU_LOG_DIR"]
|
|
else:
|
|
tpu_log_dir = "/tmp/tpu_logs"
|
|
|
|
if os.path.isdir(tpu_log_dir):
|
|
tpu_logs_symlink = os.path.join(self._logs_dir, "tpu_logs")
|
|
try_to_symlink(tpu_logs_symlink, tpu_log_dir)
|
|
|
|
def get_resource_and_label_spec(self):
|
|
"""Resolve and return the current ResourceAndLabelSpec for the node."""
|
|
if not self._resource_and_label_spec:
|
|
self._resource_and_label_spec = ResourceAndLabelSpec(
|
|
self._ray_params.num_cpus,
|
|
self._ray_params.num_gpus,
|
|
self._ray_params.memory,
|
|
self._ray_params.object_store_memory,
|
|
self._ray_params.resources,
|
|
self._ray_params.labels,
|
|
).resolve(
|
|
is_head=self.head,
|
|
node_ip_address=self.node_ip_address,
|
|
resource_isolation_config=self.resource_isolation_config,
|
|
)
|
|
return self._resource_and_label_spec
|
|
|
|
@property
|
|
def node_id(self):
|
|
"""Get the node ID."""
|
|
return self._node_id
|
|
|
|
@property
|
|
def session_name(self):
|
|
"""Get the current Ray session name."""
|
|
return self._session_name
|
|
|
|
@property
|
|
def node_ip_address(self):
|
|
"""Get the IP address of this node."""
|
|
return self._node_ip_address
|
|
|
|
@property
|
|
def address(self):
|
|
"""Get the address for bootstrapping, e.g. the address to pass to
|
|
`ray start` or `ray.init()` to start worker nodes, that has been
|
|
converted to ip:port format.
|
|
"""
|
|
return self._gcs_address
|
|
|
|
@property
|
|
def gcs_address(self):
|
|
"""Get the gcs address."""
|
|
assert self._gcs_address is not None, "Gcs address is not set"
|
|
return self._gcs_address
|
|
|
|
@property
|
|
def redis_address(self):
|
|
"""Get the cluster Redis address."""
|
|
return self._redis_address
|
|
|
|
@property
|
|
def redis_username(self):
|
|
"""Get the cluster Redis username."""
|
|
return self._ray_params.redis_username
|
|
|
|
@property
|
|
def redis_password(self):
|
|
"""Get the cluster Redis password."""
|
|
return self._ray_params.redis_password
|
|
|
|
@property
|
|
def plasma_store_socket_name(self):
|
|
"""Get the node's plasma store socket name."""
|
|
return self._plasma_store_socket_name
|
|
|
|
@property
|
|
def unique_id(self):
|
|
"""Get a unique identifier for this node."""
|
|
return f"{self.node_ip_address}:{self._plasma_store_socket_name}"
|
|
|
|
@property
|
|
def webui_url(self):
|
|
"""Get the cluster's web UI url."""
|
|
return self._webui_url
|
|
|
|
@property
|
|
def raylet_socket_name(self):
|
|
"""Get the node's raylet socket name."""
|
|
return self._raylet_socket_name
|
|
|
|
@property
|
|
def node_manager_port(self):
|
|
"""Get the node manager's port."""
|
|
return self._ray_params.node_manager_port
|
|
|
|
@property
|
|
def metrics_export_port(self):
|
|
"""Get the port that exposes metrics"""
|
|
return self._ray_params.metrics_export_port
|
|
|
|
@property
|
|
def metrics_agent_port(self):
|
|
"""Get the metrics agent gRPC port"""
|
|
return self._ray_params.metrics_agent_port
|
|
|
|
@property
|
|
def runtime_env_agent_port(self):
|
|
"""Get the port that exposes runtime env agent as http"""
|
|
return self._ray_params.runtime_env_agent_port
|
|
|
|
@property
|
|
def runtime_env_agent_address(self):
|
|
"""Get the address that exposes runtime env agent as http"""
|
|
return f"http://{build_address(self._node_ip_address, self._ray_params.runtime_env_agent_port)}"
|
|
|
|
@property
|
|
def dashboard_agent_listen_port(self):
|
|
"""Get the dashboard agent's listen port"""
|
|
return self._ray_params.dashboard_agent_listen_port
|
|
|
|
@property
|
|
def logging_config(self):
|
|
"""Get the logging config of the current node."""
|
|
return {
|
|
"log_rotation_max_bytes": self.max_bytes,
|
|
"log_rotation_backup_count": self.backup_count,
|
|
}
|
|
|
|
@property
|
|
def address_info(self):
|
|
"""Get a dictionary of addresses."""
|
|
return {
|
|
"node_ip_address": self._node_ip_address,
|
|
"redis_address": self.redis_address,
|
|
"object_store_address": self._plasma_store_socket_name,
|
|
"raylet_socket_name": self._raylet_socket_name,
|
|
"webui_url": self._webui_url,
|
|
"session_dir": self._session_dir,
|
|
"metrics_export_port": self._ray_params.metrics_export_port,
|
|
"gcs_address": self.gcs_address,
|
|
"address": self.address,
|
|
"dashboard_agent_listen_port": self._ray_params.dashboard_agent_listen_port,
|
|
}
|
|
|
|
@property
|
|
def node_labels(self):
|
|
"""Get the node labels."""
|
|
return self._node_labels
|
|
|
|
def is_head(self):
|
|
return self.head
|
|
|
|
def get_gcs_client(self):
|
|
if self._gcs_client is None:
|
|
self._init_gcs_client()
|
|
return self._gcs_client
|
|
|
|
def _init_gcs_client(self):
|
|
if self.head:
|
|
gcs_process = self.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER][
|
|
0
|
|
].process
|
|
else:
|
|
gcs_process = None
|
|
|
|
# TODO(ryw) instead of create a new GcsClient, wrap the one from
|
|
# CoreWorkerProcess to save a grpc channel.
|
|
#
|
|
# RocksDB GCS recovery (open two on-disk DBs, WAL replay, GetAll
|
|
# table scans that grow with cluster state) is slow -- worst on a
|
|
# head restart over existing state -- and can exceed the default
|
|
# ~20s connect window (NUM_REDIS_GET_RETRIES x ~1s sleep below).
|
|
# This affects any node connecting while the head is reopening
|
|
# RocksDB: the head itself (whose fault-tolerance restart reuses a
|
|
# fixed GCS port, skipping start_gcs_server()'s port-file wait that
|
|
# is gated on gcs_server_port == 0), and also workers/drivers
|
|
# joining during that window.
|
|
num_retries = ray_constants.NUM_REDIS_GET_RETRIES
|
|
gcs_wait_override = os.environ.get("RAY_gcs_server_port_wait_time_s")
|
|
if gcs_wait_override is not None:
|
|
# Explicit operator budget: honor it exactly -- may lengthen OR
|
|
# shorten the window -- so this env var behaves identically here
|
|
# and in start_gcs_server()'s port-file wait. Floor at one
|
|
# attempt (~1s per iteration below) so a 0 value still tries
|
|
# once rather than skipping the connect entirely. This is also
|
|
# the opt-in for workers/drivers whose pods lack RAY_gcs_storage
|
|
# / RAY_gcs_storage_path and thus can't auto-detect the backend.
|
|
num_retries = max(1, int(gcs_wait_override))
|
|
elif self._is_rocksdb_gcs():
|
|
# No explicit override, but we can see the backend is RocksDB:
|
|
# extend to the same 120s budget as the port-zero path, without
|
|
# shrinking a larger operator-configured retry count.
|
|
num_retries = max(num_retries, 120)
|
|
for _ in range(num_retries):
|
|
gcs_address = None
|
|
last_ex = None
|
|
try:
|
|
gcs_address = self.gcs_address
|
|
client = GcsClient(
|
|
address=gcs_address,
|
|
cluster_id=self._ray_params.cluster_id, # Hex string
|
|
)
|
|
self.cluster_id = client.cluster_id
|
|
if self.head:
|
|
# Send a simple request to make sure GCS is alive
|
|
# if it's a head node.
|
|
client.internal_kv_get(b"dummy", None)
|
|
self._gcs_client = client
|
|
break
|
|
except Exception:
|
|
if gcs_process is not None and gcs_process.poll() is not None:
|
|
# GCS has exited.
|
|
break
|
|
last_ex = traceback.format_exc()
|
|
logger.debug(f"Connecting to GCS: {last_ex}")
|
|
time.sleep(1)
|
|
|
|
if self._gcs_client is None:
|
|
if hasattr(self, "_logs_dir"):
|
|
with open(os.path.join(self._logs_dir, "gcs_server.err")) as err:
|
|
# Use " C " or " E " to exclude the stacktrace.
|
|
# This should work for most cases, especitally
|
|
# it's when GCS is starting. Only display last 10 lines of logs.
|
|
errors = [e for e in err.readlines() if " C " in e or " E " in e][
|
|
-10:
|
|
]
|
|
error_msg = "\n" + "".join(errors) + "\n"
|
|
raise RuntimeError(
|
|
f"Failed to {'start' if self.head else 'connect to'} GCS. "
|
|
f" Last {len(errors)} lines of error files:"
|
|
f"{error_msg}."
|
|
f"Please check {os.path.join(self._logs_dir, 'gcs_server.out')}"
|
|
f" for details. Last connection error: {last_ex}"
|
|
)
|
|
else:
|
|
raise RuntimeError(
|
|
f"Failed to {'start' if self.head else 'connect to'} GCS. Last "
|
|
f"connection error: {last_ex}"
|
|
)
|
|
|
|
ray.experimental.internal_kv._initialize_internal_kv(self._gcs_client)
|
|
|
|
def get_temp_dir_path(self):
|
|
"""Get the path of the temporary directory."""
|
|
return self.temp_dir
|
|
|
|
def get_runtime_env_dir_path(self):
|
|
"""Get the path of the runtime env."""
|
|
return self._runtime_env_dir
|
|
|
|
def get_session_dir_path(self):
|
|
"""Get the path of the session directory."""
|
|
return self._session_dir
|
|
|
|
def get_logs_dir_path(self):
|
|
"""Get the path of the log files directory."""
|
|
return self._logs_dir
|
|
|
|
def get_sockets_dir_path(self):
|
|
"""Get the path of the sockets directory."""
|
|
return self._sockets_dir
|
|
|
|
def _make_inc_temp(
|
|
self, suffix: str = "", prefix: str = "", directory_name: Optional[str] = None
|
|
):
|
|
"""Return an incremental temporary file name. The file is not created.
|
|
|
|
Args:
|
|
suffix: The suffix of the temp file.
|
|
prefix: The prefix of the temp file.
|
|
directory_name: The base directory of the temp file.
|
|
|
|
Returns:
|
|
A string of file name. If there existing a file having
|
|
the same name, the returned name will look like
|
|
"{directory_name}/{prefix}.{unique_index}{suffix}"
|
|
"""
|
|
if directory_name is None:
|
|
directory_name = self.temp_dir
|
|
directory_name = os.path.expanduser(directory_name)
|
|
index = self._incremental_dict[suffix, prefix, directory_name]
|
|
# `tempfile.TMP_MAX` could be extremely large,
|
|
# so using `range` in Python2.x should be avoided.
|
|
while index < tempfile.TMP_MAX:
|
|
if index == 0:
|
|
filename = os.path.join(directory_name, prefix + suffix)
|
|
else:
|
|
filename = os.path.join(
|
|
directory_name, prefix + "." + str(index) + suffix
|
|
)
|
|
index += 1
|
|
if not os.path.exists(filename):
|
|
# Save the index.
|
|
self._incremental_dict[suffix, prefix, directory_name] = index
|
|
return filename
|
|
|
|
raise FileExistsError(errno.EEXIST, "No usable temporary filename found")
|
|
|
|
def should_redirect_logs(self):
|
|
# Preferred: thread the setting explicitly via RayParams.log_to_stderr.
|
|
# This avoids relying on process-global environment variables.
|
|
if getattr(self._ray_params, "log_to_stderr", None) is not None:
|
|
return not self._ray_params.log_to_stderr
|
|
|
|
# Deprecated (kept for backward compatibility): RayParams.redirect_output.
|
|
redirect_output = self._ray_params.redirect_output
|
|
if redirect_output is not None:
|
|
return redirect_output
|
|
|
|
# Fall back to stderr redirect environment variable.
|
|
return (
|
|
os.environ.get(ray_constants.LOGGING_REDIRECT_STDERR_ENVIRONMENT_VARIABLE)
|
|
!= "1"
|
|
)
|
|
|
|
# TODO(hjiang): Re-implement the logic in C++, and expose via cython.
|
|
def get_log_file_names(
|
|
self,
|
|
name: str,
|
|
unique: bool = False,
|
|
create_out: bool = True,
|
|
create_err: bool = True,
|
|
) -> Tuple[Optional[str], Optional[str]]:
|
|
"""Get filename to dump logs for stdout and stderr, with no files opened.
|
|
If output redirection has been disabled, no files will
|
|
be opened and `(None, None)` will be returned.
|
|
|
|
Args:
|
|
name: descriptive string for this log file.
|
|
unique: if true, a counter will be attached to `name` to
|
|
ensure the returned filename is not already used.
|
|
create_out: if True, create a .out file.
|
|
create_err: if True, create a .err file.
|
|
|
|
Returns:
|
|
A tuple of two file handles for redirecting optional (stdout, stderr),
|
|
or `(None, None)` if output redirection is disabled.
|
|
"""
|
|
if not self.should_redirect_logs():
|
|
return None, None
|
|
|
|
log_stdout = None
|
|
log_stderr = None
|
|
|
|
if create_out:
|
|
log_stdout = self._get_log_file_name(name, "out", unique=unique)
|
|
if create_err:
|
|
log_stderr = self._get_log_file_name(name, "err", unique=unique)
|
|
return log_stdout, log_stderr
|
|
|
|
def get_log_file_handles(
|
|
self,
|
|
name: str,
|
|
unique: bool = False,
|
|
create_out: bool = True,
|
|
create_err: bool = True,
|
|
) -> Tuple[Optional[IO[AnyStr]], Optional[IO[AnyStr]]]:
|
|
"""Open log files with partially randomized filenames, returning the
|
|
file handles. If output redirection has been disabled, no files will
|
|
be opened and `(None, None)` will be returned.
|
|
|
|
Args:
|
|
name: descriptive string for this log file.
|
|
unique: if true, a counter will be attached to `name` to
|
|
ensure the returned filename is not already used.
|
|
create_out: if True, create a .out file.
|
|
create_err: if True, create a .err file.
|
|
|
|
Returns:
|
|
A tuple of two file handles for redirecting optional (stdout, stderr),
|
|
or `(None, None)` if output redirection is disabled.
|
|
"""
|
|
log_stdout_fname, log_stderr_fname = self.get_log_file_names(
|
|
name, unique=unique, create_out=create_out, create_err=create_err
|
|
)
|
|
log_stdout = None if log_stdout_fname is None else open_log(log_stdout_fname)
|
|
log_stderr = None if log_stderr_fname is None else open_log(log_stderr_fname)
|
|
return log_stdout, log_stderr
|
|
|
|
def _get_log_file_name(
|
|
self,
|
|
name: str,
|
|
suffix: str,
|
|
unique: bool = False,
|
|
) -> str:
|
|
"""Generate partially randomized filenames for log files.
|
|
|
|
Args:
|
|
name: descriptive string for this log file.
|
|
suffix: suffix of the file. Usually it is .out of .err.
|
|
unique: if true, a counter will be attached to `name` to
|
|
ensure the returned filename is not already used.
|
|
|
|
Returns:
|
|
A tuple of two file names for redirecting (stdout, stderr).
|
|
"""
|
|
# strip if the suffix is something like .out.
|
|
suffix = suffix.strip(".")
|
|
|
|
if unique:
|
|
filename = self._make_inc_temp(
|
|
suffix=f".{suffix}", prefix=name, directory_name=self._logs_dir
|
|
)
|
|
else:
|
|
filename = os.path.join(self._logs_dir, f"{name}.{suffix}")
|
|
return filename
|
|
|
|
def _get_unused_port(self, allocated_ports=None):
|
|
if allocated_ports is None:
|
|
allocated_ports = set()
|
|
|
|
s = socket.socket(
|
|
socket.AF_INET6 if is_ipv6(self._node_ip_address) else socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
)
|
|
s.bind(("", 0))
|
|
port = s.getsockname()[1]
|
|
|
|
# Try to generate a port that is far above the 'next available' one.
|
|
# This solves issue #8254 where GRPC fails because the port assigned
|
|
# from this method has been used by a different process.
|
|
for _ in range(ray_constants.NUM_PORT_RETRIES):
|
|
new_port = random.randint(port, 65535)
|
|
if new_port in allocated_ports:
|
|
# This port is allocated for other usage already,
|
|
# so we shouldn't use it even if it's not in use right now.
|
|
continue
|
|
new_s = socket.socket(
|
|
socket.AF_INET6 if is_ipv6(self._node_ip_address) else socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
)
|
|
try:
|
|
new_s.bind(("", new_port))
|
|
except OSError:
|
|
new_s.close()
|
|
continue
|
|
s.close()
|
|
new_s.close()
|
|
return new_port
|
|
logger.error("Unable to succeed in selecting a random port.")
|
|
s.close()
|
|
return port
|
|
|
|
def _prepare_socket_file(self, socket_path: str, default_prefix: str):
|
|
"""Prepare the socket file for raylet and plasma.
|
|
|
|
This method helps to prepare a socket file.
|
|
1. Make the directory if the directory does not exist.
|
|
2. If the socket file exists, do nothing (this just means we aren't the
|
|
first worker on the node).
|
|
|
|
Args:
|
|
socket_path: the socket file to prepare.
|
|
default_prefix: the filename prefix to use when ``socket_path`` is
|
|
``None`` and a new socket path needs to be generated.
|
|
|
|
Returns:
|
|
The resolved socket path string.
|
|
"""
|
|
result = socket_path
|
|
if sys.platform == "win32":
|
|
if socket_path is None:
|
|
result = (
|
|
f"tcp://{build_address(self._localhost, self._get_unused_port())}"
|
|
)
|
|
else:
|
|
if socket_path is None:
|
|
result = self._make_inc_temp(
|
|
prefix=default_prefix, directory_name=self._sockets_dir
|
|
)
|
|
else:
|
|
try_to_create_directory(os.path.dirname(socket_path))
|
|
|
|
validate_socket_filepath(result.split("://", 1)[-1])
|
|
return result
|
|
|
|
def start_reaper_process(self):
|
|
"""
|
|
Start the reaper process.
|
|
|
|
This must be the first process spawned and should only be called when
|
|
ray processes should be cleaned up if this process dies.
|
|
"""
|
|
assert (
|
|
not self.kernel_fate_share
|
|
), "a reaper should not be used with kernel fate-sharing"
|
|
process_info = ray._private.services.start_reaper(fate_share=False)
|
|
assert ray_constants.PROCESS_TYPE_REAPER not in self.all_processes
|
|
if process_info is not None:
|
|
self.all_processes[ray_constants.PROCESS_TYPE_REAPER] = [
|
|
process_info,
|
|
]
|
|
|
|
def start_log_monitor(self):
|
|
"""Start the log monitor."""
|
|
stdout_log_fname, stderr_log_fname = self.get_log_file_names(
|
|
"log_monitor", unique=True, create_out=True, create_err=True
|
|
)
|
|
process_info = ray._private.services.start_log_monitor(
|
|
self.get_session_dir_path(),
|
|
self._logs_dir,
|
|
self.gcs_address,
|
|
self._node_ip_address,
|
|
fate_share=self.kernel_fate_share,
|
|
max_bytes=self.max_bytes,
|
|
backup_count=self.backup_count,
|
|
stdout_filepath=stdout_log_fname,
|
|
stderr_filepath=stderr_log_fname,
|
|
)
|
|
assert ray_constants.PROCESS_TYPE_LOG_MONITOR not in self.all_processes
|
|
self.all_processes[ray_constants.PROCESS_TYPE_LOG_MONITOR] = [
|
|
process_info,
|
|
]
|
|
|
|
def start_api_server(
|
|
self, *, include_dashboard: Optional[bool], raise_on_failure: bool
|
|
):
|
|
"""Start the dashboard.
|
|
|
|
Args:
|
|
include_dashboard: If true, this will load all dashboard-related modules
|
|
when starting the API server. Otherwise, it will only
|
|
start the modules that are not relevant to the dashboard.
|
|
raise_on_failure: If true, this will raise an exception
|
|
if we fail to start the API server. Otherwise it will print
|
|
a warning if we fail to start the API server.
|
|
"""
|
|
stdout_log_fname, stderr_log_fname = self.get_log_file_names(
|
|
"dashboard", unique=True, create_out=True, create_err=True
|
|
)
|
|
self._webui_url, process_info = ray._private.services.start_api_server(
|
|
include_dashboard,
|
|
raise_on_failure,
|
|
self._ray_params.dashboard_host,
|
|
self.gcs_address,
|
|
self.cluster_id.hex(),
|
|
self._node_ip_address,
|
|
self.temp_dir,
|
|
self._logs_dir,
|
|
self._session_dir,
|
|
port=self._ray_params.dashboard_port,
|
|
fate_share=self.kernel_fate_share,
|
|
max_bytes=self.max_bytes,
|
|
backup_count=self.backup_count,
|
|
stdout_filepath=stdout_log_fname,
|
|
stderr_filepath=stderr_log_fname,
|
|
proxy_server_url=self._ray_params.proxy_server_url,
|
|
)
|
|
assert ray_constants.PROCESS_TYPE_DASHBOARD not in self.all_processes
|
|
if process_info is not None:
|
|
self.all_processes[ray_constants.PROCESS_TYPE_DASHBOARD] = [
|
|
process_info,
|
|
]
|
|
self.get_gcs_client().internal_kv_put(
|
|
b"webui:url",
|
|
self._webui_url.encode(),
|
|
True,
|
|
ray_constants.KV_NAMESPACE_DASHBOARD,
|
|
)
|
|
|
|
def start_gcs_server(self):
|
|
"""Start the gcs server."""
|
|
assert self._ray_params.gcs_server_port >= 0
|
|
assert self._gcs_address is None, "GCS server is already running."
|
|
assert self._gcs_client is None, "GCS client is already connected."
|
|
|
|
stdout_log_fname, stderr_log_fname = self.get_log_file_names(
|
|
"gcs_server", unique=True, create_out=True, create_err=True
|
|
)
|
|
process_info = ray._private.services.start_gcs_server(
|
|
self.redis_address,
|
|
log_dir=self._logs_dir,
|
|
stdout_filepath=stdout_log_fname,
|
|
stderr_filepath=stderr_log_fname,
|
|
session_name=self.session_name,
|
|
redis_username=self._ray_params.redis_username,
|
|
redis_password=self._ray_params.redis_password,
|
|
config=self._config,
|
|
fate_share=self.kernel_fate_share,
|
|
gcs_server_port=self._ray_params.gcs_server_port,
|
|
metrics_agent_port=self._ray_params.metrics_agent_port,
|
|
node_ip_address=self._node_ip_address,
|
|
session_dir=self._session_dir,
|
|
node_id=self._node_id,
|
|
)
|
|
assert ray_constants.PROCESS_TYPE_GCS_SERVER not in self.all_processes
|
|
self.all_processes[ray_constants.PROCESS_TYPE_GCS_SERVER] = [
|
|
process_info,
|
|
]
|
|
|
|
if self._ray_params.gcs_server_port == 0:
|
|
# The GCS port file is published only at the very end of
|
|
# startup: GcsServer binds its RPC port after GcsInitData
|
|
# loads the full persisted table set (GetAll over the job,
|
|
# node, actor, actor-task-spec, and placement-group tables)
|
|
# and every manager is constructed. With the RocksDB backend
|
|
# this is meaningfully slower than in-memory -- two on-disk
|
|
# DB instances are opened (column-family discovery, WAL
|
|
# replay, manifest read) and those table scans hit disk, the
|
|
# latter growing with cluster state on restart recovery (the
|
|
# port file lives on ephemeral /tmp, so a restarted head
|
|
# re-enters this path over an existing store). The default
|
|
# 30s wait can elapse before the port is bound, so bump it to
|
|
# 120s when the GCS backend is rocksdb; operator override via
|
|
# RAY_gcs_server_port_wait_time_s.
|
|
default_wait = "120" if self._is_rocksdb_gcs() else "30"
|
|
gcs_port_wait_s = int(
|
|
os.environ.get("RAY_gcs_server_port_wait_time_s", default_wait)
|
|
)
|
|
self._ray_params.gcs_server_port = wait_for_persisted_port(
|
|
self._session_dir,
|
|
self._node_id,
|
|
GCS_SERVER_PORT_NAME,
|
|
timeout_ms=gcs_port_wait_s * 1000,
|
|
)
|
|
|
|
# Connecting via non-localhost address may be blocked by firewall rule,
|
|
# e.g. https://github.com/ray-project/ray/issues/15780
|
|
# TODO(mwtian): figure out a way to use 127.0.0.1 for local connection
|
|
# when possible.
|
|
self._gcs_address = build_address(
|
|
self._node_ip_address, self._ray_params.gcs_server_port
|
|
)
|
|
|
|
def start_raylet(
|
|
self,
|
|
plasma_directory: str,
|
|
fallback_directory: str,
|
|
object_store_memory: int,
|
|
use_valgrind: bool = False,
|
|
use_profiler: bool = False,
|
|
):
|
|
"""Start the raylet.
|
|
|
|
Args:
|
|
plasma_directory: Filesystem directory backing the plasma store's
|
|
shared memory.
|
|
fallback_directory: Directory used by plasma when memory mapped
|
|
files cannot be created in ``plasma_directory``.
|
|
object_store_memory: Maximum number of bytes the object store can
|
|
use.
|
|
use_valgrind: True if we should start the process in
|
|
valgrind.
|
|
use_profiler: True if we should start the process in the
|
|
valgrind profiler.
|
|
"""
|
|
raylet_stdout_filepath, raylet_stderr_filepath = self.get_log_file_names(
|
|
ray_constants.PROCESS_TYPE_RAYLET,
|
|
unique=True,
|
|
create_out=True,
|
|
create_err=True,
|
|
)
|
|
(
|
|
dashboard_agent_stdout_filepath,
|
|
dashboard_agent_stderr_filepath,
|
|
) = self.get_log_file_names(
|
|
ray_constants.PROCESS_TYPE_DASHBOARD_AGENT,
|
|
unique=True,
|
|
create_out=True,
|
|
create_err=True,
|
|
)
|
|
(
|
|
runtime_env_agent_stdout_filepath,
|
|
runtime_env_agent_stderr_filepath,
|
|
) = self.get_log_file_names(
|
|
ray_constants.PROCESS_TYPE_RUNTIME_ENV_AGENT,
|
|
unique=True,
|
|
create_out=True,
|
|
create_err=True,
|
|
)
|
|
|
|
dashboard_agent_log_filepath = None
|
|
if dashboard_agent_stdout_filepath is not None:
|
|
dashboard_agent_log_filepath = self._get_log_file_name(
|
|
ray_constants.PROCESS_TYPE_DASHBOARD_AGENT, "log", unique=True
|
|
)
|
|
|
|
runtime_env_agent_log_filepath = None
|
|
if runtime_env_agent_stdout_filepath is not None:
|
|
runtime_env_agent_log_filepath = self._get_log_file_name(
|
|
ray_constants.PROCESS_TYPE_RUNTIME_ENV_AGENT, "log", unique=True
|
|
)
|
|
|
|
self.resource_isolation_config.add_system_pids(
|
|
self._get_system_processes_for_resource_isolation()
|
|
)
|
|
|
|
process_info = ray._private.services.start_raylet(
|
|
self.redis_address,
|
|
self.gcs_address,
|
|
self._node_id,
|
|
self._node_ip_address,
|
|
self._ray_params.node_manager_port,
|
|
self._raylet_socket_name,
|
|
self._plasma_store_socket_name,
|
|
self.cluster_id.hex(),
|
|
self._ray_params.worker_path,
|
|
self._ray_params.setup_worker_path,
|
|
self.temp_dir,
|
|
self._session_dir,
|
|
self._runtime_env_dir,
|
|
self._logs_dir,
|
|
self.get_resource_and_label_spec(),
|
|
plasma_directory,
|
|
fallback_directory,
|
|
object_store_memory,
|
|
self.session_name,
|
|
is_head_node=self.is_head(),
|
|
min_worker_port=self._ray_params.min_worker_port,
|
|
max_worker_port=self._ray_params.max_worker_port,
|
|
worker_port_list=self._ray_params.worker_port_list,
|
|
object_manager_port=self._ray_params.object_manager_port,
|
|
redis_username=self._ray_params.redis_username,
|
|
redis_password=self._ray_params.redis_password,
|
|
metrics_agent_port=self._ray_params.metrics_agent_port,
|
|
runtime_env_agent_port=self._ray_params.runtime_env_agent_port,
|
|
metrics_export_port=self._ray_params.metrics_export_port,
|
|
dashboard_agent_listen_port=self._ray_params.dashboard_agent_listen_port,
|
|
use_valgrind=use_valgrind,
|
|
use_profiler=use_profiler,
|
|
raylet_stdout_filepath=raylet_stdout_filepath,
|
|
raylet_stderr_filepath=raylet_stderr_filepath,
|
|
dashboard_agent_stdout_filepath=dashboard_agent_stdout_filepath,
|
|
dashboard_agent_stderr_filepath=dashboard_agent_stderr_filepath,
|
|
dashboard_agent_log_filepath=dashboard_agent_log_filepath,
|
|
runtime_env_agent_stdout_filepath=runtime_env_agent_stdout_filepath,
|
|
runtime_env_agent_stderr_filepath=runtime_env_agent_stderr_filepath,
|
|
runtime_env_agent_log_filepath=runtime_env_agent_log_filepath,
|
|
huge_pages=self._ray_params.huge_pages,
|
|
fate_share=self.kernel_fate_share,
|
|
socket_to_use=None,
|
|
max_bytes=self.max_bytes,
|
|
backup_count=self.backup_count,
|
|
ray_debugger_external=self._ray_params.ray_debugger_external,
|
|
env_updates=self._ray_params.env_vars,
|
|
node_name=self._ray_params.node_name,
|
|
webui=self._webui_url,
|
|
resource_isolation_config=self.resource_isolation_config,
|
|
)
|
|
assert ray_constants.PROCESS_TYPE_RAYLET not in self.all_processes
|
|
self.all_processes[ray_constants.PROCESS_TYPE_RAYLET] = [process_info]
|
|
|
|
def start_monitor(self):
|
|
"""Start the monitor.
|
|
|
|
Autoscaling output goes to these monitor.err/out files, and
|
|
any modification to these files may break existing
|
|
cluster launching commands.
|
|
"""
|
|
from ray.autoscaler.v2.utils import is_autoscaler_v2
|
|
|
|
stdout_log_fname, stderr_log_fname = self.get_log_file_names(
|
|
"monitor", unique=True, create_out=True, create_err=True
|
|
)
|
|
process_info = ray._private.services.start_monitor(
|
|
self.gcs_address,
|
|
self._logs_dir,
|
|
stdout_filepath=stdout_log_fname,
|
|
stderr_filepath=stderr_log_fname,
|
|
autoscaling_config=self._ray_params.autoscaling_config,
|
|
fate_share=self.kernel_fate_share,
|
|
max_bytes=self.max_bytes,
|
|
backup_count=self.backup_count,
|
|
monitor_ip=self._node_ip_address,
|
|
autoscaler_v2=is_autoscaler_v2(fetch_from_server=True),
|
|
)
|
|
assert ray_constants.PROCESS_TYPE_MONITOR not in self.all_processes
|
|
self.all_processes[ray_constants.PROCESS_TYPE_MONITOR] = [process_info]
|
|
|
|
def start_ray_client_server(self):
|
|
"""Start the ray client server process."""
|
|
stdout_file, stderr_file = self.get_log_file_handles(
|
|
"ray_client_server", unique=True
|
|
)
|
|
process_info = ray._private.services.start_ray_client_server(
|
|
self.address,
|
|
self._node_ip_address,
|
|
self._ray_params.ray_client_server_port,
|
|
stdout_file=stdout_file,
|
|
stderr_file=stderr_file,
|
|
redis_username=self._ray_params.redis_username,
|
|
redis_password=self._ray_params.redis_password,
|
|
fate_share=self.kernel_fate_share,
|
|
runtime_env_agent_address=self.runtime_env_agent_address,
|
|
node_id=self._node_id,
|
|
)
|
|
assert ray_constants.PROCESS_TYPE_RAY_CLIENT_SERVER not in self.all_processes
|
|
self.all_processes[ray_constants.PROCESS_TYPE_RAY_CLIENT_SERVER] = [
|
|
process_info
|
|
]
|
|
|
|
def _write_cluster_info_to_kv(self):
|
|
"""Write the cluster metadata to GCS.
|
|
Cluster metadata is always recorded, but they are
|
|
not reported unless usage report is enabled.
|
|
Check `usage_stats_head.py` for more details.
|
|
"""
|
|
# Make sure the cluster metadata wasn't reported before.
|
|
import ray._common.usage.usage_lib as ray_usage_lib
|
|
|
|
ray_usage_lib.put_cluster_metadata(
|
|
self.get_gcs_client(), ray_init_cluster=self.ray_init_cluster
|
|
)
|
|
# On restart with the RocksDB GCS backend, check_persisted_session_name()
|
|
# needs the previous session_name before GCS (and thus internal_kv) is
|
|
# back up. We bridge that gap by also writing session_name to a plain
|
|
# file in the GCS storage directory. Only done for the RocksDB backend;
|
|
# others are unaffected.
|
|
#
|
|
# Write the file BEFORE the internal_kv_put below so the two never
|
|
# disagree: if we crash in between, the next restart reads the file and
|
|
# the internal_kv_put inserts cleanly. A write failure here is fatal --
|
|
# the storage path also holds RocksDB's own files, so an unwritable path
|
|
# means GCS can't run anyway.
|
|
if self._is_rocksdb_gcs():
|
|
self._persist_rocksdb_session_name_file()
|
|
|
|
# Make sure GCS is up.
|
|
added = self.get_gcs_client().internal_kv_put(
|
|
b"session_name",
|
|
self._session_name.encode(),
|
|
False,
|
|
ray_constants.KV_NAMESPACE_SESSION,
|
|
)
|
|
if not added:
|
|
curr_val = self.get_gcs_client().internal_kv_get(
|
|
b"session_name", ray_constants.KV_NAMESPACE_SESSION
|
|
)
|
|
assert curr_val == self._session_name.encode("utf-8"), (
|
|
f"Session name {self._session_name} does not match "
|
|
f"persisted value {curr_val}. Perhaps there was an "
|
|
f"error connecting to the GCS storage backend."
|
|
)
|
|
|
|
# Add tracing_startup_hook to redis / internal kv manually
|
|
# since internal kv is not yet initialized.
|
|
if self._ray_params.tracing_startup_hook:
|
|
self.get_gcs_client().internal_kv_put(
|
|
b"tracing_startup_hook",
|
|
self._ray_params.tracing_startup_hook.encode(),
|
|
True,
|
|
ray_constants.KV_NAMESPACE_TRACING,
|
|
)
|
|
|
|
def start_head_processes(self):
|
|
"""Start head processes on the node."""
|
|
logger.debug(
|
|
f"Process STDOUT and STDERR is being " f"redirected to {self._logs_dir}."
|
|
)
|
|
assert self._gcs_address is None
|
|
assert self._gcs_client is None
|
|
|
|
self.start_gcs_server()
|
|
assert self.get_gcs_client() is not None
|
|
self._write_cluster_info_to_kv()
|
|
|
|
if not self._ray_params.no_monitor:
|
|
self.start_monitor()
|
|
|
|
if self._ray_params.ray_client_server_port:
|
|
self.start_ray_client_server()
|
|
|
|
if self._ray_params.include_dashboard is None:
|
|
# Default
|
|
raise_on_api_server_failure = False
|
|
else:
|
|
raise_on_api_server_failure = self._ray_params.include_dashboard
|
|
|
|
self.start_api_server(
|
|
include_dashboard=self._ray_params.include_dashboard,
|
|
raise_on_failure=raise_on_api_server_failure,
|
|
)
|
|
|
|
def start_ray_processes(self):
|
|
"""Start all of the processes on the node."""
|
|
logger.debug(
|
|
f"Process STDOUT and STDERR is being " f"redirected to {self._logs_dir}."
|
|
)
|
|
|
|
if not self.head:
|
|
# Get the system config from GCS first if this is a non-head node.
|
|
gcs_options = ray._raylet.GcsClientOptions.create(
|
|
self.gcs_address,
|
|
self.cluster_id.hex(),
|
|
allow_cluster_id_nil=False,
|
|
fetch_cluster_id_if_nil=False,
|
|
)
|
|
global_state = ray._private.state.GlobalState()
|
|
global_state._initialize_global_state(gcs_options)
|
|
new_config = global_state.get_system_config()
|
|
assert self._config.items() <= new_config.items(), (
|
|
"The system config from GCS is not a superset of the local"
|
|
" system config. There might be a configuration inconsistency"
|
|
" issue between the head node and non-head nodes."
|
|
f" Local system config: {self._config},"
|
|
f" GCS system config: {new_config}"
|
|
)
|
|
|
|
# Note: We decide which object spilling directory to use based on the following policy:
|
|
# 1. If this node specifies an object spilling directory, use it.
|
|
# 2. If the head node specifies an object spilling directory, and the worker node doesn't specify one,
|
|
# use the head node's object spilling directory.
|
|
# 3. If the head node doesn't specify an object spilling directory, and the worker node doesn't specify one,
|
|
# use the temp_dir of the worker node as the object spilling directory.
|
|
try:
|
|
if new_config["automatic_object_spilling_enabled"]:
|
|
config = json.loads(new_config["object_spilling_config"])
|
|
if config.get("type") == "filesystem":
|
|
fetched_config_directory_path = config["params"][
|
|
"directory_path"
|
|
]
|
|
head_spill_directory_path = None
|
|
if isinstance(fetched_config_directory_path, list):
|
|
head_spill_directory_path = fetched_config_directory_path[0]
|
|
elif isinstance(fetched_config_directory_path, str):
|
|
head_spill_directory_path = fetched_config_directory_path
|
|
if head_spill_directory_path is None:
|
|
raise ValueError(
|
|
f"Failed to obtain spill directory path from "
|
|
f"object spilling config: {fetched_config_directory_path}"
|
|
)
|
|
|
|
if (
|
|
self._fallback_directory != self._session_dir
|
|
or head_spill_directory_path == self._head_session_dir
|
|
):
|
|
config["params"][
|
|
"directory_path"
|
|
] = self._fallback_directory
|
|
new_config["object_spilling_config"] = json.dumps(config)
|
|
else:
|
|
self._fallback_directory = head_spill_directory_path
|
|
|
|
try_to_create_directory(self._fallback_directory)
|
|
self._config = new_config
|
|
except Exception as e:
|
|
raise Exception(
|
|
"Expected valid object_spilling_config to be received from head node "
|
|
f"but got: {repr(e)}"
|
|
)
|
|
|
|
# Make sure we don't call `determine_plasma_store_config` multiple
|
|
# times to avoid printing multiple warnings.
|
|
resource_and_label_spec = self.get_resource_and_label_spec()
|
|
if resource_and_label_spec.labels.get(
|
|
ray._raylet.RAY_NODE_ACCELERATOR_TYPE_KEY
|
|
):
|
|
from ray._common.usage import usage_lib
|
|
|
|
usage_lib.record_hardware_usage(
|
|
resource_and_label_spec.labels.get(
|
|
ray._raylet.RAY_NODE_ACCELERATOR_TYPE_KEY
|
|
)
|
|
)
|
|
|
|
(
|
|
plasma_directory,
|
|
fallback_directory,
|
|
object_store_memory,
|
|
) = ray._private.services.determine_plasma_store_config(
|
|
resource_and_label_spec.object_store_memory,
|
|
self.temp_dir,
|
|
plasma_directory=self._ray_params.plasma_directory,
|
|
fallback_directory=self._fallback_directory,
|
|
huge_pages=self._ray_params.huge_pages,
|
|
)
|
|
|
|
if self._ray_params.include_log_monitor:
|
|
self.start_log_monitor()
|
|
|
|
self.start_raylet(plasma_directory, fallback_directory, object_store_memory)
|
|
|
|
def _get_system_processes_for_resource_isolation(self) -> str:
|
|
"""Returns a list of system processes that will be isolated by raylet.
|
|
|
|
NOTE: If a new system process is started before the raylet starts up, it needs to be
|
|
added to self.all_processes so it can be moved into the raylet's managed cgroup
|
|
hierarchy.
|
|
"""
|
|
system_process_pids = [
|
|
str(p[0].process.pid) for p in self.all_processes.values()
|
|
]
|
|
|
|
# If the dashboard api server was started on the head node, then include all of the api server's
|
|
# child processes.
|
|
if ray_constants.PROCESS_TYPE_DASHBOARD in self.all_processes:
|
|
dashboard_pid = self.all_processes[ray_constants.PROCESS_TYPE_DASHBOARD][
|
|
0
|
|
].process.pid
|
|
dashboard_process = psutil.Process(dashboard_pid)
|
|
system_process_pids += [str(p.pid) for p in dashboard_process.children()]
|
|
|
|
return ",".join(system_process_pids)
|
|
|
|
def _kill_process_type(
|
|
self,
|
|
process_type: str,
|
|
allow_graceful: bool = False,
|
|
check_alive: bool = True,
|
|
wait: bool = False,
|
|
):
|
|
"""Kill a process of a given type.
|
|
|
|
If the process type is PROCESS_TYPE_REDIS_SERVER, then we will kill all
|
|
of the Redis servers.
|
|
|
|
If the process was started in valgrind, then we will raise an exception
|
|
if the process has a non-zero exit code.
|
|
|
|
Args:
|
|
process_type: The type of the process to kill.
|
|
allow_graceful: Send a SIGTERM first and give the process
|
|
time to exit gracefully. If that doesn't work, then use
|
|
SIGKILL. We usually want to do this outside of tests.
|
|
check_alive: If true, then we expect the process to be alive
|
|
and will raise an exception if the process is already dead.
|
|
wait: If true, then this method will not return until the
|
|
process in question has exited.
|
|
|
|
Raises:
|
|
This process raises an exception in the following cases:
|
|
1. The process had already died and check_alive is true.
|
|
2. The process had been started in valgrind and had a non-zero
|
|
exit code.
|
|
"""
|
|
|
|
# Ensure thread safety
|
|
with self.removal_lock:
|
|
self._kill_process_impl(
|
|
process_type,
|
|
allow_graceful=allow_graceful,
|
|
check_alive=check_alive,
|
|
wait=wait,
|
|
)
|
|
|
|
def _kill_process_impl(
|
|
self, process_type, allow_graceful=False, check_alive=True, wait=False
|
|
):
|
|
"""See `_kill_process_type`."""
|
|
if process_type not in self.all_processes:
|
|
return
|
|
process_infos = self.all_processes[process_type]
|
|
if process_type != ray_constants.PROCESS_TYPE_REDIS_SERVER:
|
|
assert len(process_infos) == 1
|
|
wait_timeout_seconds = 1
|
|
for process_info in process_infos:
|
|
process = process_info.process
|
|
# Handle the case where the process has already exited.
|
|
if process.poll() is not None:
|
|
if check_alive:
|
|
raise RuntimeError(
|
|
"Attempting to kill a process of type "
|
|
f"'{process_type}', but this process is already dead."
|
|
)
|
|
else:
|
|
continue
|
|
|
|
if process_info.use_valgrind:
|
|
process.terminate()
|
|
process.wait()
|
|
if process.returncode != 0:
|
|
message = (
|
|
"Valgrind detected some errors in process of "
|
|
f"type {process_type}. Error code {process.returncode}."
|
|
)
|
|
if process_info.stdout_file is not None:
|
|
with open(process_info.stdout_file, "r") as f:
|
|
message += "\nPROCESS STDOUT:\n" + f.read()
|
|
if process_info.stderr_file is not None:
|
|
with open(process_info.stderr_file, "r") as f:
|
|
message += "\nPROCESS STDERR:\n" + f.read()
|
|
raise RuntimeError(message)
|
|
continue
|
|
|
|
if process_info.use_valgrind_profiler:
|
|
# Give process signal to write profiler data.
|
|
os.kill(process.pid, signal.SIGINT)
|
|
# Wait for profiling data to be written.
|
|
time.sleep(0.1)
|
|
|
|
if allow_graceful:
|
|
process.terminate()
|
|
# Allow the process one second to exit gracefully.
|
|
try:
|
|
process.wait(timeout=wait_timeout_seconds)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
# If the process did not exit, force kill it.
|
|
if process.poll() is None:
|
|
process.kill()
|
|
# After kill, wait must be called
|
|
# The reason we usually don't set timeout=None here is that
|
|
# there's some chance we'd end up waiting a really long time.
|
|
try:
|
|
process.wait(timeout=None if wait else wait_timeout_seconds)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
del self.all_processes[process_type]
|
|
|
|
def kill_redis(self, check_alive: bool = True):
|
|
"""Kill the Redis servers.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if any of the processes
|
|
were already dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_REDIS_SERVER, check_alive=check_alive
|
|
)
|
|
|
|
def kill_raylet(self, check_alive: bool = True):
|
|
"""Kill the raylet.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if the process was already
|
|
dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_RAYLET, check_alive=check_alive
|
|
)
|
|
|
|
def kill_log_monitor(self, check_alive: bool = True):
|
|
"""Kill the log monitor.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if the process was already
|
|
dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_LOG_MONITOR, check_alive=check_alive
|
|
)
|
|
|
|
def kill_dashboard(self, check_alive: bool = True):
|
|
"""Kill the dashboard.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if the process was already
|
|
dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_DASHBOARD, check_alive=check_alive
|
|
)
|
|
|
|
def kill_monitor(self, check_alive: bool = True):
|
|
"""Kill the monitor.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if the process was already
|
|
dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_MONITOR, check_alive=check_alive
|
|
)
|
|
|
|
def kill_gcs_server(self, check_alive: bool = True):
|
|
"""Kill the gcs server.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if the process was already
|
|
dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_GCS_SERVER, check_alive=check_alive, wait=True
|
|
)
|
|
# Clear GCS client and address to indicate no GCS server is running.
|
|
self._gcs_address = None
|
|
self._gcs_client = None
|
|
|
|
def kill_reaper(self, check_alive: bool = True):
|
|
"""Kill the reaper process.
|
|
|
|
Args:
|
|
check_alive: Raise an exception if the process was already
|
|
dead.
|
|
"""
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_REAPER, check_alive=check_alive
|
|
)
|
|
|
|
def kill_all_processes(
|
|
self,
|
|
check_alive: bool = True,
|
|
allow_graceful: bool = False,
|
|
wait: bool = False,
|
|
):
|
|
"""Kill all of the processes.
|
|
|
|
Note that This is slower than necessary because it calls kill, wait,
|
|
kill, wait, ... instead of kill, kill, ..., wait, wait, ...
|
|
|
|
Args:
|
|
check_alive: Raise an exception if any of the processes were
|
|
already dead.
|
|
allow_graceful: Send a SIGTERM first and give each process time
|
|
to exit gracefully before falling back to SIGKILL.
|
|
wait: If true, then this method will not return until the
|
|
process in question has exited.
|
|
"""
|
|
# Kill the raylet first. This is important for suppressing errors at
|
|
# shutdown because we give the raylet a chance to exit gracefully and
|
|
# clean up its child worker processes. If we were to kill the plasma
|
|
# store (or Redis) first, that could cause the raylet to exit
|
|
# ungracefully, leading to more verbose output from the workers.
|
|
if ray_constants.PROCESS_TYPE_RAYLET in self.all_processes:
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_RAYLET,
|
|
check_alive=check_alive,
|
|
allow_graceful=allow_graceful,
|
|
wait=wait,
|
|
)
|
|
|
|
if ray_constants.PROCESS_TYPE_GCS_SERVER in self.all_processes:
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_GCS_SERVER,
|
|
check_alive=check_alive,
|
|
allow_graceful=allow_graceful,
|
|
wait=wait,
|
|
)
|
|
|
|
# We call "list" to copy the keys because we are modifying the
|
|
# dictionary while iterating over it.
|
|
for process_type in list(self.all_processes.keys()):
|
|
# Need to kill the reaper process last in case we die unexpectedly
|
|
# while cleaning up.
|
|
if process_type != ray_constants.PROCESS_TYPE_REAPER:
|
|
self._kill_process_type(
|
|
process_type,
|
|
check_alive=check_alive,
|
|
allow_graceful=allow_graceful,
|
|
wait=wait,
|
|
)
|
|
|
|
if ray_constants.PROCESS_TYPE_REAPER in self.all_processes:
|
|
self._kill_process_type(
|
|
ray_constants.PROCESS_TYPE_REAPER,
|
|
check_alive=check_alive,
|
|
allow_graceful=allow_graceful,
|
|
wait=wait,
|
|
)
|
|
|
|
def live_processes(self):
|
|
"""Return a list of the live processes.
|
|
|
|
Returns:
|
|
A list of the live processes.
|
|
"""
|
|
result = []
|
|
for process_type, process_infos in self.all_processes.items():
|
|
for process_info in process_infos:
|
|
if process_info.process.poll() is None:
|
|
result.append((process_type, process_info.process))
|
|
return result
|
|
|
|
def dead_processes(self):
|
|
"""Return a list of the dead processes.
|
|
|
|
Note that this ignores processes that have been explicitly killed,
|
|
e.g., via a command like node.kill_raylet().
|
|
|
|
Returns:
|
|
A list of the dead processes ignoring the ones that have been
|
|
explicitly killed.
|
|
"""
|
|
result = []
|
|
for process_type, process_infos in self.all_processes.items():
|
|
for process_info in process_infos:
|
|
if process_info.process.poll() is not None:
|
|
result.append((process_type, process_info.process))
|
|
return result
|
|
|
|
def any_processes_alive(self):
|
|
"""Return true if any processes are still alive.
|
|
|
|
Returns:
|
|
True if any process is still alive.
|
|
"""
|
|
return any(self.live_processes())
|
|
|
|
def remaining_processes_alive(self):
|
|
"""Return true if all remaining processes are still alive.
|
|
|
|
Note that this ignores processes that have been explicitly killed,
|
|
e.g., via a command like node.kill_raylet().
|
|
|
|
Returns:
|
|
True if any process that wasn't explicitly killed is still alive.
|
|
"""
|
|
return not any(self.dead_processes())
|
|
|
|
def destroy_external_storage(self):
|
|
object_spilling_config = self._config.get("object_spilling_config", {})
|
|
if object_spilling_config:
|
|
object_spilling_config = json.loads(object_spilling_config)
|
|
from ray._private import external_storage
|
|
|
|
storage = external_storage.setup_external_storage(
|
|
object_spilling_config, self._node_id, self._session_name
|
|
)
|
|
storage.destroy_external_storage()
|
|
|
|
def validate_external_storage(self):
|
|
"""Make sure we can setup the object spilling external storage."""
|
|
|
|
automatic_spilling_enabled = self._config.get(
|
|
"automatic_object_spilling_enabled", True
|
|
)
|
|
if not automatic_spilling_enabled:
|
|
return
|
|
self._config["automatic_object_spilling_enabled"] = True
|
|
|
|
object_spilling_config = self._object_spilling_config
|
|
# Try setting up the storage.
|
|
# Configure the proper system config.
|
|
# We need to set both ray param's system config and self._config
|
|
# because they could've been diverged at this point.
|
|
deserialized_config = json.loads(object_spilling_config)
|
|
self._ray_params._system_config[
|
|
"object_spilling_config"
|
|
] = object_spilling_config
|
|
self._config["object_spilling_config"] = object_spilling_config
|
|
|
|
is_external_storage_type_fs = deserialized_config["type"] == "filesystem"
|
|
self._ray_params._system_config[
|
|
"is_external_storage_type_fs"
|
|
] = is_external_storage_type_fs
|
|
self._config["is_external_storage_type_fs"] = is_external_storage_type_fs
|
|
|
|
# Validate external storage usage.
|
|
from ray._private import external_storage
|
|
|
|
# Node ID is available only after GCS is connected. However,
|
|
# validate_external_storage() needs to be called before it to
|
|
# be able to validate the configs early. Therefore, we use a
|
|
# dummy node ID here and make sure external storage can be set
|
|
# up based on the provided config. This storage is destroyed
|
|
# right after the validation.
|
|
dummy_node_id = ray.NodeID.from_random().hex()
|
|
storage = external_storage.setup_external_storage(
|
|
deserialized_config, dummy_node_id, self._session_name
|
|
)
|
|
storage.destroy_external_storage()
|
|
external_storage.reset_external_storage()
|
|
|
|
def _get_object_spilling_config(self):
|
|
"""Consolidate the object spilling config from the ray params, environment
|
|
variable, and system config. The object spilling directory specified through
|
|
ray params will override the one specified through environment variable and
|
|
system config."""
|
|
|
|
object_spilling_directory = self._ray_params.object_spilling_directory
|
|
if not object_spilling_directory:
|
|
object_spilling_directory = self._config.get(
|
|
"object_spilling_directory", ""
|
|
)
|
|
|
|
if not object_spilling_directory:
|
|
object_spilling_directory = os.environ.get(
|
|
"RAY_object_spilling_directory", ""
|
|
)
|
|
|
|
if object_spilling_directory:
|
|
return json.dumps(
|
|
{
|
|
"type": "filesystem",
|
|
"params": {"directory_path": object_spilling_directory},
|
|
}
|
|
)
|
|
|
|
object_spilling_config = self._config.get("object_spilling_config", {})
|
|
if not object_spilling_config:
|
|
object_spilling_config = os.environ.get("RAY_object_spilling_config", "")
|
|
|
|
# If the config is not specified in ray params, system config or environment
|
|
# variable, we fill up the default.
|
|
if not object_spilling_config:
|
|
object_spilling_config = json.dumps(
|
|
{"type": "filesystem", "params": {"directory_path": self._session_dir}}
|
|
)
|
|
else:
|
|
if not is_in_test():
|
|
logger.warning(
|
|
"The object spilling config is specified from an unstable "
|
|
"API - system config or environment variable. This is "
|
|
"subject to change in the future. You can use the stable "
|
|
"API - --object-spilling-directory in ray start or "
|
|
"object_spilling_directory in ray.init() to specify the "
|
|
"object spilling directory instead. If you need more "
|
|
"advanced settings, please open a github issue with the "
|
|
"Ray team."
|
|
)
|
|
|
|
return object_spilling_config
|
|
|
|
def _record_stats(self):
|
|
# This is only called when a new node is started.
|
|
# Initialize the internal kv so that the metrics can be put
|
|
from ray._common.usage.usage_lib import (
|
|
TagKey,
|
|
record_extra_usage_tag,
|
|
record_hardware_usage,
|
|
)
|
|
|
|
if not ray.experimental.internal_kv._internal_kv_initialized():
|
|
ray.experimental.internal_kv._initialize_internal_kv(self.get_gcs_client())
|
|
assert ray.experimental.internal_kv._internal_kv_initialized()
|
|
if self.head:
|
|
# record head node stats
|
|
if self._is_rocksdb_gcs():
|
|
gcs_storage_type = "rocksdb"
|
|
elif os.environ.get("RAY_REDIS_ADDRESS") is not None:
|
|
gcs_storage_type = "redis"
|
|
else:
|
|
gcs_storage_type = "memory"
|
|
record_extra_usage_tag(TagKey.GCS_STORAGE, gcs_storage_type)
|
|
cpu_model_name = ray._private.utils.get_current_node_cpu_model_name()
|
|
if cpu_model_name:
|
|
# CPU model name can be an arbitrary long string
|
|
# so we truncate it to the first 50 characters
|
|
# to avoid any issues.
|
|
record_hardware_usage(cpu_model_name[:50])
|