1229 lines
39 KiB
Python
1229 lines
39 KiB
Python
import asyncio
|
|
import logging
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from tempfile import NamedTemporaryFile
|
|
|
|
import httpx
|
|
import pytest
|
|
import requests
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.network_utils import get_all_interfaces_ip
|
|
from ray._common.test_utils import (
|
|
SignalActor,
|
|
wait_for_condition,
|
|
)
|
|
from ray.actor import ActorHandle
|
|
from ray.cluster_utils import Cluster
|
|
from ray.serve._private.build_app import CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR
|
|
from ray.serve._private.constants import (
|
|
DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S,
|
|
RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT,
|
|
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
|
|
RAY_SERVE_ENABLE_HA_PROXY,
|
|
SERVE_NAMESPACE,
|
|
SERVE_SESSION_ID,
|
|
)
|
|
from ray.serve._private.haproxy import HAProxyManager
|
|
from ray.serve._private.test_utils import (
|
|
alive_actor_counts,
|
|
expected_proxy_actors,
|
|
get_application_url,
|
|
)
|
|
from ray.serve.config import HTTPOptions, RequestRouterConfig
|
|
from ray.serve.context import _get_global_client
|
|
from ray.serve.exceptions import RayServeException
|
|
from ray.serve.experimental.round_robin_router import RoundRobinRouter
|
|
from ray.serve.schema import (
|
|
ProxyStatus,
|
|
ServeDeploySchema,
|
|
ServeInstanceDetails,
|
|
)
|
|
from ray.serve.tests.conftest import * # noqa
|
|
from ray.serve.tests.test_cli_2 import ping_endpoint
|
|
from ray.tests.conftest import call_ray_stop_only # noqa: F401
|
|
from ray.util.state import list_actors
|
|
|
|
import psutil
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Skip all tests in this module if the HAProxy feature flag is not enabled
|
|
pytestmark = pytest.mark.skipif(
|
|
not RAY_SERVE_ENABLE_HA_PROXY,
|
|
reason="RAY_SERVE_ENABLE_HA_PROXY not set.",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_up_haproxy_processes():
|
|
"""Clean up haproxy processes before and after each test."""
|
|
subprocess.run(
|
|
["pkill", "-x", "haproxy"], capture_output=True, text=True, check=False
|
|
)
|
|
yield
|
|
# After test: verify no haproxy processes are running
|
|
result = subprocess.run(
|
|
["pgrep", "-x", "haproxy"], capture_output=True, text=True, check=False
|
|
)
|
|
assert (
|
|
result.returncode != 0
|
|
), f"HAProxy processes still running after test: {result.stdout.strip()}"
|
|
|
|
|
|
@pytest.fixture
|
|
def shutdown_ray():
|
|
if ray.is_initialized():
|
|
ray.shutdown()
|
|
yield
|
|
if ray.is_initialized():
|
|
ray.shutdown()
|
|
|
|
|
|
def test_deploy_with_no_applications(ray_shutdown):
|
|
"""Deploy an empty list of applications, serve should just be started."""
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
client = _get_global_client()
|
|
config = ServeDeploySchema.model_validate({"applications": []})
|
|
client.deploy_apps(config)
|
|
|
|
def serve_running():
|
|
ServeInstanceDetails.model_validate(
|
|
ray.get(client._controller.get_serve_instance_details.remote())
|
|
)
|
|
actors = list_actors(
|
|
filters=[
|
|
("ray_namespace", "=", SERVE_NAMESPACE),
|
|
("state", "=", "ALIVE"),
|
|
]
|
|
)
|
|
actor_names = [actor["class_name"] for actor in actors]
|
|
return "ServeController" in actor_names and "HAProxyManager" in actor_names
|
|
|
|
wait_for_condition(serve_running)
|
|
client.shutdown()
|
|
|
|
|
|
def test_single_app_shutdown_actors(ray_shutdown):
|
|
"""Tests serve.shutdown() works correctly in single-app case
|
|
|
|
Ensures that after deploying a (nameless) app using serve.run(), serve.shutdown()
|
|
deletes all actors (controller, haproxy, all replicas) in the "serve" namespace.
|
|
"""
|
|
address = ray.init(num_cpus=8)["address"]
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app")
|
|
|
|
actor_names = {
|
|
"ServeController",
|
|
*expected_proxy_actors(),
|
|
"ServeReplica:app:f",
|
|
}
|
|
|
|
def check_alive():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return {actor["class_name"] for actor in actors} == actor_names
|
|
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_alive)
|
|
serve.shutdown()
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_app_shutdown_actors_async(ray_shutdown):
|
|
"""Tests serve.shutdown_async() works correctly in single-app case
|
|
|
|
Ensures that after deploying a (nameless) app using serve.run(), serve.shutdown_async()
|
|
deletes all actors (controller, haproxy, all replicas) in the "serve" namespace.
|
|
"""
|
|
address = ray.init(num_cpus=8)["address"]
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app")
|
|
|
|
actor_names = {
|
|
"ServeController",
|
|
*expected_proxy_actors(),
|
|
"ServeReplica:app:f",
|
|
}
|
|
|
|
def check_alive():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return {actor["class_name"] for actor in actors} == actor_names
|
|
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_alive)
|
|
await serve.shutdown_async()
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
def test_haproxy_subprocess_killed_on_manager_shutdown(ray_shutdown):
|
|
"""Test that the HAProxy subprocess is killed when the HAProxyManager actor is shutdown.
|
|
|
|
This ensures proper cleanup of HAProxy processes when the manager is killed,
|
|
preventing orphaned HAProxy processes.
|
|
"""
|
|
|
|
def get_haproxy_pids():
|
|
"""Get all haproxy process PIDs."""
|
|
result = subprocess.run(
|
|
["pgrep", "-x", "haproxy"], capture_output=True, text=True, timeout=2
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return [int(pid) for pid in result.stdout.strip().split("\n")]
|
|
|
|
return []
|
|
|
|
wait_for_condition(
|
|
lambda: len(get_haproxy_pids()) == 0, timeout=5, retry_interval_ms=100
|
|
)
|
|
|
|
@serve.deployment
|
|
def hello():
|
|
return "hello"
|
|
|
|
serve.run(hello.bind())
|
|
wait_for_condition(
|
|
lambda: len(get_haproxy_pids()) == 1, timeout=10, retry_interval_ms=100
|
|
)
|
|
|
|
serve.shutdown()
|
|
|
|
wait_for_condition(
|
|
lambda: len(get_haproxy_pids()) == 0, timeout=10, retry_interval_ms=100
|
|
)
|
|
|
|
|
|
# TODO(alexyang): Delete these tests and run test_proxy.py instead once HAProxy is fully supported.
|
|
class TestTimeoutKeepAliveConfig:
|
|
"""Test setting keep_alive_timeout_s in config and env."""
|
|
|
|
def get_proxy_actor(self) -> ActorHandle:
|
|
[proxy_actor] = list_actors(filters=[("class_name", "=", "HAProxyManager")])
|
|
return ray.get_actor(proxy_actor.name, namespace=SERVE_NAMESPACE)
|
|
|
|
def test_default_keep_alive_timeout_s(self, ray_shutdown):
|
|
"""Test when no keep_alive_timeout_s is set.
|
|
|
|
When the keep_alive_timeout_s is not set, the uvicorn keep alive is 5.
|
|
"""
|
|
serve.start()
|
|
proxy_actor = self.get_proxy_actor()
|
|
assert (
|
|
ray.get(proxy_actor._get_http_options.remote()).keep_alive_timeout_s
|
|
== DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S
|
|
)
|
|
|
|
def test_set_keep_alive_timeout_in_http_configs(self, ray_shutdown):
|
|
"""Test when keep_alive_timeout_s is in http configs.
|
|
|
|
When the keep_alive_timeout_s is set in http configs, the uvicorn keep alive
|
|
is set correctly.
|
|
"""
|
|
keep_alive_timeout_s = 222
|
|
serve.start(http_options={"keep_alive_timeout_s": keep_alive_timeout_s})
|
|
proxy_actor = self.get_proxy_actor()
|
|
assert (
|
|
ray.get(proxy_actor._get_http_options.remote()).keep_alive_timeout_s
|
|
== keep_alive_timeout_s
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_instance",
|
|
[
|
|
{"RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S": "333"},
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_set_keep_alive_timeout_in_env(self, ray_instance, ray_shutdown):
|
|
"""Test when keep_alive_timeout_s is in env.
|
|
|
|
When the keep_alive_timeout_s is set in env, the uvicorn keep alive
|
|
is set correctly.
|
|
"""
|
|
serve.start()
|
|
proxy_actor = self.get_proxy_actor()
|
|
assert (
|
|
ray.get(proxy_actor._get_http_options.remote()).keep_alive_timeout_s == 333
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_instance",
|
|
[
|
|
{"RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S": "333"},
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_set_timeout_keep_alive_in_both_config_and_env(
|
|
self, ray_instance, ray_shutdown
|
|
):
|
|
"""Test when keep_alive_timeout_s is in both http configs and env.
|
|
|
|
When the keep_alive_timeout_s is set in env, the uvicorn keep alive
|
|
is set to the one in env.
|
|
"""
|
|
keep_alive_timeout_s = 222
|
|
serve.start(http_options={"keep_alive_timeout_s": keep_alive_timeout_s})
|
|
proxy_actor = self.get_proxy_actor()
|
|
assert (
|
|
ray.get(proxy_actor._get_http_options.remote()).keep_alive_timeout_s == 333
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain_and_undrain_haproxy_manager(
|
|
monkeypatch, shutdown_ray, call_ray_stop_only # noqa: F811
|
|
):
|
|
"""Test the state transtion of the haproxy manager between
|
|
HEALTHY, DRAINING and DRAINED
|
|
"""
|
|
monkeypatch.setenv("RAY_SERVE_PROXY_MIN_DRAINING_PERIOD_S", "10")
|
|
|
|
# No SO_REUSEPORT. Each node's HAProxy binds its own port. The head keeps the
|
|
# default 8000. Each worker gets a distinct HTTP port via
|
|
# RAY_SERVE_WORKER_PROXY_HTTP_PORT and distinct stats/metrics ports so the
|
|
# three co-located HAProxies don't collide.
|
|
cluster = Cluster()
|
|
head_node = cluster.add_node(num_cpus=0)
|
|
cluster.add_node(
|
|
num_cpus=1,
|
|
env_vars={
|
|
"RAY_SERVE_WORKER_PROXY_HTTP_PORT": "8001",
|
|
"RAY_SERVE_HAPROXY_STATS_PORT": "8405",
|
|
"RAY_SERVE_HAPROXY_METRICS_PORT": "9102",
|
|
},
|
|
)
|
|
cluster.add_node(
|
|
num_cpus=1,
|
|
env_vars={
|
|
"RAY_SERVE_WORKER_PROXY_HTTP_PORT": "8002",
|
|
"RAY_SERVE_HAPROXY_STATS_PORT": "8406",
|
|
"RAY_SERVE_HAPROXY_METRICS_PORT": "9103",
|
|
},
|
|
)
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=head_node.address)
|
|
serve.start(http_options={"location": "EveryNode"})
|
|
|
|
signal_actor = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
class HelloModel:
|
|
async def __call__(self):
|
|
await signal_actor.wait.remote()
|
|
return "hello"
|
|
|
|
serve.run(HelloModel.options(num_replicas=2).bind())
|
|
|
|
expected_actors = {
|
|
"ServeController": 1,
|
|
**expected_proxy_actors(num_proxy_nodes=3),
|
|
"ServeReplica:default:HelloModel": 2,
|
|
"SignalActor": 1,
|
|
}
|
|
wait_for_condition(lambda: alive_actor_counts() == expected_actors)
|
|
assert len(ray.nodes()) == 3
|
|
|
|
client = _get_global_client()
|
|
serve_details = ServeInstanceDetails(
|
|
**ray.get(client._controller.get_serve_instance_details.remote())
|
|
)
|
|
proxy_actor_ids = {proxy.actor_id for _, proxy in serve_details.proxies.items()}
|
|
|
|
assert len(proxy_actor_ids) == 3
|
|
|
|
# Each HAProxy binds its own port (head 8000, workers 8001/8002); wait until
|
|
# all three answer health checks.
|
|
def all_haproxies_ready():
|
|
try:
|
|
return all(
|
|
httpx.get(f"http://localhost:{port}/-/healthz", timeout=2).status_code
|
|
== 200
|
|
for port in (8000, 8001, 8002)
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
wait_for_condition(all_haproxies_ready, timeout=20)
|
|
|
|
# Start a long-running request in background to test draining behavior
|
|
request_result = []
|
|
|
|
def make_blocking_request():
|
|
try:
|
|
response = httpx.get("http://localhost:8000/", timeout=5)
|
|
request_result.append(("success", response.status_code))
|
|
except Exception as e:
|
|
request_result.append(("error", str(e)))
|
|
|
|
request_thread = threading.Thread(target=make_blocking_request)
|
|
request_thread.start()
|
|
|
|
wait_for_condition(
|
|
lambda: ray.get(signal_actor.cur_num_waiters.remote()) >= 1, timeout=10
|
|
)
|
|
|
|
serve.run(HelloModel.options(num_replicas=1).bind())
|
|
|
|
# 1 proxy should be draining
|
|
|
|
def check_proxy_status(proxy_status_to_count):
|
|
serve_details = ServeInstanceDetails(
|
|
**ray.get(client._controller.get_serve_instance_details.remote())
|
|
)
|
|
proxy_status_list = [proxy.status for _, proxy in serve_details.proxies.items()]
|
|
current_status = {
|
|
status: proxy_status_list.count(status) for status in proxy_status_list
|
|
}
|
|
return current_status == proxy_status_to_count
|
|
|
|
wait_for_condition(
|
|
condition_predictor=check_proxy_status,
|
|
proxy_status_to_count={ProxyStatus.HEALTHY: 2, ProxyStatus.DRAINING: 1},
|
|
)
|
|
|
|
# should stay in draining status until the signal is sent
|
|
await asyncio.sleep(1)
|
|
|
|
assert check_proxy_status(
|
|
proxy_status_to_count={ProxyStatus.HEALTHY: 2, ProxyStatus.DRAINING: 1}
|
|
)
|
|
|
|
serve.run(HelloModel.options(num_replicas=2).bind())
|
|
# The proxy should return to healthy status
|
|
wait_for_condition(
|
|
condition_predictor=check_proxy_status,
|
|
proxy_status_to_count={ProxyStatus.HEALTHY: 3},
|
|
)
|
|
serve_details = ServeInstanceDetails(
|
|
**ray.get(client._controller.get_serve_instance_details.remote())
|
|
)
|
|
|
|
assert {
|
|
proxy.actor_id for _, proxy in serve_details.proxies.items()
|
|
} == proxy_actor_ids
|
|
|
|
serve.run(HelloModel.options(num_replicas=1).bind())
|
|
await signal_actor.send.remote()
|
|
# 1 proxy should be draining and eventually be drained.
|
|
wait_for_condition(
|
|
condition_predictor=check_proxy_status,
|
|
timeout=40,
|
|
proxy_status_to_count={ProxyStatus.HEALTHY: 2},
|
|
)
|
|
|
|
# Verify the long-running request completed successfully
|
|
request_thread.join(timeout=5)
|
|
|
|
# Clean up serve.
|
|
serve.shutdown()
|
|
|
|
|
|
def test_haproxy_failure(ray_shutdown):
|
|
"""Test HAProxyManager is successfully restarted after being killed."""
|
|
ray.init(num_cpus=1)
|
|
serve.start()
|
|
|
|
@serve.deployment(name="proxy_failure")
|
|
def function(_):
|
|
return "hello1"
|
|
|
|
serve.run(function.bind())
|
|
|
|
def check_proxy_alive():
|
|
actors = list_actors(
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return "HAProxyManager" in {actor["class_name"] for actor in actors}
|
|
|
|
wait_for_condition(check_proxy_alive)
|
|
|
|
[proxy_actor] = list_actors(
|
|
filters=[("class_name", "=", "HAProxyManager"), ("state", "=", "ALIVE")]
|
|
)
|
|
proxy_actor_id = proxy_actor.actor_id
|
|
|
|
proxy_actor = ray.get_actor(proxy_actor.name, namespace=SERVE_NAMESPACE)
|
|
ray.kill(proxy_actor, no_restart=False)
|
|
|
|
def check_new_proxy():
|
|
proxies = list_actors(
|
|
filters=[("class_name", "=", "HAProxyManager"), ("state", "=", "ALIVE")]
|
|
)
|
|
return len(proxies) == 1 and proxies[0].actor_id != proxy_actor_id
|
|
|
|
wait_for_condition(check_new_proxy, timeout=45)
|
|
serve.shutdown()
|
|
|
|
|
|
def test_haproxy_get_target_groups(shutdown_ray):
|
|
"""Test that haproxy get_target_groups retrieves the correct target groups."""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
def function(_):
|
|
return "hello1"
|
|
|
|
# Deploy the application
|
|
serve.run(
|
|
function.options(num_replicas=1).bind(), name="test_app", route_prefix="/test"
|
|
)
|
|
|
|
def check_proxy_alive():
|
|
actors = list_actors(
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return "HAProxyManager" in {actor["class_name"] for actor in actors}
|
|
|
|
wait_for_condition(check_proxy_alive)
|
|
|
|
[proxy_actor] = list_actors(
|
|
filters=[("class_name", "=", "HAProxyManager"), ("state", "=", "ALIVE")]
|
|
)
|
|
proxy_actor = ray.get_actor(proxy_actor.name, namespace=SERVE_NAMESPACE)
|
|
|
|
def has_n_targets(route_prefix: str, n: int):
|
|
target_groups = ray.get(proxy_actor.get_target_groups.remote())
|
|
for tg in target_groups:
|
|
if tg.route_prefix == route_prefix and len(tg.targets) == n:
|
|
return True
|
|
return False
|
|
|
|
wait_for_condition(has_n_targets, route_prefix="/test", n=1)
|
|
|
|
serve.run(
|
|
function.options(num_replicas=2).bind(), name="test_app", route_prefix="/test2"
|
|
)
|
|
wait_for_condition(has_n_targets, route_prefix="/test2", n=2)
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_haproxy_update_target_groups(ray_shutdown):
|
|
"""Test that the haproxy correctly updates the target groups."""
|
|
ray.init(num_cpus=4)
|
|
serve.start(http_options={"host": "0.0.0.0"})
|
|
|
|
@serve.deployment
|
|
def function(_):
|
|
return "hello1"
|
|
|
|
serve.run(
|
|
function.options(num_replicas=1).bind(), name="app1", route_prefix="/test"
|
|
)
|
|
assert httpx.get("http://localhost:8000/test").text == "hello1"
|
|
assert httpx.get("http://localhost:8000/test2").status_code == 404
|
|
|
|
serve.run(
|
|
function.options(num_replicas=1).bind(), name="app2", route_prefix="/test2"
|
|
)
|
|
assert httpx.get("http://localhost:8000/test").text == "hello1"
|
|
assert httpx.get("http://localhost:8000/test2").text == "hello1"
|
|
|
|
serve.delete("app1")
|
|
assert httpx.get("http://localhost:8000/test").status_code == 404
|
|
assert httpx.get("http://localhost:8000/test2").text == "hello1"
|
|
|
|
serve.run(
|
|
function.options(num_replicas=1).bind(), name="app1", route_prefix="/test"
|
|
)
|
|
assert httpx.get("http://localhost:8000/test").text == "hello1"
|
|
assert httpx.get("http://localhost:8000/test2").text == "hello1"
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_haproxy_update_draining_health_checks(ray_shutdown):
|
|
"""Test that the haproxy update_draining method updates the HAProxy health checks."""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
signal_actor = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
async def function(_):
|
|
await signal_actor.wait.remote()
|
|
return "hello1"
|
|
|
|
serve.run(function.bind())
|
|
|
|
def check_proxy_alive():
|
|
actors = list_actors(
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return "HAProxyManager" in {actor["class_name"] for actor in actors}
|
|
|
|
wait_for_condition(check_proxy_alive)
|
|
|
|
[proxy_actor] = list_actors(
|
|
filters=[("class_name", "=", "HAProxyManager"), ("state", "=", "ALIVE")]
|
|
)
|
|
proxy_actor = ray.get_actor(proxy_actor.name, namespace=SERVE_NAMESPACE)
|
|
|
|
assert httpx.get("http://localhost:8000/-/healthz").status_code == 200
|
|
|
|
await proxy_actor.update_draining.remote(draining=True)
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/-/healthz").status_code == 503
|
|
)
|
|
|
|
await proxy_actor.update_draining.remote(draining=False)
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/-/healthz").status_code == 200
|
|
)
|
|
assert not await proxy_actor._is_draining.remote()
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_haproxy_http_options(ray_shutdown):
|
|
"""Test that the haproxy config file is generated correctly with http options."""
|
|
ray.init(num_cpus=4)
|
|
serve.start(
|
|
http_options={
|
|
"host": "0.0.0.0",
|
|
"port": 8001,
|
|
"keep_alive_timeout_s": 30,
|
|
},
|
|
)
|
|
|
|
@serve.deployment
|
|
def function(_):
|
|
return "hello1"
|
|
|
|
serve.run(function.bind(), name="test_app", route_prefix="/test")
|
|
url = get_application_url(app_name="test_app", use_localhost=False)
|
|
assert httpx.get(url).text == "hello1"
|
|
with pytest.raises(httpx.ConnectError):
|
|
_ = httpx.get(url.replace(":8001", ":8000")).status_code
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"header_key",
|
|
[
|
|
SERVE_SESSION_ID, # underscore form
|
|
"x-session-id", # hyphenated form
|
|
"X-Session-Id", # title-cased hyphenated form
|
|
],
|
|
)
|
|
def test_session_id_header_forwarded_through_haproxy(ray_shutdown, header_key):
|
|
"""The session_id header must survive an HAProxy hop and reach the deployment."""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
class Model:
|
|
def __call__(self) -> str:
|
|
return ray.serve.context._get_serve_request_context().session_id
|
|
|
|
serve.run(Model.bind())
|
|
|
|
session_id = "sess_user_42"
|
|
resp = httpx.get("http://localhost:8000/", headers={header_key: session_id})
|
|
assert resp.status_code == 200
|
|
assert resp.text == session_id
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_haproxy_metrics(ray_shutdown):
|
|
"""Test that the haproxy metrics are exported correctly."""
|
|
ray.init(num_cpus=4)
|
|
serve.start(
|
|
http_options={
|
|
"host": "0.0.0.0",
|
|
},
|
|
)
|
|
|
|
@serve.deployment
|
|
def function(_):
|
|
return "hello1"
|
|
|
|
serve.run(function.bind())
|
|
|
|
assert httpx.get("http://localhost:8000/").text == "hello1"
|
|
|
|
metric_prefix = (
|
|
'haproxy_backend_http_responses_total{proxy="http-default",code="2xx"} '
|
|
)
|
|
last_metrics = [""]
|
|
|
|
def metrics_show_2xx_response():
|
|
assert httpx.get("http://localhost:8000/").text == "hello1"
|
|
resp = httpx.get("http://localhost:9101/metrics")
|
|
assert resp.status_code == 200
|
|
last_metrics[0] = resp.text
|
|
for line in resp.text.splitlines():
|
|
if line.startswith(metric_prefix):
|
|
return float(line[len(metric_prefix) :]) >= 1
|
|
return False
|
|
|
|
try:
|
|
wait_for_condition(metrics_show_2xx_response, timeout=30)
|
|
except RuntimeError:
|
|
# Dump full /metrics so bazel test logs show the real backend counters
|
|
# instead of pytest's truncated assertion message.
|
|
print("Final /metrics output:\n" + last_metrics[0])
|
|
raise
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_haproxy_safe_name():
|
|
"""Test that the safe name is generated correctly."""
|
|
assert HAProxyManager.get_safe_name("HTTP-test_foo.bar") == "HTTP-test_foo.bar"
|
|
assert HAProxyManager.get_safe_name("HTTP:test") == "HTTP_test"
|
|
assert HAProxyManager.get_safe_name("HTTP:test/foo") == "HTTP_test.foo"
|
|
assert HAProxyManager.get_safe_name("replica#abc") == "replica-abc"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
|
|
def test_build_multi_app(ray_start_stop):
|
|
with NamedTemporaryFile(mode="w+", suffix=".yaml") as tmp:
|
|
print('Building nodes "TestApp1Node" and "TestApp2Node".')
|
|
# Build an app
|
|
subprocess.check_output(
|
|
[
|
|
"serve",
|
|
"build",
|
|
"ray.serve.tests.test_cli_3.TestApp1Node",
|
|
"ray.serve.tests.test_cli_3.TestApp2Node",
|
|
"-o",
|
|
tmp.name,
|
|
]
|
|
)
|
|
print("Build succeeded! Deploying node.")
|
|
|
|
subprocess.check_output(["serve", "deploy", tmp.name])
|
|
print("Deploy succeeded!")
|
|
wait_for_condition(
|
|
lambda: ping_endpoint("app1") == "wonderful world", timeout=15
|
|
)
|
|
print("App 1 is live and reachable over HTTP.")
|
|
wait_for_condition(
|
|
lambda: ping_endpoint("app2") == "wonderful world", timeout=15
|
|
)
|
|
print("App 2 is live and reachable over HTTP.")
|
|
|
|
print("Deleting applications.")
|
|
app_urls = [
|
|
get_application_url("HTTP", app_name=app) for app in ["app1", "app2"]
|
|
]
|
|
subprocess.check_output(["serve", "shutdown", "-y"])
|
|
|
|
def check_no_apps():
|
|
for url in app_urls:
|
|
with pytest.raises(httpx.HTTPError):
|
|
_ = httpx.get(url).text
|
|
return True
|
|
|
|
wait_for_condition(check_no_apps, timeout=15)
|
|
print("Delete succeeded! Node is no longer reachable over HTTP.")
|
|
|
|
|
|
def test_haproxy_manager_ready_with_application(ray_shutdown):
|
|
"""Test that HAProxyManager.ready() succeeds when an application is deployed."""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
def function(_):
|
|
return "hello"
|
|
|
|
# Deploy application
|
|
serve.run(function.bind(), name="test_app", route_prefix="/test")
|
|
|
|
# Get HAProxyManager actor
|
|
def check_proxy_alive():
|
|
actors = list_actors(
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return "HAProxyManager" in {actor["class_name"] for actor in actors}
|
|
|
|
wait_for_condition(check_proxy_alive)
|
|
|
|
[proxy_actor] = list_actors(
|
|
filters=[("class_name", "=", "HAProxyManager"), ("state", "=", "ALIVE")]
|
|
)
|
|
proxy_actor = ray.get_actor(proxy_actor.name, namespace=SERVE_NAMESPACE)
|
|
|
|
# Call ready() - should succeed with active targets
|
|
ready_result = ray.get(proxy_actor.ready.remote())
|
|
assert ready_result is not None
|
|
|
|
wait_for_condition(lambda: httpx.get("http://localhost:8000/test").text == "hello")
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_504_error_translated_to_500(ray_shutdown, monkeypatch):
|
|
"""Test that HAProxy translates 504 Gateway Timeout errors to 500 Internal Server Error."""
|
|
monkeypatch.setenv("RAY_SERVE_HAPROXY_TIMEOUT_SERVER_S", "2")
|
|
monkeypatch.setenv("RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S", "1")
|
|
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
class TimeoutDeployment:
|
|
def __call__(self, request):
|
|
# Sleep for 3 seconds, longer than HAProxy's 2s timeout
|
|
# Use regular time.sleep (not async) to avoid event loop issues
|
|
time.sleep(3)
|
|
return "This should not be reached"
|
|
|
|
serve.run(TimeoutDeployment.bind(), name="timeout_app", route_prefix="/test")
|
|
|
|
url = get_application_url("HTTP", app_name="timeout_app")
|
|
|
|
# HAProxy should timeout after 2s and return 504->500
|
|
# Client timeout is 10s to ensure HAProxy times out first
|
|
response = requests.get(f"{url}/test", timeout=10)
|
|
|
|
# Verify we got 500 (translated from 504), not 504 or 200
|
|
assert (
|
|
response.status_code == 500
|
|
), f"Expected 500 Internal Server Error (translated from 504), got {response.status_code}"
|
|
assert (
|
|
"Internal Server Error" in response.text
|
|
), f"Response should contain 'Internal Server Error' message, got: {response.text}"
|
|
|
|
|
|
def test_502_error_translated_to_500(ray_shutdown):
|
|
"""Test that HAProxy translates 502 Bad Gateway errors to 500 Internal Server Error."""
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
class BrokenDeployment:
|
|
def __call__(self, request):
|
|
# Always raise an exception to simulate backend failure
|
|
raise RuntimeError("Simulated backend failure for 502 error")
|
|
|
|
serve.run(
|
|
BrokenDeployment.bind(), name="broken_app", route_prefix="/test", blocking=False
|
|
)
|
|
url = get_application_url("HTTP", app_name="broken_app")
|
|
response = requests.get(f"{url}/test", timeout=5)
|
|
|
|
assert (
|
|
response.status_code == 500
|
|
), f"Expected 500 Internal Server Error, got {response.status_code}"
|
|
assert (
|
|
"Internal Server Error" in response.text
|
|
), "Response should contain 'Internal Server Error' message"
|
|
|
|
|
|
def test_haproxy_healthcheck_multiple_apps_and_backends(ray_shutdown):
|
|
"""Health check behavior with 3 apps and 2 servers per backend.
|
|
|
|
Expectations:
|
|
- With two servers per backend, healthz returns 200 (all backends have a primary UP).
|
|
- Disabling one primary in each backend keeps health at 200 (the other primary is UP).
|
|
- Disabling all servers in each backend results in healthz 200 (the fallback proxy is UP).
|
|
"""
|
|
ray.init(num_cpus=8)
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
def f(_):
|
|
return "hello"
|
|
|
|
# Helpers
|
|
SOCKET_PATH = (
|
|
f"/tmp/haproxy-serve/{ray.get_runtime_context().get_node_id()}/admin.sock"
|
|
)
|
|
|
|
def app_to_backend(app: str) -> str:
|
|
return f"http-{app}"
|
|
|
|
def haproxy_show_stat() -> str:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
s.settimeout(5)
|
|
s.connect(SOCKET_PATH)
|
|
s.sendall(b"show stat\n")
|
|
chunks = []
|
|
while True:
|
|
data = s.recv(4096)
|
|
if not data:
|
|
break
|
|
chunks.append(data)
|
|
return b"".join(chunks).decode("utf-8", errors="ignore")
|
|
|
|
def list_primary_servers(backend_name: str) -> list:
|
|
lines = haproxy_show_stat().strip().split("\n")
|
|
servers = []
|
|
for line in lines:
|
|
parts = line.split(",")
|
|
if len(parts) < 2:
|
|
continue
|
|
pxname, svname = parts[0], parts[1]
|
|
if pxname == backend_name and svname not in [
|
|
"FRONTEND",
|
|
"BACKEND",
|
|
]:
|
|
servers.append(svname)
|
|
return servers
|
|
|
|
def set_server_state(backend: str, server: str, state: str) -> None:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
s.settimeout(5)
|
|
s.connect(SOCKET_PATH)
|
|
s.sendall(f"set server {backend}/{server} state {state}\n".encode())
|
|
|
|
def wait_health(expected: int, timeout: float = 15.0) -> None:
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/-/healthz").status_code
|
|
== expected,
|
|
timeout=timeout,
|
|
)
|
|
|
|
# Deploy 3 apps, each with 2 replicas (servers) so each backend has 2 servers + 1 backup
|
|
apps = [
|
|
("app_a", "/a"),
|
|
("app_b", "/b"),
|
|
("app_c", "/c"),
|
|
]
|
|
for app_name, route in apps:
|
|
serve.run(f.options(num_replicas=2).bind(), name=app_name, route_prefix=route)
|
|
|
|
# Wait for all endpoints to be reachable
|
|
for _, route in apps:
|
|
wait_for_condition(
|
|
lambda r=route: httpx.get(f"http://localhost:8000{r}").text == "hello"
|
|
)
|
|
|
|
# Wait until each backend shows 2 primary servers in HAProxy stats
|
|
backends = [app_to_backend(app) for app, _ in apps]
|
|
for be in backends:
|
|
wait_for_condition(lambda b=be: len(list_primary_servers(b)) >= 2, timeout=20)
|
|
|
|
# Initially healthy
|
|
wait_health(200, timeout=20)
|
|
|
|
# Disable one primary per backend, should remain healthy (one primary still UP)
|
|
disabled_servers = []
|
|
for be in backends:
|
|
servers = list_primary_servers(be)
|
|
set_server_state(be, servers[0], "maint")
|
|
disabled_servers.append((be, servers[0]))
|
|
|
|
wait_health(200, timeout=20)
|
|
|
|
# Disable the remaining primary per backend, should remain healthy (the fallback proxy is UP).
|
|
disabled_all = []
|
|
for be in backends:
|
|
servers = list_primary_servers(be)
|
|
# Disable any remaining primary (skip ones already disabled)
|
|
for sv in servers:
|
|
if (be, sv) not in disabled_servers:
|
|
set_server_state(be, sv, "maint")
|
|
disabled_all.append((be, sv))
|
|
break
|
|
|
|
# The fallback proxy is UP.
|
|
wait_health(200, timeout=20)
|
|
|
|
# Re-enable all servers and expect health to be 200.
|
|
for be, sv in disabled_servers + disabled_all:
|
|
set_server_state(be, sv, "ready")
|
|
wait_health(200, timeout=20)
|
|
|
|
# Sanity: all apps still respond
|
|
for _, route in apps:
|
|
resp = httpx.get(f"http://localhost:8000{route}")
|
|
assert resp.status_code == 200 and resp.text == "hello"
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_haproxy_empty_backends_for_scaled_down_apps(ray_shutdown):
|
|
"""Test that HAProxy has no backend servers for deleted apps.
|
|
|
|
Verifies that when RAY_SERVE_ENABLE_HA_PROXY is True and apps are
|
|
deleted, the HAProxy stats show the backend is removed or has no servers.
|
|
"""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
def hello():
|
|
return "hello"
|
|
|
|
# Deploy app with 1 replica
|
|
serve.run(
|
|
hello.options(num_replicas=1).bind(), name="test_app", route_prefix="/test"
|
|
)
|
|
|
|
r = httpx.get("http://localhost:8000/test")
|
|
assert r.status_code == 200
|
|
assert r.text == "hello"
|
|
|
|
# Delete the app - this should remove or empty the backend
|
|
serve.delete("test_app")
|
|
|
|
r = httpx.get("http://localhost:8000/test")
|
|
assert r.status_code == 404
|
|
|
|
# Healthz stays 200 with no app backends instead of hitting the 404 default backend.
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/-/healthz").status_code == 200
|
|
)
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_fallback_proxy_starts_with_native_proxy_on_head_node(
|
|
shutdown_ray, call_ray_stop_only # noqa: F811
|
|
):
|
|
"""When HAProxy is enabled, verify that two proxy actors run on the head
|
|
node (the native proxy + the fallback Serve proxy) and only the native
|
|
proxy runs on worker nodes."""
|
|
cluster = Cluster()
|
|
head_node = cluster.add_node(num_cpus=1)
|
|
cluster.add_node(num_cpus=1)
|
|
cluster.wait_for_nodes()
|
|
ray.init(address=head_node.address)
|
|
serve.start(http_options={"location": "EveryNode"})
|
|
|
|
@serve.deployment
|
|
def hello():
|
|
return "hello"
|
|
|
|
serve.run(hello.options(num_replicas=2).bind(), name="app1", route_prefix="/app1")
|
|
|
|
head_node_id = ray.get_runtime_context().get_node_id()
|
|
|
|
def check_proxies():
|
|
actors = list_actors(
|
|
filters=[
|
|
("ray_namespace", "=", SERVE_NAMESPACE),
|
|
("state", "=", "ALIVE"),
|
|
],
|
|
)
|
|
|
|
# Count native proxies (HAProxyManager) and Serve proxies (ProxyActor)
|
|
haproxy_actors = [a for a in actors if a["class_name"] == "HAProxyManager"]
|
|
serve_proxy_actors = [a for a in actors if a["class_name"] == "ProxyActor"]
|
|
|
|
# There should be one HAProxy manager per node
|
|
if len(haproxy_actors) != 2:
|
|
return False
|
|
|
|
# The head node should have a fallback Serve proxy,
|
|
# worker nodes should not.
|
|
if len(serve_proxy_actors) != 1:
|
|
return False
|
|
|
|
if serve_proxy_actors[0]["node_id"] != head_node_id:
|
|
return False
|
|
|
|
return True
|
|
|
|
wait_for_condition(check_proxies, timeout=30)
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_scale_from_zero_via_fallback_proxy(ray_shutdown):
|
|
"""Test that a request to an app scaled to zero succeeds via the fallback proxy.
|
|
|
|
Flow:
|
|
1. Deploy an app with autoscaling min_replicas=0
|
|
2. Wait for it to scale down to 0 replicas
|
|
3. Send an HTTP request through HAProxy
|
|
4. HAProxy routes to the fallback Serve proxy (backup server) since
|
|
there are no ingress replicas
|
|
5. The fallback proxy queues the request and triggers upscaling
|
|
6. Once a replica starts, the request completes successfully
|
|
"""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
@serve.deployment(
|
|
autoscaling_config={
|
|
"min_replicas": 0,
|
|
"max_replicas": 1,
|
|
"metrics_interval_s": 0.1,
|
|
"look_back_period_s": 0.2,
|
|
"downscale_delay_s": 5,
|
|
"upscale_delay_s": 0,
|
|
},
|
|
)
|
|
class ScaleToZeroApp:
|
|
def __call__(self):
|
|
return "hello from scale-to-zero"
|
|
|
|
serve.run(ScaleToZeroApp.bind(), name="s2z_app", route_prefix="/s2z")
|
|
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/-/routes").status_code == 200,
|
|
)
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/-/healthz").status_code == 200,
|
|
)
|
|
|
|
# Wait for the app to be running and initially serve a request
|
|
wait_for_condition(
|
|
lambda: httpx.get("http://localhost:8000/s2z").status_code == 200,
|
|
timeout=30,
|
|
)
|
|
assert httpx.get("http://localhost:8000/s2z").text == "hello from scale-to-zero"
|
|
|
|
# Wait for the app to scale down to 0 replicas
|
|
def check_zero_replicas():
|
|
actors = list_actors(
|
|
filters=[
|
|
("ray_namespace", "=", SERVE_NAMESPACE),
|
|
("state", "=", "ALIVE"),
|
|
("class_name", "=", "ServeReplica:s2z_app:ScaleToZeroApp"),
|
|
],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_zero_replicas, timeout=30)
|
|
|
|
# Now send a request. HAProxy has no primary servers for this backend,
|
|
# so it should route to the fallback Serve proxy (backup server).
|
|
# The fallback proxy will queue the request and trigger upscaling.
|
|
# The request should eventually succeed once a replica starts.
|
|
response = httpx.get("http://localhost:8000/s2z", timeout=30)
|
|
assert response.status_code == 200, (
|
|
f"Expected 200 after scale-from-zero, got {response.status_code}: "
|
|
f"{response.text}"
|
|
)
|
|
assert response.text == "hello from scale-to-zero"
|
|
|
|
serve.shutdown()
|
|
|
|
|
|
def test_default_host_is_all_interfaces(ray_shutdown):
|
|
"""When HAProxy is enabled, the default HTTPOptions.host binds to all
|
|
interfaces so HAProxy on other nodes can reach the replica backend ports.
|
|
"""
|
|
serve.start(http_options=HTTPOptions())
|
|
|
|
@serve.deployment
|
|
class App:
|
|
async def __call__(self):
|
|
return "ok"
|
|
|
|
serve.run(App.bind())
|
|
|
|
def _direct_ingress_listeners():
|
|
return [
|
|
c
|
|
for c in psutil.net_connections(kind="tcp")
|
|
if c.status == psutil.CONN_LISTEN
|
|
and RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT
|
|
<= c.laddr.port
|
|
<= RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT
|
|
]
|
|
|
|
wait_for_condition(lambda: len(_direct_ingress_listeners()) > 0, timeout=30)
|
|
|
|
expected = get_all_interfaces_ip()
|
|
for conn in _direct_ingress_listeners():
|
|
assert conn.laddr.ip == expected, (
|
|
f"direct ingress port {conn.laddr.port} bound to {conn.laddr.ip!r}, "
|
|
f"expected {expected!r}"
|
|
)
|
|
|
|
|
|
def test_serve_run_rejects_custom_ingress_request_router(ray_shutdown):
|
|
"""serve.run rejects a custom router on the ingress under HAProxy."""
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment(
|
|
request_router_config=RequestRouterConfig(request_router_class=RoundRobinRouter)
|
|
)
|
|
class Ingress:
|
|
async def __call__(self):
|
|
return "hi"
|
|
|
|
with pytest.raises(
|
|
RayServeException, match=CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR
|
|
):
|
|
serve.run(Ingress.bind())
|
|
|
|
|
|
def test_deploy_config_rejects_custom_ingress_request_router(ray_shutdown):
|
|
"""A config override that sets a custom router on the ingress fails to deploy
|
|
under HAProxy."""
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
client = _get_global_client()
|
|
|
|
module = "ray.serve.tests.test_config_files.use_custom_request_router"
|
|
config = ServeDeploySchema.model_validate(
|
|
{
|
|
"applications": [
|
|
{
|
|
"name": "app",
|
|
"import_path": f"{module}.app",
|
|
"deployments": [
|
|
{
|
|
"name": "UniformRequestRouterApp",
|
|
"request_router_config": {
|
|
"request_router_class": f"{module}.UniformRequestRouter"
|
|
},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
client.deploy_apps(config)
|
|
|
|
def deploy_failed():
|
|
status = serve.status().applications["app"]
|
|
assert status.status == "DEPLOY_FAILED"
|
|
assert CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR in status.message
|
|
return True
|
|
|
|
wait_for_condition(deploy_failed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|