Skip to content

KEDA

Who this page is for: in plain English, KEDA scales your workloads on things the built-in autoscaler can't see - queue depth, Kafka lag, a cron schedule - and can scale them all the way down to zero. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track.

KEDA (Kubernetes Event-Driven Autoscaling) extends the Kubernetes HPA to scale workloads based on external event sources - Kafka consumer lag, queue depth, Prometheus metrics, HTTP request rate, cron schedules, and dozens more - including scaling all the way to zero.

The standard HPA only scales on CPU and memory. For event-driven workloads, those signals arrive too late or don't reflect the actual backlog. A Kafka consumer might be idle (low CPU) but sitting behind 10,000 unprocessed messages. KEDA surfaces that backlog as the scaling signal.

Architecture

flowchart TD
    KEDA[KEDA Operator] --> |reads| SO[ScaledObject\nor ScaledJob]
    KEDA --> |creates/manages| HPA[HorizontalPodAutoscaler]
    KEDA --> |queries| Scaler[External scaler\nKafka / Redis / SQS / Prometheus...]
    Scaler --> |metric value| KEDA
    HPA --> |scales| Workload[Deployment / StatefulSet\nor Jobs]

KEDA doesn't replace the HPA - it generates and manages HPA objects on your behalf. The KEDA operator translates external metric values into ExternalMetrics API objects that the HPA's scaling loop can consume. This means KEDA integrates with standard Kubernetes cluster autoscaler behavior.

How KEDA and the HPA actually fit together

This mechanism is worth understanding precisely, because it's the source of most "why isn't KEDA scaling" confusion.

  1. keda-operator watches ScaledObjects and polls each scaler at pollingInterval.
  2. keda-operator-metrics-apiserver registers itself with the Kubernetes API aggregation layer as an implementation of the external.metrics.k8s.io API group. It does not push metrics anywhere - it serves them on demand.
  3. When you create a ScaledObject, KEDA creates a standard HorizontalPodAutoscaler object named keda-hpa-<scaledobject-name> (or the name you set in advanced.horizontalPodAutoscalerConfig.name), with scaleTargetRef pointed at your workload and metric sources of type External, referencing keda-operator-metrics-apiserver as the backing API.
  4. The stock Kubernetes controller manager's HPA controller runs its normal reconciliation loop (every 15s by default, --horizontal-pod-autoscaler-sync-period) and calls the external metrics API to get the current value - exactly the same code path it uses for a hand-written HPA against metrics.k8s.io or custom.metrics.k8s.io.
  5. The HPA controller computes desired replicas and scales the Deployment/StatefulSet itself. KEDA never touches spec.replicas directly for Deployment-backed ScaledObjects - the HPA does.

Because of this, kubectl get hpa -n production shows the KEDA-managed HPA like any other:

$ kubectl get hpa -n production
NAME                          REFERENCE                     TARGETS         MINPODS   MAXPODS   REPLICAS
keda-hpa-order-processor      Deployment/order-processor     10500m/100 (avg)  0        50        6

kubectl describe hpa keda-hpa-order-processor shows the same ScalingActive/AbleToScale conditions you'd see debugging any HPA, plus events when it can't reach the external metrics API. This is your first stop when replicas seem stuck - if the HPA shows unable to get external metric, the problem is between the HPA controller and keda-operator-metrics-apiserver, not in your scaler logic.

One consequence: KEDA respects the same HPA-level constraints as everything else. minReplicaCount: 0 is special-cased - KEDA temporarily removes the HPA (or sets minReplicas: 1 and pauses it) when scaling to zero, because the HPA API itself cannot express zero, then recreates/resumes it once idleReplicaCount/activation conditions are met and a scale-up is needed. This is also why ScaledObject deletion during a scale-to-zero window can occasionally leave things in a slightly odd state - check kubectl get hpa after deleting a ScaledObject that was at zero.

ScaledObject

