Autoscaling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

ReplicaSets and Deployments covers how a Deployment changes its replica count or Pod template; this page covers what decides those numbers automatically, at three independent levels: the HorizontalPodAutoscaler (HPA) changes a Deployment or StatefulSet’s replicas, the VerticalPodAutoscaler (VPA) changes a Pod’s own CPU/memory requests/limits, and node autoscaling changes how many nodes the cluster has for either of those to schedule onto. KEDA sits alongside the HPA as an alternative driver for workloads with no CPU/memory signal worth scaling on at all. Everything below is current for Kubernetes 1.37 (kubectl 1.37) and reuses the Bookshelf catalog-api Deployment and the notifier worker introduced in Jobs and CronJobs. Book 1 ch. 3, "Organize, Grow, and Go", already distinguishes scheduling (placing a Pod) from scaling (changing how many there are) and sketches scaling driven by an external load balancer’s own signals; every mechanism below is the current, API-driven descendant of that idea, not the load-balancer-side workaround the book describes.

HorizontalPodAutoscaler: autoscaling/v2

An HPA is its own object, kind: HorizontalPodAutoscaler in the autoscaling/v2 API group (GA since Kubernetes 1.23; the once-parallel autoscaling/v1, resource-metrics-only shape is still served but v2 is the only version worth writing new manifests against). It targets one scalable resource (spec.scaleTargetRef, almost always the Bookshelf catalog-api Deployment) through its scale subresource, and a dedicated horizontal pod autoscaler controller inside kube-controller-manager polls the metrics APIs described below on a fixed sync period (15 seconds by default), computes a desired replica count from spec.metrics, and writes it to the target’s spec.replicas — the HPA never touches Pods directly, only the Deployment’s replica count, which is why it composes cleanly with the rolling-update mechanics already covered on ReplicaSets and Deployments.

spec.metrics accepts four metric types, and an HPA may list several of them at once, in which case the controller computes a desired replica count from each and scales to the largest:

Resource

CPU or memory Utilization (percentage of the Pod’s own requests) or AverageValue (absolute quantity), averaged across the target’s Pods. This is the everyday case for catalog-api, whose load correlates with CPU.

Pods

A custom metric already expressed per-Pod (e.g. requests-per-second per Pod, scraped by a custom or external metrics adapter), averaged the same way as a resource metric.

Object

A metric describing one other Kubernetes object rather than the target’s own Pods — classically an Ingress or Gateway’s total requests-per-second — divided by the target’s replica count to decide how many replicas that total needs.

External

