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 ownrequests) orAverageValue(absolute quantity), averaged across the target’s Pods. This is the everyday case forcatalog-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
InPlacefeature gate. InPlaceOrRecreate-
Tries the in-place resize described below first, and only falls back to the evict-and-recreate behaviour of
Recreatewhen 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, subdirectorycluster-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 aPodDisruptionBudget. 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
Source: KEDA Docs — Redis Lists Scaler.
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
Source: KEDA Docs — Deploy KEDA.
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.
References
-
Kubernetes Docs — Resize CPU and Memory Resources assigned to Containers
-
Kubernetes Autoscaler (GitHub) — Cluster Autoscaler and VerticalPodAutoscaler
-
Rensin, D. (2015). Kubernetes: Scheduling the Future at Cloud Scale. O’Reilly. Ch. 3, "Organize, Grow, and Go".