A ScaledObject ties a workload to one or more scalers:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-processor
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-processor

  pollingInterval: 15           # check scaler every 15s
  cooldownPeriod: 60            # wait 60s after last event before scaling to zero
  minReplicaCount: 0            # allow scale-to-zero
  maxReplicaCount: 50
  idleReplicaCount: 0           # if no messages, scale to 0 (overrides minReplicaCount)

  triggers:
    - type: kafka
      metadata:
        bootstrapServers: kafka.platform:9092
        consumerGroup: order-processor
        topic: orders
        lagThreshold: "100"          # scale up when lag exceeds 100 messages per replica
        offsetResetPolicy: latest
      authenticationRef:
        name: kafka-trigger-auth

lagThreshold: "100" means KEDA targets: desiredReplicas = ceil(currentLag / lagThreshold). With 1,000 messages queued and threshold 100, KEDA targets 10 replicas. With 0 messages and idleReplicaCount: 0, KEDA scales to zero.

pollingInterval and cooldownPeriod, precisely

These two fields are the most commonly misunderstood in the spec:

  • pollingInterval (default 30s) is how often the KEDA operator queries the scaler (Kafka, Redis, Prometheus, etc.) to compute the external metric value. This is independent of the HPA controller's own sync loop - KEDA writes the fresh value to the metrics API server, and the HPA controller picks it up on its own cadence (default every 15s). A short pollingInterval doesn't make the HPA react faster than its own sync period; it just keeps the value KEDA serves current.
  • cooldownPeriod (default 300s) only applies to the scale-to-zero transition. It's the time KEDA waits, after the trigger last reported an "active" state, before scaling the Deployment down to minReplicaCount (typically 0). It does not apply to normal scale-down between nonzero replica counts - that's governed entirely by the HPA's own scale-down behavior (see below). A short cooldownPeriod on a bursty queue causes rapid zero-to-one-to-zero cycling; a long one keeps a pod warm (and billed) longer than necessary after the last event.

In short: pollingInterval controls how fresh the signal is, cooldownPeriod controls how eager KEDA is to remove the last replica.

Tuning scale-up/down rate with HPA behavior

The standard HPA behavior block controls stabilization windows and the rate of change for both directions, and it's the correct lever for taming flapping - KEDA just exposes it under advanced:

spec:
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 300   # wait 5m of sustained low demand before scaling down
          policies:
            - type: Percent
              value: 25                     # remove at most 25% of replicas per period
              periodSeconds: 60
        scaleUp:
          stabilizationWindowSeconds: 0     # scale up immediately, no debounce
          policies:
            - type: Pods
              value: 4                      # add at most 4 pods per period
              periodSeconds: 30
          selectPolicy: Max

stabilizationWindowSeconds on scaleDown is the single most effective fix for consumer workloads that flap between N and N-1 replicas as lag oscillates around the threshold - it makes the HPA look back over the window and use the highest recommended replica count seen in that period before scaling down. Leave scaleUp unstabilized (or nearly so) for latency-sensitive queues; you almost always want to add capacity fast and remove it slowly.

TriggerAuthentication

Credentials for scalers are separated from the ScaledObject using TriggerAuthentication:

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: kafka-trigger-auth
  namespace: production
spec:
  secretTargetRef:
    - parameter: sasl
      name: kafka-credentials
      key: sasl-mechanism
    - parameter: username
      name: kafka-credentials
      key: username
    - parameter: password
      name: kafka-credentials
      key: password
    - parameter: tls
      name: kafka-credentials
      key: tls

Use ClusterTriggerAuthentication for credentials shared across namespaces (e.g., a single AWS IAM role or cloud credentials). TriggerAuthentication is namespaced and only visible to ScaledObjects in the same namespace; ClusterTriggerAuthentication is cluster-scoped and referenced the same way from any namespace via authenticationRef.kind: ClusterTriggerAuthentication.

Secret-based vs identity-based auth

KEDA supports several authentication provider types inside TriggerAuthentication.spec, and the choice matters for security posture, not just convenience:

  • secretTargetRef - pulls values out of a Kubernetes Secret, as shown above. Simplest to set up, but it's a long-lived credential sitting in etcd (and in Secret sprawl across namespaces if you're not careful with RBAC).
  • env - reads from an environment variable on the KEDA operator pod itself. Rarely used outside quick tests; it centralizes the credential but ties it to the operator's own pod spec.
  • podIdentity - delegates to the cloud provider's identity mechanism instead of a static credential:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: aws-sqs-auth
  namespace: production