A metric with no Kubernetes object behind it at all, sourced from an external system through a custom metrics adapter — a message-queue depth, a cloud provider’s own metric, or a SaaS API’s usage counter. This is the type `notifier’s queue-depth scaling would use if driven by a plain HPA instead of by KEDA (see below).

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: catalog-api
  namespace: bookshelf
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: catalog-api
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Object
      object:
        metric:
          name: requests-per-second
        describedObject:
          apiVersion: gateway.networking.k8s.io/v1
          kind: HTTPRoute
          name: catalog-api
        target:
          type: AverageValue
          averageValue: "200"
kubectl apply -f catalog-api-hpa.yaml
kubectl get hpa catalog-api -n bookshelf
kubectl describe hpa catalog-api -n bookshelf
NAME          REFERENCE                TARGETS                        MINPODS   MAXPODS   REPLICAS   AGE
catalog-api   Deployment/catalog-api   cpu: 42%/70%, requests-per-second: 180/200   3         12        5          3m

Watching an HPA react: the official walkthrough with a load generator

The official HPA walkthrough proves the loop above end to end by driving real CPU load against a target and watching the controller react, rather than reading describe output in the abstract. Applied to catalog-api, a throwaway busybox Pod is the load generator, looping an unauthenticated request as fast as it can:

kubectl run load-generator --image=busybox:1.36 --restart=Never -n bookshelf -- \
  /bin/sh -c "while true; do wget -q -O- http://catalog-api.bookshelf.svc.cluster.local; done"
kubectl get hpa catalog-api -n bookshelf --watch
NAME          REFERENCE                TARGETS           MINPODS   MAXPODS   REPLICAS   AGE
catalog-api   Deployment/catalog-api   cpu: 305%/70%     3         12        3          15s
catalog-api   Deployment/catalog-api   cpu: 305%/70%     3         12        6          45s
catalog-api   Deployment/catalog-api   cpu: 68%/70%       3         12        12         2m

Utilisation climbs well past the 70% target the instant the load generator starts (there is only whatever capacity minReplicas: 3 already provides), the controller responds within one sync period, and replicas keep climbing until either maxReplicas is hit or per-replica utilisation settles back near the target — 12 replicas here brings average CPU back down to roughly the target because the same total load is now divided across four times as many Pods. Deleting load-generator (kubectl delete pod load-generator -n bookshelf) removes the load, and the behavior.scaleDown stabilisation window below is exactly what paces the retreat back down to minReplicas rather than dropping instantly.

behavior: scaling policies and stabilisation windows

Left to the defaults, an HPA scales up as fast as the metric demands and scales down cautiously, using a built-in five-minute stabilisation window. spec.behavior.scaleUp/scaleDown make both directions explicit instead of relying on that default, each with its own stabilizationWindowSeconds (the controller looks back over that window and picks the least aggressive recommendation it saw, which damps flapping from a bursty metric) and a list of policies (Pods or Percent, each with a periodSeconds) combined by selectPolicy: Max (the default) or Min:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: catalog-api
  namespace: bookshelf
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: catalog-api
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0        # react to a spike immediately
      policies:
        - type: Percent
          value: 100                       # at most double the replica count
          periodSeconds: 60
        - type: Pods
          value: 4                         # or at most +4 pods
          periodSeconds: 60
      selectPolicy: Max                    # whichever policy allows more pods wins
    scaleDown:
      stabilizationWindowSeconds: 300       # 5 minutes of low load before scaling down
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120
      selectPolicy: Min

A common pitfall is setting scaleUp.stabilizationWindowSeconds too high: it delays reacting to a genuine spike by exactly that many seconds, whereas the scale-down window is where most of the value of stabilisation actually sits — reacting slowly to "load just dropped" is far cheaper than reacting slowly to "load just spiked".

Scaling to and from zero (beta, Kubernetes 1.37)

Scaling an HPA-managed workload down to minReplicas: 0 reached beta, enabled by default, in Kubernetes 1.37 (feature gate HPAScaleToZero). Scale-to-zero is available only for Object and External metrics — never for Resource metrics such as CPU or memory, because there is no way to measure CPU utilisation on a Pod that does not exist. This is notifier’s shape exactly: an external queue-depth metric that is legitimately zero for long stretches, at which point running zero Pods is strictly better than running the `minReplicas: 1 floor a plain HPA would otherwise require. A plain autoscaling/v2 HPA scaling notifier to zero on an external queue-depth metric would look like this (KEDA, covered later on this page, wraps the same idea in a friendlier ScaledObject and adds the metrics adapter for free, which is why Bookshelf actually uses KEDA rather than this form in production):

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: notifier
  namespace: bookshelf
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: notifier
  minReplicas: 0
  maxReplicas: 8
  metrics:
    - type: External
      external:
        metric:
          name: notifier_queue_depth
        target:
          type: AverageValue
          averageValue: "10"

metrics-server and the Metrics API

spec.metrics[].type: Resource entries are served by the Metrics API (metrics.k8s.io), a narrow, current-usage-only API distinct from the full custom.metrics.k8s.io / external.metrics.k8s.io APIs that back Pods/Object/External metrics. metrics-server is the in-cluster component that implements it: it scrapes every kubelet’s Summary API on an interval, keeps only the latest sample in memory (no history, no long-term storage — kubectl top and the HPA controller are its only real clients), and registers itself as an APIService aggregating metrics.k8s.io into the main API server. Its Metrics API became stable in Kubernetes 1.37; earlier releases already shipped metrics-server broadly, but the API surface itself only reached that maturity level at 1.37.

kind create cluster --name bookshelf
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
# kind's kubelet serving certificates are self-signed, so metrics-server cannot verify them. For a local
# cluster only (never a real one), add --kubelet-insecure-tls to metrics-server's container args:
kubectl patch deployment metrics-server -n kube-system --type=json \
  -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl rollout status deployment/metrics-server -n kube-system
kubectl top nodes
kubectl top pods -n bookshelf
NAME                          CPU(cores)   MEMORY(bytes)
catalog-api-6cd8f6bcdb-4qxsl   38m          210Mi
catalog-api-6cd8f6bcdb-9zvwn   45m          198Mi
notifier-7f9c9b6d75-k2p7t      6m           64Mi

If kubectl top or kubectl describe hpa shows unknown targets, metrics-server is either not installed, not reachable (a common kind/minikube pitfall, addressed above), or has not completed its first scrape yet — the HPA controller simply skips a metric it cannot read on that sync cycle rather than failing outright, so a transient gap self-heals once metrics-server catches up.

VerticalPodAutoscaler

Where an HPA changes replica count, a VPA changes a Pod’s own resource requests (and, depending on mode, limits) over time, based on historical usage it observes — the fit for a workload whose per-replica CPU/memory need drifts as its data or traffic shape changes, rather than one whose number of replicas should track load. A VPA is not part of core Kubernetes; it ships as a separate project (kubernetes/autoscaler, subdirectory vertical-pod-autoscaler) with three components: a recommender (watches usage via the Metrics API and computes a recommendation), an updater (resizes or evicts Pods whose current requests drift too far from the recommendation, in the Recreate, InPlaceOrRecreate and InPlace modes), and an admission controller (rewrites a Pod’s requests at creation time to the current recommendation).

