Lesson 50 of 66 · 15 min read · Updated

Vertical Pod Autoscaler and In-Place Pod Resize

In the previous article the HorizontalPodAutoscaler added Pods when CPU utilization rose. Everything depended on a number you chose by hand: the container's CPU request. Choose it too small and the HPA panics at normal load. Choose it too large and you pay for capacity that sits idle, because the scheduler reserves every requested millicore whether you use it or not.

Most teams guess that number once and never revisit it. The Vertical Pod Autoscaler (VPA) replaces the guess with a measurement. It watches what your containers actually use and recommends, or applies, better requests and limits. Since Kubernetes 1.35 it can apply them to running Pods without restarting them, thanks to in-place Pod resize.

We will cover the following topics:

  1. What the VPA's three components do
  2. The update modes, from read-only Off to InPlaceOrRecreate
  3. Reading a recommendation: target, lower bound, upper bound
  4. In-place Pod resize, the resize subresource and resizePolicy
  5. What happens when a resize does not fit on the node
  6. Why VPA and HPA must not both scale on CPU or memory

Technical Requirements

  1. A local multi-node cluster. The examples use Docker Desktop's Kubernetes with the kind provisioner (one control-plane node and three workers), Kubernetes v1.36. In-place resize needs Kubernetes 1.33 or later, and it is stable from 1.35.
  2. metrics-server, installed as shown in the previous article.
  3. git and OpenSSL 3 or later to install the VPA. On macOS, read the note in Step 1 first.
  4. The scaling-api:1.0 image from the previous article.

Core Concepts

Three Components

The VPA is not built into Kubernetes. It is a separate project (kubernetes/autoscaler, version 1.8.0 at the time of writing) made of three Deployments:

ComponentJob
RecommenderReads usage from the metrics API, keeps a decaying histogram per container, and writes a recommendation into each VPA object's status.
UpdaterCompares running Pods with the recommendation and, depending on the mode, resizes them in place or evicts them.
Admission controllerA mutating webhook that rewrites the requests of every new Pod covered by a VPA, so recreated Pods start with the recommended values.

The VPA object itself is small. It points at a workload with targetRef and says how much the VPA may do.

Update Modes

spec.updatePolicy.updateMode decides how far the VPA goes:

ModeNew PodsRunning Pods
OffUnchangedUnchanged. Recommendations only.
InitialGet the recommendationNever touched
RecreateGet the recommendationEvicted and recreated when far from the recommendation. This is the default.
InPlaceOrRecreateGet the recommendationResized in place. Falls back to eviction if in-place resize fails.
InPlaceGet the recommendationResized in place only, never evicted. Alpha, behind the InPlace feature gate in VPA 1.7+.
AutoSame as RecreateDeprecated. Use an explicit mode.

Off is where every team should start. It costs nothing and turns guessing into data. InPlaceOrRecreate no longer needs a feature gate: it became default-on in VPA 1.5 and the gate was removed in 1.7.

Reading a Recommendation

The recommender publishes four numbers per container:

FieldMeaning
targetThe request the VPA would set.
lowerBoundBelow this, the Pod is considered under-provisioned.
upperBoundAbove this, the Pod is considered over-provisioned.
uncappedTargetThe target before minAllowed and maxAllowed are applied.

The updater does not chase every small movement of the target. It acts on a Pod whose requests fall outside the lower and upper bounds. For a Pod that has been running more than 12 hours, it also acts when the requests differ from the target by more than 10%. The bounds are wide when there is little history and narrow as data accumulates over days.

The recommender also has floors: by default it never recommends less than 25m CPU or 250Mi memory per Pod. On a small .NET API you will see memory jump to 250Mi even if the container uses 25Mi. That floor is configurable on the recommender with --pod-recommendation-min-memory-mb and --pod-recommendation-min-cpu-millicores.

Limits Follow Requests

With controlledValues: RequestsAndLimits (the default), the VPA keeps the ratio between limit and request. A container with cpu: 50m request and 100m limit has a 1:2 ratio. If the VPA raises the request to 500m, the limit becomes 1000m. Set controlledValues: RequestsOnly if you want limits left exactly as written.

In-Place Pod Resize

