653 lines
19 KiB
Python
653 lines
19 KiB
Python
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from typing import Any, Iterator, List, Optional, Sequence, Tuple
|
|
|
|
import yaml
|
|
|
|
import ray # noqa: F401
|
|
from ray.autoscaler._private.cli_logger import cli_logger
|
|
from ray.autoscaler._private.providers import _get_node_provider
|
|
from ray.autoscaler.tags import NODE_KIND_HEAD, NODE_KIND_WORKER, TAG_RAY_NODE_KIND
|
|
|
|
# Import psutil after ray so the packaged version is used.
|
|
import psutil
|
|
|
|
MAX_PARALLEL_SSH_WORKERS = 8
|
|
DEFAULT_SSH_USER = "ubuntu"
|
|
DEFAULT_SSH_KEYS = ["~/ray_bootstrap_key.pem", "~/.ssh/ray-autoscaler_2_us-west-2.pem"]
|
|
|
|
|
|
class CommandFailed(RuntimeError):
|
|
pass
|
|
|
|
|
|
class LocalCommandFailed(CommandFailed):
|
|
pass
|
|
|
|
|
|
class RemoteCommandFailed(CommandFailed):
|
|
pass
|
|
|
|
|
|
class GetParameters:
|
|
def __init__(
|
|
self,
|
|
logs: bool = True,
|
|
debug_state: bool = True,
|
|
pip: bool = True,
|
|
processes: bool = True,
|
|
processes_verbose: bool = True,
|
|
processes_list: Optional[List[Tuple[str, bool]]] = None,
|
|
):
|
|
self.logs = logs
|
|
self.debug_state = debug_state
|
|
self.pip = pip
|
|
self.processes = processes
|
|
self.processes_verbose = processes_verbose
|
|
self.processes_list = processes_list
|
|
|
|
|
|
class Node:
|
|
"""Node (as in "machine")"""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
ssh_user: str = "ubuntu",
|
|
ssh_key: str = "~/ray_bootstrap_key.pem",
|
|
docker_container: Optional[str] = None,
|
|
is_head: bool = False,
|
|
):
|
|
self.host = host
|
|
self.ssh_user = ssh_user
|
|
self.ssh_key = ssh_key
|
|
self.docker_container = docker_container
|
|
self.is_head = is_head
|
|
|
|
|
|
class Archive:
|
|
"""Archive object to collect and compress files into a single file.
|
|
|
|
Objects of this class can be passed around to different data collection
|
|
functions. These functions can use the :meth:`subdir` method to add
|
|
files to a sub directory of the archive.
|
|
|
|
"""
|
|
|
|
def __init__(self, file: Optional[str] = None):
|
|
self.file = file or tempfile.mkstemp(prefix="ray_logs_", suffix=".tar.gz")[1]
|
|
self.tar = None
|
|
self._lock = threading.Lock()
|
|
|
|
@property
|
|
def is_open(self):
|
|
return bool(self.tar)
|
|
|
|
def open(self):
|
|
self.tar = tarfile.open(self.file, "w:gz")
|
|
|
|
def close(self):
|
|
self.tar.close()
|
|
self.tar = None
|
|
|
|
def __enter__(self):
|
|
self.open()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
|
|
@contextmanager
|
|
def subdir(self, subdir: str, root: Optional[str] = "/") -> Iterator[Any]:
|
|
"""Open a context to add files to the archive.
|
|
|
|
Example:
|
|
|
|
.. code-block:: python
|
|
|
|
with Archive("file.tar.gz") as archive:
|
|
with archive.subdir("logfiles", root="/tmp/logs") as sd:
|
|
# Will be added as `logfiles/nested/file.txt`
|
|
sd.add("/tmp/logs/nested/file.txt")
|
|
|
|
Args:
|
|
subdir: Subdir to which to add files to. Calling the
|
|
``add(path)`` command will place files into the ``subdir``
|
|
directory of the archive.
|
|
root: Root path. Files without an explicit ``arcname``
|
|
will be named relatively to this path.
|
|
|
|
Yields:
|
|
Any: A context object that can be used to add files to the archive.
|
|
"""
|
|
root = os.path.abspath(root)
|
|
|
|
class _Context:
|
|
@staticmethod
|
|
def add(path: str, arcname: Optional[str] = None):
|
|
path = os.path.abspath(path)
|
|
arcname = arcname or os.path.join(subdir, os.path.relpath(path, root))
|
|
|
|
self._lock.acquire()
|
|
self.tar.add(path, arcname=arcname)
|
|
self._lock.release()
|
|
|
|
yield _Context()
|
|
|
|
|
|
###
|
|
# Functions to gather logs and information on the local node
|
|
###
|
|
|
|
|
|
def get_local_ray_logs(
|
|
archive: Archive,
|
|
exclude: Optional[Sequence[str]] = None,
|
|
session_log_dir: str = "/tmp/ray/session_latest",
|
|
) -> Archive:
|
|
"""Copy local log files into an archive.
|
|
|
|
Args:
|
|
archive: Archive object to add log files to.
|
|
exclude: Sequence of regex patterns. Files that match
|
|
any of these patterns will not be included in the archive.
|
|
session_log_dir: Path to the Ray session files. Defaults to
|
|
``/tmp/ray/session_latest``
|
|
|
|
Returns:
|
|
Open archive object.
|
|
|
|
"""
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
exclude = exclude or []
|
|
|
|
session_log_dir = os.path.join(os.path.expanduser(session_log_dir), "logs")
|
|
|
|
with archive.subdir("logs", root=session_log_dir) as sd:
|
|
for root, dirs, files in os.walk(session_log_dir):
|
|
for file in files:
|
|
file_path = os.path.join(root, file)
|
|
rel_path = os.path.relpath(file_path, start=session_log_dir)
|
|
# Skip file if it matches any pattern in `exclude`
|
|
if any(re.match(pattern, rel_path) for pattern in exclude):
|
|
continue
|
|
sd.add(file_path)
|
|
|
|
return archive
|
|
|
|
|
|
def get_local_debug_state(
|
|
archive: Archive, session_dir: str = "/tmp/ray/session_latest"
|
|
) -> Archive:
|
|
"""Copy local log files into an archive.
|
|
|
|
Args:
|
|
archive: Archive object to add log files to.
|
|
session_dir: Path to the Ray session files. Defaults to
|
|
``/tmp/ray/session_latest``
|
|
|
|
Returns:
|
|
Open archive object.
|
|
|
|
"""
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
session_dir = os.path.expanduser(session_dir)
|
|
debug_state_file = os.path.join(session_dir, "logs/debug_state.txt")
|
|
|
|
if not os.path.exists(debug_state_file):
|
|
raise LocalCommandFailed("No `debug_state.txt` file found.")
|
|
|
|
with archive.subdir("", root=session_dir) as sd:
|
|
sd.add(debug_state_file)
|
|
|
|
return archive
|
|
|
|
|
|
def get_local_pip_packages(archive: Archive):
|
|
"""Get currently installed pip packages and write into an archive.
|
|
|
|
Args:
|
|
archive: Archive object to add meta files to.
|
|
|
|
Returns:
|
|
Open archive object.
|
|
"""
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
try:
|
|
from pip._internal.operations import freeze
|
|
except ImportError: # pip < 10.0
|
|
from pip.operations import freeze
|
|
|
|
with tempfile.NamedTemporaryFile("wt") as fp:
|
|
for line in freeze.freeze():
|
|
fp.writelines([line, "\n"])
|
|
|
|
fp.flush()
|
|
with archive.subdir("") as sd:
|
|
sd.add(fp.name, "pip_packages.txt")
|
|
|
|
return archive
|
|
|
|
|
|
def get_local_ray_processes(
|
|
archive: Archive,
|
|
processes: Optional[List[Tuple[str, bool]]] = None,
|
|
verbose: bool = False,
|
|
):
|
|
"""Get the status of all the relevant ray processes.
|
|
Args:
|
|
archive: Archive object to add process info files to.
|
|
processes: List of processes to get information on. The first
|
|
element of the tuple is a string to filter by, and the second
|
|
element is a boolean indicating if we should filter by command
|
|
name (True) or command line including parameters (False)
|
|
verbose: If True, show entire executable command line.
|
|
If False, show just the first term.
|
|
Returns:
|
|
Open archive object.
|
|
"""
|
|
if not processes:
|
|
# local import to avoid circular dependencies
|
|
from ray.autoscaler._private.constants import RAY_PROCESSES
|
|
|
|
processes = RAY_PROCESSES
|
|
|
|
process_infos = []
|
|
for process in psutil.process_iter(["pid", "name", "cmdline", "status"]):
|
|
try:
|
|
with process.oneshot():
|
|
cmdline = " ".join(process.cmdline())
|
|
process_infos.append(
|
|
(
|
|
{
|
|
"executable": cmdline
|
|
if verbose
|
|
else cmdline.split("--", 1)[0][:-1],
|
|
"name": process.name(),
|
|
"pid": process.pid,
|
|
"status": process.status(),
|
|
},
|
|
process.cmdline(),
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
raise LocalCommandFailed(exc) from exc
|
|
|
|
relevant_processes = {}
|
|
for process_dict, cmdline in process_infos:
|
|
for keyword, filter_by_cmd in processes:
|
|
if filter_by_cmd:
|
|
corpus = process_dict["name"]
|
|
else:
|
|
corpus = subprocess.list2cmdline(cmdline)
|
|
if keyword in corpus and process_dict["pid"] not in relevant_processes:
|
|
relevant_processes[process_dict["pid"]] = process_dict
|
|
|
|
with tempfile.NamedTemporaryFile("wt") as fp:
|
|
for line in relevant_processes.values():
|
|
fp.writelines([yaml.dump(line), "\n"])
|
|
|
|
fp.flush()
|
|
with archive.subdir("meta") as sd:
|
|
sd.add(fp.name, "process_info.txt")
|
|
|
|
return archive
|
|
|
|
|
|
def get_all_local_data(archive: Archive, parameters: GetParameters):
|
|
"""Get all local data.
|
|
|
|
Gets:
|
|
- The Ray logs of the latest session
|
|
- The currently installed pip packages
|
|
|
|
Args:
|
|
archive: Archive object to add meta files to.
|
|
parameters: Parameters (settings) for getting data.
|
|
|
|
Returns:
|
|
Open archive object.
|
|
"""
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
if parameters.logs:
|
|
try:
|
|
get_local_ray_logs(archive=archive)
|
|
except LocalCommandFailed as exc:
|
|
cli_logger.error(exc)
|
|
if parameters.debug_state:
|
|
try:
|
|
get_local_debug_state(archive=archive)
|
|
except LocalCommandFailed as exc:
|
|
cli_logger.error(exc)
|
|
if parameters.pip:
|
|
try:
|
|
get_local_pip_packages(archive=archive)
|
|
except LocalCommandFailed as exc:
|
|
cli_logger.error(exc)
|
|
if parameters.processes:
|
|
try:
|
|
get_local_ray_processes(
|
|
archive=archive,
|
|
processes=parameters.processes_list,
|
|
verbose=parameters.processes_verbose,
|
|
)
|
|
except LocalCommandFailed as exc:
|
|
cli_logger.error(exc)
|
|
|
|
return archive
|
|
|
|
|
|
###
|
|
# Functions to invoke remote scripts and gather data from remote nodes
|
|
###
|
|
|
|
|
|
def _wrap(items: List[str], quotes="'"):
|
|
return f"{quotes}{' '.join(items)}{quotes}"
|
|
|
|
|
|
def create_and_get_archive_from_remote_node(
|
|
remote_node: Node, parameters: GetParameters, script_path: str = "ray"
|
|
) -> Optional[str]:
|
|
"""Create an archive containing logs on a remote node and transfer.
|
|
|
|
This will call ``ray local-dump --stream`` on the remote
|
|
node. The resulting file will be saved locally in a temporary file and
|
|
returned.
|
|
|
|
Args:
|
|
remote_node: Remote node to gather archive from.
|
|
parameters: Parameters (settings) for getting data.
|
|
script_path: Path to this script on the remote node.
|
|
|
|
Returns:
|
|
Path to a temporary file containing the node's collected data.
|
|
|
|
"""
|
|
cmd = [
|
|
"ssh",
|
|
"-o StrictHostKeyChecking=no",
|
|
"-o UserKnownHostsFile=/dev/null",
|
|
"-o LogLevel=ERROR",
|
|
"-i",
|
|
remote_node.ssh_key,
|
|
f"{remote_node.ssh_user}@{remote_node.host}",
|
|
]
|
|
|
|
if remote_node.docker_container:
|
|
cmd += [
|
|
"docker",
|
|
"exec",
|
|
remote_node.docker_container,
|
|
]
|
|
|
|
collect_cmd = [script_path, "local-dump", "--stream"]
|
|
collect_cmd += ["--logs"] if parameters.logs else ["--no-logs"]
|
|
collect_cmd += ["--debug-state"] if parameters.debug_state else ["--no-debug-state"]
|
|
collect_cmd += ["--pip"] if parameters.pip else ["--no-pip"]
|
|
collect_cmd += ["--processes"] if parameters.processes else ["--no-processes"]
|
|
if parameters.processes:
|
|
collect_cmd += (
|
|
["--processes-verbose"]
|
|
if parameters.processes_verbose
|
|
else ["--no-proccesses-verbose"]
|
|
)
|
|
|
|
cmd += ["/bin/bash", "-c", _wrap(collect_cmd, quotes='"')]
|
|
|
|
cat = "node" if not remote_node.is_head else "head"
|
|
|
|
cli_logger.print(f"Collecting data from remote node: {remote_node.host}")
|
|
tmp = tempfile.mkstemp(prefix=f"ray_{cat}_{remote_node.host}_", suffix=".tar.gz")[1]
|
|
with open(tmp, "wb") as fp:
|
|
try:
|
|
subprocess.check_call(cmd, stdout=fp, stderr=sys.stderr)
|
|
except subprocess.CalledProcessError as exc:
|
|
raise RemoteCommandFailed(
|
|
f"Gathering logs from remote node failed: {' '.join(cmd)}"
|
|
) from exc
|
|
|
|
return tmp
|
|
|
|
|
|
def create_and_add_remote_data_to_local_archive(
|
|
archive: Archive, remote_node: Node, parameters: GetParameters
|
|
):
|
|
"""Create and get data from remote node and add to local archive.
|
|
|
|
Args:
|
|
archive: Archive object to add remote data to.
|
|
remote_node: Remote node to gather archive from.
|
|
parameters: Parameters (settings) for getting data.
|
|
|
|
Returns:
|
|
Open archive object.
|
|
"""
|
|
tmp = create_and_get_archive_from_remote_node(remote_node, parameters)
|
|
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
cat = "node" if not remote_node.is_head else "head"
|
|
|
|
with archive.subdir("", root=os.path.dirname(tmp)) as sd:
|
|
sd.add(tmp, arcname=f"ray_{cat}_{remote_node.host}.tar.gz")
|
|
|
|
return archive
|
|
|
|
|
|
def create_and_add_local_data_to_local_archive(
|
|
archive: Archive, parameters: GetParameters
|
|
):
|
|
"""Create and get data from this node and add to archive.
|
|
|
|
Args:
|
|
archive: Archive object to add remote data to.
|
|
parameters: Parameters (settings) for getting data.
|
|
|
|
Returns:
|
|
Open archive object.
|
|
"""
|
|
with Archive() as local_data_archive:
|
|
get_all_local_data(local_data_archive, parameters)
|
|
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
with archive.subdir("", root=os.path.dirname(local_data_archive.file)) as sd:
|
|
sd.add(local_data_archive.file, arcname="local_node.tar.gz")
|
|
|
|
os.remove(local_data_archive.file)
|
|
|
|
return archive
|
|
|
|
|
|
def create_archive_for_remote_nodes(
|
|
archive: Archive, remote_nodes: Sequence[Node], parameters: GetParameters
|
|
):
|
|
"""Create an archive combining data from the remote nodes.
|
|
|
|
This will parallelize calls to get data from remote nodes.
|
|
|
|
Args:
|
|
archive: Archive object to add remote data to.
|
|
remote_nodes: Sequence of remote nodes.
|
|
parameters: Parameters (settings) for getting data.
|
|
|
|
Returns:
|
|
Open archive object.
|
|
|
|
"""
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
with ThreadPoolExecutor(max_workers=MAX_PARALLEL_SSH_WORKERS) as executor:
|
|
for remote_node in remote_nodes:
|
|
executor.submit(
|
|
create_and_add_remote_data_to_local_archive,
|
|
archive=archive,
|
|
remote_node=remote_node,
|
|
parameters=parameters,
|
|
)
|
|
|
|
return archive
|
|
|
|
|
|
def create_archive_for_local_and_remote_nodes(
|
|
archive: Archive, remote_nodes: Sequence[Node], parameters: GetParameters
|
|
):
|
|
"""Create an archive combining data from the local and remote nodes.
|
|
|
|
This will parallelize calls to get data from remote nodes.
|
|
|
|
Args:
|
|
archive: Archive object to add data to.
|
|
remote_nodes: Sequence of remote nodes.
|
|
parameters: Parameters (settings) for getting data.
|
|
|
|
Returns:
|
|
Open archive object.
|
|
|
|
"""
|
|
if not archive.is_open:
|
|
archive.open()
|
|
|
|
try:
|
|
create_and_add_local_data_to_local_archive(archive, parameters)
|
|
except CommandFailed as exc:
|
|
cli_logger.error(exc)
|
|
|
|
create_archive_for_remote_nodes(archive, remote_nodes, parameters)
|
|
|
|
cli_logger.print(
|
|
f"Collected data from local node and {len(remote_nodes)} " f"remote nodes."
|
|
)
|
|
return archive
|
|
|
|
|
|
###
|
|
# Ray cluster info
|
|
###
|
|
def get_info_from_ray_cluster_config(
|
|
cluster_config: str,
|
|
) -> Tuple[List[str], str, str, Optional[str], Optional[str]]:
|
|
"""Get information from Ray cluster config.
|
|
|
|
Return list of host IPs, ssh user, ssh key file, and optional docker
|
|
container.
|
|
|
|
Args:
|
|
cluster_config: Path to ray cluster config.
|
|
|
|
Returns:
|
|
Tuple of list of host IPs, ssh user name, ssh key file path,
|
|
optional docker container name, optional cluster name.
|
|
"""
|
|
from ray.autoscaler._private.commands import _bootstrap_config
|
|
|
|
cli_logger.print(
|
|
f"Retrieving cluster information from ray cluster file: " f"{cluster_config}"
|
|
)
|
|
|
|
cluster_config = os.path.expanduser(cluster_config)
|
|
|
|
config = yaml.safe_load(open(cluster_config).read())
|
|
config = _bootstrap_config(config, no_config_cache=True)
|
|
|
|
provider = _get_node_provider(config["provider"], config["cluster_name"])
|
|
head_nodes = provider.non_terminated_nodes({TAG_RAY_NODE_KIND: NODE_KIND_HEAD})
|
|
worker_nodes = provider.non_terminated_nodes({TAG_RAY_NODE_KIND: NODE_KIND_WORKER})
|
|
|
|
hosts = [provider.external_ip(node) for node in head_nodes + worker_nodes]
|
|
ssh_user = config["auth"]["ssh_user"]
|
|
ssh_key = config["auth"]["ssh_private_key"]
|
|
|
|
docker = None
|
|
docker_config = config.get("docker", None)
|
|
if docker_config:
|
|
docker = docker_config.get("container_name", None)
|
|
|
|
cluster_name = config.get("cluster_name", None)
|
|
|
|
return hosts, ssh_user, ssh_key, docker, cluster_name
|
|
|
|
|
|
def _info_from_params(
|
|
cluster: Optional[str] = None,
|
|
host: Optional[str] = None,
|
|
ssh_user: Optional[str] = None,
|
|
ssh_key: Optional[str] = None,
|
|
docker: Optional[str] = None,
|
|
):
|
|
"""Parse command line arguments.
|
|
|
|
Note: This returns a list of hosts, not a comma separated string!
|
|
"""
|
|
if not host and not cluster:
|
|
bootstrap_config = os.path.expanduser("~/ray_bootstrap_config.yaml")
|
|
if os.path.exists(bootstrap_config):
|
|
cluster = bootstrap_config
|
|
cli_logger.warning(
|
|
f"Detected cluster config file at {cluster}. "
|
|
f"If this is incorrect, specify with "
|
|
f"`ray cluster-dump <config>`"
|
|
)
|
|
elif cluster:
|
|
cluster = os.path.expanduser(cluster)
|
|
|
|
cluster_name = None
|
|
|
|
if cluster:
|
|
h, u, k, d, cluster_name = get_info_from_ray_cluster_config(cluster)
|
|
|
|
ssh_user = ssh_user or u
|
|
ssh_key = ssh_key or k
|
|
docker = docker or d
|
|
hosts = host.split(",") if host else h
|
|
|
|
if not hosts:
|
|
raise LocalCommandFailed(
|
|
f"Invalid cluster file or cluster has no running nodes: " f"{cluster}"
|
|
)
|
|
elif host:
|
|
hosts = host.split(",")
|
|
else:
|
|
raise LocalCommandFailed(
|
|
"You need to either specify a `<cluster_config>` or `--host`."
|
|
)
|
|
|
|
if not ssh_user:
|
|
ssh_user = DEFAULT_SSH_USER
|
|
cli_logger.warning(
|
|
f"Using default SSH user `{ssh_user}`. "
|
|
f"If this is incorrect, specify with `--ssh-user <user>`"
|
|
)
|
|
|
|
if not ssh_key:
|
|
for cand_key in DEFAULT_SSH_KEYS:
|
|
cand_key_file = os.path.expanduser(cand_key)
|
|
if os.path.exists(cand_key_file):
|
|
ssh_key = cand_key_file
|
|
cli_logger.warning(
|
|
f"Auto detected SSH key file: {ssh_key}. "
|
|
f"If this is incorrect, specify with `--ssh-key <key>`"
|
|
)
|
|
break
|
|
|
|
return cluster, hosts, ssh_user, ssh_key, docker, cluster_name
|