# Kubernetes Deep Dive — Part 12: Autoscaling: HPA, VPA, KEDA & Cluster Autoscaling

> **Series:** Kubernetes Deep Dive (12 of 19)
> **Part 1:** `01-architecture-and-control-plane.md` — Architecture & Control Plane
> **Part 2:** `02-scheduling-and-workloads.md` — Scheduling & Workload Objects
> **Part 3:** `03-networking-and-storage.md` — Networking (CNI) & Storage (CSI)
> **Part 4:** `04-service-mesh-and-advanced-topics.md` — Service Mesh, etcd & Operators
> **Part 5:** `05-managed-kubernetes-eks-aks-gke.md` — Managed Kubernetes: EKS, AKS, GKE
> **Part 6:** `06-onprem-and-cluster-provisioning.md` — On-Prem & Self-Managed Kubernetes
> **Part 7:** `07-eks-deep-dive.md` — Amazon EKS in Production Depth
> **Part 8:** `08-gateway-api-and-envoy-gateway.md` — Gateway API & Envoy Gateway
> **Part 9:** `09-gateway-api-across-providers.md` — Gateway API Across GKE, EKS, AKS & On-Prem
> **Part 10:** `10-troubleshooting-kubernetes.md` — Troubleshooting Kubernetes, Systematically
> **Part 11:** `11-kubernetes-security-deep-dive.md` — Kubernetes Security Deep Dive (CKS-Aligned)
> **Part 12:** This file — Autoscaling: HPA, VPA, KEDA & Cluster Autoscaling
> **Part 13:** `13-multi-tenancy-and-cluster-sharing.md` — Multi-Tenancy & Cluster Sharing at Scale
> **Part 14:** `14-ai-ml-workloads-on-kubernetes.md` — Running AI/ML Workloads on Kubernetes
> **Part 15:** `15-cluster-upgrades-and-lifecycle.md` — Cluster Upgrades & Lifecycle Management
> **Part 16:** `16-capacity-planning.md` — Capacity Planning for Kubernetes Clusters
> **Part 17:** `17-cilium-ebpf-networking.md` — Cilium & eBPF Networking Deep Dive
> **Part 18:** `18-backup-and-disaster-recovery.md` — Backup & Disaster Recovery for Workloads
> **Part 19:** `19-building-custom-controllers-and-operators.md` — Building Custom Controllers and Operators
> **Questions:** `questions.md`

Assumes you're comfortable with resource requests/limits and QoS classes from Part 2, and Karpenter/Cluster
Autoscaler's node-level scaling from Part 7 — this chapter fills the pod-level autoscaling gap those parts
referenced but never fully covered.

## Table of Contents