Before Kubernetes 1.33, a Pod's resources were immutable. Changing them meant deleting the Pod and creating a new one. In-place resize changes that:

  1. spec.containers[*].resources for CPU and memory is now mutable, but only through the Pod's resize subresource. A plain kubectl edit of the Pod still refuses.
  2. The kubelet applies the change through the container runtime by updating the container's cgroup. The process keeps running, with the same Pod name, IP and restart count.
  3. status.containerStatuses[*].resources shows what is actually applied. While the two differ, the resize is in progress.

The feature was alpha in 1.27, beta and on by default in 1.33, and stable in 1.35.

Each container can say how it reacts to a resize with resizePolicy:

resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired # apply live (default)
- resourceName: memory
restartPolicy: RestartContainer # restart this container to apply

RestartContainer is useful for memory with runtimes that size themselves at startup. A .NET process reads its memory limit when it starts and sizes the GC heap from it. If you raise the memory limit live, the GC will not use the new headroom until the process restarts. For CPU, NotRequired is almost always right.

When the Resize Does Not Fit

A resize asks the node for more resources. The node may not have them:

SituationWhat you see
Request is larger than the node's total allocatableThe API server rejects the patch with Forbidden: node didn't have enough allocatable resources.
Request fits the node, but not alongside the Pods already thereThe patch is accepted, and the Pod gets condition PodResizePending with reason Deferred. The kubelet retries when room appears.
Accepted and appliedCondition PodResizeInProgress appears briefly, then events ResizeStarted and ResizeCompleted.

A resize never moves a Pod to another node. That is what the VPA's InPlaceOrRecreate fallback is for: if the Pod cannot grow where it is, the updater evicts it and the scheduler places the new Pod somewhere with room.

VPA and HPA Together

Do not let the VPA and the HPA act on the same resource. If the HPA scales on CPU utilization and the VPA raises the CPU request, utilization drops, so the HPA removes Pods. The remaining Pods get busier, so the VPA raises the request again. The two controllers keep reacting to each other.

Safe combinations:

  1. VPA in Off mode alongside any HPA. Use it as a sizing report.
  2. HPA on a custom or external metric (requests per second, queue length) while the VPA manages CPU and memory.
  3. VPA controlling memory only (controlledResources: ["memory"]) while the HPA scales on CPU.

If you would rather browse recommendations than run kubectl describe, Goldilocks creates a VPA in Off mode for every workload in a namespace and shows the results in a dashboard. It is covered later in this section.

Hands-On: Kubernetes Commands

List VPAs with their current recommendation

The CPU and MEM columns are the target values.

kubectl get vpa -n as-vpa

Read the full recommendation

Shows target, lower bound, upper bound and uncapped target per container.

kubectl describe vpa scaling-api -n as-vpa

Compare desired and actual resources of a Pod

spec is what you asked for. status is what the kubelet has applied.

kubectl get pod <pod> -n as-vpa -o jsonpath='{.spec.containers[0].resources}{"\n"}{.status.containerStatuses[0].resources}{"\n"}'

Resize a running Pod in place

Patching the resize subresource is the only way to change a running Pod's CPU and memory.

kubectl patch pod <pod> -n as-vpa --subresource resize \
--patch '{"spec":{"containers":[{"name":"scaling-api","resources":{"requests":{"cpu":"150m"},"limits":{"cpu":"300m"}}}]}}'

See resize progress and problems

Resize conditions appear only while something is pending or in progress.

kubectl get pod <pod> -n as-vpa -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason} {.message}{"\n"}{end}'
kubectl get events -n as-vpa --field-selector involvedObject.name=<pod>

See what the VPA components are doing

The updater logs every decision it makes.

kubectl -n kube-system logs deploy/vpa-updater --tail=20

Step-by-Step Example

The Scenario

You deploy scaling-api with requests you guessed: 50m CPU and 64Mi memory. A steady background load hits it. First you ask the VPA for an opinion without letting it change anything. Then you resize a Pod by hand to see in-place resize at work. Finally you let the VPA resize the Pods itself.

Step 1 — Install the VPA

The project installs with a script from its repository. Pin the release tag:

git clone --depth 1 --branch vertical-pod-autoscaler-1.8.0 https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-up.sh

The script installs the CRDs, RBAC and the three Deployments into kube-system, and generates a self-signed certificate for the admission webhook with openssl.