spec:
  podIdentity:
    provider: aws     # or azure-workload, gcp, aws-eks (deprecated, removal planned for KEDA v3)

With provider: aws (the current IRSA-based provider) the ScaledObject's target workload's ServiceAccount carries the IAM role annotation, and KEDA's own operator pod (or, depending on scaler, the workload itself) assumes that role via the metadata/token exchange - no AWS access keys ever touch a Secret. The older aws-eks provider value still works but is deprecated and slated for removal in KEDA v3; new configs should use aws. azure-workload does the equivalent with Entra Workload Identity federation for Azure Service Bus, Event Hubs, and Blob Storage scalers; gcp does it with Workload Identity for Pub/Sub and Cloud Monitoring-based scalers. (The aws-kiam provider referenced in older KEDA docs/tutorials no longer exists - it was deprecated years ago in favor of aws-eks and has since been removed entirely.)

Prefer pod/workload identity for every cloud-native trigger (SQS, Service Bus, Pub/Sub, Azure Monitor, Cloud Monitoring). Reach for secretTargetRef for self-hosted systems that only support static credentials (self-managed Kafka with SASL, RabbitMQ, self-hosted Prometheus behind basic auth). Never put long-lived cloud keys in a Secret when the platform offers pod identity - it's strictly more auditable and rotates automatically.

Common scalers

Kafka - consumer lag mechanics

triggers:
  - type: kafka
    metadata:
      bootstrapServers: kafka.platform:9092
      consumerGroup: my-consumer
      topic: my-topic
      lagThreshold: "50"
      activationLagThreshold: "5"     # below this, don't activate from zero
      allowIdleConsumers: "false"
      scaleToZeroOnInvalidOffset: "false"

The Kafka scaler connects to the brokers as an admin/consumer client and, for every partition of the topic, computes lag = latestOffset - committedOffset from the consumer group's committed offsets. It sums lag across all partitions the group owns, then applies the same ceil(totalLag / lagThreshold) formula. activationLagThreshold is a separate, usually lower, threshold that governs whether KEDA activates the workload at all from zero - this avoids waking up a consumer for a handful of stray messages while still using lagThreshold for how aggressively to scale once active.

A crucial constraint: you cannot usefully scale a Kafka consumer group beyond its partition count. If orders has 12 partitions, replica 13 onward sits idle - Kafka only assigns one consumer per partition per group. Set maxReplicaCount to the partition count (or lower, if you want each pod to handle multiple partitions). allowIdleConsumers: true disables the scaler's own partition-count cap if you deliberately want more replicas than partitions for other reasons (e.g. hot standby), but it won't get you more parallelism.

RabbitMQ and Azure Service Bus (queue-length scalers)

triggers:
  - type: rabbitmq
    metadata:
      protocol: amqp                # or "http" to use the management API instead
      queueName: orders
      mode: QueueLength              # or MessageRate
      value: "20"                    # target messages (or rate) per replica
    authenticationRef:
      name: rabbitmq-auth

The amqp protocol mode polls the queue directly for messages_ready; the http mode calls the RabbitMQ management API, which additionally exposes rates and per-vhost detail but requires the management plugin enabled. Both compute desiredReplicas = ceil(queueLength / value) the same way as the other queue scalers.

triggers:
  - type: azure-servicebus
    metadata:
      queueName: orders
      messageCount: "50"
      namespace: my-servicebus-namespace
    authenticationRef:
      name: azure-servicebus-auth   # typically podIdentity: azure-workload

Azure Service Bus works identically in shape - active message count on the queue (or topic subscription) divided by messageCount per replica. All three queue-length scalers (Redis, RabbitMQ, Service Bus) share the same mental model: pick a target "items per replica" and let KEDA do the division.

Redis Lists (queue depth)

triggers:
  - type: redis
    metadata:
      address: redis.platform:6379
      listName: job-queue
      listLength: "20"     # target: one replica per 20 items
    authenticationRef:
      name: redis-auth

AWS SQS

triggers:
  - type: aws-sqs-queue
    metadata:
      queueURL: https://sqs.us-east-1.amazonaws.com/123456789/my-queue
      queueLength: "10"
      awsRegion: us-east-1
    authenticationRef:
      name: aws-keda-auth

