mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
1251 lines
39 KiB
Python
1251 lines
39 KiB
Python
# Copyright 2025 The KServe Authors.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
E2E tests for LLMISVC autoscaling.
|
|
|
|
These tests verify behavioral outcomes of the autoscaling pipeline:
|
|
- Scaling resources (VariantAutoscaling, HPA, KEDA ScaledObject) exist by name
|
|
- Pods actually scale up under load
|
|
- Lifecycle operations (cleanup, stop, update) work end-to-end
|
|
|
|
HPA and KEDA tests are isolated into separate CI jobs because their infra
|
|
requirements conflict: Prometheus Adapter and KEDA both register the
|
|
v1beta1.external.metrics.k8s.io APIService. Tests are tagged with
|
|
autoscaling_hpa / autoscaling_keda markers for filtering.
|
|
|
|
Actuator-switch tests (HPA<->KEDA) are covered by integration tests in
|
|
pkg/controller/v1alpha2/llmisvc/scaling_int_test.go and are not repeated here.
|
|
|
|
Scale-down is not asserted because it depends on WVA saturation metrics
|
|
(KV cache, queue depth) from the inference server; the llm-d-inference-sim
|
|
simulator does not emit the decreasing metrics WVA needs to lower
|
|
desired_replicas after load stops.
|
|
|
|
Spec-field assertions are intentionally omitted -- those are covered by
|
|
integration tests in pkg/controller/v1alpha2/llmisvc/scaling_int_test.go.
|
|
"""
|
|
|
|
import concurrent.futures
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
import requests
|
|
from kserve import KServeClient, constants
|
|
from kubernetes import client
|
|
|
|
from .fixtures import (
|
|
KSERVE_TEST_NAMESPACE,
|
|
generate_test_id,
|
|
inject_k8s_proxy,
|
|
)
|
|
from .logging import log_execution
|
|
from .test_llm_inference_service import (
|
|
TestCase,
|
|
create_llmisvc,
|
|
delete_llmisvc,
|
|
get_llm_service_url,
|
|
wait_for,
|
|
wait_for_llm_isvc_ready,
|
|
)
|
|
|
|
KSERVE_PLURAL_LLMINFERENCESERVICE = "llminferenceservices"
|
|
STOP_ANNOTATION_KEY = "serving.kserve.io/stop"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- Resource existence helpers ---
|
|
|
|
VA_GROUP = "llmd.ai"
|
|
VA_VERSION = "v1alpha1"
|
|
VA_PLURAL = "variantautoscalings"
|
|
|
|
KEDA_GROUP = "keda.sh"
|
|
KEDA_VERSION = "v1alpha1"
|
|
KEDA_PLURAL = "scaledobjects"
|
|
|
|
HPA_GROUP = "autoscaling"
|
|
HPA_VERSION = "v2"
|
|
HPA_PLURAL = "horizontalpodautoscalers"
|
|
|
|
PROMETHEUS_NAMESPACE = os.environ.get("PROMETHEUS_NAMESPACE", "monitoring")
|
|
KEDA_NAMESPACE = os.environ.get("KEDA_NAMESPACE", "keda")
|
|
|
|
|
|
def _get_custom_resource(group, version, plural, name, namespace):
|
|
"""Fetch a custom resource, return the object or None if 404."""
|
|
api = client.CustomObjectsApi()
|
|
try:
|
|
return api.get_namespaced_custom_object(group, version, namespace, plural, name)
|
|
except client.rest.ApiException as e:
|
|
if e.status == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
def resource_exists(group, version, plural, name, namespace):
|
|
return _get_custom_resource(group, version, plural, name, namespace) is not None
|
|
|
|
|
|
def wait_for_resource(group, version, plural, name, namespace, timeout=120):
|
|
"""Poll until the resource exists."""
|
|
|
|
def _check():
|
|
assert resource_exists(group, version, plural, name, namespace), (
|
|
f"{plural}/{name} not found in {namespace}"
|
|
)
|
|
|
|
wait_for(_check, timeout=timeout, interval=2.0)
|
|
|
|
|
|
def wait_for_resource_deleted(group, version, plural, name, namespace, timeout=120):
|
|
"""Poll until the resource returns 404."""
|
|
|
|
def _check():
|
|
assert not resource_exists(group, version, plural, name, namespace), (
|
|
f"{plural}/{name} still exists in {namespace}"
|
|
)
|
|
|
|
wait_for(_check, timeout=timeout, interval=2.0)
|
|
|
|
|
|
# --- Scaling resource name helpers (mirrors controller naming) ---
|
|
|
|
|
|
def va_name(service_name, prefill=False):
|
|
suffix = "-kserve-prefill-va" if prefill else "-kserve-va"
|
|
return _child_name(service_name, suffix)
|
|
|
|
|
|
def hpa_name(service_name, prefill=False):
|
|
suffix = "-kserve-prefill-hpa" if prefill else "-kserve-hpa"
|
|
return _child_name(service_name, suffix)
|
|
|
|
|
|
def scaled_object_name(service_name, prefill=False):
|
|
suffix = "-kserve-prefill-keda" if prefill else "-kserve-keda"
|
|
return _child_name(service_name, suffix)
|
|
|
|
|
|
def _child_name(parent, suffix):
|
|
"""Replicate knative.dev/pkg/kmeta.ChildName truncation to 63 chars."""
|
|
result = parent + suffix
|
|
if len(result) > 63:
|
|
result = result[:63]
|
|
return result
|
|
|
|
|
|
# --- Pod count helpers ---
|
|
|
|
|
|
def get_pod_count(service_name, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Count Running or Pending pods for this LLMISVC workload."""
|
|
v1 = client.CoreV1Api()
|
|
pods = v1.list_namespaced_pod(
|
|
namespace=namespace,
|
|
label_selector=f"app.kubernetes.io/name={service_name}",
|
|
)
|
|
count = 0
|
|
for pod in pods.items:
|
|
if pod.status.phase in ("Running", "Pending"):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def wait_for_pod_count(
|
|
service_name, min_count, namespace=KSERVE_TEST_NAMESPACE, timeout=300
|
|
):
|
|
"""Poll until the pod count reaches at least min_count."""
|
|
|
|
def _check():
|
|
current = get_pod_count(service_name, namespace)
|
|
assert current >= min_count, (
|
|
f"Pod count for {service_name}: {current}, expected >= {min_count}"
|
|
)
|
|
|
|
wait_for(_check, timeout=timeout, interval=5.0)
|
|
|
|
|
|
def wait_for_pod_count_exact(
|
|
service_name, count, namespace=KSERVE_TEST_NAMESPACE, timeout=300
|
|
):
|
|
"""Poll until the pod count is exactly count."""
|
|
|
|
def _check():
|
|
current = get_pod_count(service_name, namespace)
|
|
assert current == count, (
|
|
f"Pod count for {service_name}: {current}, expected {count}"
|
|
)
|
|
|
|
wait_for(_check, timeout=timeout, interval=5.0)
|
|
|
|
|
|
# --- Load generation ---
|
|
|
|
|
|
def send_load(service_url, model_name, concurrency=5, duration_seconds=30):
|
|
"""Send concurrent requests to the service to trigger scale-up."""
|
|
endpoint = service_url + "/v1/completions"
|
|
payload = {
|
|
"model": model_name,
|
|
"prompt": "Explain autoscaling in Kubernetes in great detail. " * 10,
|
|
"max_tokens": 200,
|
|
}
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
deadline = time.time() + duration_seconds
|
|
lock = threading.Lock()
|
|
counters = {"success": 0, "client_error": 0, "server_error": 0}
|
|
|
|
def _worker():
|
|
while time.time() < deadline:
|
|
try:
|
|
resp = requests.post(
|
|
endpoint, json=payload, headers=headers, timeout=30
|
|
)
|
|
with lock:
|
|
if resp.status_code == 200:
|
|
counters["success"] += 1
|
|
elif resp.status_code < 500:
|
|
counters["client_error"] += 1
|
|
else:
|
|
counters["server_error"] += 1
|
|
except Exception:
|
|
with lock:
|
|
counters["server_error"] += 1
|
|
time.sleep(0.1)
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
futures = [pool.submit(_worker) for _ in range(concurrency)]
|
|
concurrent.futures.wait(futures)
|
|
|
|
logger.info(
|
|
f"send_load complete: {counters['success']} ok, "
|
|
f"{counters['client_error']} client errors, "
|
|
f"{counters['server_error']} server errors to {endpoint}"
|
|
)
|
|
total_delivered = counters["success"] + counters["client_error"]
|
|
assert total_delivered > 0, (
|
|
f"send_load: all {counters['server_error']} requests failed to {endpoint}"
|
|
)
|
|
|
|
|
|
# --- Patching helpers ---
|
|
|
|
|
|
def patch_llmisvc(kserve_client, llm_isvc, patch_body):
|
|
"""Apply a JSON merge patch to the LLMInferenceService."""
|
|
result = kserve_client.api_instance.patch_namespaced_custom_object(
|
|
constants.KSERVE_GROUP,
|
|
llm_isvc.api_version.split("/")[1],
|
|
llm_isvc.metadata.namespace,
|
|
KSERVE_PLURAL_LLMINFERENCESERVICE,
|
|
llm_isvc.metadata.name,
|
|
patch_body,
|
|
)
|
|
logger.info(f"Patched LLMISVC {llm_isvc.metadata.name}")
|
|
return result
|
|
|
|
|
|
# --- Assertion helpers for scaling resources ---
|
|
|
|
|
|
def assert_scaling_resources_exist(service_name, actuator="hpa", prefill=False):
|
|
"""Assert that VA + actuator exist by name."""
|
|
ns = KSERVE_TEST_NAMESPACE
|
|
wait_for_resource(
|
|
VA_GROUP, VA_VERSION, VA_PLURAL, va_name(service_name, prefill), ns
|
|
)
|
|
if actuator == "hpa":
|
|
wait_for_resource(
|
|
HPA_GROUP, HPA_VERSION, HPA_PLURAL, hpa_name(service_name, prefill), ns
|
|
)
|
|
elif actuator == "keda":
|
|
wait_for_resource(
|
|
KEDA_GROUP,
|
|
KEDA_VERSION,
|
|
KEDA_PLURAL,
|
|
scaled_object_name(service_name, prefill),
|
|
ns,
|
|
)
|
|
|
|
|
|
def assert_scaling_resources_deleted(
|
|
service_name, actuator="hpa", prefill=False, timeout=120
|
|
):
|
|
"""Assert that VA + actuator are gone (404)."""
|
|
ns = KSERVE_TEST_NAMESPACE
|
|
wait_for_resource_deleted(
|
|
VA_GROUP, VA_VERSION, VA_PLURAL, va_name(service_name, prefill), ns, timeout
|
|
)
|
|
if actuator == "hpa":
|
|
wait_for_resource_deleted(
|
|
HPA_GROUP,
|
|
HPA_VERSION,
|
|
HPA_PLURAL,
|
|
hpa_name(service_name, prefill),
|
|
ns,
|
|
timeout,
|
|
)
|
|
elif actuator == "keda":
|
|
wait_for_resource_deleted(
|
|
KEDA_GROUP,
|
|
KEDA_VERSION,
|
|
KEDA_PLURAL,
|
|
scaled_object_name(service_name, prefill),
|
|
ns,
|
|
timeout,
|
|
)
|
|
|
|
|
|
# --- Pipeline and actuator health assertions ---
|
|
|
|
|
|
def assert_metrics_pipeline_ready(actuator="hpa"):
|
|
"""Verify the external metrics API is reachable before relying on scaling."""
|
|
api = client.ApiregistrationV1Api()
|
|
apiservice = api.read_api_service("v1beta1.external.metrics.k8s.io")
|
|
conditions = apiservice.status.conditions or []
|
|
available = next((c for c in conditions if c.type == "Available"), None)
|
|
assert available and available.status == "True", (
|
|
f"External metrics APIService not Available: {conditions}"
|
|
)
|
|
|
|
if actuator == "hpa":
|
|
v1 = client.CoreV1Api()
|
|
pods = v1.list_namespaced_pod(
|
|
namespace=PROMETHEUS_NAMESPACE,
|
|
label_selector="app.kubernetes.io/name=prometheus-adapter",
|
|
)
|
|
running_names = [
|
|
p.metadata.name for p in pods.items if p.status.phase == "Running"
|
|
]
|
|
assert running_names, "No running Prometheus Adapter pods found"
|
|
elif actuator == "keda":
|
|
v1 = client.CoreV1Api()
|
|
pods = v1.list_namespaced_pod(
|
|
namespace=KEDA_NAMESPACE,
|
|
label_selector="app.kubernetes.io/name=keda-operator",
|
|
)
|
|
running_names = [
|
|
p.metadata.name for p in pods.items if p.status.phase == "Running"
|
|
]
|
|
assert running_names, "No running KEDA operator pods found"
|
|
|
|
|
|
def assert_wva_metric_exists(service_name, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Verify WVA computed a scaling decision (desiredOptimizedAlloc present)."""
|
|
|
|
def _check():
|
|
va = _get_custom_resource(
|
|
VA_GROUP, VA_VERSION, VA_PLURAL, va_name(service_name), namespace
|
|
)
|
|
assert va is not None, f"VariantAutoscaling {va_name(service_name)} not found"
|
|
status = va.get("status", {})
|
|
alloc = status.get("desiredOptimizedAlloc", {})
|
|
num_replicas = alloc.get("numReplicas")
|
|
assert num_replicas is not None and num_replicas >= 1, (
|
|
f"WVA did not compute desiredOptimizedAlloc.numReplicas; status: {status}"
|
|
)
|
|
|
|
wait_for(_check, timeout=120, interval=5.0)
|
|
|
|
|
|
def assert_hpa_active(service_name, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Verify HPA has ScalingActive=True condition (polls for up to 60s)."""
|
|
|
|
def _check():
|
|
hpa = _get_custom_resource(
|
|
HPA_GROUP, HPA_VERSION, HPA_PLURAL, hpa_name(service_name), namespace
|
|
)
|
|
assert hpa is not None, f"HPA {hpa_name(service_name)} not found"
|
|
conditions = hpa.get("status", {}).get("conditions", [])
|
|
scaling_active = next(
|
|
(c for c in conditions if c["type"] == "ScalingActive"), None
|
|
)
|
|
assert scaling_active and scaling_active["status"] == "True", (
|
|
f"HPA ScalingActive is not True: {conditions}"
|
|
)
|
|
|
|
wait_for(_check, timeout=60, interval=5.0)
|
|
|
|
|
|
def assert_scaled_object_active(service_name, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Verify KEDA ScaledObject has Ready=True condition (polls for up to 60s)."""
|
|
|
|
def _check():
|
|
so = _get_custom_resource(
|
|
KEDA_GROUP,
|
|
KEDA_VERSION,
|
|
KEDA_PLURAL,
|
|
scaled_object_name(service_name),
|
|
namespace,
|
|
)
|
|
assert so is not None, (
|
|
f"ScaledObject {scaled_object_name(service_name)} not found"
|
|
)
|
|
conditions = so.get("status", {}).get("conditions", [])
|
|
ready = next((c for c in conditions if c["type"] == "Ready"), None)
|
|
assert ready and ready["status"] == "True", (
|
|
f"ScaledObject Ready is not True: {conditions}"
|
|
)
|
|
|
|
wait_for(_check, timeout=60, interval=5.0)
|
|
|
|
|
|
def assert_lws_replicas(service_name, min_replicas, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Verify LeaderWorkerSet status reflects scale-up (polls for up to 60s)."""
|
|
lws_name = _child_name(service_name, "-kserve-mn")
|
|
|
|
def _check():
|
|
api = client.CustomObjectsApi()
|
|
lws = api.get_namespaced_custom_object(
|
|
"leaderworkerset.x-k8s.io", "v1", namespace, "leaderworkersets", lws_name
|
|
)
|
|
actual = lws.get("status", {}).get("replicas", 0)
|
|
assert actual >= min_replicas, (
|
|
f"LWS {lws_name} replicas={actual}, expected >= {min_replicas}"
|
|
)
|
|
|
|
wait_for(_check, timeout=60, interval=5.0)
|
|
|
|
|
|
# --- ScalingReady condition helpers ---
|
|
|
|
|
|
def _get_llmisvc_condition(name, namespace, condition_type):
|
|
"""Get a specific condition from LLMInferenceService status."""
|
|
api = client.CustomObjectsApi()
|
|
resource = api.get_namespaced_custom_object(
|
|
constants.KSERVE_GROUP,
|
|
"v1alpha2",
|
|
namespace,
|
|
KSERVE_PLURAL_LLMINFERENCESERVICE,
|
|
name,
|
|
)
|
|
conditions = resource.get("status", {}).get("conditions", [])
|
|
for cond in conditions:
|
|
if cond.get("type") == condition_type:
|
|
return cond
|
|
return None
|
|
|
|
|
|
def assert_scaling_ready_condition(service_name, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Verify the ScalingReady condition is present and True."""
|
|
|
|
def _check():
|
|
cond = _get_llmisvc_condition(service_name, namespace, "ScalingReady")
|
|
assert cond is not None, f"ScalingReady condition not found on {service_name}"
|
|
assert cond.get("status") == "True", (
|
|
f"ScalingReady status is {cond.get('status')} "
|
|
f"(reason={cond.get('reason')}), expected True"
|
|
)
|
|
|
|
wait_for(_check, timeout=180, interval=5.0)
|
|
|
|
|
|
def assert_scaling_ready_absent(service_name, namespace=KSERVE_TEST_NAMESPACE):
|
|
"""Verify the ScalingReady condition is absent from the LLMInferenceService."""
|
|
cond = _get_llmisvc_condition(service_name, namespace, "ScalingReady")
|
|
assert cond is None, (
|
|
f"ScalingReady condition should be absent on {service_name}, got: {cond}"
|
|
)
|
|
|
|
|
|
# --- Common test lifecycle ---
|
|
|
|
|
|
def _create_and_wait(kserve_client, test_case):
|
|
"""Create LLMISVC and wait for it to be ready."""
|
|
create_llmisvc(kserve_client, test_case.llm_service)
|
|
wait_for_llm_isvc_ready(
|
|
kserve_client, test_case.llm_service, test_case.wait_timeout
|
|
)
|
|
|
|
|
|
def _cleanup(kserve_client, test_case):
|
|
"""Delete LLMISVC unless SKIP_RESOURCE_DELETION is set."""
|
|
if os.getenv("SKIP_RESOURCE_DELETION", "False").lower() in ("false", "0", "f"):
|
|
try:
|
|
delete_llmisvc(kserve_client, test_case.llm_service)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to cleanup service: {e}")
|
|
|
|
|
|
def _new_kserve_client():
|
|
return KServeClient(
|
|
config_file=os.environ.get("KUBECONFIG", "~/.kube/config"),
|
|
client_configuration=client.Configuration(),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 1: HPA + Deployment
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_hpa
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-hpa",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-hpa-deploy",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_hpa_deployment(test_case: TestCase):
|
|
"""HPA + Deployment: VA and HPA exist; pods scale up under load."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
|
|
assert_scaling_resources_exist(service_name, actuator="hpa")
|
|
assert not resource_exists(
|
|
KEDA_GROUP,
|
|
KEDA_VERSION,
|
|
KEDA_PLURAL,
|
|
scaled_object_name(service_name),
|
|
KSERVE_TEST_NAMESPACE,
|
|
)
|
|
assert_metrics_pipeline_ready(actuator="hpa")
|
|
|
|
service_url = get_llm_service_url(kserve_client, test_case.llm_service)
|
|
send_load(
|
|
service_url, test_case.model_name, concurrency=10, duration_seconds=60
|
|
)
|
|
|
|
assert_wva_metric_exists(service_name)
|
|
wait_for_pod_count(service_name, min_count=2, timeout=300)
|
|
assert_hpa_active(service_name)
|
|
assert_scaling_ready_condition(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 2: KEDA + Deployment
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_keda
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-keda",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-keda-deploy",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_keda_deployment(test_case: TestCase):
|
|
"""KEDA + Deployment: VA and ScaledObject exist; no HPA; pods scale up under load."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
|
|
assert_scaling_resources_exist(service_name, actuator="keda")
|
|
assert not resource_exists(
|
|
HPA_GROUP,
|
|
HPA_VERSION,
|
|
HPA_PLURAL,
|
|
hpa_name(service_name),
|
|
KSERVE_TEST_NAMESPACE,
|
|
)
|
|
assert_metrics_pipeline_ready(actuator="keda")
|
|
|
|
service_url = get_llm_service_url(kserve_client, test_case.llm_service)
|
|
send_load(
|
|
service_url, test_case.model_name, concurrency=10, duration_seconds=60
|
|
)
|
|
|
|
assert_wva_metric_exists(service_name)
|
|
wait_for_pod_count(service_name, min_count=2, timeout=300)
|
|
assert_scaled_object_active(service_name)
|
|
assert_scaling_ready_condition(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 3: HPA + LWS (multi-node)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_hpa
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-lws",
|
|
"prometheus-scrape",
|
|
"scaling-hpa",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-hpa-lws",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_multi_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_hpa_lws(test_case: TestCase):
|
|
"""HPA + LWS: VA and HPA exist; pods scale under load."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
|
|
assert_scaling_resources_exist(service_name, actuator="hpa")
|
|
assert_metrics_pipeline_ready(actuator="hpa")
|
|
|
|
service_url = get_llm_service_url(kserve_client, test_case.llm_service)
|
|
send_load(
|
|
service_url, test_case.model_name, concurrency=10, duration_seconds=60
|
|
)
|
|
|
|
assert_wva_metric_exists(service_name)
|
|
wait_for_pod_count(service_name, min_count=2, timeout=300)
|
|
assert_hpa_active(service_name)
|
|
assert_lws_replicas(service_name, min_replicas=1)
|
|
assert_scaling_ready_condition(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 4: KEDA + LWS (multi-node)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_keda
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-lws",
|
|
"prometheus-scrape",
|
|
"scaling-keda",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-keda-lws",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_multi_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_keda_lws(test_case: TestCase):
|
|
"""KEDA + LWS: VA and ScaledObject exist; pods scale under load."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
|
|
assert_scaling_resources_exist(service_name, actuator="keda")
|
|
assert_metrics_pipeline_ready(actuator="keda")
|
|
|
|
service_url = get_llm_service_url(kserve_client, test_case.llm_service)
|
|
send_load(
|
|
service_url, test_case.model_name, concurrency=10, duration_seconds=60
|
|
)
|
|
|
|
assert_wva_metric_exists(service_name)
|
|
wait_for_pod_count(service_name, min_count=2, timeout=300)
|
|
assert_scaled_object_active(service_name)
|
|
assert_lws_replicas(service_name, min_replicas=1)
|
|
assert_scaling_ready_condition(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 5: Prefill + HPA (P/D disaggregated)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_hpa
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-pd",
|
|
"prometheus-scrape",
|
|
"scaling-hpa",
|
|
"scaling-prefill-hpa",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-prefill-hpa",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_gpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_prefill_hpa(test_case: TestCase):
|
|
"""P/D + HPA: separate VA and HPA for both decode and prefill workloads."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
|
|
assert_scaling_resources_exist(service_name, actuator="hpa", prefill=False)
|
|
assert_scaling_resources_exist(service_name, actuator="hpa", prefill=True)
|
|
assert_scaling_ready_condition(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 6: Prefill + KEDA (P/D disaggregated)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_keda
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-pd",
|
|
"prometheus-scrape",
|
|
"scaling-keda",
|
|
"scaling-prefill-keda",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-prefill-keda",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_gpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_prefill_keda(test_case: TestCase):
|
|
"""P/D + KEDA: separate VA and ScaledObject for both decode and prefill workloads."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
|
|
assert_scaling_resources_exist(service_name, actuator="keda", prefill=False)
|
|
assert_scaling_resources_exist(service_name, actuator="keda", prefill=True)
|
|
assert_scaling_ready_condition(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 7: Cleanup -- remove scaling, verify resources deleted (HPA)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_hpa
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-hpa",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-cleanup-hpa",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_cleanup_hpa(test_case: TestCase):
|
|
"""Removing scaling config should delete VA and HPA."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
assert_scaling_resources_exist(service_name, actuator="hpa")
|
|
assert_scaling_ready_condition(service_name)
|
|
|
|
# Remove the scaling baseRef; keep only non-scaling refs.
|
|
base_refs = test_case.llm_service.spec["baseRefs"]
|
|
non_scaling_refs = [ref for ref in base_refs if "scaling" not in ref["name"]]
|
|
patch_llmisvc(
|
|
kserve_client,
|
|
test_case.llm_service,
|
|
{
|
|
"spec": {
|
|
"baseRefs": non_scaling_refs,
|
|
"replicas": 1,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert_scaling_resources_deleted(service_name, actuator="hpa")
|
|
assert_scaling_ready_absent(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 7b: Cleanup -- remove scaling, verify resources deleted (KEDA)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_keda
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-keda",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-cleanup-keda",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_cleanup_keda(test_case: TestCase):
|
|
"""Removing scaling config should delete VA and ScaledObject."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
assert_scaling_resources_exist(service_name, actuator="keda")
|
|
assert_scaling_ready_condition(service_name)
|
|
|
|
base_refs = test_case.llm_service.spec["baseRefs"]
|
|
non_scaling_refs = [ref for ref in base_refs if "scaling" not in ref["name"]]
|
|
patch_llmisvc(
|
|
kserve_client,
|
|
test_case.llm_service,
|
|
{
|
|
"spec": {
|
|
"baseRefs": non_scaling_refs,
|
|
"replicas": 1,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert_scaling_resources_deleted(service_name, actuator="keda")
|
|
assert_scaling_ready_absent(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 8: Stop -- set stop annotation, verify resources deleted (HPA)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_hpa
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-hpa",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-stop-hpa",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_stop_hpa(test_case: TestCase):
|
|
"""Setting stop annotation should delete VA and HPA."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
assert_scaling_resources_exist(service_name, actuator="hpa")
|
|
assert_scaling_ready_condition(service_name)
|
|
|
|
patch_llmisvc(
|
|
kserve_client,
|
|
test_case.llm_service,
|
|
{
|
|
"metadata": {
|
|
"annotations": {
|
|
STOP_ANNOTATION_KEY: "true",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
assert_scaling_resources_deleted(service_name, actuator="hpa")
|
|
assert_scaling_ready_absent(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 8b: Stop -- set stop annotation, verify resources deleted (KEDA)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_keda
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-keda",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-stop-keda",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_stop_keda(test_case: TestCase):
|
|
"""Setting stop annotation should delete VA and ScaledObject."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
assert_scaling_resources_exist(service_name, actuator="keda")
|
|
assert_scaling_ready_condition(service_name)
|
|
|
|
patch_llmisvc(
|
|
kserve_client,
|
|
test_case.llm_service,
|
|
{
|
|
"metadata": {
|
|
"annotations": {
|
|
STOP_ANNOTATION_KEY: "true",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
assert_scaling_resources_deleted(service_name, actuator="keda")
|
|
assert_scaling_ready_absent(service_name)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 9: Update -- patch maxReplicas, verify scaling reflects new limit (HPA)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_hpa
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-hpa",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-update-hpa",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_update_hpa(test_case: TestCase):
|
|
"""Patching maxReplicas should update the HPA; VA and HPA still exist."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
assert_scaling_resources_exist(service_name, actuator="hpa")
|
|
|
|
# JSON merge patch replaces `scaling` entirely, so we must include
|
|
# the full spec with `wva` to satisfy the webhook validation.
|
|
patch_llmisvc(
|
|
kserve_client,
|
|
test_case.llm_service,
|
|
{
|
|
"spec": {
|
|
"scaling": {
|
|
"minReplicas": 1,
|
|
"maxReplicas": 5,
|
|
"wva": {"hpa": {}},
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
time.sleep(5)
|
|
assert_scaling_resources_exist(service_name, actuator="hpa")
|
|
|
|
hpa = _get_custom_resource(
|
|
HPA_GROUP,
|
|
HPA_VERSION,
|
|
HPA_PLURAL,
|
|
hpa_name(service_name),
|
|
KSERVE_TEST_NAMESPACE,
|
|
)
|
|
assert hpa is not None
|
|
assert hpa["spec"]["maxReplicas"] == 5, (
|
|
f"Expected maxReplicas=5 after update, got {hpa['spec']['maxReplicas']}"
|
|
)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 9b: Update -- patch maxReplicas, verify scaling reflects new limit (KEDA)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.llminferenceservice
|
|
@pytest.mark.autoscaling
|
|
@pytest.mark.autoscaling_keda
|
|
@pytest.mark.parametrize(
|
|
"test_case",
|
|
[
|
|
pytest.param(
|
|
TestCase(
|
|
base_refs=[
|
|
"router-managed",
|
|
"workload-llmd-simulator-no-replicas",
|
|
"prometheus-scrape",
|
|
"scaling-keda",
|
|
],
|
|
prompt="KServe is a",
|
|
service_name="autoscale-update-keda",
|
|
),
|
|
marks=[
|
|
pytest.mark.cluster_cpu,
|
|
pytest.mark.cluster_single_node,
|
|
pytest.mark.llmd_simulator,
|
|
],
|
|
),
|
|
],
|
|
indirect=["test_case"],
|
|
ids=generate_test_id,
|
|
)
|
|
@log_execution
|
|
def test_llm_autoscaling_update_keda(test_case: TestCase):
|
|
"""Patching maxReplicas should update the ScaledObject; VA and ScaledObject still exist."""
|
|
inject_k8s_proxy()
|
|
kserve_client = _new_kserve_client()
|
|
service_name = test_case.llm_service.metadata.name
|
|
|
|
try:
|
|
_create_and_wait(kserve_client, test_case)
|
|
assert_scaling_resources_exist(service_name, actuator="keda")
|
|
|
|
patch_llmisvc(
|
|
kserve_client,
|
|
test_case.llm_service,
|
|
{
|
|
"spec": {
|
|
"scaling": {
|
|
"minReplicas": 1,
|
|
"maxReplicas": 5,
|
|
"wva": {
|
|
"keda": {
|
|
"pollingInterval": 5,
|
|
"cooldownPeriod": 10,
|
|
"initialCooldownPeriod": 0,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
time.sleep(5)
|
|
assert_scaling_resources_exist(service_name, actuator="keda")
|
|
|
|
so = _get_custom_resource(
|
|
KEDA_GROUP,
|
|
KEDA_VERSION,
|
|
KEDA_PLURAL,
|
|
scaled_object_name(service_name),
|
|
KSERVE_TEST_NAMESPACE,
|
|
)
|
|
assert so is not None
|
|
assert so["spec"]["maxReplicaCount"] == 5, (
|
|
f"Expected maxReplicaCount=5 after update, got {so['spec']['maxReplicaCount']}"
|
|
)
|
|
finally:
|
|
_cleanup(kserve_client, test_case)
|