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

503 lines
20 KiB
Python

import asyncio
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
import ray
import ray.dashboard.consts as dashboard_consts
import ray.dashboard.utils as dashboard_utils
import ray.experimental.internal_kv as internal_kv
from ray._common.network_utils import build_address, get_localhost_ip, is_localhost
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
from ray._private import ray_constants
from ray._private.async_utils import enable_monitor_loop_lag
from ray._private.ray_constants import env_integer
from ray._raylet import GcsClient
from ray.dashboard.consts import (
AVAILABLE_COMPONENT_NAMES_FOR_METRICS,
DASHBOARD_METRIC_PORT,
)
from ray.dashboard.dashboard_metrics import DashboardPrometheusMetrics
from ray.dashboard.utils import (
DashboardHeadModule,
DashboardHeadModuleConfig,
async_loop_forever,
)
import psutil
try:
import prometheus_client
except ImportError:
prometheus_client = None
if TYPE_CHECKING:
from ray.dashboard.subprocesses.handle import SubprocessModuleHandle
logger = logging.getLogger(__name__)
# NOTE: Executor in this head is intentionally constrained to just 1 thread by
# default to limit its concurrency, therefore reducing potential for
# GIL contention
RAY_DASHBOARD_DASHBOARD_HEAD_TPE_MAX_WORKERS = env_integer(
"RAY_DASHBOARD_DASHBOARD_HEAD_TPE_MAX_WORKERS", 1
)
class DashboardHead:
def __init__(
self,
http_host: str,
http_port: int,
http_port_retries: int,
gcs_address: str,
cluster_id_hex: str,
node_ip_address: str,
log_dir: str,
logging_level: int,
logging_format: str,
logging_filename: str,
logging_rotate_bytes: int,
logging_rotate_backup_count: int,
temp_dir: str,
session_dir: str,
minimal: bool,
serve_frontend: bool,
modules_to_load: Optional[Set[str]] = None,
proxy_server_url: Optional[str] = None,
):
"""
Dashboard head
Args:
http_host: The host address for the Http server.
http_port: The port for the Http server.
http_port_retries: The maximum retry to bind ports for the Http server.
gcs_address: The GCS address in the {address}:{port} format.
cluster_id_hex: Cluster ID in hex
node_ip_address: The IP address of the dashboard
log_dir: The log directory. E.g., /tmp/session_latest/logs.
logging_level: The logging level (e.g. logging.INFO, logging.DEBUG)
logging_format: The format string for log messages
logging_filename: The name of the log file
logging_rotate_bytes: Max size in bytes before rotating log file
logging_rotate_backup_count: Number of backup files to keep when rotating
temp_dir: The temp directory. E.g., /tmp.
session_dir: The session directory. E.g., tmp/session_latest.
minimal: Whether or not it will load the minimal modules.
serve_frontend: If configured, frontend HTML is
served from the dashboard.
modules_to_load: A set of module name in string to load.
By default (None), it loads all available modules.
Note that available modules could be changed depending on
minimal flags.
proxy_server_url: The proxy url to redirect api requests to
Ex: proxy_server_url=http://historyserver:8080
"""
self.minimal = minimal
self.serve_frontend = serve_frontend
# If it is the minimal mode, we shouldn't serve frontend.
if self.minimal:
self.serve_frontend = False
# Public attributes are accessible for all head modules.
# Walkaround for issue: https://github.com/ray-project/ray/issues/7084
self.http_host = get_localhost_ip() if is_localhost(http_host) else http_host
self.http_port = http_port
self.http_port_retries = http_port_retries
self._modules_to_load = modules_to_load
self._modules_loaded = False
self.metrics = None
self._executor = ThreadPoolExecutor(
max_workers=RAY_DASHBOARD_DASHBOARD_HEAD_TPE_MAX_WORKERS,
thread_name_prefix="dashboard_head_executor",
)
assert gcs_address is not None
self.gcs_address = gcs_address
self.cluster_id_hex = cluster_id_hex
self.log_dir = log_dir
self.logging_level = logging_level
self.logging_format = logging_format
self.logging_filename = logging_filename
self.logging_rotate_bytes = logging_rotate_bytes
self.logging_rotate_backup_count = logging_rotate_backup_count
self.temp_dir = temp_dir
self.session_dir = session_dir
self.session_name = Path(session_dir).name
self.gcs_error_subscriber = None
self.gcs_log_subscriber = None
self.ip = node_ip_address
self.pid = os.getpid()
self.dashboard_proc = psutil.Process()
self.proxy_server_url = proxy_server_url
# If the dashboard is started as non-minimal version, http server should
# be configured to expose APIs.
self.http_server = None
async def _configure_http_server(
self,
dashboard_head_modules: List[DashboardHeadModule],
subprocess_module_handles: List["SubprocessModuleHandle"],
):
from ray.dashboard.http_server_head import HttpServerDashboardHead
self.http_server = HttpServerDashboardHead(
self.ip,
self.http_host,
self.http_port,
self.http_port_retries,
self.gcs_address,
self.session_name,
self.metrics,
self.proxy_server_url,
)
await self.http_server.run(dashboard_head_modules, subprocess_module_handles)
@property
def http_session(self):
if not self._modules_loaded and not self.http_server:
# When the dashboard is still starting up, this property gets
# called as part of the method_route_table_factory magic. In
# this case, the property is not actually used but the magic
# method calls every property to look for a route to add to
# the global route table. It should be okay for http_server
# to still be None at this point.
return None
assert self.http_server, "Accessing unsupported API in a minimal ray."
return self.http_server.http_session
@async_loop_forever(dashboard_consts.GCS_CHECK_ALIVE_INTERVAL_SECONDS)
async def _gcs_check_alive(self):
try:
# If gcs is permanently dead, gcs client will exit the process
# (see gcs_rpc_client.h)
await self.gcs_client.async_check_alive(node_ids=[], timeout=None)
except Exception:
logger.warning("Failed to check gcs aliveness, will retry", exc_info=True)
def _load_modules(
self, modules_to_load: Optional[Set[str]] = None
) -> Tuple[List[DashboardHeadModule], List["SubprocessModuleHandle"]]:
"""
If minimal, only load DashboardHeadModule.
If non-minimal, load both kinds of modules: DashboardHeadModule, SubprocessModule.
If modules_to_load is not None, only load the modules in the set.
"""
(
dashboard_head_modules,
skipped_head_modules,
) = self._load_dashboard_head_modules(modules_to_load)
subprocess_module_handles = self._load_subprocess_module_handles(
modules_to_load
)
all_names = {type(m).__name__ for m in dashboard_head_modules} | {
h.module_cls.__name__ for h in subprocess_module_handles
}
assert len(all_names) == len(dashboard_head_modules) + len(
subprocess_module_handles
), "Duplicate module names. A module name can't be a DashboardHeadModule and a SubprocessModule at the same time."
# Verify modules are loaded as expected.
if modules_to_load is not None:
expected_names = modules_to_load - skipped_head_modules
if all_names != expected_names:
assert False, (
f"Actual loaded modules {all_names}, doesn't match the requested modules "
f"to load, {expected_names}."
)
self._modules_loaded = True
return dashboard_head_modules, subprocess_module_handles
def _load_dashboard_head_modules(
self, modules_to_load: Optional[Set[str]] = None
) -> Tuple[List[DashboardHeadModule], Set[str]]:
"""Load `DashboardHeadModule`s.
Args:
modules_to_load: A set of module names to load. By default (None),
it loads all modules.
Returns:
A tuple of ``(loaded_modules, skipped_module_names)``.
"""
modules = []
skipped_modules = set()
head_cls_list = dashboard_utils.get_all_modules(DashboardHeadModule)
config = DashboardHeadModuleConfig(
minimal=self.minimal,
cluster_id_hex=self.cluster_id_hex,
session_name=self.session_name,
gcs_address=self.gcs_address,
log_dir=self.log_dir,
temp_dir=self.temp_dir,
session_dir=self.session_dir,
ip=self.ip,
http_host=self.http_host,
http_port=self.http_port,
)
# Select modules to load.
if modules_to_load is not None:
head_cls_list = [
cls for cls in head_cls_list if cls.__name__ in modules_to_load
]
logger.info(f"DashboardHeadModules to load: {modules_to_load}.")
for cls in head_cls_list:
if not cls.is_enabled():
skipped_modules.add(cls.__name__)
continue
logger.info(f"Loading {DashboardHeadModule.__name__}: {cls}.")
c = cls(config)
modules.append(c)
logger.info(f"Loaded {len(modules)} dashboard head modules: {modules}.")
return modules, skipped_modules
def _load_subprocess_module_handles(
self, modules_to_load: Optional[Set[str]] = None
) -> List["SubprocessModuleHandle"]:
"""Load ``SubprocessModule`` handles.
If minimal, return an empty list.
If non-minimal, load `SubprocessModule`s by creating Handles to them.
Args:
modules_to_load: A set of module names to load. By default (None),
it loads all modules.
Returns:
A list of ``SubprocessModuleHandle`` instances, or an empty list in
minimal mode.
"""
if self.minimal:
logger.info("Subprocess modules not loaded in minimal mode.")
return []
from ray.dashboard.subprocesses.handle import SubprocessModuleHandle
from ray.dashboard.subprocesses.module import (
SubprocessModule,
SubprocessModuleConfig,
)
handles = []
subprocess_cls_list = dashboard_utils.get_all_modules(SubprocessModule)
loop = ray._common.utils.get_or_create_event_loop()
config = SubprocessModuleConfig(
cluster_id_hex=self.cluster_id_hex,
gcs_address=self.gcs_address,
session_name=self.session_name,
temp_dir=self.temp_dir,
session_dir=self.session_dir,
logging_level=self.logging_level,
logging_format=self.logging_format,
log_dir=self.log_dir,
logging_filename=self.logging_filename,
logging_rotate_bytes=self.logging_rotate_bytes,
logging_rotate_backup_count=self.logging_rotate_backup_count,
socket_dir=str(Path(self.session_dir) / "sockets"),
)
# Select modules to load.
if modules_to_load is not None:
subprocess_cls_list = [
cls for cls in subprocess_cls_list if cls.__name__ in modules_to_load
]
for cls in subprocess_cls_list:
logger.info(f"Loading {SubprocessModule.__name__}: {cls}.")
handle = SubprocessModuleHandle(loop, cls, config)
handles.append(handle)
logger.info(f"Loaded {len(handles)} subprocess modules: {handles}.")
return handles
async def _setup_metrics(self, gcs_client):
metrics = DashboardPrometheusMetrics()
# Setup prometheus metrics export server
assert internal_kv._internal_kv_initialized()
assert gcs_client is not None
address = build_address(self.ip, DASHBOARD_METRIC_PORT)
await gcs_client.async_internal_kv_put(
"DashboardMetricsAddress".encode(), address.encode(), True, namespace=None
)
if prometheus_client:
try:
logger.info(
"Starting dashboard metrics server on port {}".format(
DASHBOARD_METRIC_PORT
)
)
kwargs = {"addr": get_localhost_ip()} if is_localhost(self.ip) else {}
prometheus_client.start_http_server(
port=DASHBOARD_METRIC_PORT,
registry=metrics.registry,
**kwargs,
)
except Exception:
logger.exception(
"An exception occurred while starting the metrics server."
)
elif not prometheus_client:
logger.warning(
"`prometheus_client` not found, so metrics will not be exported."
)
return metrics
@dashboard_utils.async_loop_forever(dashboard_consts.METRICS_RECORD_INTERVAL_S)
async def _record_dashboard_metrics(
self, subprocess_module_handles: List["SubprocessModuleHandle"]
):
labels = {
"ip": self.ip,
"pid": self.pid,
"Version": ray.__version__,
"Component": "dashboard",
"SessionName": self.session_name,
}
assert "dashboard" in AVAILABLE_COMPONENT_NAMES_FOR_METRICS
self._record_cpu_mem_metrics_for_proc(self.dashboard_proc)
for subprocess_module_handle in subprocess_module_handles:
assert subprocess_module_handle.process is not None
proc = psutil.Process(subprocess_module_handle.process.pid)
self._record_cpu_mem_metrics_for_proc(
proc, subprocess_module_handle.module_cls.__name__
)
loop = ray._common.utils.get_or_create_event_loop()
self.metrics.metrics_event_loop_tasks.labels(**labels).set(
len(asyncio.all_tasks(loop))
)
# Report the max lag since the last export, if any.
if self._event_loop_lag_s_max is not None:
self.metrics.metrics_event_loop_lag.labels(**labels).set(
float(self._event_loop_lag_s_max)
)
self._event_loop_lag_s_max = None
def _record_cpu_mem_metrics_for_proc(
self, proc: psutil.Process, module_name: str = ""
):
labels = {
"ip": self.ip,
"pid": proc.pid,
"Version": ray.__version__,
"Component": "dashboard" if not module_name else "dashboard_" + module_name,
"SessionName": self.session_name,
}
proc_attrs = proc.as_dict(attrs=["cpu_percent", "memory_full_info"])
self.metrics.metrics_dashboard_cpu.labels(**labels).set(
float(proc_attrs.get("cpu_percent", 0.0))
)
# memory_full_info is None on Mac due to the permission issue
# (https://github.com/giampaolo/psutil/issues/883)
if proc_attrs.get("memory_full_info") is not None:
self.metrics.metrics_dashboard_mem_uss_mb.labels(**labels).set(
float(proc_attrs.get("memory_full_info").uss) / 1.0e6
)
self.metrics.metrics_dashboard_mem_uss_bytes.labels(**labels).set(
float(proc_attrs.get("memory_full_info").uss)
)
self.metrics.metrics_dashboard_mem_rss_mb.labels(**labels).set(
float(proc_attrs.get("memory_full_info").rss) / 1.0e6
)
self.metrics.metrics_dashboard_mem_rss_bytes.labels(**labels).set(
float(proc_attrs.get("memory_full_info").rss)
)
async def run(self):
gcs_address = self.gcs_address
# Dashboard will handle connection failure automatically
self.gcs_client = GcsClient(address=gcs_address, cluster_id=self.cluster_id_hex)
internal_kv._initialize_internal_kv(self.gcs_client)
dashboard_head_modules, subprocess_module_handles = self._load_modules(
self._modules_to_load
)
# Parallel start all subprocess modules.
for handle in subprocess_module_handles:
handle.start_module()
# Wait for all subprocess modules to be ready.
for handle in subprocess_module_handles:
handle.wait_for_module_ready()
if not self.minimal:
self.metrics = await self._setup_metrics(self.gcs_client)
self._event_loop_lag_s_max: Optional[float] = None
def on_new_lag(lag_s):
# Record the lag. It's exported in `record_dashboard_metrics`
self._event_loop_lag_s_max = max(self._event_loop_lag_s_max or 0, lag_s)
enable_monitor_loop_lag(on_new_lag)
self.record_dashboard_metrics_task = asyncio.create_task(
self._record_dashboard_metrics(subprocess_module_handles)
)
try:
assert internal_kv._internal_kv_initialized()
# Note: We always record the usage, but it is not reported
# if the usage stats is disabled.
record_extra_usage_tag(TagKey.DASHBOARD_USED, "False")
except Exception as e:
logger.warning(
"Failed to record the dashboard usage. "
"This error message is harmless and can be ignored. "
f"Error: {e}"
)
http_host, http_port = self.http_host, self.http_port
if self.serve_frontend:
logger.info("Initialize the http server.")
await self._configure_http_server(
dashboard_head_modules, subprocess_module_handles
)
http_host, http_port = self.http_server.get_address()
logger.info(
f"http server initialized at {build_address(http_host, http_port)}"
)
else:
logger.info("http server disabled.")
# We need to expose dashboard's node's ip for other worker nodes
# if it's not localhost.
dashboard_http_host = self.ip if not is_localhost(self.http_host) else http_host
# This synchronous code inside an async context is not great.
# It is however acceptable, because this only gets run once
# during initialization and therefore cannot block the event loop.
# This could be done better in the future, including
# removing the polling on the Ray side, by communicating the
# server address to Ray via stdin / stdout or a pipe.
self.gcs_client.internal_kv_put(
ray_constants.DASHBOARD_ADDRESS.encode(),
build_address(dashboard_http_host, http_port).encode(),
True,
namespace=ray_constants.KV_NAMESPACE_DASHBOARD,
)
concurrent_tasks = [
self._gcs_check_alive(),
]
for m in dashboard_head_modules:
concurrent_tasks.append(m.run())
await asyncio.gather(*concurrent_tasks)
if self.http_server:
await self.http_server.cleanup()