spec.updatePolicy.updateMode selects how aggressively a VPA acts:

Off

Only produces status.recommendation; nothing is applied automatically. The safe way to start — read the recommendation for a few days before trusting it.

Initial

Applies the recommendation only at Pod creation time, never afterwards.

Recreate

The default. Applies the recommendation at Pod creation and by evicting and recreating the Pod when its current requests are far enough from the recommendation, subject to a PodDisruptionBudget.

Auto

Deprecated and currently equivalent to Recreate; set one of the explicit modes instead.

InPlace

Applies the recommendation only through the in-place resize described below and never evicts; a resize that cannot be applied is retried later. Needs the VPA InPlace feature gate.

InPlaceOrRecreate

Tries the in-place resize described below first, and only falls back to the evict-and-recreate behaviour of Recreate when an in-place resize is not possible (a downward memory resize, for instance, cannot be applied in place safely and always falls back).

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: notifier
  namespace: bookshelf
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: notifier
  updatePolicy:
    updateMode: InPlaceOrRecreate
  resourcePolicy:
    containerPolicies:
      - containerName: notifier
        minAllowed:
          cpu: 50m
          memory: 64Mi
        maxAllowed:
          cpu: 500m
          memory: 512Mi
kubectl apply -f notifier-vpa.yaml
kubectl describe vpa notifier -n bookshelf
Recommendation:
  Container Recommendations:
    Container Name:  notifier
    Target:
      Cpu:     120m
      Memory:  180Mi

A VPA and an HPA must never scale the same resource dimension of the same workload at once — a VPA raising a Pod’s CPU requests while an HPA is scaling replica count on CPU utilisation changes the denominator the HPA’s percentage is computed against, and the two fight each other. `notifier’s replica count is already driven by KEDA (below) on queue depth, not CPU, so pointing a VPA at its CPU/memory requests is safe; a VPA must never be pointed at `catalog-api’s CPU while its HPA above is also scaling on CPU utilisation.

In-place Pod resize (GA, Kubernetes 1.35)

Resizing a running container’s CPU/memory requests/limits without restarting its Pod reached Stable (GA) in Kubernetes 1.35. Before this feature, any change to spec.containers[].resources was immutable after Pod creation and forced a full Pod replacement (delete and recreate), which is what VPA’s InPlaceOrRecreate mode above falls back to when a resize cannot be applied in place.

Each container’s resizePolicy states, per resource, whether a change can be absorbed by the running process (NotRequired, the default) or needs the container itself restarted (RestartContainer — typical for memory limits, since most runtimes cannot shrink a process’s live memory ceiling):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: notifier
  namespace: bookshelf
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: notifier
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notifier
    spec:
      containers:
        - name: notifier
          image: ghcr.io/example/bookshelf-notifier:1.0.0
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          resizePolicy:
            - resourceName: cpu
              restartPolicy: NotRequired
            - resourceName: memory
              restartPolicy: RestartContainer

A resize is requested against the Pod’s dedicated resize subresource, not by editing the Pod’s own spec.containers[].resources field directly:

kubectl patch pod notifier-7f9c9b6d75-k2p7t -n bookshelf --subresource resize --patch \
  '{"spec":{"containers":[{"name":"notifier","resources":{"requests":{"cpu":"200m"},"limits":{"cpu":"800m"}}}]}}'
kubectl get pod notifier-7f9c9b6d75-k2p7t -n bookshelf \
  -o jsonpath='{.status.conditions[?(@.type=="PodResizePending")].reason}'   # empty once the resize is applied

The kubelet reports progress through two Pod conditions (the old status.resize field is gone): PodResizeInProgress (the resize was accepted and is being applied) and PodResizePending, whose reason is Deferred (the node currently lacks room; retried later) or Infeasible (the request exceeds the node’s capacity outright, or cannot be applied on it). Per container, status.containerStatuses[].resources/allocatedResources show what is actually applied. This subresource is exactly what VPA’s InPlaceOrRecreate mode drives internally instead of evicting the Pod, and it is also usable by hand for a one-off adjustment with no VPA installed at all.

Node autoscaling: Cluster Autoscaler vs Karpenter

Every autoscaler above only changes how many Pods, or how large each Pod’s requests are; none of them can add capacity if every node is already full. Node-level autoscaling watches for Pods that are Pending because nothing schedulable exists for them (or, in the other direction, nodes that are underused for long enough), and adds or removes nodes accordingly — exactly the "external LB changes replica counts" idea Book 1 sketches, reframed one layer down: the book’s scaling signal came from a load balancer noticing demand and asking for more replicas; here the signal is the scheduler itself noticing it has no room for the replicas an HPA already asked for, and asking the cloud provider for more nodes.

