LabHub

Blog

Kubernetes HPA, VPA, and KEDA Autoscaling Strategies

한국어English日本語

Kubernetes HPA VPA KEDA Autoscaling

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.

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:

VPA Modes

ModeBehaviorPod restartWhen to use
OffRecommendation only, nothing applied automaticallyNoneResource analysis, early adoption
InitialApplies the recommendation only at Pod creationNone (new Pods only)Workloads where stability comes first
AutoApplies the recommendation to existing Pods too (restart)YesStateless workloads, dev environments
RecreateSame as Auto, but the restart is guaranteedYesWhen 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:

The Key VPA Limitations

  1. 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.
  2. A limit of 1,000 Pods: it is recommended that the number of Pods VPA manages does not exceed 1,000 per cluster.
  3. CronJob/Job not supported: VPA works only on long-running workloads (Deployment, StatefulSet).
  4. 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:

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:

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

CategoryHPAVPAKEDA
Scaling directionHorizontal (Pod count)Vertical (resource size)Horizontal (Pod count) + scale to zero
Default metricsCPU, memoryCPU, memory60+ external event sources
Custom metricsNeeds an adapterNot supportedNatively supported
Scale to zeroNot possible (minReplicas >= 1)Not applicablePossible (idleReplicaCount: 0)
Pod restartNoneYes (Auto/Recreate)None
Configuration complexityLowMediumMedium to high
Built into KubernetesYesInstalled separatelyInstalled separately
CronJob supportLimitedNot supportedSupported through ScaledJob
Stateful workloadsNeeds careSupportedNeeds care
Community/ecosystemVery activeActiveCNCF 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: 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:

  1. metrics-server is not installed: check with kubectl get deployment metrics-server -n kube-system
  2. Resource requests are not set: without resources.requests on the Pod, utilization cannot be calculated. The requests must be set
  3. maxReplicas has been reached: check whether MAXPODS has been reached in kubectl get hpa
  4. 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:

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

Monitoring Checklist

Cost Optimization Checklist

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

  1. Kubernetes official documentation - Horizontal Pod Autoscaling
  2. Kubernetes official documentation - Vertical Pod Autoscaling
  3. KEDA official documentation - ScaledObject Specification
  4. KEDA official documentation - Authentication
  5. Kubernetes Autoscaler GitHub - VPA
  6. KEDA Apache Kafka Scaler
  7. Kubernetes HPA Walkthrough

Comments

No comments yet.

Sign in to leave a comment