Use IRSA (IAM Roles for Service Accounts) with ClusterTriggerAuthentication to avoid long-lived credentials.

Prometheus

Scale on any Prometheus metric:

triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus.monitoring:9090
      metricName: active_transactions
      query: |
        sum(active_transactions{namespace="production"})
      threshold: "100"     # one replica per 100 active transactions

This is the most flexible scaler. Any signal you can express in PromQL - queue depth, active sessions, p99 latency above a threshold, error rate - can drive scaling. The scaler executes query against serverAddress on every pollingInterval tick, using the Prometheus HTTP API's instant-query endpoint, and treats the single scalar result as the current metric value. Because the query is arbitrary PromQL, you can encode logic KEDA has no native concept of - a ratio of two metrics, a rate() over a window, a query scoped with topk(). The tradeoff is that KEDA doesn't validate the query beyond "did it return one number"; a query returning a vector with multiple series causes scaler errors, so sum() or otherwise reduce to a scalar defensively.

Cron

Scale on a schedule (useful for batch windows or predictive pre-warming):

triggers:
  - type: cron
    metadata:
      timezone: America/Chicago
      start: "0 7 * * 1-5"     # weekdays 7am
      end: "0 20 * * 1-5"      # weekdays 8pm
      desiredReplicas: "5"

Outside the start/end window the cron trigger reports zero - it doesn't hold the previous value, it actively contributes 0 to the max-across-triggers calculation. That matters when cron is combined with a reactive trigger (see Multi-trigger behavior): cron acts as a floor during the window and simply drops out entirely outside it, letting the other trigger take over. Multiple cron triggers on a single ScaledObject create overlapping scale-up windows, and KEDA takes the max desired replicas among them just like any other multi-trigger case.

HTTP - the KEDA HTTP Add-on

The HTTP Add-on is a separate installable component from core KEDA (its own Helm chart, kedacore/keda-add-ons-http), purpose-built for scaling synchronous HTTP services - including all the way to zero - without dropping requests during cold start. Core KEDA's http scaler type only exists once the add-on's CRDs and interceptor are installed. The add-on is still pre-1.0 and explicitly not GA, though it's considered stable enough for production use with the caveat that minor breaking changes can still land before v1.0.

flowchart LR
    Client[Client request] --> Interceptor[HTTP Add-on\ninterceptor proxy]
    Interceptor --> |queue depth metric| Scaler[HTTP Add-on\nexternal scaler]
    Scaler --> |external scaler gRPC| KEDA[KEDA operator]
    KEDA --> HPA[HorizontalPodAutoscaler]
    HPA --> |scales 0 to N| Backend[Backend Deployment]
    Interceptor -. holds request until ready .-> Backend

The add-on's API is the HTTPScaledObject CRD (http.keda.sh), which declares both what traffic the interceptor should route and what metric drives scaling. KEDA's own ScaledObject is generated for you from it - you don't write one by hand for HTTP scaling:

apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
  name: api-frontend
  namespace: production
spec:
  hosts:
    - api.example.com
  scaleTargetRef:
    name: api-frontend         # the Deployment to scale
    kind: Deployment
    service: api-frontend      # the Service the interceptor forwards to
    port: 8080
  replicas:
    min: 0
    max: 20
  scaledownPeriod: 300
  scalingMetric:
    requestRate:
      targetValue: 100         # requests/sec per replica

The add-on is pre-1.0 and its CRD fields have changed across releases - scalingMetric, replicas, and scaledownPeriod in particular have moved or been renamed over time. Check the CRD reference in the http-add-on repository's docs/ directory for the exact schema shipped with the version you install rather than copying a manifest from any blog post, this page included.

The mechanism: traffic for the host is routed through the interceptor, a proxy the add-on deploys, based on the HTTPScaledObject's hosts rules. When the backend is at zero replicas, the interceptor doesn't 502 - it holds the incoming request in a queue and simultaneously reports demand to the add-on's external scaler, which KEDA queries as an external scaler and turns into a metric the HPA acts on like any other KEDA metric. KEDA scales the Deployment from 0 to 1, and once a replica passes readiness, the interceptor forwards the held request (subject to a configurable timeout, after which it fails the request rather than holding forever). Once at least one replica is running, the interceptor also tracks in-flight/pending request counts to feed the requestRate or concurrency scaling metric for scale-out beyond one replica. This solves the "first request problem" that a naive scale-to-zero HTTP setup has: without an interceptor, the first request after scale-to-zero simply fails because there's nothing to route to yet.

