-
Notifications
You must be signed in to change notification settings - Fork 84
Add KEDA autoscaling support for ModelServing via Prometheus #839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
bca1b9f
75d78ee
fc2b651
cdeb40f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| apiVersion: rbac.authorization.k8s.io/v1 | ||
| kind: ClusterRole | ||
| metadata: | ||
| name: keda-modelserving-scaling | ||
| rules: | ||
| - apiGroups: | ||
| - workload.serving.volcano.sh | ||
| resources: | ||
| - modelservings | ||
| verbs: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do |
||
| - get | ||
| - list | ||
| - watch | ||
| - apiGroups: | ||
| - workload.serving.volcano.sh | ||
| resources: | ||
| - modelservings/scale | ||
| verbs: | ||
| - get | ||
| - update | ||
| - patch | ||
| --- | ||
| apiVersion: rbac.authorization.k8s.io/v1 | ||
| kind: ClusterRoleBinding | ||
| metadata: | ||
| name: keda-modelserving-scaling | ||
|
Comment on lines
+1
to
+26
|
||
| roleRef: | ||
| apiGroup: rbac.authorization.k8s.io | ||
| kind: ClusterRole | ||
| name: keda-modelserving-scaling | ||
| subjects: | ||
| - kind: ServiceAccount | ||
| name: keda-operator | ||
| namespace: keda | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1617,9 +1617,14 @@ func (c *ModelServingController) UpdateModelServingStatus(ms *workloadv1alpha1.M | |
| // If no groups exist, handle gracefully by setting revisions to the new revision | ||
| if errors.Is(err, datastore.ErrServingGroupNotFound) { | ||
| copy := latestMS.DeepCopy() | ||
| if copy.Status.CurrentRevision != revision || copy.Status.UpdateRevision != revision { | ||
| selector := labels.Set{ | ||
| workloadv1alpha1.ModelServingNameLabelKey: latestMS.Name, | ||
| }.String() | ||
| needsUpdate := copy.Status.CurrentRevision != revision || copy.Status.UpdateRevision != revision || copy.Status.LabelSelector != selector | ||
| if needsUpdate { | ||
| copy.Status.CurrentRevision = revision | ||
| copy.Status.UpdateRevision = revision | ||
| copy.Status.LabelSelector = selector | ||
| _, updateErr := c.modelServingClient.WorkloadV1alpha1().ModelServings(copy.GetNamespace()).UpdateStatus(context.TODO(), copy, metav1.UpdateOptions{}) | ||
| return updateErr | ||
| } | ||
|
|
@@ -1745,6 +1750,18 @@ func (c *ModelServingController) UpdateModelServingStatus(ms *workloadv1alpha1.M | |
| copy.Status.ObservedGeneration = latestMS.Generation | ||
| } | ||
|
|
||
| // Set labelSelector so the scale subresource can report it to HPA. | ||
| // Without this, HPA fails with "selector is required" because it cannot | ||
| // determine which pods belong to this ModelServing. | ||
| // The selector matches the label applied to all pods by createBasePod(). | ||
| selector := labels.Set{ | ||
| workloadv1alpha1.ModelServingNameLabelKey: latestMS.Name, | ||
| }.String() | ||
| if copy.Status.LabelSelector != selector { | ||
| shouldUpdate = true | ||
| copy.Status.LabelSelector = selector | ||
| } | ||
|
Comment on lines
+1753
to
+1763
|
||
|
|
||
| if shouldUpdate { | ||
| _, err := c.modelServingClient.WorkloadV1alpha1().ModelServings(copy.GetNamespace()).UpdateStatus(context.TODO(), copy, metav1.UpdateOptions{}) | ||
| if err != nil { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -3431,6 +3431,109 @@ func TestScaleUpServingGroups_TemplateRecovery(t *testing.T) { | |||||
|
|
||||||
| // TestUpdateModelServingStatusRevisionFields tests the CurrentRevision and UpdateRevision logic | ||||||
| // following StatefulSet's behavior | ||||||
| func TestUpdateModelServingStatusLabelSelector(t *testing.T) { | ||||||
| tests := []struct { | ||||||
| name string | ||||||
| msName string | ||||||
| existingGroups map[int]string // ordinal -> revision; nil means no groups (ErrServingGroupNotFound path) | ||||||
| revision string | ||||||
| }{ | ||||||
| { | ||||||
| name: "no ServingGroups yet — labelSelector is set on empty status", | ||||||
| msName: "my-llm", | ||||||
| existingGroups: nil, | ||||||
| revision: "rev-1", | ||||||
| }, | ||||||
| { | ||||||
| name: "existing ServingGroups — labelSelector is set consistently", | ||||||
| msName: "my-llm", | ||||||
| existingGroups: map[int]string{ | ||||||
| 0: "rev-1", | ||||||
| 1: "rev-1", | ||||||
| }, | ||||||
| revision: "rev-1", | ||||||
| }, | ||||||
| { | ||||||
| name: "name with special characters — selector encodes correctly", | ||||||
|
||||||
| name: "name with special characters — selector encodes correctly", | |
| name: "name with dashes and numbers — selector string contains name unmodified", |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,20 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiVersion: keda.sh/v1alpha1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kind: ScaledObject | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: modelserving-scaler | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| namespace: default | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spec: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scaleTargetRef: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiVersion: workload.serving.volcano.sh/v1alpha1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kind: ModelServing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: test-model | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| minReplicaCount: 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxReplicaCount: 5 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pollingInterval: 15 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cooldownPeriod: 60 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| triggers: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - type: prometheus | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc.cluster.local:9090 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| query: sum(rate(process_cpu_seconds_total[1m])) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| query: sum(rate(process_cpu_seconds_total[1m])) | |
| query: sum(rate(process_cpu_seconds_total{namespace="default", pod=~"test-model-.*"}[1m])) |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Prometheus query uses the metric process_cpu_seconds_total, but the mock deployments in test-deployment.yaml do not expose this metric. They expose kthena_router_* and vllm_* metrics.
To make the example consistent and functional, the query should use one of the available metrics. For example, using vllm_num_requests_running from the dummy-inference-vllm deployment would be more appropriate, as those pods are labeled to be part of the ModelServing instance.
You could change the query to something like this, assuming the goal is to scale when there's at least one running request:
query: sum(vllm_num_requests_running{modelserving_volcano_sh_name="test-model"})
threshold: "1"
Outdated
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This ScaledObject manifest appears to be an example asset; to align with the repo’s existing structure for sample YAML, consider moving it under examples/ (e.g., examples/autoscaling/keda/) rather than adding it at repository root.
| apiVersion: keda.sh/v1alpha1 | |
| kind: ScaledObject | |
| metadata: | |
| name: modelserving-scaler | |
| namespace: default | |
| spec: | |
| scaleTargetRef: | |
| apiVersion: workload.serving.volcano.sh/v1alpha1 | |
| kind: ModelServing | |
| name: test-model | |
| minReplicaCount: 1 | |
| maxReplicaCount: 5 | |
| pollingInterval: 15 | |
| cooldownPeriod: 60 | |
| triggers: | |
| - type: prometheus | |
| metadata: | |
| serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc.cluster.local:9090 | |
| query: sum(rate(process_cpu_seconds_total[1m])) | |
| threshold: "0.01" | |
| # Placeholder file at repository root. | |
| # The example KEDA ScaledObject manifest has been moved under the examples tree, | |
| # for example: examples/autoscaling/keda/scaledobject.yaml | |
| # | |
| # This file is intentionally left without any Kubernetes resources to avoid | |
| # having example manifests at the repository root. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| apiVersion: monitoring.coreos.com/v1 | ||
| kind: ServiceMonitor | ||
| metadata: | ||
| name: kthena-router | ||
| namespace: monitoring | ||
| spec: | ||
|
Comment on lines
+1
to
+6
|
||
| namespaceSelector: | ||
| matchNames: | ||
| - default | ||
| selector: | ||
| matchLabels: | ||
| app.kubernetes.io/component: kthena-router | ||
| endpoints: | ||
| - port: http | ||
| path: /metrics | ||
| interval: 15s | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,113 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiVersion: apps/v1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kind: Deployment | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| labels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.kubernetes.io/component: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spec: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| replicas: 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| selector: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| matchLabels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.kubernetes.io/component: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| template: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| labels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.kubernetes.io/component: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spec: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| containers: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - name: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| image: nginx:alpine | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ports: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - containerPort: 8080 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| volumeMounts: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - name: nginx-config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mountPath: /etc/nginx/conf.d | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| volumes: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - name: nginx-config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| configMap: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: kthena-router-nginx-config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiVersion: v1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kind: ConfigMap | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: kthena-router-nginx-config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default.conf: | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| server { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| listen 8080; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| location /metrics { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default_type text/plain; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 200 '# HELP kthena_router_active_downstream_requests Number of active downstream requests\n# TYPE kthena_router_active_downstream_requests gauge\nkthena_router_active_downstream_requests 3\n# HELP kthena_router_requests_total Total requests\n# TYPE kthena_router_requests_total counter\nkthena_router_requests_total 100\n'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| location / { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 200 'kthena-router ok\n'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiVersion: v1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kind: Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| labels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.kubernetes.io/component: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spec: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| selector: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.kubernetes.io/component: kthena-router | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ports: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - name: http | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| port: 80 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| targetPort: 8080 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| protocol: TCP | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apiVersion: apps/v1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kind: Deployment | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: dummy-inference-vllm | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| labels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelserving.volcano.sh/name: test-model | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelserving.volcano.sh/entry: "true" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spec: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| replicas: 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| selector: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| matchLabels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelserving.volcano.sh/name: test-model | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelserving.volcano.sh/entry: "true" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| template: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| labels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelserving.volcano.sh/name: test-model | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelserving.volcano.sh/name: test-model | |
| modelserving.volcano.sh/entry: "true" | |
| spec: | |
| replicas: 1 | |
| selector: | |
| matchLabels: | |
| modelserving.volcano.sh/name: test-model | |
| modelserving.volcano.sh/entry: "true" | |
| template: | |
| metadata: | |
| labels: | |
| modelserving.volcano.sh/name: test-model | |
| modelserving.volcano.sh/name: dummy-test-model | |
| modelserving.volcano.sh/entry: "true" | |
| spec: | |
| replicas: 1 | |
| selector: | |
| matchLabels: | |
| modelserving.volcano.sh/name: dummy-test-model | |
| modelserving.volcano.sh/entry: "true" | |
| template: | |
| metadata: | |
| labels: | |
| modelserving.volcano.sh/name: dummy-test-model |
Outdated
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This example Deployment is labeled with the same modelserving.volcano.sh/name: test-model key/value that the controller now publishes via status.labelSelector. If someone applies this alongside a real ModelServing named test-model, HPA will likely count these pods as part of the scale target, skewing replica calculations and metrics. Recommend updating the example to avoid reusing the ModelServingNameLabelKey label (or use a different msName value that cannot collide with an actual ModelServing), so it doesn’t interfere with autoscaling behavior.
| modelserving.volcano.sh/name: test-model | |
| modelserving.volcano.sh/entry: "true" | |
| spec: | |
| replicas: 1 | |
| selector: | |
| matchLabels: | |
| modelserving.volcano.sh/name: test-model | |
| modelserving.volcano.sh/entry: "true" | |
| template: | |
| metadata: | |
| labels: | |
| modelserving.volcano.sh/name: test-model | |
| modelserving.volcano.sh/entry: "true" | |
| app.kubernetes.io/name: dummy-inference-vllm | |
| app.kubernetes.io/entry: "true" | |
| spec: | |
| replicas: 1 | |
| selector: | |
| matchLabels: | |
| app.kubernetes.io/name: dummy-inference-vllm | |
| app.kubernetes.io/entry: "true" | |
| template: | |
| metadata: | |
| labels: | |
| app.kubernetes.io/name: dummy-inference-vllm | |
| app.kubernetes.io/entry: "true" |
Outdated
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The NGINX return 200 '...\\n...' payload will emit literal backslash-n sequences (NGINX doesn’t interpret \\n escapes here), which can make the Prometheus exposition invalid/unparseable. To make these manifests reliably scrapeable, serve metrics with real newlines (e.g., by returning a multi-line literal string with actual newline characters, serving a static metrics file, or using a tiny HTTP server/exporter image that emits valid Prometheus text format).
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dummy-inference-vllm deployment exposes vllm_* metrics that would be useful for autoscaling, but there is no Service defined to expose its pods for scraping. Without a Service and a corresponding ServiceMonitor, Prometheus will not be able to collect these metrics.
To make this example fully functional, a Service for this deployment should be added. For example:
---
apiVersion: v1
kind: Service
metadata:
name: dummy-inference-vllm
labels:
modelserving.volcano.sh/name: test-model
spec:
selector:
modelserving.volcano.sh/name: test-model
ports:
- name: http-metrics
port: 8000
targetPort: 8000
protocol: TCPA corresponding ServiceMonitor would also be needed to instruct Prometheus to scrape this new service.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This RBAC manifest also appears to be an example asset; consider relocating it under
examples//docs/so users can find it alongside other sample manifests instead of at the repository root.