diff --git a/doc/source/serve/advanced.rst b/doc/source/serve/advanced.rst index f0f545c9b..81bbeb9a7 100644 --- a/doc/source/serve/advanced.rst +++ b/doc/source/serve/advanced.rst @@ -268,3 +268,31 @@ Ray Serve exposes important system metrics like the number of successful and errored requests through the Ray metrics monitoring infrastructure. By default, the metrics are exposed in Prometheus format on each node. See the `Ray Monitoring documentation <../ray-metrics.html>`__ for more information. + +Dependency Management +===================== + +Ray Serve supports serving backends with different (possibly conflicting) +python dependencies. For example, you can simultaneously serve one backend +that uses legacy Tensorflow 1 and another backend that uses Tensorflow 2. + +Currently this is supported using `conda `_. +You must have a conda environment set up for each set of +dependencies you want to isolate. If using a multi-node cluster, the +conda configuration must be identical across all nodes. + +Here's an example script. For it to work, first create a conda +environment named ``ray-tf1`` with Ray Serve and Tensorflow 1 installed, +and another named ``ray-tf2`` with Ray Serve and Tensorflow 2. The Ray and +python versions must be the same in both environments. To specify +an environment for a backend to use, simply pass the environment name in to +:mod:`client.create_backend ` +as shown below. Be sure to run the script in an activated conda environment +(not required to be ``ray-tf1`` or ``ray-tf2``). + +.. literalinclude:: ../../../python/ray/serve/examples/doc/conda_env.py + +Alternatively, you may omit the argument ``env`` and call +:mod:`client.create_backend ` +from a script running in the conda environment you want the backend to run in. + diff --git a/doc/source/serve/package-ref.rst b/doc/source/serve/package-ref.rst index 7041f3b40..4c1ad2f7b 100644 --- a/doc/source/serve/package-ref.rst +++ b/doc/source/serve/package-ref.rst @@ -15,6 +15,8 @@ Backend Configuration --------------------- .. autoclass:: ray.serve.BackendConfig +.. autoclass:: ray.serve.CondaEnv + Handle API ---------- .. autoclass:: ray.serve.handle.RayServeHandle diff --git a/python/ray/serve/__init__.py b/python/ray/serve/__init__.py index cd27e4907..ee23980d1 100644 --- a/python/ray/serve/__init__.py +++ b/python/ray/serve/__init__.py @@ -1,5 +1,6 @@ from ray.serve.api import (accept_batch, Client, connect, start) # noqa: F401 from ray.serve.config import BackendConfig +from ray.serve.env import CondaEnv # Mute the warning because Serve sometimes intentionally calls # ray.get inside async actors. @@ -9,7 +10,8 @@ ray.worker.blocking_get_inside_async_warned = True __all__ = [ "accept_batch", "BackendConfig", - "connect" + "CondaEnv", + "connect", "Client", "start", ] diff --git a/python/ray/serve/api.py b/python/ray/serve/api.py index 366a23273..61ec874fb 100644 --- a/python/ray/serve/api.py +++ b/python/ray/serve/api.py @@ -1,6 +1,7 @@ import atexit from functools import wraps import random +import os import ray from ray.serve.constants import (DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, @@ -8,9 +9,11 @@ from ray.serve.constants import (DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, from ray.serve.controller import ServeController from ray.serve.handle import RayServeHandle from ray.serve.utils import (block_until_http_ready, format_actor_name, - get_random_letters, logger, get_node_id_for_actor) + get_random_letters, logger, get_node_id_for_actor, + get_conda_env_dir) from ray.serve.exceptions import RayServeException from ray.serve.config import BackendConfig, ReplicaConfig, BackendMetadata +from ray.serve.env import CondaEnv from ray.actor import ActorHandle from typing import Any, Callable, Dict, List, Optional, Type, Union @@ -198,8 +201,8 @@ class Client: func_or_class: Union[Callable, Type[Callable]], *actor_init_args: Any, ray_actor_options: Optional[Dict] = None, - config: Optional[Union[BackendConfig, Dict[str, Any]]] = None - ) -> None: + config: Optional[Union[BackendConfig, Dict[str, Any]]] = None, + env: Optional[CondaEnv] = None) -> None: """Create a backend with the provided tag. The backend will serve requests with func_or_class. @@ -225,6 +228,12 @@ class Client: - "max_concurrent_queries": the maximum number of queries that will be sent to a replica of this backend without receiving a response. + env (serve.CondaEnv, optional): conda environment to run this + backend in. Requires the caller to be running in an activated + conda environment (not necessarily ``env``), and requires + ``env`` to be an existing conda environment on all nodes. If + ``env`` is not provided but conda is activated, the backend + will run in the conda environment of the caller. """ if backend_tag in self.list_backends().keys(): raise ValueError( @@ -233,6 +242,20 @@ class Client: if config is None: config = {} + if ray_actor_options is None: + ray_actor_options = {} + if env is None: + # If conda is activated, default to conda env of this process. + if os.environ.get("CONDA_PREFIX"): + if "override_environment_variables" not in ray_actor_options: + ray_actor_options["override_environment_variables"] = {} + ray_actor_options["override_environment_variables"].update({ + "PYTHONHOME": os.environ.get("CONDA_PREFIX") + }) + else: + conda_env_dir = get_conda_env_dir(env.name) + ray_actor_options.update( + override_environment_variables={"PYTHONHOME": conda_env_dir}) replica_config = ReplicaConfig( func_or_class, *actor_init_args, diff --git a/python/ray/serve/env.py b/python/ray/serve/env.py new file mode 100644 index 000000000..9dd6ab33a --- /dev/null +++ b/python/ray/serve/env.py @@ -0,0 +1,6 @@ +from pydantic.dataclasses import dataclass + + +@dataclass +class CondaEnv: + name: str diff --git a/python/ray/serve/examples/doc/conda_env.py b/python/ray/serve/examples/doc/conda_env.py new file mode 100644 index 000000000..4b8239d67 --- /dev/null +++ b/python/ray/serve/examples/doc/conda_env.py @@ -0,0 +1,21 @@ +import requests +import ray +from ray import serve +from ray.serve import CondaEnv +import tensorflow as tf + +ray.init() +client = serve.start() + + +def tf_version(request): + return ("Tensorflow " + tf.__version__) + + +client.create_backend("tf1", tf_version, env=CondaEnv("ray-tf1")) +client.create_endpoint("tf1", backend="tf1", route="/tf1") +client.create_backend("tf2", tf_version, env=CondaEnv("ray-tf2")) +client.create_endpoint("tf2", backend="tf2", route="/tf2") + +print(requests.get("http://127.0.0.1:8000/tf1").text) # Tensorflow 1.15.0 +print(requests.get("http://127.0.0.1:8000/tf2").text) # Tensorflow 2.3.0 diff --git a/python/ray/serve/tests/test_util.py b/python/ray/serve/tests/test_util.py index e67ff2c26..d492a3b1e 100644 --- a/python/ray/serve/tests/test_util.py +++ b/python/ray/serve/tests/test_util.py @@ -1,4 +1,5 @@ import asyncio +import os import json from copy import deepcopy @@ -6,7 +7,8 @@ import numpy as np import pytest from ray.serve.utils import (ServeEncoder, chain_future, unpack_future, - try_schedule_resources_on_nodes) + try_schedule_resources_on_nodes, + get_conda_env_dir) def test_bytes_encoder(): @@ -109,6 +111,20 @@ def test_mock_scheduler(): deepcopy(ray_nodes)) == [False] +def test_get_conda_env_dir(tmp_path): + d = tmp_path / "tf1" + d.mkdir() + os.environ["CONDA_PREFIX"] = str(d) + with pytest.raises(ValueError): + # env does not exist + env_dir = get_conda_env_dir("tf2") + tf2_dir = tmp_path / "tf2" + tf2_dir.mkdir() + env_dir = get_conda_env_dir("tf2") + assert (env_dir == str(tmp_path / "tf2")) + os.environ["CONDA_PREFIX"] = "" + + if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", "-s", __file__])) diff --git a/python/ray/serve/utils.py b/python/ray/serve/utils.py index b227c049d..e9c95925d 100644 --- a/python/ray/serve/utils.py +++ b/python/ray/serve/utils.py @@ -179,6 +179,39 @@ def format_actor_name(actor_name, controller_name=None, *modifiers): return name +def get_conda_env_dir(env_name): + """Given a environment name like `tf1`, find and validate the + corresponding conda directory. + """ + conda_prefix = os.environ.get("CONDA_PREFIX") + if conda_prefix is None: + raise ValueError( + "Serve cannot find environment variables installed by conda. " + + "Are you sure you are in a conda env?") + + # There are two cases: + # 1. We are in conda base env: CONDA_DEFAULT_ENV=base and + # CONDA_PREFIX=$HOME/anaconda3 + # 2. We are in user created conda env: CONDA_DEFAULT_ENV=$env_name and + # CONDA_PREFIX=$HOME/anaconda3/envs/$env_name + if os.environ.get("CONDA_DEFAULT_ENV") == "base": + # Caller is running in base conda env. + # Not recommended by conda, but we can still try to support it. + env_dir = os.path.join(conda_prefix, "envs", env_name) + else: + # Now `conda_prefix` should be something like + # $HOME/anaconda3/envs/$env_name + # We want to strip the $env_name component. + conda_envs_dir = os.path.split(conda_prefix)[0] + env_dir = os.path.join(conda_envs_dir, env_name) + if not os.path.isdir(env_dir): + raise ValueError( + "conda env " + env_name + + " not found in conda envs directory. Run `conda env list` to " + + "verify the name is correct.") + return env_dir + + @singledispatch def chain_future(src, dst): """Base method for chaining futures together.