ScaledJob

ScaledJob manages Jobs instead of Deployments. Each "unit of work" spawns its own Job, rather than scaling a long-running consumer.

apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
  name: image-processor
  namespace: production
spec:
  jobTargetRef:
    parallelism: 1
    completions: 1
    template:
      spec:
        restartPolicy: Never
        containers:
          - name: processor
            image: my-org/image-processor:v1.2.0
            command: ["python", "process_one.py"]

  pollingInterval: 10
  maxReplicaCount: 30          # max concurrent Jobs
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5

  scalingStrategy:
    strategy: accurate         # create one Job per pending message

  triggers:
    - type: rabbitmq
      metadata:
        protocol: amqp
        queueName: image-processing
        mode: QueueLength
        value: "1"             # one Job per message
      authenticationRef:
        name: rabbitmq-auth

Use ScaledJob when: - each message is a discrete unit of work with its own completion state - you need Job-level tracking (completion, failure history) - the processing time is long enough that sharing a Deployment adds overhead

Use ScaledObject (Deployment) when: - consumers are long-running, stateful, or maintain connections (Kafka consumers hold partition leases) - rapid scale events would create too many Jobs

Scale-to-zero patterns

Scale-to-zero reduces cost for workloads with intermittent traffic. The tradeoff is cold-start latency.

Design considerations: - Image pull time: pre-pull images to nodes or use lightweight base images. With large images, cold start can take 30-60 seconds. - Application startup: readiness probes determine when the pod is ready to receive traffic. Optimize your startup path. - The first message problem: for Kafka, the consumer must join the consumer group before it can read. With minReplicaCount: 0, the first message has no consumer. KEDA detects the lag and starts scaling, but there's a polling delay (pollingInterval). Set this low (5-10s) for latency-sensitive queues. - Graceful shutdown: consumers should finish processing their current message before exiting. Use preStop hooks and terminationGracePeriodSeconds.

Pause autoscaling

Temporarily disable autoscaling without removing the ScaledObject:

kubectl annotate scaledobject order-processor \
  autoscaling.keda.sh/paused-replicas=2

This freezes the replica count at 2. Remove the annotation to resume:

kubectl annotate scaledobject order-processor \
  autoscaling.keda.sh/paused-replicas-

Useful during deployments or when you want to drain a queue without scaling up.

Multi-trigger behavior

When a ScaledObject has multiple triggers, KEDA takes the maximum desired replica count across all triggers:

triggers:
  - type: kafka
    metadata:
      lagThreshold: "100"   # with 500 lag → 5 replicas
  - type: prometheus
    metadata:
      threshold: "50"       # with 200 active sessions → 4 replicas

Result: KEDA targets max(5, 4) = 5 replicas. This is the right behavior - you want to keep up with the most demanding signal.

Operational patterns

Monitor KEDA metrics: KEDA exposes its own Prometheus metrics at :2222/metrics - keda_scaler_metrics_value, keda_scaler_active, keda_scaled_object_errors. Alert on scaler errors.

Scaler connectivity: if a scaler can't reach its external system (Kafka is down, Redis is unreachable), KEDA logs an error and holds the last known scale. It does not scale to zero on scaler failure. Test this behavior before relying on it for availability.

HPA coexistence: if you already have an HPA for CPU scaling, KEDA can manage it. Use advanced.horizontalPodAutoscalerConfig to set HPA behavior (stabilization windows, scale-up/down rates).

Fallback: KEDA supports a fallback block - if the scaler fails N consecutive times, use a fixed replica count as the fallback:

fallback:
  failureThreshold: 3
  replicas: 5

Troubleshooting

Start with the operator logs.

kubectl logs -n keda deploy/keda-operator -f