macOS note. The openssl that ships with macOS is LibreSSL. With it, the script produces a CA certificate without the CA:TRUE basic constraint. The API server then refuses to call the webhook. Because the webhook's failure policy is Ignore, nothing visibly breaks: new Pods are silently created without the recommended values, and the admission controller log fills with this:

http: TLS handshake error from 172.18.0.7:48087: remote error: tls: bad certificate

Run the script with OpenSSL 3 or later first on the PATH, for example the Homebrew build:

PATH="$(brew --prefix openssl)/bin:$PATH" ./hack/vpa-up.sh

If you already installed with LibreSSL, regenerate the certificate and restart the admission controller:

kubectl -n kube-system delete secret vpa-tls-certs
PATH="$(brew --prefix openssl)/bin:$PATH" ./pkg/admission-controller/gencerts.sh
kubectl -n kube-system rollout restart deploy/vpa-admission-controller

Check that all three components are running:

kubectl -n kube-system get pods | grep vpa
vpa-admission-controller-84b689964b-xtmtz 1/1 Running 0 59s
vpa-recommender-64649ddd65-ln5zv 1/1 Running 0 59s
vpa-updater-5649d9c85b-nt87s 1/1 Running 0 59s

Step 2 — Deploy the Application With Guessed Requests

Save as 2-scaling-api.yaml. The resizePolicy allows both CPU and memory to change without a restart, so you can watch the restart count stay at zero.

apiVersion: v1
kind: Namespace
metadata:
name: as-vpa
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: scaling-api
namespace: as-vpa
spec:
replicas: 2
selector:
matchLabels:
app: scaling-api
template:
metadata:
labels:
app: scaling-api
spec:
containers:
- name: scaling-api
image: scaling-api:1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired
- resourceName: memory
restartPolicy: NotRequired
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: scaling-api
namespace: as-vpa
spec:
selector:
app: scaling-api
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: load-generator
namespace: as-vpa
spec:
replicas: 2
selector:
matchLabels:
app: load-generator
template:
metadata:
labels:
app: load-generator
spec:
containers:
- name: load
image: busybox:1.37
command: ["/bin/sh", "-c"]
args:
- while true; do wget -q -O- "http://scaling-api/burn?ms=100" > /dev/null; done
resources:
requests:
cpu: 10m
memory: 16Mi
kubectl apply -f 2-scaling-api.yaml

Step 3 — Ask for a Recommendation With Mode Off

Save as 3-vpa-off.yaml. minAllowed and maxAllowed are guard rails: whatever the data says, the VPA stays inside them.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: scaling-api
namespace: as-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: scaling-api
updatePolicy:
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: scaling-api
controlledResources: ["cpu", "memory"]
controlledValues: RequestsAndLimits
minAllowed:
cpu: 25m
memory: 64Mi
maxAllowed:
cpu: "1"
memory: 512Mi
kubectl apply -f 3-vpa-off.yaml

Within a minute or two the recommender has enough samples for a first opinion:

kubectl get vpa -n as-vpa
NAME MODE CPU MEM PROVIDED AGE
scaling-api Off 109m 250Mi True 53s
kubectl describe vpa scaling-api -n as-vpa
Recommendation:
Container Recommendations:
Container Name: scaling-api
Lower Bound:
Cpu: 25m
Memory: 250Mi
Target:
Cpu: 109m
Memory: 250Mi
Uncapped Target:
Cpu: 109m
Memory: 250Mi
Upper Bound:
Cpu: 1
Memory: 512Mi

Three things stand out:

  1. The CPU target of 109m is more than double the 50m guess. Under this load, the guess was too low.
  2. Memory is 250Mi, which is the recommender's built-in floor, not real usage. kubectl top pods shows the containers using about 25Mi.
  3. The bounds are wide (25m to 1 CPU) because the recommender has only a minute of history. With mode Off, nothing changes, and the Pods still run with 50m.

Step 4 — Resize a Pod by Hand

Before handing control to the VPA, do what it will do, by hand. Pick one Pod:

POD=$(kubectl get pod -n as-vpa -l app=scaling-api -o jsonpath='{.items[0].metadata.name}')
kubectl get pod $POD -n as-vpa -o jsonpath='{.status.containerStatuses[0].resources}{"\n"}{.status.containerStatuses[0].restartCount}{"\n"}'
{"limits":{"cpu":"100m","memory":"256Mi"},"requests":{"cpu":"50m","memory":"64Mi"}}
0

Triple its CPU through the resize subresource:

kubectl patch pod $POD -n as-vpa --subresource resize \
--patch '{"spec":{"containers":[{"name":"scaling-api","resources":{"requests":{"cpu":"150m"},"limits":{"cpu":"300m"}}}]}}'

Read desired and actual resources and the restart count again:

kubectl get pod $POD -n as-vpa -o jsonpath='{.spec.containers[0].resources}{"\n"}{.status.containerStatuses[0].resources}{"\n"}{.status.containerStatuses[0].restartCount}{"\n"}'
{"limits":{"cpu":"300m","memory":"256Mi"},"requests":{"cpu":"150m","memory":"64Mi"}}
{"limits":{"cpu":"300m","memory":"256Mi"},"requests":{"cpu":"150m","memory":"64Mi"}}
0

Spec and status agree, and the restart count is still 0. The same process, with the same Pod name and IP, now has three times the CPU. The events record it:

Normal ResizeStarted pod/scaling-api-6b4c68c46b-lbnzp Pod resize started: {"containers":[{"name":"scaling-api","resources":{"limits":{"cpu":"300m","memory":"256Mi"},"requests":{"cpu":"150m","memory":"64Mi"}}}],"generation":2}
Normal ResizeCompleted pod/scaling-api-6b4c68c46b-lbnzp Pod resize completed: {"containers":[{"name":"scaling-api","resources":{"limits":{"cpu":"300m","memory":"256Mi"},"requests":{"cpu":"150m","memory":"64Mi"}}}],"generation":2}

Step 5 — Ask for Too Much

First ask for more CPU than the node has in total. Each worker node in this cluster has 8 CPUs:

kubectl patch pod $POD -n as-vpa --subresource resize \
--patch '{"spec":{"containers":[{"name":"scaling-api","resources":{"requests":{"cpu":"20"},"limits":{"cpu":"20"}}}]}}'
Error from server (Forbidden): pods "scaling-api-6b4c68c46b-lbnzp" is forbidden: node didn't have enough allocatable resources: cpu, requested: 20000, allocatable: 8000

The API server rejects it outright, so nothing changes. Now ask for 7.8 CPUs, which fits the node on paper but not next to the Pods already running there:

kubectl patch pod $POD -n as-vpa --subresource resize \
--patch '{"spec":{"containers":[{"name":"scaling-api","resources":{"requests":{"cpu":"7800m"},"limits":{"cpu":"7800m"}}}]}}'
kubectl get pod $POD -n as-vpa -o jsonpath='{range .status.conditions[?(@.type=="PodResizePending")]}{.type} {.status} {.reason}: {.message}{"\n"}{end}'
PodResizePending True Deferred: Node didn't have enough resource: cpu, requested: 7800, used: 970, capacity: 8000

The patch was accepted, but the kubelet parks it as Deferred and keeps running the Pod at its old size. It will apply the change later if other Pods leave the node. Undo it by patching back to the previous values. The PodResizePending condition disappears:

kubectl patch pod $POD -n as-vpa --subresource resize \
--patch '{"spec":{"containers":[{"name":"scaling-api","resources":{"requests":{"cpu":"150m"},"limits":{"cpu":"300m"}}}]}}'

Step 6 — Let the VPA Resize In Place

Switch the mode. Everything else stays the same:

sed 's/updateMode: "Off"/updateMode: "InPlaceOrRecreate"/' 3-vpa-off.yaml > 4-vpa-inplace.yaml
kubectl apply -f 4-vpa-inplace.yaml

The updater runs once a minute. Within two loops both Pods change:

kubectl get pods -n as-vpa -l app=scaling-api -o custom-columns='NAME:.metadata.name,CPU_REQ:.spec.containers[0].resources.requests.cpu,MEM_REQ:.spec.containers[0].resources.requests.memory,CPU_LIM:.spec.containers[0].resources.limits.cpu,MEM_LIM:.spec.containers[0].resources.limits.memory,RESTARTS:.status.containerStatuses[0].restartCount'
NAME CPU_REQ MEM_REQ CPU_LIM MEM_LIM RESTARTS
scaling-api-6b4c68c46b-lbnzp 511m 250Mi 1022m 1000Mi 0
scaling-api-6b4c68c46b-x54vk 247m 250Mi 494m 1000Mi 0

