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

381 lines
15 KiB
Python

import asyncio
import inspect
import logging
from types import FunctionType
from typing import Any, Dict, List, Tuple, Union
from pydantic import BaseModel
import ray
from ray import ObjectRef
from ray._common.usage import usage_lib
from ray.actor import ActorHandle
from ray.serve._private.client import ServeControllerClient
from ray.serve._private.constants import (
HTTP_PROXY_TIMEOUT,
SERVE_LOGGER_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.default_impl import get_controller_impl
from ray.serve.config import (
ControllerOptions,
HTTPOptions,
ProxyLocation,
gRPCOptions,
)
from ray.serve.context import (
_check_cached_client_alive,
_get_global_client,
_set_global_client,
)
from ray.serve.deployment import Application
from ray.serve.exceptions import RayServeException
from ray.serve.schema import LoggingConfig
logger = logging.getLogger(SERVE_LOGGER_NAME)
def _coerce_controller_options(
controller_options: Union[None, dict, ControllerOptions],
) -> ControllerOptions:
"""Normalize an optional dict / model into a validated ControllerOptions."""
if controller_options is None:
return ControllerOptions()
if isinstance(controller_options, ControllerOptions):
return controller_options
if isinstance(controller_options, dict):
return ControllerOptions.model_validate(controller_options)
raise TypeError(
"controller_options must be a dict, ControllerOptions, or None; got "
f"{type(controller_options).__name__}."
)
def _check_http_options(
client: ServeControllerClient, http_options: Union[dict, HTTPOptions]
) -> None:
if http_options:
client_http_options = client.http_config
new_http_options = (
http_options
if isinstance(http_options, HTTPOptions)
else HTTPOptions.model_validate(http_options)
)
different_fields = []
all_http_option_fields = new_http_options.__dict__
for field in all_http_option_fields:
if getattr(new_http_options, field) != getattr(client_http_options, field):
different_fields.append(field)
if len(different_fields):
logger.warning(
"The new client HTTP config differs from the existing one "
f"in the following fields: {different_fields}. "
"The new HTTP config is ignored."
)
def _create_controller_and_proxy_refs(
http_options: Union[None, dict, HTTPOptions],
grpc_options: Union[None, dict, gRPCOptions],
global_logging_config: Union[None, dict, LoggingConfig],
controller_options: ControllerOptions,
proxy_location: Union[None, str, ProxyLocation] = None,
**kwargs,
) -> Tuple[ActorHandle, List[ObjectRef]]:
"""Create the detached ServeController actor in the caller process.
Runs everything the old ``_start_controller`` remote task did, but inline:
ray.init if needed, arg validation, controller actor creation, and resolving
``get_proxies``. Returns the controller handle (owned locally by the caller,
following normal ref-counting — no cross-process transfer) plus the list of
proxy-readiness ObjectRefs the caller must still wait on.
Caller resolves ``proxy_ready_refs`` synchronously (``ray.get``) or
asynchronously (``await asyncio.gather``). Splitting the wait out of this
helper is what lets sync and async share one creation path without
duplication (per @edoakes's suggestion on #63597).
Parameters are same as ray.serve._private.api.serve_start(), except
``controller_options`` must already be a validated ``ControllerOptions``
(callers coerce eagerly so a bad value raises locally, not from a task).
"""
# Initialize ray if needed.
ray._private.worker.global_worker._filter_logs_by_job = False
if not ray.is_initialized():
ray.init(namespace=SERVE_NAMESPACE)
# Legacy http proxy actor check
http_deprecated_args = ["http_host", "http_port", "http_middlewares"]
for key in http_deprecated_args:
if key in kwargs:
raise ValueError(
f"{key} is deprecated, please use serve.start(http_options="
f'{{"{key}": {kwargs[key]}}}) instead.'
)
if isinstance(http_options, dict):
http_options = HTTPOptions.model_validate(http_options)
if http_options is None:
http_options = HTTPOptions()
proxy_location = ProxyLocation._normalize(proxy_location)
if isinstance(grpc_options, dict):
grpc_options = gRPCOptions(**grpc_options)
if global_logging_config is None:
global_logging_config = LoggingConfig()
elif isinstance(global_logging_config, dict):
global_logging_config = LoggingConfig(**global_logging_config)
controller_impl = get_controller_impl(controller_options=controller_options)
controller = controller_impl.remote(
http_options=http_options,
grpc_options=grpc_options,
global_logging_config=global_logging_config,
proxy_location=proxy_location,
)
proxy_handles = ray.get(controller.get_proxies.remote())
proxy_ready_refs = (
[handle.ready.remote() for handle in proxy_handles.values()]
if len(proxy_handles) > 0
else []
)
return controller, proxy_ready_refs
async def serve_start_async(
http_options: Union[None, dict, HTTPOptions] = None,
grpc_options: Union[None, dict, gRPCOptions] = None,
global_logging_config: Union[None, dict, LoggingConfig] = None,
controller_options: Union[None, dict, ControllerOptions] = None,
proxy_location: Union[None, str, ProxyLocation] = None,
**kwargs,
) -> ServeControllerClient:
"""Initialize a serve instance asynchronously.
This function is not thread-safe. The caller should maintain the async lock in order
to start the serve instance asynchronously.
This function has the same functionality as ray.serve._private.api.serve_start().
Parameters & Returns are same as ray.serve._private.api.serve_start().
"""
usage_lib.record_library_usage("serve")
# Validate eagerly in the caller so a bad ``controller_options`` raises
# here, at the call site, rather than after a round-trip into the helper.
controller_options = _coerce_controller_options(controller_options)
client, _ = _check_cached_client_alive()
if client is None:
try:
client = _get_global_client()
except RayServeException:
client = None
if client is not None:
logger.info(
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
" New http_options/controller_options will not be applied."
)
if http_options:
_check_http_options(client, http_options)
return client
# Run the blocking controller-creation helper in a worker thread so its
# ray.get(controller.get_proxies.remote()) does not stall this event loop.
# serve_start_async exists (vs serve_start) precisely to keep long-lived
# callers like the Dashboard Agent responsive while Serve starts up.
# Safe because the helper only touches ray.init/ray.get/actor.remote, all
# thread-safe via the CoreWorker C++ API, and the Dashboard Agent uses sync
# ray.init (so the helper's ray.init branch is skipped). If the dashboard
# ever switched to async-mode ray.init this would need revisiting.
controller, proxy_ready_refs = await asyncio.to_thread(
_create_controller_and_proxy_refs,
http_options,
grpc_options,
global_logging_config,
controller_options,
proxy_location,
**kwargs,
)
if proxy_ready_refs:
try:
await asyncio.wait_for(
asyncio.gather(*proxy_ready_refs),
timeout=HTTP_PROXY_TIMEOUT,
)
except asyncio.TimeoutError:
raise TimeoutError(
f"HTTP proxies not available after {HTTP_PROXY_TIMEOUT}s."
)
client = ServeControllerClient(controller)
_set_global_client(client)
logger.info(f'Started Serve in namespace "{SERVE_NAMESPACE}".')
return client
def serve_start(
http_options: Union[None, dict, HTTPOptions] = None,
grpc_options: Union[None, dict, gRPCOptions] = None,
global_logging_config: Union[None, dict, LoggingConfig] = None,
controller_options: Union[None, dict, ControllerOptions] = None,
proxy_location: Union[None, str, ProxyLocation] = None,
**kwargs,
) -> ServeControllerClient:
"""Initialize a serve instance.
By default, the instance will be scoped to the lifetime of the returned
Client object (or when the script exits). This is
only relevant if connecting to a long-running Ray cluster (e.g., with
ray.init(address="auto") or ray.init("ray://<remote_addr>")).
Args:
http_options: Configuration options for HTTP proxy. You can pass in a
dictionary or HTTPOptions object with fields:
- host(str, None): Host for HTTP servers to listen on. Defaults to
localhost. To expose Serve publicly, you probably want to set
this to "0.0.0.0" for IPv4 or "::" for IPv6.
- port(int): Port for HTTP server. Defaults to 8000.
- root_path(str): Root path to mount the serve application
(for example, "/serve"). All deployment routes will be prefixed
with this path. Defaults to "".
- middlewares(list): A list of Starlette middlewares that will be
applied to the HTTP servers in the cluster. Defaults to [].
- location(str, serve.config.ProxyLocation): The deployment
location of HTTP servers:
- "HeadOnly": start one HTTP server on the head node. Serve
assumes the head node is the node you executed serve.start
on. This is the default.
- "EveryNode": start one HTTP server per node.
- "Disabled" (or legacy "NoServer") or None: disable HTTP server.
- num_cpus (int): The number of CPU cores to reserve for each
internal Serve HTTP proxy actor. Defaults to 0.
grpc_options: Configuration options for gRPC proxy.
You can pass in a gRPCOptions object with fields:
- port(int): Port for gRPC server. Defaults to 9000.
- grpc_servicer_functions(list): List of import paths for gRPC
`add_servicer_to_server` functions to add to Serve's gRPC proxy.
Default empty list, meaning not to start the gRPC server.
global_logging_config: Optional ``LoggingConfig`` (or dict) applied as
the default logging configuration for the Serve controller and all
proxies/replicas in this Serve instance.
controller_options: Optional ``ControllerOptions`` (or dict) for the
Serve controller actor. Currently only ``runtime_env.env_vars``
is honored; see ``ray.serve.config.ControllerOptions``. Only
applied on first controller creation -- ignored if the controller
is already running in this Ray cluster (a log line is emitted).
proxy_location: Where to run proxies that handle ingress traffic to
the cluster. See ``ProxyLocation`` for supported options. Defaults
to ``EveryNode`` when unspecified. An explicit (deprecated)
``HTTPOptions.location`` overrides this.
**kwargs: Reserved for forwarding to internal controller-start hooks;
no public keys are currently supported and unknown keys may raise.
Returns:
A ``ServeControllerClient`` connected to the Serve controller (either
newly started or an already-running one in this Ray cluster).
"""
usage_lib.record_library_usage("serve")
controller_options = _coerce_controller_options(controller_options)
client, _ = _check_cached_client_alive()
if client is None:
try:
client = _get_global_client()
except RayServeException:
client = None
if client is not None:
logger.info(
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
" New http_options/controller_options will not be applied."
)
if http_options:
_check_http_options(client, http_options)
return client
controller, proxy_ready_refs = _create_controller_and_proxy_refs(
http_options,
grpc_options,
global_logging_config,
controller_options,
proxy_location,
**kwargs,
)
if proxy_ready_refs:
try:
ray.get(proxy_ready_refs, timeout=HTTP_PROXY_TIMEOUT)
except ray.exceptions.GetTimeoutError:
raise TimeoutError(
f"HTTP proxies not available after {HTTP_PROXY_TIMEOUT}s."
)
client = ServeControllerClient(controller)
_set_global_client(client)
logger.info(f'Started Serve in namespace "{SERVE_NAMESPACE}".')
return client
def call_user_app_builder_with_args_if_necessary(
builder: Union[Application, FunctionType],
args: Dict[str, Any],
) -> Application:
"""Calls a user-provided function that returns Serve application.
If an Application object is passed, this is a no-op.
Else, we validate the signature of the function, convert the args dictionary to
the user-annotated Pydantic model if provided, and call the function.
The output of the function is returned (must be an Application).
"""
if isinstance(builder, Application):
if len(args) > 0:
raise ValueError(
"Arguments can only be passed to an application builder function, "
"not an already built application."
)
return builder
elif not isinstance(builder, FunctionType):
raise TypeError(
"Expected a built Serve application or an application builder function "
f"but got: {type(builder)}."
)
# Check that the builder only takes a single argument.
# TODO(edoakes): we may want to loosen this to allow optional kwargs in the future.
signature = inspect.signature(builder)
if len(signature.parameters) != 1:
raise TypeError(
"Application builder functions should take exactly one parameter, "
"a dictionary containing the passed arguments."
)
# If the sole argument to the builder is a pydantic model, convert the args dict to
# that model. This will perform standard pydantic validation (e.g., raise an
# exception if required fields are missing).
param = signature.parameters[list(signature.parameters.keys())[0]]
if inspect.isclass(param.annotation) and issubclass(param.annotation, BaseModel):
args = param.annotation.model_validate(args)
app = builder(args)
if not isinstance(app, Application):
raise TypeError(
"Application builder functions must return an `Application` returned "
f"`from `Deployment.bind()`, but got: {type(app)}."
)
return app