Cluster Autoscaler

The original, per-cloud-provider project (kubernetes/autoscaler, subdirectory cluster-autoscaler). It reasons in terms of node groups (an AWS Auto Scaling Group, an Azure VMSS, a GCP managed instance group): it simulates whether a pending Pod would fit if a node group scaled up by one, and scales that group, or removes a node once every Pod on it could be rescheduled elsewhere without violating a PodDisruptionBudget. Because it operates one predefined node group at a time, its Pod-to-node fit is only as good as however many distinct instance shapes were provisioned as groups ahead of time.

Karpenter

A newer, group-less model (originally AWS-native, now also supporting Azure AKS and other providers): instead of choosing among predefined node groups, it provisions individual nodes directly from a NodePool / EC2NodeClass-style specification, picking whatever instance type/size best fits the actual set of pending Pods at that moment (bin-packing across shapes, not just scaling a fixed shape up and down), and can consolidate or replace nodes opportunistically as workloads change, not only in response to a pending Pod.

Both projects are driven purely by the combination of Pod resource requests and scheduling constraints (Scheduling and Disruptions covers affinity, taints and topology spread in depth) — neither one reads an HPA or VPA object directly; they react to the Pending Pods (or idle nodes) those controllers' own scaling decisions produce. On a local kind or minikube cluster there is no cloud node group to scale at all, so Bookshelf's node autoscaling is prose-only here and exercised for real only on a managed cluster (see AKS clusters for the equivalent AKS-managed node pool autoscaler).

KEDA: event-driven scaling for notifier

notifier has no CPU/memory signal worth scaling on, and no HTTP traffic at all — its only load signal is how many messages are waiting on its queue, which is exactly the External metric type an HPA already supports in principle (shown above), but only after someone stands up a custom/external metrics adapter translating that queue’s API into the Kubernetes metrics APIs. KEDA (Kubernetes Event-driven Autoscaling) packages that adapter together with a ScaledObject CRD that is far more approachable than authoring a raw External HPA by hand, and its scalers cover dozens of event sources (message queues, cloud metrics, Prometheus, cron schedules) out of the box. Internally, a ScaledObject creates and owns a plain autoscaling/v2 HPA on the operator’s behalf — which is why a ScaledObject and a hand-written HPA must never target the same workload at once, the same rule already stated for VPA and HPA above.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: notifier
  namespace: bookshelf
spec:
  scaleTargetRef:
    name: notifier
  minReplicaCount: 0
  maxReplicaCount: 8
  cooldownPeriod: 120
  triggers:
    - type: redis
      metadata:
        address: redis.bookshelf.svc.cluster.local:6379
        listName: notifier-queue
        listLength: "10"
      authenticationRef:
        name: notifier-redis-auth
---
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: notifier-redis-auth
  namespace: bookshelf
spec:
  secretTargetRef:
    - parameter: password
      name: notifier-redis-credentials
      key: password
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace
kubectl apply -f notifier-scaledobject.yaml
kubectl get scaledobject notifier -n bookshelf
kubectl get hpa -n bookshelf   # keda-hpa-notifier, created and owned by the ScaledObject above

minReplicaCount: 0 gives notifier genuine scale-to-zero on an ordinary queue — the same beta HPA HPAScaleToZero capability from earlier on this page, but reached through KEDA’s own ScaledObject rather than by hand-authoring an External-metric HPA and its adapter. On a managed cluster where the message broker is a cloud service instead of the in-cluster Redis shown above, KEDA is commonly installed as the platform’s own managed add-on instead of a self-managed Helm release — AKS microservices walks through exactly that (the --enable-keda AKS add-on, a ScaledObject against an Azure Service Bus queue, and the workload-identity wiring the managed add-on needs) and is not repeated here.

flowchart TB subgraph Metrics["Metric sources"] RES["metrics-server\n(Resource metrics)"] ADAPTER["custom/external metrics adapter\n(Pods / Object / External)"] KEDAM["KEDA scaler\n(queue depth, cron, ...)"] end HPA["HorizontalPodAutoscaler\ncontroller (sync every 15s)"] KSO["KEDA ScaledObject\n(creates and owns an HPA)"] TARGET["Deployment / StatefulSet\nscale subresource"] RS["ReplicaSet"] PODS["Pods"] RES --> HPA ADAPTER --> HPA KEDAM --> KSO KSO --> HPA HPA -->|"compute desired replicas"| TARGET TARGET -->|"reconcile"| RS RS -->|"create / delete"| PODS PODS -->|"usage observed"| RES