Files
ray-project--ray/python/ray/serve/_private/build_app.py
T
2026-07-13 13:17:40 +08:00

341 lines
13 KiB
Python

import inspect
import logging
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union
from ray.dag.py_obj_scanner import _PyObjScanner
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_HA_PROXY,
SERVE_LOGGER_NAME,
)
from ray.serve._private.http_util import ASGIAppReplicaWrapper
from ray.serve.deployment import Application, Deployment
from ray.serve.exceptions import RayServeException
from ray.serve.handle import DeploymentHandle
from ray.serve.schema import LoggingConfig
logger = logging.getLogger(SERVE_LOGGER_NAME)
K = TypeVar("K")
V = TypeVar("V")
INGRESS_REQUEST_ROUTER_REQUIRES_HAPROXY_ERROR = (
"`ingress_request_router` requires HAProxy. "
"Set `RAY_SERVE_ENABLE_HA_PROXY=1` in the Ray controller's environment."
)
CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR = (
"A custom `request_router_config.request_router_class` is not supported on "
"the ingress deployment when HAProxy is enabled. HAProxy load-balances "
"ingress traffic with its own algorithm and bypasses the Serve request "
"router, so the custom router would be silently ignored. Remove the custom "
"`request_router_class` from the ingress deployment, or configure HAProxy's "
"load-balancing algorithm instead."
)
class IDDict(dict, Generic[K, V]):
"""Dictionary that uses id() for keys instead of hash().
This is necessary because Application objects aren't hashable and we want each
instance to map to a unique key.
"""
def __getitem__(self, key: K) -> V:
if not isinstance(key, int):
key = id(key)
return super().__getitem__(key)
def __setitem__(self, key: K, value: V):
if not isinstance(key, int):
key = id(key)
return super().__setitem__(key, value)
def __delitem__(self, key: K):
if not isinstance(key, int):
key = id(key)
return super().__delitem__(key)
def __contains__(self, key: object):
if not isinstance(key, int):
key = id(key)
return super().__contains__(key)
@dataclass(frozen=True)
class BuiltApplication:
# Name of the application.
name: str
route_prefix: Optional[str]
logging_config: Optional[LoggingConfig]
# Name of the application's 'ingress' deployment
# (the one exposed over gRPC/HTTP/handle).
ingress_deployment_name: str
# List of unique deployments comprising the app.
deployments: List[Deployment]
# Dict[name, DeploymentHandle] mapping deployment names to the handles that replaced
# them in other deployments' init args/kwargs.
deployment_handles: Dict[str, DeploymentHandle]
external_scaler_enabled: bool
# Optional ingress request router deployment for ingress bypass mode.
# When set, this deployment serves /internal/route for HAProxy Lua routing.
ingress_request_router_deployment: Optional[Deployment] = None
def validate_single_fastapi_ingress(self) -> None:
"""Validate that the application has at most one FastAPI ingress."""
num_ingress_deployments = sum(
inspect.isclass(deployment.func_or_class)
and issubclass(deployment.func_or_class, ASGIAppReplicaWrapper)
for deployment in self.deployments
)
if num_ingress_deployments > 1:
raise RayServeException(
f'Found multiple FastAPI deployments in application "{self.name}". '
"Please only include one deployment with @serve.ingress "
"in your application to avoid this issue."
)
def _has_custom_request_router(deployment: Deployment) -> bool:
"""Whether the deployment configures a non-default request router class."""
request_router_config = deployment._deployment_config.request_router_config
return not request_router_config.is_default_request_router()
def _make_deployment_handle_default(
deployment: Deployment, app_name: str
) -> DeploymentHandle:
return DeploymentHandle(
deployment.name,
app_name=app_name,
)
def build_app(
app: Application,
*,
name: str,
route_prefix: Optional[str] = None,
logging_config: Optional[Union[Dict, LoggingConfig]] = None,
default_runtime_env: Optional[Dict[str, Any]] = None,
make_deployment_handle: Optional[
Callable[[Deployment, str], DeploymentHandle]
] = None,
external_scaler_enabled: bool = False,
) -> BuiltApplication:
"""Builds the application into a list of finalized deployments.
The following transformations are made:
- Application objects in constructor args/kwargs are converted to
DeploymentHandles for injection at runtime.
- Name conflicts from deployments that use the same class are handled
by appending a monotonically increasing suffix (e.g., SomeClass_1).
Returns: BuiltApplication
"""
if make_deployment_handle is None:
make_deployment_handle = _make_deployment_handle_default
ingress_request_router = app._ingress_request_router
if ingress_request_router is not None and not isinstance(
ingress_request_router, Application
):
raise TypeError(
"`ingress_request_router` must be an `Application` returned by "
"`Deployment.bind()`."
)
if ingress_request_router is not None and not RAY_SERVE_ENABLE_HA_PROXY:
raise RayServeException(INGRESS_REQUEST_ROUTER_REQUIRES_HAPROXY_ERROR)
# Under HAProxy, ingress traffic is load-balanced by HAProxy and bypasses
# the ingress deployment's Serve request router, so a custom router there is
# silently ignored. Reject it unless an `ingress_request_router` is attached
# (the Serve LLM direct-streaming path), where HAProxy delegates replica
# selection back to that router.
if (
RAY_SERVE_ENABLE_HA_PROXY
and ingress_request_router is None
and _has_custom_request_router(app._bound_deployment)
):
raise RayServeException(CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR)
handles = IDDict()
deployment_names = IDDict()
deployments = _build_app_recursive(
app,
app_name=name,
handles=handles,
deployment_names=deployment_names,
default_runtime_env=default_runtime_env,
make_deployment_handle=make_deployment_handle,
)
ingress_request_router_deployment = None
if ingress_request_router is not None:
ingress_request_router_deployments = _build_app_recursive(
ingress_request_router,
app_name=name,
handles=handles,
deployment_names=deployment_names,
default_runtime_env=default_runtime_env,
make_deployment_handle=make_deployment_handle,
)
# TODO(eicherseiji): The current ingress-bypass design only supports a
# standalone single-deployment router. Revisit this once routers can
# compose helper deployments.
if len(ingress_request_router_deployments) == 0:
raise ValueError(
"Expected `ingress_request_router` to build into one standalone "
"deployment, but it did not produce any new deployments. This "
"usually means the same bound router deployment is also reachable "
"from the main application graph; attach it only as "
"`ingress_request_router`."
)
if len(ingress_request_router_deployments) > 1:
raise ValueError(
"Expected `ingress_request_router` to build into exactly one "
"standalone deployment, got "
f"{len(ingress_request_router_deployments)}."
)
ingress_request_router_deployment = ingress_request_router_deployments[0]
main_deployment_names = {deployment.name for deployment in deployments}
return BuiltApplication(
name=name,
route_prefix=route_prefix,
logging_config=logging_config,
ingress_deployment_name=deployment_names[app],
deployments=deployments,
deployment_handles={
deployment_names[app]: handle
for app, handle in handles.items()
if deployment_names[app] in main_deployment_names
},
external_scaler_enabled=external_scaler_enabled,
ingress_request_router_deployment=ingress_request_router_deployment,
)
def _build_app_recursive(
app: Application,
*,
app_name: str,
deployment_names: IDDict[Application, str],
handles: IDDict[Application, DeploymentHandle],
default_runtime_env: Optional[Dict[str, Any]] = None,
make_deployment_handle: Callable[[Deployment, str], DeploymentHandle],
) -> List[Deployment]:
"""Recursively traverses the graph of Application objects.
Each Application will have an associated DeploymentHandle created that will replace
it in any occurrences in other Applications' args or kwargs.
Also collects a list of the unique Applications encountered and returns them as
deployable Deployment objects.
"""
# This application has already been encountered.
# There's no need to recurse into its child args and we don't want to create
# a duplicate entry for it in the list of deployments.
if app in handles:
return []
deployments = []
scanner = _PyObjScanner(source_type=Application)
try:
# Recursively traverse any Application objects bound to init args/kwargs.
child_apps = scanner.find_nodes(
(app._bound_deployment.init_args, app._bound_deployment.init_kwargs)
)
for child_app in child_apps:
deployments.extend(
_build_app_recursive(
child_app,
app_name=app_name,
handles=handles,
deployment_names=deployment_names,
make_deployment_handle=make_deployment_handle,
default_runtime_env=default_runtime_env,
)
)
# Replace Application objects with their corresponding DeploymentHandles.
new_init_args, new_init_kwargs = scanner.replace_nodes(handles)
final_deployment = app._bound_deployment.options(
name=_get_unique_deployment_name_memoized(app, deployment_names),
_init_args=new_init_args,
_init_kwargs=new_init_kwargs,
)
final_deployment = _set_default_runtime_env(
final_deployment, default_runtime_env
)
# Create the DeploymentHandle that will be used to replace this application
# in the arguments of its parent(s).
handles[app] = make_deployment_handle(
final_deployment,
app_name,
)
return deployments + [final_deployment]
finally:
scanner.clear()
def _set_default_runtime_env(
d: Deployment, default_runtime_env: Optional[Dict[str, Any]]
) -> Deployment:
"""Configures the deployment with the provided default runtime_env.
If the deployment does not have a runtime_env configured, the default will be set.
If it does have a runtime_env configured but that runtime_env does not have a
working_dir, only the working_dir field will be set.
Else the deployment's runtime_env will be left untouched.
"""
if not default_runtime_env:
return d
ray_actor_options = deepcopy(d.ray_actor_options or {})
default_working_dir = default_runtime_env.get("working_dir", None)
if "runtime_env" not in ray_actor_options:
ray_actor_options["runtime_env"] = default_runtime_env
elif default_working_dir is not None:
ray_actor_options["runtime_env"].setdefault("working_dir", default_working_dir)
return d.options(ray_actor_options=ray_actor_options)
def _get_unique_deployment_name_memoized(
app: Application, deployment_names: IDDict[Application, str]
) -> str:
"""Generates a name for the deployment.
This is used to handle collisions when the user does not specify a name
explicitly, so typically we'd use the class name as the default.
In that case, we append a monotonically increasing suffix to the name, e.g.,
Deployment, then Deployment_1, then Deployment_2, ...
Names are memoized in the `deployment_names` dict, which should be passed to
subsequent calls to this function.
"""
if app in deployment_names:
return deployment_names[app]
idx = 1
name = app._bound_deployment.name
while name in deployment_names.values():
name = f"{app._bound_deployment.name}_{idx}"
idx += 1
if idx != 1:
logger.warning(
"There are multiple deployments with the same name "
f"'{app._bound_deployment.name}'. Renaming one to '{name}'."
)
deployment_names[app] = name
return name