1. [Why This Part Exists](#why-this-part-exists)
2. [Three Kinds of Autoscaling, and How They Compose](#three-kinds-of-autoscaling-and-how-they-compose)
3. [HPA Deep Dive — the Control Loop Mechanics](#hpa-deep-dive--the-control-loop-mechanics)
4. [HPA Metric Types — Resource, Pods, Object, External](#hpa-metric-types--resource-pods-object-external)
5. [HPA `behavior` — Stabilization Windows and Scaling Policies](#hpa-behavior--stabilization-windows-and-scaling-policies)
6. [Scaling on Custom Metrics — a Prometheus Adapter Worked Example](#scaling-on-custom-metrics--a-prometheus-adapter-worked-example)
7. [VPA Deep Dive — Recommender, Updater, Admission Controller](#vpa-deep-dive--recommender-updater-admission-controller)
8. [VPA `updateMode` — Off, Initial, Recreate, InPlaceOrRecreate](#vpa-updatemode--off-initial-recreate-inplaceorrecreate)
9. [Why HPA and VPA Conflict on the Same Metric](#why-hpa-and-vpa-conflict-on-the-same-metric)
10. [KEDA Deep Dive — Architecture](#keda-deep-dive--architecture)
11. [A Worked KEDA Example: Scaling on Queue Depth, Including Scale-to-Zero](#a-worked-keda-example-scaling-on-queue-depth-including-scale-to-zero)
12. [KEDA `ScaledJob` — Scaling Jobs, Not Deployments](#keda-scaledjob--scaling-jobs-not-deployments)
13. [KEDA vs. Plain HPA — Decision Framework](#keda-vs-plain-hpa--decision-framework)
14. [Autoscaling StatefulSets — What's Different](#autoscaling-statefulsets--whats-different)
15. [Tying Pod Scaling to Node Scaling](#tying-pod-scaling-to-node-scaling)
16. [A Full Worked Scenario: The Whole Stack Under Load](#a-full-worked-scenario-the-whole-stack-under-load)
17. [Monitoring Autoscaler Health](#monitoring-autoscaler-health)
18. [Cost Implications of Autoscaling Choices](#cost-implications-of-autoscaling-choices)
19. [Common Pitfalls: Flapping, Mismatched Requests, Autoscaler Races](#common-pitfalls-flapping-mismatched-requests-autoscaler-races)
20. [Scheduled and Predictive Scaling — Handling Known Traffic Patterns](#scheduled-and-predictive-scaling--handling-known-traffic-patterns)
21. [Part 12 CLI Cheat Sheet](#part-12-cli-cheat-sheet)
22. [Quick Reference: Every Autoscaler, Side by Side](#quick-reference-every-autoscaler-side-by-side)
23. [Common Mistakes and Interview Traps](#common-mistakes-and-interview-traps)
24. [Sane Starting Defaults by Workload Tier](#sane-starting-defaults-by-workload-tier)
25. [Worked Practice Problems](#worked-practice-problems)
26. [Summary and What's Next](#summary-and-whats-next)

---

## Why This Part Exists

**Autoscaling is one of the highest-leverage levers a platform team has, and also one of the easiest to
misconfigure quietly — a wrongly-tuned autoscaler rarely fails loudly, it just costs more or serves worse
than it should for months before anyone notices.** Autoscaling references have appeared throughout this
series without a dedicated treatment — Part 2
mentioned it as the trigger condition for scheduling pressure, Part 7 referenced "VPA (Part 2)" for a
right-sizing decision that Part 2 never actually covered — and that gap matters, because autoscaling is
where most of a cluster's real cost and reliability tradeoffs actually get made.** This chapter covers all
three pod/cluster autoscaling mechanisms Kubernetes offers in the depth CKA and CKAD both expect, and — per
the industry research behind this series' chapter list — KEDA specifically, now a CNCF-graduated project
running in production at thousands of organizations.

The throughline system continues: `checkout-service` handling synchronous HTTP traffic (a natural fit for
HPA), and a new `order-processor` component consuming from a queue (the natural fit for KEDA's event-driven
model), both living in the `checkout` namespace.

## Three Kinds of Autoscaling, and How They Compose

**These three mechanisms answer three different questions, and a mature production setup typically runs
some combination of all three simultaneously, not one instead of the others.**

```mermaid
mindmap
  root((Kubernetes<br/>Autoscaling))
    Horizontal Pod Autoscaler
      More/fewer replicas
      Reacts to CPU, memory,<br/>custom, external metrics
    Vertical Pod Autoscaler
      Bigger/smaller requests<br/>per replica
      Right-sizes based on<br/>observed usage history
    KEDA
      Event-driven HPA<br/>replacement
      Scales on queue depth,<br/>Kafka lag, cron, 60+ sources
      Scale-to-zero
    Cluster-level: Karpenter /<br/>Cluster Autoscaler
      More/fewer NODES
      Reacts to unschedulable<br/>pods (Part 7)
```

| Question it answers | Mechanism | What actually changes |
|---|---|---|
| "Do I need more copies of this pod?" | HPA (or KEDA, which extends the same idea) | Replica count |
| "Is each individual pod sized correctly?" | VPA | CPU/memory `requests`/`limits` per pod |
| "Do I have enough nodes to run all these pods?" | Karpenter / Cluster Autoscaler (Part 7) | Node count |

**The three layers are complementary, not competing** — HPA/KEDA decide *how many* pods to run, VPA decides
*how big* each one should be, and cluster-level autoscaling makes sure there's somewhere to actually put
them once HPA has decided to add more. A cluster running only HPA with permanently wrong, hand-guessed
resource requests wastes money on oversized requests or gets OOMKilled on undersized ones (Part 10) no
matter how well HPA itself is tuned — VPA is what keeps the *inputs* to that scaling decision honest. Losing
sight of this layering is a common source of confused troubleshooting: a symptom that looks like "HPA isn't
working" is often actually a VPA sizing problem, or a node-capacity problem one layer below HPA entirely.

## HPA Deep Dive — the Control Loop Mechanics

**The HorizontalPodAutoscaler is a controller running its own reconciliation loop (Part 1's general
controller pattern, specialized for this one job) — by default every 15 seconds, it compares a target
metric against the desired value and computes a new replica count.**

```mermaid
sequenceDiagram
    participant HPA as HPA Controller
    participant MS as metrics-server /<br/>metrics adapter
    participant API as API Server
    participant Deploy as Deployment

    loop Every syncPeriod (default 15s)
        HPA->>MS: Query current metric value<br/>(e.g. average CPU across pods)
        MS-->>HPA: currentMetricValue
        HPA->>HPA: desiredReplicas = ceil(<br/>currentReplicas *<br/>currentMetricValue / targetValue)
        HPA->>API: Patch Deployment's<br/>scale subresource
        API->>Deploy: replicas updated
    end
```

**The formula itself is worth memorizing exactly, because it explains behavior that otherwise looks
surprising**: `desiredReplicas = ceil(currentReplicas × currentMetricValue / desiredMetricValue)`. If
`checkout-service` runs 4 replicas averaging 80% CPU against a 50% target, HPA computes
`ceil(4 × 80 / 50) = 7` — a jump of 3 replicas in one reconciliation, not a gradual +1. This is why HPA
without `behavior` tuning (next section) can look "twitchy" under bursty load: the math itself is
proportional, not incremental.

```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: checkout-service
  namespace: checkout
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: checkout-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50
```

> [!IMPORTANT]
> HPA scales the **Deployment's `replicas` field**, not pods directly — it needs `resource requests` set on
> every container it's scaling by (Part 2), because `Utilization` targets are always a percentage *of the
> request*, not an absolute value. A container with no CPU request set makes HPA's utilization percentage
> mathematically undefined, and `kubectl describe hpa` will show `<unknown>` for current metrics instead of
> a real number — this is the single most common reason a freshly-created HPA appears to do nothing at all.

## HPA Metric Types — Resource, Pods, Object, External

**`autoscaling/v2` supports four distinct metric source types, and picking the right one for a given signal
is what separates HPA configs that actually track real load from ones that technically work but scale on
the wrong thing.**

| Type | Metric source | Example |
|---|---|---|
| `Resource` | Built into `metrics-server`, no extra install | CPU/memory utilization — the default, works out of the box |
| `Pods` | A metrics adapter, averaged across all pods matching the target | Requests-per-second per pod, exposed via a Prometheus Adapter |
| `Object` | A metrics adapter, describing one specific Kubernetes object, not averaged per-pod | Requests queued at an Ingress controller, or a specific Service's connection count |
| `External` | A metrics adapter, describing something entirely outside the cluster | SQS queue depth, a cloud load balancer's request count, a managed database's connection count |

```yaml
metrics:
  - type: Resource
    resource:
      name: cpu
      target: { type: Utilization, averageUtilization: 50 }
  - type: Pods
    pods:
      metric: { name: http_requests_per_second }
      target: { type: AverageValue, averageValue: "200" }
```

**When an HPA lists more than one metric, Kubernetes computes a desired replica count independently for
each one and takes the largest** — the autoscaler is deliberately biased toward over-provisioning rather
than under-provisioning when signals disagree, on the reasoning that the cost of a few extra replicas is
almost always smaller than the cost of an outage from under-scaling on the metric that mattered.

## HPA `behavior` — Stabilization Windows and Scaling Policies

**Without `behavior` tuning, HPA's default scale-down stabilization window is 300 seconds and scale-up is
0 seconds — meaning it reacts to a load spike almost instantly but waits five minutes of sustained lower
load before scaling back down, a deliberately asymmetric default to avoid flapping.**

```yaml
spec:
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100          # can double replica count in one step
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 2            # remove at most 2 pods per step
          periodSeconds: 60
```

```mermaid
flowchart TD
    Metric["Metric spikes at t=0"] --> Up["scaleUp: 0s stabilization —<br/>reacts almost immediately"]
    Up --> Peak["Replicas scale up fast<br/>to absorb the spike"]
    Peak --> Drop["Load drops back to normal<br/>at t=60s"]
    Drop --> Down["scaleDown: 300s stabilization —<br/>HPA looks at the highest<br/>recommended value over the<br/>last 5 minutes, not the<br/>instantaneous current one"]
    Down --> Slow["Replicas scale down<br/>gradually, avoiding a<br/>flap back up if load<br/>spikes again immediately"]
```

**The stabilization window doesn't delay the decision — it changes *which value* the decision is based
on.** During the scale-down stabilization window, HPA doesn't wait silently and then check once; it
continuously tracks the highest recommended replica count seen across the entire window and only scales
down to that value, which is precisely what prevents a brief dip in load from triggering a scale-down that
a load spike 30 seconds later would immediately have to reverse.

## Scaling on Custom Metrics — a Prometheus Adapter Worked Example

**Scaling `checkout-service` on requests-per-second instead of CPU requires a metrics adapter translating
Prometheus queries into the Kubernetes Custom Metrics API — CPU/memory work out of the box via
metrics-server, but any other signal needs this extra piece.**

```mermaid
flowchart LR
    App["checkout-service<br/>exposes /metrics"] --> Prom["Prometheus scrapes<br/>and stores the series"]
    Prom --> Adapter["prometheus-adapter<br/>translates a PromQL query<br/>into the Custom Metrics API"]
    Adapter --> HPA["HPA queries the<br/>Custom Metrics API<br/>like any built-in metric"]
```

```yaml
# prometheus-adapter config: maps a PromQL query to a Kubernetes metric name
rules:
  - seriesQuery: 'http_requests_total{namespace="checkout",pod!=""}'
    resources:
      overrides:
        namespace: { resource: "namespace" }
        pod: { resource: "pod" }
    name:
      matches: "http_requests_total"
      as: "http_requests_per_second"
    metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'
```

```bash
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/checkout/pods/*/http_requests_per_second" | jq .
```

**Verifying the adapter is actually exposing the metric before troubleshooting HPA itself is the fast path
here** — if the `custom.metrics.k8s.io` query above returns data, HPA will work; if it returns an empty
list or an error, the problem is entirely in the Prometheus/adapter pipeline, and no amount of editing the
HPA object will fix it.

## VPA Deep Dive — Recommender, Updater, Admission Controller

**The Vertical Pod Autoscaler is three separate components working together, not one monolithic
controller — understanding the split explains exactly when and how it actually changes a running pod's
resources.**

```mermaid
flowchart TD
    Hist["Historical usage data<br/>(from metrics-server /<br/>a metrics history store)"] --> Rec["Recommender:<br/>computes target/lower/<br/>upper bound requests"]
    Rec --> VPAObj["VPA object's<br/>.status.recommendation"]
    VPAObj --> Update["Updater: decides WHICH<br/>running pods deviate enough<br/>to need eviction"]
    Update -->|"evicts"| Pod["Pod recreated by its<br/>controller (Deployment, etc.)"]
    Pod --> Admission["Admission controller<br/>webhook: rewrites the NEW<br/>pod's requests at creation<br/>time to match the<br/>recommendation"]
```

```yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: checkout-service
  namespace: checkout
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: checkout-service
  updatePolicy:
    updateMode: "Off"    # recommend only — see next section
  resourcePolicy:
    containerPolicies:
      - containerName: checkout-service
        minAllowed: { cpu: 100m, memory: 128Mi }
        maxAllowed: { cpu: 2, memory: 2Gi }
```

**The Recommender never touches a running pod by itself** — it only ever writes a recommendation to the
VPA object's status. Whether that recommendation actually changes anything running depends entirely on
`updateMode`, which is why `Off` mode is both completely safe and genuinely useful on its own: it gives you
real, workload-specific sizing data (`kubectl describe vpa checkout-service`) without any risk of an
unexpected pod eviction.

## VPA `updateMode` — Off, Initial, Recreate, InPlaceOrRecreate

| Mode | Behavior | When to use |
|---|---|---|
| `Off` | Computes and exposes recommendations only; never touches a running pod | Always the correct starting point for any workload — pure observability with zero risk |
| `Initial` | Applies the recommendation only at pod creation time; never touches an already-running pod | A workload where mid-life eviction is unacceptable but fresh-start sizing accuracy still helps (a rolling deploy naturally picks up the latest recommendation on its own) |
| `Recreate` | Applies recommendations to running pods by evicting and recreating them when the deviation is significant, respecting any `PodDisruptionBudget` | Stateless workloads tolerant of an occasional restart |
| `InPlaceOrRecreate` | Attempts to resize the pod's resources in place (no restart) where the runtime/kubelet supports it, falling back to `Recreate` only when an in-place resize isn't possible | The newest and generally preferred mode once available on your cluster's Kubernetes version — avoids unnecessary restarts entirely for the common case |

> [!NOTE]
> `Auto` mode is deprecated as of VPA 1.4.0 and is now simply an alias for `Recreate` — any documentation or
> example still distinguishing the two as separate behaviors is describing an older VPA release. Use
> `Recreate` or `InPlaceOrRecreate` explicitly rather than `Auto` going forward.

> [!WARNING]
> `Recreate` mode evicting a pod is a real, user-visible disruption — for a service with only 2-3 replicas
> and no `PodDisruptionBudget`, a VPA-triggered eviction landing during a traffic spike can measurably hurt
> availability. Always pair `Recreate`/`InPlaceOrRecreate` mode with a `PodDisruptionBudget` (Part 2) sized
> to guarantee enough replicas stay up through any single eviction.

## Why HPA and VPA Conflict on the Same Metric

**Running HPA and VPA on the same Deployment, both targeting CPU, creates a feedback loop neither one is
designed to resolve: VPA raises CPU requests to fit observed usage, which changes the utilization
percentage HPA is computing against, which changes HPA's replica count, which changes per-pod load and
therefore VPA's next observed usage.**

```mermaid
flowchart LR
    VPA["VPA raises CPU<br/>request per pod"] --> HPAcalc["HPA's utilization %<br/>= usage / request<br/>drops, since request<br/>just went up"]
    HPAcalc --> HPAdown["HPA scales replica<br/>count DOWN"]
    HPAdown --> MoreLoad["Fewer pods absorb<br/>the same total load —<br/>per-pod usage rises"]
    MoreLoad --> VPA
```

**Working the numbers through one full cycle makes the loop concrete rather than abstract.** Start with 4
replicas, each requesting 500m CPU, each actually using 400m (80% utilization against a 50% target — HPA
wants to scale up). VPA's recommender, watching the same usage data, recommends raising the request to
800m per pod to reduce the *ratio* of usage-to-request it considers healthy. Once applied, that same 400m
of real usage against an 800m request is now only 50% utilization — exactly at HPA's target, so HPA now
computes zero need to scale. If usage later climbs to 480m per pod under the new 800m request (60%), HPA
scales up again, redistributing load and dropping per-pod usage — which VPA's next observation window sees
as evidence its 800m recommendation was too generous, nudging it back down, restarting the cycle. Neither
controller is malfunctioning; each is behaving exactly as designed on an input the other one keeps moving.

**The supported combination is HPA on one metric and VPA on a *different* one it doesn't influence** — the
most common working pattern is HPA scaling on a custom/external metric (requests-per-second, queue depth)
while VPA right-sizes CPU/memory requests, since neither one's decision then feeds back into the other's
input. Scaling both mechanisms on CPU or memory simultaneously for the same workload is explicitly
unsupported and will produce exactly the oscillation described above — the fix is always changing which
input each controller reacts to, never tuning either one's thresholds more finely, since finer tuning only
changes how fast the cycle oscillates, not whether it does at all.

| Combination | Supported? |
|---|---|
| HPA on CPU + VPA on CPU, same workload | No — direct feedback loop |
| HPA on a custom metric (RPS) + VPA on CPU/memory | Yes — the standard working pattern |
| HPA only, VPA in `Off` mode for observability | Yes — always safe |
| KEDA (any trigger) + VPA on CPU/memory | Yes — KEDA generates an HPA under the hood targeting its own external metric, same non-conflict as above |

## KEDA Deep Dive — Architecture

**KEDA doesn't replace HPA — it extends it, by generating and managing an HPA object behind the scenes
while adding two things vanilla HPA doesn't have: a huge catalog of 60+ built-in event-source scalers, and
native scale-to-zero.**

```mermaid
flowchart TD
    Source["Event source:<br/>SQS, Kafka, RabbitMQ,<br/>cron, Prometheus, ..."] --> Scaler["KEDA Scaler:<br/>polls the source for<br/>its current metric<br/>(e.g. queue depth)"]
    Scaler --> Adapter["KEDA Metrics Adapter:<br/>exposes it via the<br/>External Metrics API"]
    Adapter --> HPAGen["KEDA-managed HPA object<br/>(keda-hpa-order-processor)"]
    HPAGen --> Native["Standard Kubernetes HPA<br/>controller takes over<br/>from here — same<br/>mechanics as earlier<br/>in this chapter"]
    Scaler -->|"0 events, below threshold"| Zero["KEDA itself scales the<br/>Deployment to 0 replicas —<br/>outside HPA's own logic,<br/>since HPA can't scale<br/>below minReplicas=1"]
```

> [!NOTE]
> KEDA reached CNCF Graduated status and runs in production at thousands of organizations — the same
> maturity tier as Kubernetes itself, Prometheus, and Envoy. This matters practically, not just as a trust
> signal: a graduated CNCF project has a large, active scaler ecosystem (60+ built-in event sources at last
> count), meaning a new event source a team wants to scale on is far more likely to already have a
> maintained scaler than to require writing a custom one from scratch. Writing a genuinely custom scaler is
> still supported when nothing in the existing catalog fits, but it's worth checking the full scaler list
> first — the catalog covers most mainstream message queues, databases, and cloud-provider metrics already.

**Scale-to-zero is the genuinely new capability HPA structurally cannot offer** — a standard HPA's
`minReplicas` can't go below 1, because HPA has no way to know a workload should wake back up once there
are zero pods left to observe any metric from. KEDA solves this by running its own lightweight polling loop
*outside* the HPA/pod lifecycle entirely: at zero replicas, KEDA itself watches the event source directly,
and the moment a new message/event appears, it scales the Deployment back up to `minReplicaCount` (which
can genuinely be `0`), at which point the normal HPA mechanism takes back over for everything above 1.

## A Worked KEDA Example: Scaling on Queue Depth, Including Scale-to-Zero

```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-processor
  namespace: checkout
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-processor
  minReplicaCount: 0
  maxReplicaCount: 30
  cooldownPeriod: 120
  pollingInterval: 15
  triggers:
    - type: aws-sqs-queue
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789/order-events
        queueLength: "5"          # target: 5 messages per replica
      authenticationRef:
        name: keda-trigger-auth-aws-credentials
```

```bash
kubectl get scaledobject order-processor -n checkout
kubectl get hpa -n checkout    # confirm KEDA's generated HPA: keda-hpa-order-processor
```

| Field | What it controls |
|---|---|
| `minReplicaCount: 0` | Enables scale-to-zero — impossible with a plain HPA |
| `cooldownPeriod: 120` | How long to wait with zero events before actually scaling down to `minReplicaCount` — avoids scaling to zero and immediately back up for a brief lull |
| `queueLength: "5"` | The desired messages-per-replica ratio — KEDA computes replica count the same proportional way HPA does, just fed by SQS's queue depth instead of CPU |

**The `cooldownPeriod`/`pollingInterval` pair matters for cost vs. responsiveness tradeoffs the same way
HPA's `behavior` stabilization windows do** — a short `pollingInterval` reacts to a new message quickly
(low latency to first response) but polls the event source more often (a real, if usually small, cost for
a managed queue service); a long `cooldownPeriod` avoids flapping to zero and back but means paying for
idle replicas slightly longer after traffic genuinely stops.

## KEDA `ScaledJob` — Scaling Jobs, Not Deployments

**Everything above (`ScaledObject`) targets a scalable resource like a Deployment — `ScaledJob` is a
distinct KEDA CRD for a different shape of problem entirely: creating one Kubernetes `Job` per unit of
work, rather than scaling replica count on a long-running Deployment.**

```mermaid
flowchart TD
    Compare["Same event source:<br/>a queue of work items"] --> SO["ScaledObject approach:<br/>N long-running replicas,<br/>each pulling from the<br/>queue in a loop"]
    Compare --> SJ["ScaledJob approach:<br/>one Job (and one pod)<br/>created PER message,<br/>runs to completion, exits"]
```

```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
  name: invoice-batch-processor
  namespace: checkout
spec:
  jobTargetRef:
    template:
      spec:
        containers:
          - name: invoice-processor
            image: registry.internal/invoice-processor:2.1.0
        restartPolicy: Never
    backoffLimit: 2
  minReplicaCount: 0
  maxReplicaCount: 50
  pollingInterval: 10
  triggers:
    - type: aws-sqs-queue
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789/invoice-batches
        queueLength: "1"
```

| | `ScaledObject` | `ScaledJob` |
|---|---|---|
| Underlying object | Deployment (or StatefulSet/custom) via a generated HPA | A new `Job` object per unit of work, no HPA involved at all |
| Best fit | Steady-stream consumption where a long-running process pulls repeatedly | Discrete, independent units of work that should run in isolation and terminate cleanly |
| Failure isolation | One bad message can crash a shared long-running pod, affecting whatever else it was mid-processing | Each `Job`'s failure (`backoffLimit` exceeded) is fully isolated to that one work item |
| Concurrency control | Standard HPA replica ceiling (`maxReplicaCount`) | `maxReplicaCount` also caps concurrently *running* Jobs directly |

**`ScaledJob` is the better fit whenever "one message, one isolated unit of work, then exit" describes the
processing model more accurately than "a pool of workers continuously pulling"** — invoice generation,
one-off video transcoding, or any batch task where a single item's failure genuinely shouldn't risk the
state of whatever else a shared long-running pod happened to be doing at the same time.

## KEDA vs. Plain HPA — Decision Framework

| Choose... | When |
|---|---|
| Plain HPA | Signal is CPU/memory, or a custom metric you're already exposing through Prometheus/an adapter you control; no need for scale-to-zero |
| KEDA | Signal is an external event source (queue depth, Kafka lag, a cron schedule, a cloud provider metric) with an existing built-in scaler; scale-to-zero would meaningfully cut cost for a bursty/idle-often workload |
| KEDA even for CPU/memory | You want one consistent autoscaling tool/CRD across a cluster with a genuinely mixed set of trigger types, rather than plain HPA for some workloads and KEDA for others |

## Autoscaling StatefulSets — What's Different

**HPA and VPA both technically support targeting a `StatefulSet` instead of a `Deployment`, but the
ordinal, identity-bound nature of StatefulSet pods (Part 2) changes what "scaling" actually costs and how
safely it can happen.**

| Consideration | Deployment | StatefulSet |
|---|---|---|
| Which pod gets removed on scale-down | Any pod — they're interchangeable | Always the highest-ordinal pod (e.g. `-2` before `-1`) — never an arbitrary one |
| VPA `Recreate` eviction risk | Low — a fresh, interchangeable pod replaces it immediately | Higher — the pod's identity and any attached volume (Part 3) come back with it, but a poorly-behaved application that assumes its ordinal peer is always present can misbehave during the gap |
| HPA scale-up cost | Cheap — new pods start from a shared, stateless image | Often more expensive — a new ordinal pod may need to join a cluster protocol (Kafka broker rebalance, Elasticsearch shard reallocation) before it's actually useful |
| Typical use with HPA | Very common | Uncommon outside stateless-but-identity-requiring edge cases — most genuinely stateful systems (databases, brokers) are scaled deliberately, not reactively |

> [!CAUTION]
> Applying an HPA to a `StatefulSet` backing a clustered, quorum-based system (a self-managed Kafka or
> Elasticsearch cluster, for instance) can trigger a rebalance or shard reallocation storm every time HPA
> reacts to a load metric — the "cost" of adding or removing one replica is not the same near-zero cost it
> is for a stateless Deployment, and treating it that way is a common, expensive misconfiguration. For
> workloads like this, prefer deliberate, reviewed scaling operations over reactive autoscaling entirely.

## Tying Pod Scaling to Node Scaling

**Part 7 covered Karpenter and Cluster Autoscaler in full depth — the connection worth making explicit here
is that pod-level and node-level autoscaling are two independent control loops linked only by one signal:
an unschedulable pod.**

```mermaid
sequenceDiagram
    participant HPA
    participant Sched as Scheduler
    participant CA as Karpenter /<br/>Cluster Autoscaler

    HPA->>Sched: New replica created<br/>(scale-up decision)
    Sched->>Sched: Try to place it on<br/>an existing node
    alt No node has capacity
        Sched-->>CA: Pod stuck Pending<br/>(visible via watch)
        CA->>CA: Provision a new node<br/>sized for the pending pod(s)
        Sched->>Sched: Retry scheduling once<br/>the new node is Ready
    else Capacity exists
        Sched->>Sched: Pod scheduled immediately
    end
```

**HPA has no awareness of node capacity at all, and Karpenter/Cluster Autoscaler have no awareness of HPA's
scaling decisions** — the only coupling between the two systems is the Scheduler leaving a pod `Pending`
when it can't place it, which is precisely why a burst of new replicas from HPA can show up briefly stuck
`Pending` (Part 10's scheduling diagnostic tree) even in a perfectly healthy cluster: it's waiting on a new
node, not failing.

## A Full Worked Scenario: The Whole Stack Under Load

**Walking a single traffic event through every layer covered in this chapter and Part 7, in the order
things actually happen:**

```mermaid
flowchart TD
    T0["t=0: Traffic spikes 5x<br/>on checkout-service"] --> T1["t=15s: HPA's next sync<br/>sees CPU utilization<br/>at 95% vs 50% target"]
    T1 --> T2["t=15s: HPA computes<br/>desiredReplicas, scales<br/>Deployment from 4 to 9"]
    T2 --> T3["t=15s: Scheduler places<br/>6 new pods on existing<br/>nodes, 3 stay Pending —<br/>insufficient CPU cluster-wide"]
    T3 --> T4["t=20s: Karpenter sees<br/>the 3 Pending pods,<br/>provisions a right-sized<br/>new node"]
    T4 --> T5["t=90s: New node Ready,<br/>remaining 3 pods scheduled"]
    T5 --> T6["t=10min: Traffic returns<br/>to normal"]
    T6 --> T7["t=15min: HPA's 300s<br/>scale-down stabilization<br/>elapses, scales back to 4"]
    T7 --> T8["t=15min: Karpenter observes<br/>the now-empty node,<br/>consolidates/removes it<br/>(Part 7)"]
```

**The end-to-end latency from "traffic spikes" to "fully absorbed" is dominated by node provisioning
(t=15s to t=90s above), not by HPA's own decision speed** — HPA reacted within one sync period, but new
compute capacity takes real wall-clock time to boot regardless of how fast the scaling *decision* was made.
This is exactly why over-provisioning a small buffer of headroom (or using Karpenter's consolidation
settings conservatively, Part 7) matters for genuinely latency-sensitive services: the autoscaling math can
be perfect and a service can still serve degraded traffic for 60-90 seconds purely waiting on physical node
boot time.

## Monitoring Autoscaler Health

**An autoscaler that's silently misbehaving is worse than one that's off, because the system looks
automated and handled right up until the moment it isn't — a handful of specific metrics turn "is
autoscaling actually working" from a guess into a real, alertable signal.**

```bash
kubectl get hpa -n checkout -w
kubectl describe hpa checkout-service -n checkout | tail -15
```

| Metric to watch | What it reveals |
|---|---|
| `kube_horizontalpodautoscaler_status_desired_replicas` vs. `..._current_replicas` (both exported by kube-state-metrics) | A sustained gap means something is preventing the Scheduler from actually fulfilling HPA's request — usually the `Pending`-pod/node-capacity situation from earlier in this chapter |
| HPA's `desiredReplicas` pinned at `maxReplicas` for an extended period | The ceiling itself may now be the actual bottleneck — a silent cap on legitimate growth that looks like "autoscaling is working" (no errors) while quietly limiting capacity |
| VPA `.status.recommendation` drifting far from the pod's actual configured `requests` over time, in `Off` mode | A workload's real resource profile has changed (a new feature added real memory pressure, e.g.) and nobody has revisited the static request/limit values yet |
| KEDA `ScaledObject` metric `keda_scaler_errors` | The scaler itself is failing to reach its event source (expired queue credentials, a network path change) — the workload silently stops scaling while looking otherwise fine |

> [!TIP]
> Alert on `desiredReplicas == maxReplicas` sustained for longer than one HPA sync period as a distinct
> condition from "autoscaling isn't working" — it's the opposite problem, a scaling ceiling actively
> capping the system exactly when it's under enough load to need more capacity, and it produces zero errors
> anywhere to page on unless it's monitored for directly.

## Cost Implications of Autoscaling Choices

**Every decision in this chapter has a direct FinOps consequence, and treating autoscaling as a pure
reliability lever while ignoring its cost side produces predictably expensive surprises.**

| Decision | Cost consequence |
|---|---|
| `maxReplicas` set far above any realistic traffic ceiling "just to be safe" | A runaway metric (a bug causing a CPU spin-loop, a misfiring alert triggering retry storms) can scale to the ceiling and stay there, multiplying compute cost by the ceiling's headroom rather than real need |
| VPA left permanently in `Off` mode, recommendations never reviewed | Static, guessed resource requests drift further from reality over time — usually toward over-provisioning, since raising a request "to be safe" after one incident is common and rarely gets revisited downward later |
| `cooldownPeriod`/scale-down `stabilizationWindowSeconds` set very long "to avoid flapping" | Real, measurable cost — replicas stay up well past when load actually justified them, on every single scale-down event, all day, every day |
| KEDA scale-to-zero adopted for a workload with a slow cold start | Trades steady-state idle cost for a worse user-facing latency spike on every wake-from-zero event — not free, just a different tradeoff |
| Reactive-only scaling for genuinely predictable traffic | Over-provisions a safety buffer around every ramp-up window to cover node-boot latency, when a scheduled pre-scale (previous section) could shrink that buffer with equal safety |

> [!NOTE]
> None of this argues against generous `maxReplicas` ceilings or conservative stabilization windows in
> general — both are legitimate reliability choices. The point is that every one of them is also a cost
> decision, and a platform team that reviews reliability tuning without ever asking "what does this cost if
> it's wrong in the expensive direction" is missing half of what these settings actually control.

## Common Pitfalls: Flapping, Mismatched Requests, Autoscaler Races

| Pitfall | Cause | Fix |
|---|---|---|
| HPA flaps rapidly between replica counts | No `behavior` tuning, or a target utilization very close to actual steady-state usage (small noise crosses the threshold repeatedly) | Add stabilization windows; set the target with real headroom, not right at the edge |
| HPA shows `<unknown>` for current metrics | No resource requests set on the target container (Resource-type metrics) or the metrics adapter isn't exposing the custom metric yet | Set requests; verify the raw Custom/External Metrics API query directly before troubleshooting the HPA object itself |
| VPA and HPA fighting each other | Both targeting the same metric on the same workload | Split metrics per the decision table above, or drop one of the two |
| Pods stuck `Pending` right after an HPA scale-up | Normal — waiting on Karpenter/Cluster Autoscaler node provisioning, not a scheduling bug | Confirm with `kubectl describe pod` (Part 10) before assuming something's broken; budget for node-boot latency in SLOs |
| KEDA `ScaledObject` has no effect | The generated HPA (`keda-hpa-<name>`) was manually deleted or edited directly | Never hand-edit KEDA's generated HPA — change the `ScaledObject` instead, KEDA reconciles the HPA to match it |

## Scheduled and Predictive Scaling — Handling Known Traffic Patterns

**Every mechanism covered so far is reactive — it scales *after* a metric crosses a threshold. For load
that's genuinely predictable (a checkout system's traffic reliably climbs at 9am on a weekday, or spikes
hard on a known sale date), waiting for a reactive signal wastes the exact node-boot latency window this
chapter's worked scenario walked through.**

```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: checkout-service-scheduled
  namespace: checkout
spec:
  scaleTargetRef: { name: checkout-service }
  minReplicaCount: 3
  maxReplicaCount: 20
  triggers:
    - type: cron
      metadata:
        timezone: America/New_York
        start: 0 8 * * 1-5     # 8am weekdays: pre-scale ahead of the 9am traffic ramp
        end: 0 19 * * 1-5      # 7pm weekdays: relax back to reactive-only scaling
        desiredReplicas: "10"
```

**KEDA's `cron` trigger composes with any other trigger on the same `ScaledObject`** — the effective
replica count is the *maximum* across all active triggers, so a `cron` trigger here acts as a scheduled
floor (guaranteeing at least 10 replicas during known business hours) while a CPU or queue-depth trigger
can still scale above that floor reactively if real load exceeds what was predicted.

| Approach | Best for |
|---|---|
| Reactive only (plain HPA/KEDA) | Genuinely unpredictable, bursty load with no reliable pattern |
| `cron` trigger as a scaling floor | Known, recurring time-of-day/day-of-week patterns (business hours, batch windows) |
| Manual pre-scale before a known one-off event | A single unprecedented event (a product launch, a marketing campaign) too irregular to encode as a recurring schedule |

> [!TIP]
> A scheduled floor is a genuinely effective way to buy back the node-provisioning latency from this
> chapter's worked scenario — pre-scaling pods (and therefore triggering Karpenter/Cluster Autoscaler node
> provisioning) 30-60 minutes ahead of a known traffic ramp means the capacity is already warm when real
> load arrives, instead of racing to provision it reactively during the ramp itself.

## Part 12 CLI Cheat Sheet

| Command | Purpose |
|---|---|
| `kubectl get hpa -n <ns> -w` | Watch an HPA's current/desired replicas and target metrics live |
| `kubectl describe hpa <name> -n <ns>` | Full event history and computed metric values — the first stop when an HPA "isn't working" |
| `kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/<ns>/pods/*/<metric>"` | Confirm a custom metric is actually being exposed, independent of the HPA object itself |
| `kubectl describe vpa <name> -n <ns>` | VPA's current recommendation (`Target`/`Lower Bound`/`Upper Bound`) regardless of `updateMode` |
| `kubectl get scaledobject -n <ns>` / `kubectl get scaledjob -n <ns>` | KEDA's own resources, independent of the HPA it generates |
| `kubectl get hpa -n <ns> -l scaledobject.keda.sh/name=<name>` | The specific HPA a given `ScaledObject` generated |
| `kubectl top pods -n <ns>` / `kubectl top nodes` | Live resource usage (requires metrics-server, Part 10) — sanity-check against what HPA/VPA report |

## Quick Reference: Every Autoscaler, Side by Side

**A single comparison table worth keeping close, since these four mechanisms are easy to mix up under
exam or incident pressure.**

| | HPA | VPA | KEDA | Karpenter / Cluster Autoscaler |
|---|---|---|---|---|
| Changes | Replica count | Per-pod `requests`/`limits` | Replica count (via a generated HPA) | Node count |
| Object kind | `HorizontalPodAutoscaler` | `VerticalPodAutoscaler` | `ScaledObject` / `ScaledJob` | Provider-specific (`NodePool`/`NodeClass` for Karpenter) |
| Reacts to | CPU/memory/custom/external metrics | Historical usage observation | 60+ event sources, cron, or any HPA-compatible metric | Unschedulable (`Pending`) pods |
| Minimum floor | 1 replica | N/A (resizes, doesn't remove pods) | 0 replicas (scale-to-zero) | 0 nodes |
| Covered in | This chapter | This chapter | This chapter | Part 7 |
| Common misconfiguration | Missing resource requests -> `<unknown>` metrics | Sharing a metric with HPA -> feedback loop | Hand-editing the generated HPA -> silently reverted | Mismatched instance types for pending pod shapes |

## Common Mistakes and Interview Traps

| Mistake | Why it's wrong | Correct approach |
|---|---|---|
| Setting an HPA CPU target without setting the container's CPU request | `Utilization` targets are a percentage of the request — with no request, the percentage is undefined | Always set resource requests before adding a Resource-type HPA metric |
| Running HPA and VPA on the same metric for the same workload | Creates an unresolvable feedback loop between the two controllers | Scale each on a different, non-overlapping metric |
| Assuming `Auto` is VPA's most current/best mode | Deprecated since 1.4.0, now just an alias for `Recreate` | Use `Recreate` or `InPlaceOrRecreate` explicitly |
| Treating a `Pending` pod right after a scale-up event as a failure | Often just normal node-provisioning latency (Karpenter/Cluster Autoscaler catching up) | Check `describe`'s Events reason before assuming something is broken |
| Believing plain HPA can scale to zero | HPA's `minReplicas` has a hard floor of 1 | Use KEDA for any workload that genuinely needs scale-to-zero |
| Hand-editing a KEDA-generated `keda-hpa-*` object | KEDA reconciles it back to match the `ScaledObject` on its next sync, silently discarding the manual edit | Change the `ScaledObject` spec instead |
| Applying HPA reactively to a StatefulSet backing a quorum-based cluster (Kafka, Elasticsearch) | Each scale event can trigger an expensive rebalance/shard reallocation, unlike a stateless Deployment | Prefer deliberate, reviewed scaling for quorum-sensitive StatefulSets over reactive autoscaling |
| Using `ScaledObject` for genuinely isolated, one-shot units of work | A shared long-running pod means one bad message's crash can affect whatever else that pod was concurrently processing | Use `ScaledJob` when failure isolation per work item matters |
| Setting a KEDA `cron` trigger without realizing it composes as a floor, not an override | The effective replica count is the max across all active triggers — a reactive trigger can still push well above a `cron` floor if real load exceeds the prediction | This is usually desired behavior, but confirm `maxReplicaCount` is still sized for the reactive case, not just the scheduled floor |
| Assuming VPA and HPA can never coexist on the same Deployment at all | The actual constraint is narrower — they conflict specifically when both react to the same metric, not categorically | Scale HPA on one metric and let VPA manage a genuinely different one (or run VPA in `Off` mode for observability only) |

## Sane Starting Defaults by Workload Tier

**Teams new to tuning these controllers often either leave every default untouched or over-tune from day
one — a reasonable starting point by workload criticality avoids both extremes.**

| Setting | Low-traffic internal tool | Customer-facing service (e.g. `checkout-service`) | High-throughput/latency-critical |
|---|---|---|---|
| `minReplicas` | 1 | 2-3 (survives one node failure without a gap) | 3+, sized to absorb one AZ's worth of capacity loss |
| HPA target utilization | 70-80% (cost-optimized, some slack) | 50-60% (room to absorb a burst before scaling reacts) | 40-50% (maximum headroom, scale-up latency matters most here) |
| `scaleDown.stabilizationWindowSeconds` | Default (300s) is fine | 300-600s (avoid flapping on daily traffic noise) | Tuned per real traffic-pattern analysis, not left at default |
| VPA `updateMode` | `Recreate` (occasional restart is fine) | `Initial` or `InPlaceOrRecreate` (avoid disruptive eviction) | `Off` (recommendations reviewed manually, changes rolled out deliberately) |
| KEDA scale-to-zero | Good fit if genuinely idle often | Rarely appropriate — cold-start latency hits real users | Almost never appropriate |
| Monitoring alert on `desiredReplicas == maxReplicas` | Optional | Recommended | Required |

These are starting points to tune from with real traffic data, not permanent settings — revisit them after
the workload has run long enough to show its actual pattern, the same way VPA's own recommendations
improve as it accumulates more observed history. A quarterly review of these settings against actual
observed traffic and cost data is a reasonable cadence for most teams — frequent enough to catch a workload
that's outgrown its original sizing assumptions, infrequent enough not to become its own maintenance
burden.

## Worked Practice Problems

**Problem 1**: An HPA targeting 50% CPU utilization shows `<unknown>` under `TARGETS` in `kubectl get hpa`,
and the Deployment never scales regardless of load. What's the single most likely cause, and how do you
confirm it in one command?

*Answer*: The target container almost certainly has no CPU `resources.requests` set — `Utilization` targets
are computed as a percentage of the request, and with no request there's no denominator for that
percentage, so the metrics pipeline has nothing valid to report. Confirm with
`kubectl get deployment checkout-service -o jsonpath='{.spec.template.spec.containers[*].resources}'`; an
empty or missing `requests` field confirms the cause immediately.

**Problem 2**: A team configures both an HPA and a VPA on the same Deployment, both targeting CPU, hoping
for "the best of both." A week later they notice replica count oscillating unpredictably with no
corresponding change in real traffic. Explain what's happening.

*Answer*: VPA raising the container's CPU request lowers the observed utilization percentage HPA computes
(same usage divided by a now-larger request), which HPA interprets as reduced load and scales down for;
fewer replicas then absorb the same total traffic, raising per-pod usage back up, which VPA's next
recommendation cycle reacts to by raising the request further — the two controllers are each reacting to
a symptom the other one caused, with no traffic change required to keep the cycle going. The fix is
scaling HPA and VPA on different metrics for this workload, or using VPA only in `Off` (recommendation-only)
mode alongside HPA.

**Problem 3**: A `ScaledObject` sets `minReplicaCount: 0` for a queue-consuming Deployment, but the team
notices new messages sit in the queue for up to 30 seconds before a replica appears to process them, even
though the queue was previously empty. Is this a bug, and if not, what governs that delay?

*Answer*: Not a bug — it's KEDA's `pollingInterval` (how often it checks the event source while scaled to
zero) plus the real time needed to schedule and start a new pod from zero. A `pollingInterval` of 15-30
seconds means, in the worst case, a message can sit for nearly that entire interval before KEDA even
notices it exists, on top of normal pod startup time; lowering `pollingInterval` trades a small increase in
polling cost against lower worst-case latency for the first message after an idle period.

**Problem 4**: A `ScaledJob` processing invoices has `maxReplicaCount: 50` and a queue that briefly backs up
to 400 messages during a batch import, then drains. The team wants to know whether this is safe to leave as
configured. What should they check before deciding?

*Answer*: `maxReplicaCount` on a `ScaledJob` directly caps concurrently running Jobs, so 50 concurrent
invoice-processor pods will each need scheduling capacity (Part 7's Karpenter/Cluster Autoscaler) and each
likely holds a connection to whatever backing service (a database, a third-party invoicing API) the
processor calls — the real question isn't whether KEDA can create 50 Jobs, it's whether that downstream
dependency can absorb 50x its normal concurrent load without being the actual bottleneck or getting rate
limited. Checking the downstream service's own concurrency/rate limits before treating `maxReplicaCount` as
a safe ceiling is the necessary follow-up, not just confirming the KEDA config is syntactically fine.

## Summary and What's Next

HPA, VPA, and KEDA solve three different sizing questions and are designed to compose — the one hard rule
is never letting two of them react to the same metric on the same workload. KEDA extends rather than
replaces HPA, adding scale-to-zero and a wide event-source catalog while generating a standard HPA
underneath for everything above one replica. None of this operates in isolation from node-level scaling —
an HPA scale-up decision and a Karpenter/Cluster Autoscaler node-provisioning decision are two separate
control loops linked only through the Scheduler's `Pending` state, which is why real-world scale-up latency
is often dominated by node boot time rather than any autoscaler's own decision speed.

Part 13 moves to a structural rather than reactive concern: multi-tenancy. Running many teams or customers
safely on one shared cluster raises questions ResourceQuota and LimitRange (Part 2) only partially answer —
Part 13 covers the harder isolation boundaries (namespace-based multi-tenancy limits, virtual clusters, and
policy-based tenant isolation) that a growing platform team eventually has to solve. The two chapters
connect directly: a shared cluster running autoscaling per the patterns in this chapter needs multi-tenant
guardrails to stop one team's `maxReplicas` ceiling (or a KEDA scaler gone haywire on a bad event source)
from starving every other tenant's workload of the very node capacity Karpenter or Cluster Autoscaler just
provisioned in response. Recognizing which of HPA, VPA, or KEDA a given production symptom actually calls
for — rather than reaching for whichever one a team happens to already know — is most of the practical
skill this chapter aimed to build.