Look for error getting metric value, scaler error, or authentication failures. The operator logs every scaler poll failure, including the underlying error from the target system (connection refused, auth denied, DNS failure) - this is almost always more informative than anything visible on the ScaledObject status.

Check ScaledObject status and conditions:

kubectl describe scaledobject order-processor -n production

Look at status.conditions: Active (is any trigger currently reporting activity), Fallback (is KEDA using the fallback replica count because the scaler is failing), and Ready. A ScaledObject stuck with Ready=False usually means the HPA it should be managing was never created or was deleted out from under it - check for a duplicate HPA name conflict (another HPA already using keda-hpa-<name>) or an RBAC issue preventing the operator from creating HPAs.

Confirm the external metrics API is actually being queried. This is the step people skip. Query it directly the same way the HPA controller does:

kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/production/s0-kafka-order-processor" | jq .

If this returns a value, the metrics pipeline (keda-operator-metrics-apiserver → API aggregation layer) is healthy and the problem is downstream in the HPA controller or workload. If it errors (no metrics returned, service unavailable), check that the APIService for external.metrics.k8s.io is available (kubectl get apiservice v1beta1.external.metrics.k8s.io) and points at a healthy keda-operator-metrics-apiserver Service/pod.

Common "stuck at minReplicas" causes:

  • Scaler misreports zero activity - e.g. a Kafka consumerGroup name typo means the scaler queries a group with no lag, because it genuinely doesn't exist yet. KEDA sees "no lag" and correctly does nothing.
  • activationLagThreshold/activation threshold set too high - the trigger has real but small values that never cross the activation bar, so the workload never leaves zero even though lagThreshold alone would have scaled it.
  • A competing HPA already exists for the same scaleTargetRef - Kubernetes allows only one HPA per target; KEDA's HPA silently fails to reconcile, or the pre-existing one wins depending on creation order. kubectl get hpa -A -o wide and look for duplicates.
  • RBAC: the KEDA operator's ServiceAccount lacks permission to read the Secret referenced by TriggerAuthentication, or lacks get/list on the external resource. Check operator logs for forbidden.
  • Paused via annotation - someone set autoscaling.keda.sh/paused-replicas (see above) and forgot to remove it. kubectl get scaledobject order-processor -o jsonpath='{.metadata.annotations}' catches this in five seconds.
  • cooldownPeriod masking activity - if you're testing scale-to-zero and see it holding at 1 replica longer than expected, that's cooldownPeriod working as designed, not a bug.

Common mistakes

  • Setting pollingInterval very low "for responsiveness" without realizing the HPA controller's own sync period (15s default) is the actual bottleneck for reaction speed - and a too-low polling interval just hammers the external system (Kafka admin API, cloud API rate limits) for no benefit.
  • Forgetting the Kafka partition ceiling. Setting maxReplicaCount above the topic's partition count wastes pod scheduling and gives a false sense of headroom - those extra replicas never receive a partition assignment.
  • No scaleDown.stabilizationWindowSeconds, leading to visible flapping on any workload whose metric oscillates near the threshold. This is the single most common "KEDA is unstable" complaint, and it's an HPA behavior config issue, not a KEDA bug.
  • Long-lived cloud credentials in a Secret when the cluster already has IRSA/Workload Identity available - adds an unnecessary rotation burden and audit gap for no operational benefit.
  • Assuming scaler failure scales to zero. It doesn't - KEDA holds the last known scale on scaler error (unless fallback is configured), which is the safe default but surprises people expecting fail-safe-to-zero behavior.
  • Deploying the HTTP Add-on and expecting core KEDA's generic scaler semantics - they're related, but the add-on's HTTPScaledObject CRD and interceptor proxy are their own moving parts with their own failure modes (interceptor pod down = every request to a zero-replica service hangs until its timeout).
  • Copying an HTTP Add-on manifest without checking which add-on version it targets. The add-on is pre-1.0 and its CRD schema has changed between minor releases; a manifest that worked a few releases ago may be silently rejected or partially ignored. Diff against the CRD reference for the version you actually installed.
  • Treating cooldownPeriod as a general "don't scale down" knob. It only governs the transition to zero; ordinary scale-down between nonzero replica counts is entirely the HPA's behavior.scaleDown policy.