KEDA-based Autoscaling with Istio Metrics
Verification baseline: KEDA/chart 2.20.2, Istio 1.31.0, Kubernetes 1.32–1.36 Last reviewed: September 11, 2026
This guide explains scaling signals and their limits. It assumes existing workloads, verified metrics and sufficient cluster capacity. Examples targeting the same Deployment are alternatives: select one ScaledObject/HPA owner per target, not all the objects on this page.
Table of Contents
- Overview
- Architecture
- Prometheus Metrics-based Scaling
- CloudWatch Metrics-based Scaling
- Practical Scaling Strategies
- Best Practices
- Troubleshooting
- Reference: KEDA Installation
Overview
Kubernetes HPA supports resource, custom and external metrics through the corresponding APIs, including multiple metrics. CloudWatch can be integrated through an adapter; it is not inherently impossible with HPA. KEDA provides scalers, an external metrics API and activation management while still using HPA for ordinary replica scaling.
| Signal | Meaning | Use and limitation |
|---|---|---|
istio_requests_total | HTTP/gRPC request counter | Rate can measure admitted load; choose one reporter and the actual target workload |
istio_request_duration_milliseconds_bucket | Classic latency histogram buckets | Quantiles are quality observations, not guaranteed inverse-capacity signals |
istio_tcp_connections_opened_total | Cumulative opened connections | Its rate is connection creation rate, not currently active connections |
istio_request_bytes_sum | Cumulative observed HTTP request bytes | A rate measures throughput; scope reporters and workloads |
envoy_cluster_upstream_rq_pending_overflow | Client-side cluster overflow counter | Diagnose pool limits/dependencies before deciding which workload, if any, should scale |
A calibrated demand/backlog metric is a starting point. Latency, errors and circuit-breaker events can result from downstream failures that more replicas will not fix. Stateful membership, storage and application semantics also constrain scaling; “stateful” or “latency-sensitive” alone does not select a safe autoscaling policy.
Architecture
KEDA creates/configures an HPA for the scale target and exposes external metrics. The HPA controller requests metrics through that API and updates the target's /scale subresource; the target controller and scheduler then create/place Pods.
| Setting or component | Responsibility |
|---|---|
KEDA pollingInterval | Trigger polling and activation, including 0→1 |
| HPA controller sync | Additional metric requests and 1→N decisions; default sync period 15 seconds, cluster configurable |
useCachedMetrics | Optional KEDA metric caching between polls; not enabled in these examples |
activationThreshold | Activation threshold for 0↔1, not a second HPA scale-down threshold |
cooldownPeriod | Wait after inactivity before KEDA scales to 0, not a pause after every scale-down |
HPA behavior | 1→N stabilization and rate-of-change limits |
With minReplicaCount above 0, do not use activation/cooldown as ordinary replica hysteresis. Capture→scrape→query→HPA→Pod startup/readiness all contribute delay; neither a 15-second poll nor zero stabilization guarantees immediate ready capacity.
Metric Types and Ideal Arithmetic
Ignoring HPA tolerance, missing/unready Pods, limits and behavior policies:
- AverageValue + total demand: desired replicas ≈
ceil(total metric / target per Pod). - Value + a workload-wide value: desired replicas ≈
ceil(current replicas × observed value / target value).
For 600 RPS at 100 RPS/Pod, AverageValue asks for 6 replicas. Dividing the query by 3 Pods first would feed 200 and incorrectly ask for 2. count(up) counts scrape targets and is not a safe replica divisor either.
With 4 replicas, a global 300ms latency and a Value target 200ms suggest 6 replicas. If adding replicas does not reduce that latency, repeated decisions can drive the workload to its cap. Treat latency/error ratio controllers as experiments requiring evidence of negative feedback, not production defaults.
Prometheus Metrics-based Scaling
The examples assume an actual Deployment named reviews in default. A Service name is not a scale target. Released Bookinfo commonly uses Deployment names such as reviews-v1; adapt both scaleTargetRef and metric selectors to the real workload rather than assuming they match a Service.
Verify one scrape of each relevant proxy and the actual labels. The primary examples use reporter="destination" to avoid counting both request reports. This measures requests admitted at the target; edge rejections/queues may require an independently measured demand signal.
1. RPS-based Scaling
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-rps-scaler
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherThe query includes failed requests as load and returns total RPS. threshold: "100" is the target per replica for AverageValue, not a global “above 100 means add a Pod” switch. Do not divide by Pod count again.
ignoreNullValues: "false" makes missing, NaN or infinite Prometheus results errors in KEDA 2.20.2. A real zero counter rate remains 0. Configure and test fallback for source failures; do not silently convert arbitrary missing metrics to 0. Bootstrap the scrape/metric data contract before enabling the scaler.
Fallback here uses the higher of the configured floor and current replicas after the configured error threshold, still subject to HPA limits/behavior. It is not protection against a down KEDA metrics API or absent node capacity.
2. Latency-based Control: Conditional Experiment
This alternative explicitly uses Value for the workload-wide p95:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-latency-experiment
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: p95
metricType: Value
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: |-
(histogram_quantile(0.95, sum by (le) (rate(istio_request_duration_milliseconds_bucket{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])))
and on() (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
threshold: '200'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherThe query returns 0 only when the actual histogram count has zero rate. With absent telemetry it stays absent; invalid quantiles remain errors rather than healthy 0. A p95 of 0 in an idle window is an intentional control value, not an observed zero-duration request.
Multiple quantiles are also Value metrics:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-quantile-experiment
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: p50
metricType: Value
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: |-
(histogram_quantile(0.5, sum by (le) (rate(istio_request_duration_milliseconds_bucket{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])))
and on() (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
threshold: '50'
ignoreNullValues: 'false'
- type: prometheus
name: p95
metricType: Value
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: |-
(histogram_quantile(0.95, sum by (le) (rate(istio_request_duration_milliseconds_bucket{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])))
and on() (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
threshold: '200'
ignoreNullValues: 'false'
- type: prometheus
name: p99
metricType: Value
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: |-
(histogram_quantile(0.99, sum by (le) (rate(istio_request_duration_milliseconds_bucket{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])))
and on() (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
threshold: '500'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherHPA chooses the largest desired replica count, not an average or a weighted blend. Quantiles are correlated; more triggers do not inherently improve stability or establish a latency guarantee.
3. Error-rate Control: Conditional Experiment
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-error-experiment
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: error-percent
metricType: Value
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: |-
(100 * (sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default",response_code=~"5..|0"}[2m])) or vector(0)) / sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))
and on() (sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
threshold: '5'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherThis is a workload-wide 5xx/zero-status percentage with a Value target. Known idle traffic returns 0; no telemetry is not fabricated as0. Only use an error-based controller after establishing that replica shortage causes those errors. Dependency outages, authorization failures or client-side pool limits can make scaling ineffective or harmful.
4. Composite Metrics
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-composite-experiment
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
- type: prometheus
name: p95
metricType: Value
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: |-
(histogram_quantile(0.95, sum by (le) (rate(istio_request_duration_milliseconds_bucket{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])))
and on() (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
threshold: '200'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherThe RPS input is total demand with AverageValue; the latency input is Value. HPA takes the largest recommendation. The scale-up selectPolicy: Max chooses the larger permitted change: a five-Pod policy is not an absolute cap when the percentage policy permits more. These alternatives still need capacity and workload tests.
CloudWatch Metrics-based Scaling
CloudWatch source cadence, publication latency, aggregation period, lookback and offset determine freshness. High-resolution custom metrics exist; a fixed “CloudWatch always has 1–3 minutes delay” is inaccurate. Prometheus also has collection and control-loop delays.
Identity and Published Metric Contract
These examples use a KEDA operator role configured through IRSA and this workload-namespace TriggerAuthentication:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: keda-aws
namespace: default
spec:
podIdentity:
provider: aws
identityOwner: kedapodIdentity.provider: aws is the current IRSA provider. Its identityOwner: keda differs from the deprecated scaler metadata identityOwner: operator/pod, which remains supported in 2.20 but is scheduled for removal in 3. Do not confuse the old aws-eks provider name with a new EKS Pod Identity association. Use the documented provider/SDK credential setup for the chosen identity mechanism.
The publishing example later in this guide emits:
| Metric | Namespace and exact dimensions | Interpretation |
|---|---|---|
IstioRequestsPerSecond | IstioScaling; ClusterName=eks-demo, destination_workload=reviews, destination_workload_namespace=default | Precomputed RPS gauge |
IstioP95LatencyMilliseconds | Same dimension set | Precomputed per-window p95 gauge in milliseconds |
All dimensions must match. A query containing only destination_workload does not identify the same custom metric.
RPS Gauge
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-cloudwatch-rps
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 60
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: aws-cloudwatch
name: cw-rps
metricType: AverageValue
authenticationRef:
name: keda-aws
metadata:
namespace: IstioScaling
metricName: IstioRequestsPerSecond
dimensionName: ClusterName;destination_workload;destination_workload_namespace
dimensionValue: eks-demo;reviews;default
targetMetricValue: '100'
minMetricValue: '0'
ignoreNullValues: 'false'
metricStatPeriod: '60'
metricStat: Average
metricCollectionTime: '300'
metricEndTimeOffset: '60'
awsRegion: us-west-2
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherAverage over the gauge's60-second period retains RPS units. Summing cumulative istio_requests_total samples is not a request count per minute. For a genuinely published delta-count metric, derive a separately calibrated per-period target instead.
minMetricValue is explicitly present for the released scaler parser, but ignoreNullValues: "false" takes precedence on empty results. metricEndTimeOffset skips recent potentially incomplete points; it adds delay and does not prove data freshness. Monitor timestamps and publisher health, including stale-but-nonempty results.
Precomputed Latency Gauge
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-cloudwatch-p95-experiment
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 60
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: aws-cloudwatch
name: cw-p95
metricType: Value
authenticationRef:
name: keda-aws
metadata:
namespace: IstioScaling
metricName: IstioP95LatencyMilliseconds
dimensionName: ClusterName;destination_workload;destination_workload_namespace
dimensionValue: eks-demo;reviews;default
targetMetricValue: '200'
minMetricValue: '0'
ignoreNullValues: 'false'
metricStatPeriod: '60'
metricStat: Maximum
metricCollectionTime: '300'
metricEndTimeOffset: '60'
awsRegion: us-west-2
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherThis asks for the largest published p95 gauge in the period. It is not the p95 of all requests in that CloudWatch period. Do not request metricStat: p95 on a Prometheus histogram conversion or a p95-of-p95 gauge and claim the original distribution is preserved. Native CloudWatch percentile use requires appropriately published samples/statistics.
Multiple Sources Are Not Ordered Failover
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-dual-source-example
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: prom-rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
- type: aws-cloudwatch
name: cw-rps
metricType: AverageValue
authenticationRef:
name: keda-aws
metadata:
namespace: IstioScaling
metricName: IstioRequestsPerSecond
dimensionName: ClusterName;destination_workload;destination_workload_namespace
dimensionValue: eks-demo;reviews;default
targetMetricValue: '100'
minMetricValue: '0'
ignoreNullValues: 'false'
metricStatPeriod: '60'
metricStat: Average
metricCollectionTime: '300'
metricEndTimeOffset: '60'
awsRegion: us-west-2
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherBoth metrics participate in the HPA's maximum recommendation. “Prometheus primary, CloudWatch secondary” is not a priority/failover policy, and stale values can retain an elevated replica recommendation. Prefer a deliberate single source or a tested multi-source/fallback design.
Practical Scaling Strategies
1. Scheduled Replica Floor
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: frontend-scheduled-floor
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: frontend
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 50
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="frontend",destination_workload_namespace="default"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
- type: cron
metadata:
timezone: Asia/Seoul
start: 0 9 * * 1-5
end: 0 18 * * 1-5
desiredReplicas: '20'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherDuring the weekday Asia/Seoul window, the Cron trigger supplies a 20-replica floor while demand can request more, up to the configured maximum. This is scheduled scaling, not a traffic-prediction model. Schedule ahead of demand when startup/readiness takes time.
2. Explicit Off-hours Scale to Zero
For a workload that may be unavailable outside office hours, use a positive desired count inside the window and minReplicaCount: 0:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: analytics-office-hours
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: analytics-service
pollingInterval: 30
cooldownPeriod: 600
minReplicaCount: 0
maxReplicaCount: 30
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: cron
metadata:
timezone: Asia/Seoul
start: 0 9 * * 1-5
end: 0 18 * * 1-5
desiredReplicas: '20'Cron desiredReplicas: "0" is invalid. Outside the active window, KEDA can return to 0 under its inactivity/cooldown rules. A client request does not wake this Cron-only workload. Destination-side Istio metrics disappear with the application, so they cannot by themselves provide a reliable 0→1 demand signal. Use an independently observable queue/interceptor or keep a positive minimum when on-demand availability is required.
PromQL hour() uses UTC; it does not inherit a Cron scaler's Asia/Seoul timezone. Avoid mixing them as if their business-hour windows were identical.
3. Circuit-breaker Signals Are Diagnostics First
Client-side overflow and current connections can be inspected separately:
sum(increase(envoy_cluster_upstream_rq_pending_overflow{
cluster_name=~"outbound[|]9080[|][^|]*[|]backend[.]default[.]svc[.]cluster[.]local"
}[1m]))
sum(envoy_cluster_upstream_cx_active{
cluster_name=~"outbound[|]9080[|][^|]*[|]backend[.]default[.]svc[.]cluster[.]local"
})
max(envoy_cluster_circuit_breakers_default_cx_open{
cluster_name=~"outbound[|]9080[|][^|]*[|]backend[.]default[.]svc[.]cluster[.]local"
})Verify the real cluster name/port, exported stats and source scrape scope. cx_open is a 0/1 circuit-breaker flag, not connection capacity; dividing active connections by it cannot produce saturation percentage. Increasing backend replicas does not raise a client's fixed connection-pool limits. Diagnose the limit/dependency before assigning a scaling target.
4. Scaling Policies Are Not Load Tiers
HPA policy lists with Percent/Pods and selectPolicy: Max or Min limit allowed changes over rolling periods. They do not automatically select “low”, “medium” and “high” load tiers from comments. Use the main behavior example to bound changes and validate it against the measured workload response.
5. Gateway-observed Backend Demand
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: backend-gateway-rps
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: backend
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 2
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: gateway-backend-rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="source",source_workload="istio-ingressgateway",source_workload_namespace="istio-system",destination_service_name="backend",destination_service_namespace="default"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherVerify the real gateway workload name and destination Service labels. This measures traffic for the specific backend from that gateway. envoy_http_downstream_rq_active is active HTTP requests, not pending connections, and a gateway-wide aggregate includes unrelated services. Do not use that aggregate to scale an arbitrary backend.
This example keeps a positive minimum. If considering 0 replicas, first prove the independent gateway/interceptor still emits the necessary metric with no backend endpoints and provides the desired request-buffering/error behavior.
Best Practices
1. One Target, One Autoscaler Owner
Do not install several example ScaledObjects or an extra “backup HPA” on the same target. Coordinate existing HPA ownership and GitOps replicas fields before a change. Multiple metrics can live in one ScaledObject; native HPA can skip downscaling when a metric errors while still allowing a valid scale-up recommendation.
KEDA 2.20 fallback supports AverageValue and Value triggers except CPU/memory; it applies to ScaledObjects, not ScaledJobs. A CPU/memory trigger needs its own metrics-server/request prerequisites and is not an independent failover controller.
2. Capacity Planning Example, Not a Benchmark
The following preserves the original numbers as hypothetical inputs:
| Assumption/calculation | Result |
|---|---|
| Assumed measured per-Pod capacity 200 RPS × chosen utilization factor 70% | 140 RPS/Pod target |
| Normal load 500 /140, rounded up | 4 replicas |
| Peak load 2000 /140, rounded up | 15 replicas |
| Chosen maximum with extra room | 20, subject to actual schedulable capacity |
These were not measured by this audit. Run a bounded, approved load test against a known replica/target and record latency, errors, resources and readiness. A Service load-balancing over several replicas does not directly measure one Pod's capacity.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: reviews-capacity-example
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: reviews
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 4
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))
threshold: '140'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 4
behavior: currentReplicasIfHigherThere is no universal “maxReplicaCount ≤70% of cluster capacity” rule: Pod count is not a CPU/memory/IP/quota percentage. HPA/KEDA scale workloads; node capacity requires separate provisioning/autoscaler configuration.
3. Resources and Health
Merge this fragment into the existing Deployment/container after confirming its container name and actual health endpoint. Retain its real image, selectors and labels:
spec:
template:
spec:
containers:
- name: reviews
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
readinessProbe:
httpGet:
path: /health
port: 9080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3Requests/limits and probes are tuning inputs, not a measured throughput guarantee. Readiness and startup/draining affect when capacity is usable. Liveness should not restart an otherwise healthy process merely because a downstream dependency is unavailable.
4. Multiple Clusters and Regions
Use a scaler in each target cluster with a verified cluster-local datasource, or explicit cluster labels that truly exist in a federated store. With cluster-local destination-reporter data, this example counts all local backend demand:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: frontend-local-demand
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: frontend
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 3
maxReplicaCount: 30
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: local-rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="frontend",destination_workload_namespace="default"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 3
behavior: currentReplicasIfHigherApply each configuration in its intended cluster context. Metadata labels such as region do not make a ScaledObject control a remote cluster. source_cluster describes origin, not the destination capacity to scale; multiplying already-filtered traffic by 0.6/0.4 does not implement a global traffic split.
Service naming patterns such as *-us-* do not establish client geography, and destination_region is not a guaranteed default Istio label. Regional SLOs need verified telemetry and workload capacity, not assumed country names in service labels.
5. Payment and Queue Workloads
A payment workload can begin with calibrated demand and conservative bounds while latency/errors remain quality indicators:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: payment-capacity-example
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payment-service
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 5
maxReplicaCount: 50
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: prometheus
name: rps
metricType: AverageValue
metadata:
serverAddress: http://prometheus.istio-system.svc.cluster.local:9090
query: sum(rate(istio_requests_total{reporter="destination",destination_workload="payment-service",destination_workload_namespace="production"}[2m]))
threshold: '100'
ignoreNullValues: 'false'
fallback:
failureThreshold: 3
replicas: 5
behavior: currentReplicasIfHigherThe 100 RPS target and limits are illustrative. Confirm bottleneck causality, idempotency, downstream limits and representative failure behavior before adding ratio-based triggers.
For a queue worker, the queue remains visible when worker replicas are 0:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: data-processor-queue
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: data-processor
pollingInterval: 30
cooldownPeriod: 600
minReplicaCount: 0
maxReplicaCount: 30
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 50
periodSeconds: 60
- type: Pods
value: 5
periodSeconds: 60
selectPolicy: Max
triggers:
- type: aws-sqs-queue
name: backlog
metricType: AverageValue
authenticationRef:
name: keda-aws
metadata:
queueURL: https://sqs.us-west-2.amazonaws.com/123456789012/data-processing-queue
queueLength: '10'
activationQueueLength: '0'
scaleOnInFlight: 'true'
scaleOnDelayed: 'false'
awsRegion: us-west-2Replace the example account/queue URL and configure the referenced identity. queueLength: "10" means target backlog per replica, not an activation threshold of ten. Activation defaults to positive backlog with the explicitly zero activation threshold. The example counts visible plus in-flight messages and excludes delayed messages; align this with processing concurrency, visibility timeout and shutdown behavior.
Istio HTTP latency is not automatically SQS job-processing duration. Instrument business processing separately instead of adding an unavailable Pod-latency trigger to the 0-replica worker.
6. Monitoring
Expose and scrape operator metrics for scaler health. The metrics adapter's metrics alone do not contain every operator counter. KEDA's namespace metric label identifies the scaled resource namespace; do not overwrite it with the exporter Pod namespace.
This is a scrape-config fragment to merge into the existing Prometheus configuration, with namespace-scoped discovery RBAC for EndpointSlices, Services and Pods:
scrape_configs:
- job_name: keda-components
kubernetes_sd_configs:
- role: endpointslice
namespaces:
names:
- keda
relabel_configs:
- source_labels:
- __meta_kubernetes_service_name
regex: keda-operator|keda-operator-metrics-apiserver
action: keep
- source_labels:
- __meta_kubernetes_endpointslice_port_name
regex: metrics
action: keep
- source_labels:
- __meta_kubernetes_namespace
target_label: exporter_namespace
- source_labels:
- __meta_kubernetes_pod_name
target_label: exporter_podIt discovers each endpoint rather than alternating between HA operator Pods through one load-balanced Service. Confirm actual Service/port names, target labels, TLS/mesh access and scrape results. With Prometheus Operator, use equivalent selected ServiceMonitors rather than overwriting its generated ConfigMap.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: keda-scaling-alerts
namespace: keda
spec:
groups:
- name: keda-scaling
rules:
- alert: KEDAMaxReplicasReached
expr: |-
max by (namespace, horizontalpodautoscaler) (
kube_horizontalpodautoscaler_status_current_replicas{horizontalpodautoscaler=~"keda-hpa-.*"}
) >= on(namespace, horizontalpodautoscaler)
max by (namespace, horizontalpodautoscaler) (
kube_horizontalpodautoscaler_spec_max_replicas{horizontalpodautoscaler=~"keda-hpa-.*"}
)
for: 5m
labels:
severity: warning
annotations:
summary: KEDA-managed HPA is at its configured maximum
- alert: KEDAScalerErrors
expr: sum by (namespace, scaledObject) (increase(keda_scaler_detail_errors_total[5m])) > 0
for: 2m
labels:
severity: warning
annotations:
summary: Scaler retrieval errors observed; inspect source/identity and fallback
- alert: KEDAReplicaCountChurn
expr: |-
max by (namespace, horizontalpodautoscaler) (
changes(kube_horizontalpodautoscaler_status_current_replicas{horizontalpodautoscaler=~"keda-hpa-.*"}[10m])
) > 6
for: 5m
labels:
severity: warning
annotations:
summary: Frequent observed replica-count changes; inspect demand, rollout and stabilizationPrometheusRule requires a matching Operator rule selector/namespace. The HPA filters use KEDA's default name prefix; adjust for custom HPA names. keda_scaler_detail_errors_total is the released error counter; keda_scaler_active is a gauge and must not be passed to rate() as a replica-flapping measure.
Replica-count changes can reflect demand or rollouts, not necessarily harmful oscillation. These alerts are investigation signals, not proof of failed scaling or sufficient ready capacity.
Troubleshooting
kubectl get scaledobject reviews-rps-scaler -n default -o yaml
kubectl describe hpa keda-hpa-reviews-rps-scaler -n default
kubectl logs -n keda deployment/keda-operator
kubectl get apiservice v1beta1.external.metrics.k8s.io
kubectl get pods -n default -o wide
# Local query inspection; use a second terminal while port-forward is active.
kubectl port-forward -n istio-system svc/prometheus 9090:9090promtool query instant http://127.0.0.1:9090 'sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))'A local port-forward proves neither KEDA Pod connectivity nor its credentials. Check provider errors, DNS, TLS/mesh policy, metric existence/labels and aggregated-API availability from the actual component path.
For slow scaling, inspect source age, lookback, HPA sync/behavior, scheduling, image pulls and readiness before reducing pollingInterval. Activation thresholds do not accelerate ordinary 1→N scaling when a positive minimum is used. For unstable counts, examine the measured capacity response and HPA stabilization/rate limits; cooldownPeriod is not its general downscale control.
For CloudWatch, inspect returned timestamps, all dimensions, statistic/unit, collection window, offset and IAM. A higher threshold on a second metric does not make it a passive backup.
Reference: KEDA Installation
Pinned Chart and Actual Compatibility
The published KEDA 2.20 deployment requirement is Kubernetes 1.30+, while the chart metadata has a looser 1.23 floor. Helm accepting a version is not proof of runtime support. Use the intersection with Istio 1.31's 1.32–1.36 support and the managed platform's supported versions.
For a new installation, or a reviewed upgrade preserving existing values, use these values. Before upgrading, also review the release changes and CRD ownership/migration procedure:
operator:
replicaCount: 2
prometheus:
operator:
enabled: true
metricServer:
enabled: true
port: 9022helm repo add kedacore https://kedacore.github.io/charts
helm repo update kedacore
helm upgrade --install keda kedacore/keda --version 2.20.2 --namespace keda --create-namespace --values keda-values.yaml
kubectl get deployments,services,pods -n kedaoperator.replicaCount: 2 and the metrics-adapter port 9022 override are valid chart values;9022 is an explicit override of the chart's8080 default. This enables operator metrics on 8080 as well. Two operator replicas alone do not make the metrics adapter/webhook or entire scaling path highly available.
If the components are injected into Istio, KEDA documents this optional port-exclusion workaround for its own TLS-protected internal protocols:
podAnnotations:
keda:
traffic.sidecar.istio.io/excludeInboundPorts: '9666'
traffic.sidecar.istio.io/excludeOutboundPorts: 9443,6443
metricsAdapter:
traffic.sidecar.istio.io/excludeInboundPorts: '6443'
traffic.sidecar.istio.io/excludeOutboundPorts: 9666,9443
webhooks:
traffic.sidecar.istio.io/excludeInboundPorts: '9443'
traffic.sidecar.istio.io/excludeOutboundPorts: 9666,6443Verify actual ports and injection settings before merging. KEDA keeps its native TLS; Istio authorization does not cover the excluded traffic. This is not permission to disable transport security globally. Test API-server aggregation, admission, operator↔adapter and Prometheus connectivity.
AWS Reader Identity
Create/review the IAM role and its EKS OIDC trust outside this example, scoped to the actual operator ServiceAccount. Apply the corresponding Helm values without blindly overwriting an existing ServiceAccount:
podIdentity:
aws:
irsa:
enabled: true
roleArn: arn:aws:iam::123456789012:role/KedaMetricsReaderFor the shown CloudWatch scaler, the released implementation calls GetMetricData:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudwatch:GetMetricData"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "us-west-2"
}
}
}
]
}This is regional metric-read permission, not a per-metric namespace boundary. cloudwatch:namespace in the AWS example policy constrains PutMetricData publishing, not this query. The separate CloudWatch PromQL API has different IAM requirements; do not infer them from this scaler.
If using the SQS example, the operator additionally needs the specific queue's attribute-read permission:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"sqs:GetQueueAttributes"
],
"Resource": "arn:aws:sqs:us-west-2:123456789012:data-processing-queue"
}
]
}The queue worker needs its own receive/delete/visibility permissions as appropriate; the scaler's read role does not grant those. Limit who may create/change ScaledObjects and TriggerAuthentications using operator identities.
Optional CloudWatch EMF Publication
This example uses upstream Collector Contrib 0.158.0 with Operator 0.158.0, matching the operator's minor-version recommendation. Operator 0.158 supports Kubernetes 1.25–1.36. A custom image is not automatically upgraded by the operator. An ADOT distribution is an alternative only after verifying its components/configuration; the commands below are not claimed tested against an unspecified ADOT image.
First load this recording-rule file into the existing Prometheus (or equivalent PrometheusRule with the appropriate selection labels):
groups:
- name: istio-scaling-export
interval: 30s
rules:
- record: istio_scaling_requests_per_second
expr: sum(rate(istio_requests_total{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m]))
labels:
destination_workload: reviews
destination_workload_namespace: default
- record: istio_scaling_p95_milliseconds
expr: |-
(histogram_quantile(0.95, sum by (le) (rate(istio_request_duration_milliseconds_bucket{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])))
and on() (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) > 0))
or on() (0 * (sum(rate(istio_request_duration_milliseconds_count{reporter="destination",destination_workload="reviews",destination_workload_namespace="default"}[2m])) == 0))
labels:
destination_workload: reviews
destination_workload_namespace: defaultOnly the shown workload is exported. These are already-calculated RPS and rolling-window p95 gauges; they are not raw cumulative request counters or a reconstructable request-latency distribution.
Then, with the compatible Operator/CRDs and an existing reviewed publisher role/log group:
apiVersion: v1
kind: ServiceAccount
metadata:
name: istio-metrics-publisher
namespace: istio-system
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/IstioMetricsPublisher
---
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: istio-scaling
namespace: istio-system
spec:
mode: deployment
replicas: 1
serviceAccount: istio-metrics-publisher
image: otel/opentelemetry-collector-contrib:0.158.0
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
config:
extensions:
health_check:
endpoint: 0.0.0.0:13133
receivers:
prometheus:
config:
scrape_configs:
- job_name: istio-scaling-federate
scrape_interval: 60s
honor_labels: true
metrics_path: /federate
params:
match[]:
- '{__name__=~"istio_scaling_requests_per_second|istio_scaling_p95_milliseconds"}'
static_configs:
- targets:
- prometheus.istio-system.svc.cluster.local:9090
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
spike_limit_mib: 64
metricstransform:
transforms:
- include: istio_scaling_requests_per_second
action: update
new_name: IstioRequestsPerSecond
operations:
- action: add_label
new_label: ClusterName
new_value: eks-demo
- include: istio_scaling_p95_milliseconds
action: update
new_name: IstioP95LatencyMilliseconds
operations:
- action: add_label
new_label: ClusterName
new_value: eks-demo
batch:
timeout: 60s
send_batch_size: 256
exporters:
awsemf:
namespace: IstioScaling
region: us-west-2
log_group_name: /aws/otel/istio-scaling
log_stream_name: eks-demo
dimension_rollup_option: NoDimensionRollup
metric_declarations:
- dimensions:
- - ClusterName
- destination_workload
- destination_workload_namespace
metric_name_selectors:
- ^IstioRequestsPerSecond$
- ^IstioP95LatencyMilliseconds$
metric_descriptors:
- metric_name: IstioRequestsPerSecond
unit: Count/Second
overwrite: true
- metric_name: IstioP95LatencyMilliseconds
unit: Milliseconds
overwrite: true
service:
extensions:
- health_check
pipelines:
metrics:
receivers:
- prometheus
processors:
- memory_limiter
- metricstransform
- batch
exporters:
- awsemfThe v1beta1 config is an object. The older v1alpha1 API remains served by this operator release, so it should not be described as removed; this example uses the current form and an explicit Contrib image with the required components.
The Collector federates only the two named recording metrics, preserves workload dimensions, adds the configured ClusterName, and writes EMF to a fixed log stream. Keep the namespace, metric names, units and all three dimensions aligned with the CloudWatch scalers. NaN/Inf are dropped by the EMF exporter; the recording expression distinguishes known idle 0 from unavailable telemetry.
One publisher replica avoids duplicate polling in this example; this is not an HA design. Configure real Prometheus authentication/mesh access, publisher identity, log retention and resource limits. The operator/controller and EMF delivery were not deployed or tested against AWS by this audit.
The publisher log group must already exist with platform-managed retention. Its example role needs stream creation/write within that group:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-west-2:123456789012:log-group:/aws/otel/istio-scaling:log-stream:eks-demo"
}
]
}EMF goes through CloudWatch Logs; cloudwatch:PutMetricData alone does not authorize this exporter. The configured metric namespace is not enforced by a PutMetricData namespace condition on these Logs calls. Log ingestion/storage and generated custom metrics have separate costs; control cardinality and retention rather than assuming replica reduction equals bill savings.
References
- KEDA ScaledObject specification
- Activation and scaling
- Prometheus scaler
- CloudWatch scaler
- SQS scaler
- Cron scaler
- AWS IRSA provider
- KEDA metrics
- KEDA with Istio
- KEDA deployment requirements
- Kubernetes HPA
- Istio standard metrics
- Operator0.158 compatibility
- Collector0.158 EMF exporter
- CloudWatch EMF
- CloudWatch namespace conditions
- Observability
- Resilience
- Traffic Management
Before production use, validate signal semantics, actual metric labels/freshness, idle/missing-data behavior, one-owner scaling, capacity, representative failure response and recovery. The example thresholds, replica floors and timing values are starting inputs to those tests.