Same Pod names, zero restarts, new sizes. The limits kept their original ratios: CPU 1:2 (511m to 1022m) and memory 1:4 (250Mi to 1000Mi). The events say who did it:

Normal InPlaceResizedByVPA pod/scaling-api-6b4c68c46b-x54vk Pod was resized in place by VPA Updater.
Normal ResizeCompleted pod/scaling-api-6b4c68c46b-x54vk Pod resize completed: {"containers":[{"name":"scaling-api","resources":{"limits":{"cpu":"494m","memory":"1000Mi"},"requests":{"cpu":"247m","memory":"250Mi"}}}],"generation":2}

The updater log shows its reasoning, including a Pod it chose to leave alone:

"Pod accepted for update" pod="as-vpa/scaling-api-6b4c68c46b-lbnzp" updatePriority=5.31 processedRecommendations="scaling-api: target: 262144k 511m; uncappedTarget: 262144k 511m;"
"Not updating a short-lived pod, request within recommended range" pod="as-vpa/scaling-api-6b4c68c46b-x54vk"
"In-place update approved" pod="as-vpa/scaling-api-6b4c68c46b-lbnzp"

Notice that the CPU target kept rising, from 109m to 511m and later past 900m. The load generator is a closed loop: the faster the API answers, the sooner the next request arrives, so more CPU creates more demand. Real traffic rarely behaves like this, but the scenario shows why maxAllowed matters. Without the 1 CPU ceiling the VPA would keep chasing the load.

Step 7 — New Pods Start With the Recommendation

Scale the Deployment up:

kubectl scale deployment scaling-api -n as-vpa --replicas=3

The new Pod never runs with the 50m written in the Deployment. The admission controller rewrote it at creation and left an annotation explaining what it changed:

kubectl get pods -n as-vpa -l app=scaling-api -o jsonpath='{range .items[*]}{.metadata.name} {.metadata.annotations}{"\n"}{end}'
scaling-api-6b4c68c46b-jns5j {"vpaObservedContainers":"scaling-api","vpaUpdates":"Pod resources updated by scaling-api: container 0: memory request, cpu request, cpu limit, memory limit"}
scaling-api-6b4c68c46b-lbnzp {"vpaInPlaceUpdated":"true"}
scaling-api-6b4c68c46b-x54vk {"vpaInPlaceUpdated":"true"}

The Deployment's own template still says 50m. The VPA changes Pods, never the Deployment. If you remove the VPA, every new Pod goes back to your original numbers. Once the recommendation has stabilized over a few days, copy it into the manifest.

Step 8 — Clean Up

kubectl delete namespace as-vpa

To remove the VPA itself, run the matching script from the same directory:

./hack/vpa-down.sh

Summary

  1. The VPA measures real usage and recommends, or applies, requests and limits. It has three parts: the recommender, the updater and the admission controller.
  2. Start with updateMode: "Off". It is free and turns a guess into data. Initial, Recreate and InPlaceOrRecreate act progressively more. Auto is deprecated.
  3. Recommendations include a target and bounds. The updater acts only when a Pod is outside the bounds. The recommender has floors (25m CPU, 250Mi memory by default), and minAllowed and maxAllowed keep it within limits you trust.
  4. In-place Pod resize is stable since Kubernetes 1.35. Change a running Pod through the resize subresource. status.containerStatuses[*].resources shows what is applied, and resizePolicy decides whether a container restarts.
  5. A resize larger than the node is rejected. One that does not fit right now is Deferred. It never moves the Pod. InPlaceOrRecreate falls back to eviction for that case.
  6. Never let the VPA and the HPA scale on the same resource. Combine VPA Off with any HPA, or let the HPA use custom or external metrics.
  7. On macOS, install the VPA with OpenSSL 3+. LibreSSL produces a webhook CA that the API server rejects without any visible error.

CPU and memory are not the only reasons to scale. In the next article we scale a .NET worker on the length of a RabbitMQ queue, all the way down to zero, with KEDA.