- Introduction
- How HPA v2 Works and Its Algorithm
- VPA Modes and Limitations
- KEDA: Event-Driven Autoscaling
- HPA vs VPA vs KEDA
- Combined Strategies: HPA + VPA + KEDA
- Troubleshooting: Failure Cases and Recovery
- Operations Checklist
- Conclusion
- References

Introduction
One of the trickiest problems in running Kubernetes workloads is resource scaling. When traffic spikes there are not enough Pods and an incident follows; when traffic drops, over-provisioning wastes money. Kubernetes offers three core autoscalers to solve this.
- HPA (Horizontal Pod Autoscaler): adjusts the number of Pods horizontally
- VPA (Vertical Pod Autoscaler): adjusts an individual Pod's CPU/memory requests vertically
- KEDA (Kubernetes Event-Driven Autoscaling): extended autoscaling driven by external event sources
In this article, we cover how each autoscaler works, how to configure it, and the troubleshooting cases you run into in practice.
How HPA v2 Works and Its Algorithm
The Scaling Algorithm
HPA uses the autoscaling/v2 API, and it calculates the desired replica count with the following formula:
desiredReplicas = ceil(currentReplicas × (currentMetricValue / desiredMetricValue))
For example, if there are currently 4 Pods at an average CPU utilization of 80% and the target is 50%, it scales out to ceil(4 × (80/50)) = ceil(6.4) = 7 Pods.
The HPA controller collects metrics on a 15-second cycle by default, and this can be adjusted with the --horizontal-pod-autoscaler-sync-period flag.
Basic HPA Configuration - CPU/Memory Based
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-server-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 75
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
- type: Pods
value: 4
periodSeconds: 15
selectPolicy: Max
The key point in this configuration is the behavior block. Scale-up happens immediately (stabilizationWindowSeconds: 0), but scale-down goes through a 5-minute stabilization window and shrinks by at most 10% every 60 seconds. This prevents a sudden contraction from affecting the service.
HPA Based on Custom Metrics
In real operations, application metrics (RPS, queue depth, active connections and so on) are a more accurate scaling signal than CPU/memory. Let us configure a custom-metric HPA using the Prometheus Adapter.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 2
maxReplicas: 30
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: '100'
- type: Object
object:
metric:
name: rabbitmq_queue_messages
describedObject:
apiVersion: v1
kind: Service
name: rabbitmq
target:
type: Value
value: '500'
This configuration uses two custom metrics. It scales out when HTTP requests per second per Pod exceed 100, or when the messages waiting in the RabbitMQ queue exceed 500. When multiple metrics are configured, HPA calculates the desired replica count for each and then picks the maximum.
The Prometheus Adapter configuration is as follows:
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-adapter-config
namespace: monitoring
data:
config.yaml: |
rules:
- seriesQuery: 'http_requests_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^(.*)_total$"
as: "${1}_per_second"
metricsQuery: 'rate(<<.Series>>{<<.LabelMatchers>>}[2m])'
- seriesQuery: 'rabbitmq_queue_messages{namespace!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
name:
matches: "^(.*)$"
as: "$1"
metricsQuery: '<<.Series>>{<<.LabelMatchers>>}'
VPA Modes and Limitations
VPA Architecture
VPA is made up of three components:
- Recommender: analyzes current and historical resource usage and recommends the optimal request
- Updater: evicts Pods whose resource requests are wrong
- Admission Controller: injects the correct resource requests into newly created Pods
VPA Modes
| Mode | Behavior | Pod restart | When to use |
|---|---|---|---|
| Off | Recommendation only, nothing applied automatically | None | Resource analysis, early adoption |
| Initial | Applies the recommendation only at Pod creation | None (new Pods only) | Workloads where stability comes first |
| Auto | Applies the recommendation to existing Pods too (restart) | Yes | Stateless workloads, dev environments |
| Recreate | Same as Auto, but the restart is guaranteed | Yes | When an explicit restart is required |
From Kubernetes 1.32 an InPlaceOrRecreate mode was added, which changes resources without restarting the Pod where possible. This feature requires the InPlacePodVerticalScaling feature gate to be enabled.
VPA Configuration Example
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: payment-service-vpa
namespace: production
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: payment-service
updatePolicy:
updateMode: 'Initial'
minReplicas: 2
resourcePolicy:
containerPolicies:
- containerName: payment-app
minAllowed:
cpu: '100m'
memory: '128Mi'
maxAllowed:
cpu: '4'
memory: '8Gi'
controlledResources: ['cpu', 'memory']
controlledValues: RequestsOnly
- containerName: sidecar-proxy
mode: 'Off'
The points worth noting in this configuration are as follows:
- Setting
updateMode: Initialapplies it only to new Pods, without restarting existing ones minReplicas: 2guarantees that at least 2 Pods are always running- The sidecar container (
sidecar-proxy) is excluded from VPA (mode: Off) controlledValues: RequestsOnlyleaves the limits alone and adjusts only the requests
The Key VPA Limitations
- Limits on using it alongside HPA: using VPA together with HPA on the same metric (CPU/memory) causes a conflict. If HPA raises the Pod count based on CPU utilization while VPA raises the CPU request at the same time, the behavior becomes unpredictable.
- A limit of 1,000 Pods: it is recommended that the number of Pods VPA manages does not exceed 1,000 per cluster.
- CronJob/Job not supported: VPA works only on long-running workloads (Deployment, StatefulSet).
- JVM workloads: a JVM's heap memory is fixed at startup, so even if VPA lowers the memory, the memory the JVM actually uses does not go down.
KEDA: Event-Driven Autoscaling
KEDA Architecture
KEDA does not replace HPA; it extends it. KEDA's job is to fetch metrics from external event sources (Kafka, Prometheus, Redis, AWS SQS and so on) and feed them into HPA.
The core CRDs are as follows:
- ScaledObject: defines the scaling rules for a Deployment/StatefulSet
- ScaledJob: defines the scaling rules for a Job-based workload
- TriggerAuthentication: manages the credentials for an external system
- ClusterTriggerAuthentication: cluster-scoped credentials
KEDA Scaling Driven by Kafka
apiVersion: v1
kind: Secret
metadata:
name: kafka-credentials
namespace: production
type: Opaque
data:
sasl_username: dXNlcm5hbWU=
sasl_password: cGFzc3dvcmQ=
ca: LS0tLS1CRUdJTi4uLg==
---
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: kafka-trigger-auth
namespace: production
spec:
secretTargetRef:
- parameter: sasl
name: kafka-credentials
key: sasl_username
- parameter: password
name: kafka-credentials
key: sasl_password
- parameter: ca
name: kafka-credentials
key: ca
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-consumer-scaler
namespace: production
spec:
scaleTargetRef:
name: order-consumer
pollingInterval: 15
cooldownPeriod: 300
idleReplicaCount: 0
minReplicaCount: 1
maxReplicaCount: 50
fallback:
failureThreshold: 3
replicas: 5
triggers:
- type: kafka
metadata:
bootstrapServers: 'kafka-0.kafka:9092,kafka-1.kafka:9092,kafka-2.kafka:9092'
consumerGroup: order-consumer-group
topic: orders
lagThreshold: '100'
activationLagThreshold: '10'
offsetResetPolicy: latest
authenticationRef:
name: kafka-trigger-auth
Let us look at the key parameters in this configuration:
idleReplicaCount: 0— with no events, the Pods drop to 0 to save cost (scale to zero)activationLagThreshold: 10— does not activate while the consumer lag is under 10lagThreshold: 100— adds 1 Pod per 100 of consumer lagfallback— if KEDA cannot fetch the metric (after 3 failures), it falls back to 5 replicas
KEDA Scaling Driven by Prometheus
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: web-frontend-scaler
namespace: production
spec:
scaleTargetRef:
name: web-frontend
pollingInterval: 30
cooldownPeriod: 120
minReplicaCount: 2
maxReplicaCount: 100
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
triggers:
- type: prometheus
metadata:
serverAddress: 'http://prometheus.monitoring.svc:9090'
query: |
sum(rate(nginx_ingress_controller_requests{
namespace="production",
service="web-frontend"
}[2m]))
threshold: '500'
activationThreshold: '50'
- type: prometheus
metadata:
serverAddress: 'http://prometheus.monitoring.svc:9090'
query: |
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket{
namespace="production",
service="web-frontend"
}[5m])) by (le))
threshold: '0.5'
activationThreshold: '0.1'
This configuration uses two Prometheus queries. It scales out when requests per second exceed 500, or when P95 latency exceeds 500ms. Through advanced.horizontalPodAutoscalerConfig you can also control the behavior of the HPA that is created internally.
HPA vs VPA vs KEDA
| Category | HPA | VPA | KEDA |
|---|---|---|---|
| Scaling direction | Horizontal (Pod count) | Vertical (resource size) | Horizontal (Pod count) + scale to zero |
| Default metrics | CPU, memory | CPU, memory | 60+ external event sources |
| Custom metrics | Needs an adapter | Not supported | Natively supported |
| Scale to zero | Not possible (minReplicas >= 1) | Not applicable | Possible (idleReplicaCount: 0) |
| Pod restart | None | Yes (Auto/Recreate) | None |
| Configuration complexity | Low | Medium | Medium to high |
| Built into Kubernetes | Yes | Installed separately | Installed separately |
| CronJob support | Limited | Not supported | Supported through ScaledJob |
| Stateful workloads | Needs care | Supported | Needs care |
| Community/ecosystem | Very active | Active | CNCF Graduated, very active |
Combined Strategies: HPA + VPA + KEDA
Combining HPA + VPA
When using HPA and VPA together you must avoid a metric conflict. The recommended pattern is as follows:
- HPA: horizontal scaling based on custom metrics (RPS, queue depth and so on)
- VPA: either recommendation only in Off mode, or vertical adjustment based on CPU/memory
# HPA: uses custom metrics only
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 3
maxReplicas: 20
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: '200'
---
# VPA: optimizes the CPU/memory resources
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: api-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
updatePolicy:
updateMode: 'Auto'
resourcePolicy:
containerPolicies:
- containerName: api
controlledResources: ['cpu', 'memory']
controlledValues: RequestsOnly
Combining HPA + KEDA
KEDA creates an HPA internally, so creating a separate HPA on the same Deployment causes a conflict. Combine them by adding multiple triggers to KEDA's ScaledObject instead.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: hybrid-scaler
namespace: production
spec:
scaleTargetRef:
name: worker-service
minReplicaCount: 2
maxReplicaCount: 100
triggers:
# CPU based (takes the place of HPA)
- type: cpu
metricType: Utilization
metadata:
value: '70'
# Kafka event based
- type: kafka
metadata:
bootstrapServers: 'kafka.default:9092'
consumerGroup: worker-group
topic: tasks
lagThreshold: '50'
# Cron based scheduled scaling
- type: cron
metadata:
timezone: Asia/Seoul
start: '0 9 * * 1-5'
end: '0 18 * * 1-5'
desiredReplicas: '10'
This configuration combines three strategies: CPU-based normally, event-based when Kafka messages back up, and Cron-based to hold at least 10 replicas during weekday business hours (09:00-18:00). KEDA uses the maximum across all triggers, so they combine safely.
Troubleshooting: Failure Cases and Recovery
Case 1: HPA Does Not Scale Out
Symptom: CPU utilization is at 90% but HPA does not react
# check the HPA status
kubectl get hpa api-server-hpa -n production -o yaml
# check the events
kubectl describe hpa api-server-hpa -n production
# check that metrics-server is working
kubectl top pods -n production
kubectl get apiservices | grep metrics
Causes and fixes:
- metrics-server is not installed: check with
kubectl get deployment metrics-server -n kube-system - Resource requests are not set: without
resources.requestson the Pod, utilization cannot be calculated. The requests must be set - maxReplicas has been reached: check whether MAXPODS has been reached in
kubectl get hpa - Unknown metric: check the custom metrics API with
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1"
Case 2: The VPA Recommendation Is Abnormally High
Symptom: VPA recommends a memory request of 32Gi while actual usage is 2Gi
Cause: in a JVM workload where -Xmx is set close to the memory limit, the JVM allocates the maximum heap, so VPA recommends a high value.
Fix: set maxAllowed appropriately, and exclude memory from VPA for JVM workloads.
resourcePolicy:
containerPolicies:
- containerName: java-app
controlledResources: ['cpu'] # memory is excluded from VPA
maxAllowed:
cpu: '4'
Case 3: KEDA Recovers Slowly After Scaling In to 0
Symptom: after the Kafka consumer scales in to 0, it takes 2-3 minutes for a Pod to start even once messages arrive
Fixes:
- Set
activationLagThresholdlow so it activates sooner - Set
minReplicaCount: 1to keep at least 1 Pod running - Shorten the Pod's
readinessProbetiming so it registers with the service faster - Use a container image pre-pull strategy
Case 4: HPA Flapping (Frequent Scale Up/Down)
Symptom: the Pod count keeps going up and down
# check the HPA event history
kubectl get events --field-selector involvedObject.name=api-hpa -n production --sort-by='.lastTimestamp'
Fix:
behavior:
scaleDown:
stabilizationWindowSeconds: 600 # 10-minute stabilization
policies:
- type: Pods
value: 1
periodSeconds: 300 # shrink by only 1 every 5 minutes
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 50
periodSeconds: 60
Operations Checklist
These are the items you must check when applying autoscaling in a production environment:
Pre-deployment Checklist
- Are
resources.requestsandresources.limitsset on every Pod? - Is a PodDisruptionBudget (PDB) configured (mandatory when using VPA)?
- Are metrics-server or the Prometheus Adapter working correctly?
- Do HPA and VPA avoid conflicting on the same metric?
- Does
maxReplicasline up with the cluster node autoscaler's maximum node count? - Is
fallbackconfigured when using KEDA?
Monitoring Checklist
- A dashboard of HPA's current replica count and desired replica count
- The trend of VPA recommendations vs actual usage
- KEDA trigger metric values and activation state
- Alerts on scaling events (integrated with Slack/PagerDuty)
- The state of the integration with the node autoscaler (monitoring pending Pods)
Cost Optimization Checklist
- Apply scale-to-zero with KEDA in development/staging environments
- Reduce the minimum replicas outside business hours with a Cron trigger
- Collect recommendations with VPA in Off mode and update the requests periodically
- Combine autoscaling with Spot/Preemptible instances
Conclusion
Kubernetes autoscaling is not solved by a single tool. You have to combine HPA, VPA and KEDA appropriately for the characteristics of the workload. HPA v2 is enough for simple CPU/memory-based scaling; add VPA when you need resource optimization; and KEDA suits event-driven workloads or anything that needs scale to zero.
In a production environment in particular, controlling the scaling rate through the behavior settings, integrating with PDB, and having a fallback strategy all matter. Use the configuration examples and troubleshooting cases covered in this article to build an autoscaling strategy that is both stable and cost-efficient.
References
- Kubernetes official documentation - Horizontal Pod Autoscaling
- Kubernetes official documentation - Vertical Pod Autoscaling
- KEDA official documentation - ScaledObject Specification
- KEDA official documentation - Authentication
- Kubernetes Autoscaler GitHub - VPA
- KEDA Apache Kafka Scaler
- Kubernetes HPA Walkthrough