The previous lesson ended on an uncomfortable note. The bookings-api HorizontalPodAutoscaler computes every one of its decisions as a percentage of requests.cpu, and that requests.cpu: 500m is a number someone picked by eye months ago. In 07-02 we recalibrated it by comparing against real consumption, but we did it by hand, one afternoon, staring at kubectl top and jotting figures into a spreadsheet. If requests is wrong, the HPA's percentage lies and all of its decisions rest on a false reference.
And the problem is wider than the HPA. requests and limits govern scheduling (where a pod fits), the QoS class (who dies first when memory runs short), OOMKilled, CPU throttling, the namespace's ResourceQuota consumption and, in the cloud, the bill. All of that rests on numbers that in most organisations were written once, copied from another service's manifest, and never looked at again.
The VerticalPodAutoscaler (VPA) exists for exactly that. It does not scale by load: it scales by knowledge. It observes what your containers actually consume over days, computes percentiles and tells you with data what they ought to be asking for. This lesson covers what it is, how it works inside, how to read its recommendations, why most serious teams use it as an adviser rather than an autopilot, and why it cannot coexist with the HPA over the same metric.
Contents
- The real problem: nobody knows what to ask for
- What the VPA is and what it is not
- It is not part of the core: installation
- The three components and how they collaborate
- The
VerticalPodAutoscalerobject field by field - The update modes:
Off,Initial,RecreateandAuto - In-place resizing and its current state
resourcePolicy: bounding, excluding and controlling- Reading a recommendation:
target,lowerBound,upperBound,uncappedTarget Offmode as an adviser: the real workflow- The classic incompatibility: VPA and HPA over the same metric
- Applying it to Rutas Norte, component by component
- Interaction with LimitRange and ResourceQuota
- The limits of the VPA
- Common Mistakes and Tips
- Exercises
- Conclusion
- The real problem: nobody knows what to ask for
Let's put numbers on the problem. These are the requests Rutas Norte has been dragging along since module 3, alongside the real consumption measured over two weeks with Prometheus:
| Container | requests.cpu |
Real CPU (p95) | requests.memory |
Real memory (p95) | Diagnosis |
|---|---|---|---|---|---|
bookings-api / api |
500m | 340m | 512Mi | 690Mi | CPU to spare, memory short |
bookings-api / exporter |
20m | 4m | 32Mi | 18Mi | Oversized x5 |
web-store / nginx |
200m | 55m | 128Mi | 64Mi | Oversized x3 |
notifications-worker |
300m | 90m | 256Mi | 410Mi | CPU to spare, memory short |
occupancy-reports |
500m | 1750m | 512Mi | 1.4Gi | Badly undersized |
bookings-postgres |
2 | 1.2 | 4Gi | 3.6Gi | Reasonable |
Each row tells a different story and each one costs money or availability:
bookings-apishort on memory. It asks for 512Mi and uses 690Mi at p95. It works because thelimitis 1Gi and there is free memory on the nodes... until there is not. The day a node fills up, the kubelet evicts pods, and aBurstablepod consuming above itsrequestsis one of the first candidates (module 3). It is a time bomb.web-storeoversized threefold. With 15 replicas at peak that is 3 reserved cores of which 0.8 are used. Those 2.2 cores are blocked for the scheduler: no other pod can use them even while they sit idle. It translates into extra nodes.occupancy-reportsbadly undersized. It asks for 500m and needs 1750m. Consequence: brutal throttling. A report that should take 8 minutes takes 40, and some nights it does not finish before the maintenance window.- The metrics exporter. 20m of
requestsfor a process that uses 4m. It looks like a trifle, but multiplied by every pod on the platform it is whole cores wasted.
Now the uncomfortable question: how do you fix this? The usual answer — "we look at Grafana and adjust" — has three problems. It requires somebody to remember. It requires that somebody to know which percentile to look at. And it has to be repeated every time the application changes, which in practice means never.
The VPA automates exactly that work.
- What the VPA is and what it is not
The VerticalPodAutoscaler is a component that:
- Observes the historical CPU and memory consumption of the containers in a set of pods.
- Computes a
requests(and optionallylimits) recommendation based on percentiles of that history, with exponential decay so that recent data weighs more. - Optionally applies that recommendation, recreating the pods with the new values.
And, very importantly, what it is not:
| The VPA... | ...but not |
|---|---|
| Adjusts the size of each pod | Changes the number of pods (that is the HPA) |
| Responds to what the application consumes | Responds to incoming load or latency |
| Corrects a wrong sizing | Absorbs a traffic spike |
| Works on a scale of hours and days | Reacts in seconds |
This distinction is the key to understanding the relationship with the HPA. The HPA is a load-response mechanism, with a time constant of seconds. The VPA is a sizing-correction mechanism, with a time constant of days. They do not compete on the same ground... except when both look at the same metric, which is the conflict of section 11.
A useful way to remember it: the HPA answers the question "how many?"; the VPA answers the question "how big?".
- It is not part of the core: installation
Unlike the HPA, which lives inside the kube-controller-manager and is available on any cluster, the VPA is an external component you have to install. It lives in the kubernetes/autoscaler repository, alongside the Cluster Autoscaler we will see in 09-03.
That has practical consequences worth knowing before you begin:
- You must check the version compatibility between the VPA and your Kubernetes. The compatibility table is in the repository; using a mismatched version produces obscure errors.
- It installs three Deployments and a handful of CRDs, RBAC and an admission webhook.
- On managed Kubernetes (10-06), many providers offer it as an addon you enable with a switch (GKE has had it built in for years). If you are in the cloud, look first at whether your provider gives it to you ready-made.
Installation with the official script
# 1. Clone the autoscaler repository on the relevant branch
git clone -b vpa-release-1.2 https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
# 2. Run the installer. It creates the CRDs, RBAC, the three deployments and the webhook.
./hack/vpa-up.shAn important prerequisite: the VPA needs metrics-server (the same one from 07-02) for the updater and the admission-controller, and its recommender can additionally read from Prometheus if you configure it. On minikube:
Verification
NAME READY STATUS RESTARTS AGE
vpa-admission-controller-6b8f9d7c4d-x2klm 1/1 Running 0 2m
vpa-recommender-7d9c5f8b6b-4mnpq 1/1 Running 0 2m
vpa-updater-5f7b8c9d6a-9wrtz 1/1 Running 0 2mAnd the registered CRDs:
verticalpodautoscalers.autoscaling.k8s.io 2026-04-12T09:14:22Z
verticalpodautoscalercheckpoints.autoscaling.k8s.io 2026-04-12T09:14:22ZThe second CRD, VerticalPodAutoscalerCheckpoint, deserves a note: that is where the recommender persists the consumption histogram every few minutes. Without it, a restart of the recommender would lose all the accumulated history and the observation period would have to start again. When somebody tells you "I restarted the VPA and now the recommendations are odd", the checkpoint is the first thing to look at.
To uninstall:
- The three components and how they collaborate
The VPA is not one process, it is three, with clearly separated responsibilities. Understanding that separation explains almost all of its behaviour.
flowchart TD
subgraph Sources["Data sources"]
MS[metrics-server<br/>metrics.k8s.io]
PR[Prometheus<br/>optional, long history]
end
subgraph VPA["VPA components"]
REC[Recommender<br/>vpa-recommender]
UPD[Updater<br/>vpa-updater]
ADM[Admission controller<br/>vpa-admission-controller]
end
OBJ[(VPA object<br/>status.recommendation)]
CKP[(Checkpoint<br/>persisted histogram)]
API[API server]
POD[bookings-api pods]
MS --> REC
PR -.-> REC
REC -->|writes recommendation| OBJ
REC <-->|saves and restores| CKP
OBJ -->|reads| UPD
OBJ -->|reads| ADM
UPD -->|eviction API<br/>only if updateMode allows it| API
API -->|recreates the pod| POD
POD -->|creation request| ADM
ADM -->|MUTATES the resources<br/>before persisting| API
style REC fill:#cde,stroke:#369
style UPD fill:#fda,stroke:#960
style ADM fill:#cfc,stroke:#393
The recommender (vpa-recommender)
It is the brain. Its job:
- Reads the CPU and memory consumption of every pod matching some VPA object's
targetRef. - Keeps, per container, a histogram with exponential decay: old samples weigh less than recent ones. The default half-life is 24 hours, which means a sample from a day ago is worth half of one from now.
- Computes percentiles over that histogram and writes the recommendation into the VPA object's
status.recommendation. - Persists the histogram in a
VerticalPodAutoscalerCheckpointso it survives restarts.
The percentiles it uses by default:
| Resource | Percentile for target |
Added margin |
|---|---|---|
| CPU | p90 of the histogram | +15% safety |
| Memory | Maximum of the 24 h windows over the last 8 days | +15% safety |
The asymmetry is deliberate and very sensible. Falling short on CPU produces slowness; falling short on memory produces OOMKilled. That is why memory is computed from the peak and CPU from a high but not extreme percentile. A container can survive perfectly well through CPU spikes above its requests (the node lends it more if there is any); a memory spike above its limit, no.
An additional detail: the recommender watches OOMKilled events and, when it detects one, raises the memory recommendation in one step (roughly 20%) without waiting for the histogram to catch up. It is direct feedback from the failure.
The recommender always runs, whatever the updateMode. Even in Off mode it is working and publishing recommendations. That is precisely what makes the adviser mode useful.
The updater (vpa-updater)
It is the enforcing arm. Every minute:
- Compares each pod's current resources against the current recommendation.
- If the difference exceeds a threshold (by default, if the current value is outside the
[lowerBound, upperBound]range), it decides the pod must be recreated. - Evicts the pod using the eviction API, the same one
kubectl drainuses. This is fundamental: the updater respects PodDisruptionBudgets (09-05). If your PDB does not allow the eviction, the VPA waits patiently. - It does not modify the pod: it kills it. The Deployment recreates it, and the new pod goes through the admission controller.
Two safeguards that prevent disasters:
- It does not evict a pod unless at least 12 hours have passed since its creation (avoiding recreation cycles).
- It never evicts all the pods of the same controller at once.
The updater only acts in Recreate and Auto modes. In Off and Initial it is idle.
The admission controller (vpa-admission-controller)
It is a MutatingAdmissionWebhook registered with the API. When a pod creation request arrives:
- It checks whether that pod matches an active VPA.
- If it does, it rewrites the pod's
requests(andlimits, keeping the ratio) before it is persisted in etcd. - The pod is born with the recommended values. The scheduler places it using those numbers.
This is the component that makes recreation work. Without it, the updater would kill a pod and the Deployment would recreate it with the manifest's original values: an infinite loop of pointless evictions.
An important and often surprising consequence: the VPA does not modify the Deployment. If you run kubectl get deployment bookings-api -o yaml you will still see requests.cpu: 500m. The real pods will have different values. The mutation happens at the pod level, in admission. To see the effective values:
kubectl get pod bookings-api-7c9d4f8b6d-2mk8p -n rutas-norte-pro \
-o jsonpath='{.spec.containers[*].resources}' | python3 -m json.toolA derived risk: if the admission webhook is down or misconfigured, depending on its failurePolicy it can either block the creation of every pod in the cluster or let them through unmutated. The official installer sets it to Ignore by default, which is the safe option.
- The
VerticalPodAutoscaler object field by field
VerticalPodAutoscaler object field by fieldapiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
# WHICH resource it applies to. Same format as the HPA's scaleTargetRef.
targetRef:
apiVersion: apps/v1
kind: Deployment
name: bookings-api
# WHAT it does with the recommendation.
updatePolicy:
updateMode: "Off" # Off | Initial | Recreate | Auto
minReplicas: 2 # Does not evict if fewer than N replicas would remain
# BOUNDS on the recommendation, per container.
resourcePolicy:
containerPolicies:
- containerName: api
minAllowed:
cpu: 200m
memory: 256Mi
maxAllowed:
cpu: "2"
memory: 2Gi
controlledResources: ["cpu", "memory"]
controlledValues: RequestsAndLimits
- containerName: metrics-exporter
mode: "Off" # This container is excluded| Field | Type | What it does |
|---|---|---|
targetRef |
object | Deployment, StatefulSet, DaemonSet, CronJob... any pod controller |
updatePolicy.updateMode |
string | What to do with the recommendation (section 6) |
updatePolicy.minReplicas |
integer | The updater does not evict if the controller has fewer replicas than this. Default 2 |
resourcePolicy.containerPolicies[].containerName |
string | Container name, or "*" for all of them |
.minAllowed / .maxAllowed |
resources | Floor and ceiling of the recommendation |
.mode |
string | Auto (default) or Off to exclude this container |
.controlledResources |
list | ["cpu"], ["memory"] or both |
.controlledValues |
string | RequestsAndLimits (default) or RequestsOnly |
A note on targetRef: unlike the HPA, it does not require the object to expose the /scale subresource. That is why the VPA can be applied to a DaemonSet, whereas the HPA cannot. This is genuinely useful: the Fluentd and Falco agents we deployed in modules 7 and 8 are DaemonSets, and their requests are just as arbitrary as everyone else's.
- The update modes:
Off, Initial, Recreate and Auto
Off, Initial, Recreate and AutoThe updateMode is the object's most important decision.
| Mode | The recommender | Mutates new pods | Evicts live pods | Risk | Typical use |
|---|---|---|---|---|---|
Off |
Yes | No | No | None | Advisory. The most used in production |
Initial |
Yes | Yes | No | Low | Applied on the next deployment, with no extra restarts |
Recreate |
Yes | Yes | Yes | Medium-high | Workloads that tolerate restarts |
Auto |
Yes | Yes | Yes (today = Recreate) |
Medium-high | The same as Recreate today |
Off — the adviser
The recommender observes and publishes to status.recommendation. Nothing more. No pod is touched, no manifest changes. It is pure information.
Operational cost: zero. Risk: zero. Value: high. That is why it is, by a wide margin, the most used mode in serious production.
Initial — apply only on creation
The admission controller mutates pods when they are born for some other reason: a new deployment, a restart after a node failure, an HPA scale-up. The updater never evicts anything.
It is an interesting middle ground: the values really are applied, but without causing a single extra restart. The trade-off is slowness: if bookings-api is deployed every two weeks, recommendations take two weeks to materialise. And it creates a disconcerting effect: two pods of the same Deployment, created at different times, can have different resources.
Recreate — apply by evicting
The updater actively evicts the pods whose resources have drifted. It is genuine vertical autoscaling, with the inevitable consequence: every adjustment is a pod restart.
For notifications-worker that is acceptable (it processes messages from a queue, a restart loses at most the in-flight message, which the queue redelivers). For bookings-postgres it is a different story, and we discuss it in section 12.
minReplicas: 2 is the essential protection: the updater will not evict if the controller has fewer replicas than that number. With a single replica, an eviction is a complete service outage.
Auto — today it is Recreate
Semantically it means "use the best mechanism available". Today, in practice, Auto behaves exactly like Recreate in most installations. The project's stated intention is that, once in-place resizing is stable, Auto will use it and stop recreating pods.
Operational advice: use Recreate explicitly if that is what you want. That way, when the meaning of Auto changes with a VPA upgrade, your behaviour will not change by surprise.
- In-place resizing and its current state
For years, the fundamental limitation of vertical scaling in Kubernetes was that a container's resources were immutable: changing them required recreating the pod.
That is changing. The feature is called In-Place Pod Vertical Scaling, and it allows a running container's requests and limits to be modified without recreating the pod, through the /resize subresource.
How it works
Each container can declare a restart policy per resource:
spec:
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:1.14.2
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired # CPU is adjusted in place
- resourceName: memory
restartPolicy: RestartContainer # Memory requires restarting the container
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 1GiAnd the change is applied with:
kubectl patch pod bookings-api-7c9d4f8b6d-2mk8p -n rutas-norte-pro \
--subresource resize \
--patch '{"spec":{"containers":[{"name":"api","resources":{"requests":{"cpu":"800m"}}}]}}'The state of the process is reflected in the pod:
status:
resize: InProgress
containerStatuses:
- name: api
allocatedResources:
cpu: 500m
resources:
requests:
cpu: 800mCurrent state and a warning
This is the part that matters for decision-making:
- The feature moved to beta in Kubernetes 1.33 and is still maturing. In 1.30-1.32 it is alpha and requires enabling the
InPlacePodVerticalScalingfeature gate on the API server and on the kubelets. - Shrinking memory in place is not generally solved: lowering the memory
limitbelow what the process has already mapped can trigger an immediateOOMKilled. That is why the usual policy for memory isRestartContainer. - The VPA's integration with this feature exists but is neither complete nor the default path.
- On managed Kubernetes (10-06), the feature gate may not be available: you do not control the control plane.
An explicit warning: do not build your production capacity strategy on in-place resizing yet. It is the right direction and in a couple of releases it will be the natural way to do things, but today:
- It may not be available on your cluster.
- Its behaviour with memory has rough edges.
- The VPA does not fully exploit it.
Get to know it, try it out in rutas-norte-dev, keep an eye on it. In rutas-norte-pro, use the workflow of section 10.
resourcePolicy: bounding, excluding and controlling
resourcePolicy: bounding, excluding and controllingLetting a recommender write numbers with no guard rails is a bad idea. resourcePolicy is where you put the human judgement.
Bounding minimums and maximums
resourcePolicy:
containerPolicies:
- containerName: api
minAllowed:
cpu: 200m # Never below: the Node.js start-up needs CPU
memory: 256Mi # Never below: the base heap already takes this
maxAllowed:
cpu: "2" # Never above: it would only fit on a few nodes
memory: 2Gi # Never above: I would suspect a memory leakWhy each guard rail:
minAllowed. The recommender relies on observed consumption. If a service spends the night without traffic, its CPU p90 is nearly zero, and without minAllowed the recommendation would drop to 15m. When the morning traffic arrives, the pod chokes while starting. minAllowed encodes "this is the minimum needed to start and respond decently, whatever the metrics say".
maxAllowed. Two reasons. First, scheduling: an 8-core pod only fits on large nodes, and if your nodes have 4 cores that pod stays Pending forever. Second, anomaly detection: if the recommendation reaches the ceiling, there is something to investigate (a memory leak, a pathological query). maxAllowed turns a silent problem into a signal.
A rule of thumb for maxAllowed: never above what fits on a real node, with room for the system. On nodes of 4 cores and 16 GiB, a reasonable maxAllowed sits around 3 cores and 12Gi.
Excluding a container
mode: "Off" at container level takes it out of the VPA entirely: it is neither recommended for nor mutated. When to do it:
- Sidecars with well-known, stable resources. A Prometheus exporter uses 5m and 20Mi today, tomorrow and always. It does not need a recommender.
- Service mesh sidecars (the Istio proxy, 08-04). They usually have their own resource management and the VPA treads on it.
- Containers with deliberately high
requestsfor reasons the metrics cannot see: reserving CPU for critical latency spikes, for example.
Watch out for one detail: if you exclude every container of the pod, the VPA will do nothing, which is correct, but the object still exists and confuses people. Better to delete it.
Wildcard for all containers
resourcePolicy:
containerPolicies:
- containerName: "*"
minAllowed:
cpu: 10m
memory: 32Mi
maxAllowed:
cpu: "1"
memory: 1Gi
- containerName: api # The specific rule WINS over the wildcard
maxAllowed:
cpu: "2"
memory: 2GicontrolledResources and controlledValues
- containerName: api
controlledResources: ["memory"] # Memory only; it leaves CPU alone
controlledValues: RequestsOnly # Requests only; it does not touch the limitscontrolledResources is the key piece for coexisting with the HPA: if the HPA scales on CPU, the VPA must confine itself to memory (section 11).
controlledValues decides whether the VPA touches the limits too:
| Value | Behaviour | Consequence for QoS |
|---|---|---|
RequestsAndLimits (default) |
Adjusts both, keeping the original ratio | Preserves the QoS: if it was Guaranteed, it stays so |
RequestsOnly |
Only adjusts requests, leaves limits as they are |
Can change the QoS from Guaranteed to Burstable |
The ratio detail deserves an example. If the manifest has requests.cpu: 500m and limits.cpu: 1000m (a 1:2 ratio) and the VPA recommends requests.cpu: 800m, with RequestsAndLimits it will set limits.cpu: 1600m to preserve the 1:2. It is sensible behaviour but a surprising one: the VPA can raise your limits higher than you expected, and that interacts with the namespace's ResourceQuota (section 13).
- Reading a recommendation:
target, lowerBound, upperBound, uncappedTarget
target, lowerBound, upperBound, uncappedTargetHere is the VPA's real value in Off mode. Let's look at a complete recommendation.
Name: bookings-api
Namespace: rutas-norte-pro
API Version: autoscaling.k8s.io/v1
Kind: VerticalPodAutoscaler
Spec:
Target Ref:
API Version: apps/v1
Kind: Deployment
Name: bookings-api
Update Policy:
Update Mode: Off
Status:
Conditions:
Type: RecommendationProvided
Status: True
Last Transition Time: 2026-05-11T08:22:14Z
Recommendation:
Container Recommendations:
Container Name: api
Lower Bound:
Cpu: 287m
Memory: 573Mi
Target:
Cpu: 412m
Memory: 792Mi
Uncapped Target:
Cpu: 412m
Memory: 792Mi
Upper Bound:
Cpu: 1140m
Memory: 1489Mi
Container Name: metrics-exporter
Lower Bound:
Cpu: 4m
Memory: 14Mi
Target:
Cpu: 6m
Memory: 21Mi
Uncapped Target:
Cpu: 6m
Memory: 21Mi
Upper Bound:
Cpu: 18m
Memory: 48MiWhat each field means
| Field | Meaning | What to do with it |
|---|---|---|
target |
The recommended value. The recommender's estimate of what the container needs | This is the one you take to the manifest |
lowerBound |
Below this, the container is clearly short of resources | If your current value is below it, act now |
upperBound |
Above this, you are clearly wasting | If your current value is above it, you are overpaying |
uncappedTarget |
The target before applying minAllowed/maxAllowed |
If it differs from target, your guard rails are clipping |
The lowerBound and upperBound limits are not consumption percentiles: they are the recommender's confidence interval. The less history it has, the wider they will be. A VPA with two hours of data will give an enormous upperBound; with two weeks, a narrow interval. The width of the interval is a direct indicator of how much you can trust the recommendation.
They are also the updater's action criterion: in Recreate/Auto modes, a pod is evicted when its current value leaves the [lowerBound, upperBound] range. Inside the range, it is left alone. This band is the vertical equivalent of the HPA's tolerance band.
Interpretation for bookings-api
Let's contrast it against what we have in the manifest:
| Resource | Current | lowerBound |
target |
upperBound |
Verdict |
|---|---|---|---|---|---|
| CPU (api) | 500m | 287m | 412m | 1140m | Inside the range, but 21% too much. Optional adjustment |
| Memory (api) | 512Mi | 573Mi | 792Mi | 1489Mi | Below the lowerBound. Fix now |
| CPU (exporter) | 20m | 4m | 6m | 18m | Above the upperBound. Oversized |
| Memory (exporter) | 32Mi | 14Mi | 21Mi | 48Mi | Inside the range, minor adjustment |
The critical row is api's memory: 512Mi is below the lowerBound of 573Mi. The VPA is saying, with two weeks of data, that this container has less memory reserved than it needs to run safely. It is exactly the risk we sensed in section 1, now quantified.
And the exporter confirms the other intuition: 20m reserved for something that needs 6m. With 30 bookings-api replicas at peak, that is 420m of CPU reserved and never used: nearly half a core blocked for the scheduler.
uncappedTarget: the signal that your guard rails are pinching
Suppose we set maxAllowed.memory: 512Mi in the resourcePolicy. The recommendation would come out as:
Target:
Memory: 512Mi <- Clipped by maxAllowed
Uncapped Target:
Memory: 792Mi <- What the recommender really wantedWhen target and uncappedTarget differ, your maxAllowed (or minAllowed) is biting. And here you have to think twice, because there are two opposite interpretations and both are plausible:
- The guard rail is set wrong. It was a conservative number from months ago and the application has legitimately grown. Raise it.
- The guard rail is doing its job. There is a memory leak and consumption grows without pause.
maxAllowedis the only thing stopping that pod from eating a whole node.
Telling them apart requires looking at the trend in Grafana (07-04): if memory grows monotonically and never comes down, it is a leak; if it settles on a higher plateau, it is legitimate growth. An uncappedTarget that drifts away from target month after month is a memory leak until proven otherwise.
Extracting the recommendation from the command line
To automate the workflow:
kubectl get vpa bookings-api -n rutas-norte-pro \
-o jsonpath='{range .status.recommendation.containerRecommendations[*]}{.containerName}{"\t"}{.target.cpu}{"\t"}{.target.memory}{"\n"}{end}'Clean output, ready to drop into a weekly report or into an automatic comment on a pull request.
Off mode as an adviser: the real workflow
Off mode as an adviser: the real workflowHere is the practice serious teams use, and it deserves a proper explanation because it runs against the grain of what the name "autoscaler" suggests.
Most organisations with Kubernetes in production use the VPA exclusively in Off mode. Not as an autopilot, but as an adviser whose recommendation a person reviews before it reaches production.
Why
| Reason | Explanation |
|---|---|
| The manifest is still the truth | With Recreate, the YAML says 500m and the pod has 412m. Nobody can tell what resources the system has by looking at the repository |
| Every adjustment is a restart | In active mode, the VPA restarts pods on its own initiative, at any moment. An unplanned restart during the May bank holiday is intolerable |
| GitOps compatibility | With Argo CD or Flux (10-05), an active VPA creates a permanent divergence between Git and the cluster. The same problem as replicas in 09-01 |
| Human review | A resource change deserves to go through code review. "The memory of bookings-api goes from 512Mi to 800Mi" is a decision with cost and capacity consequences |
| Auditing | The record of resource changes lives in the Git history, not in a controller's logs |
The workflow, step by step
flowchart TD
A[1. Create the VPA in Off mode] --> B[2. Wait 2 weeks<br/>covering a full business cycle]
B --> C[3. Extract the recommendation<br/>kubectl get vpa]
C --> D{4. Is target inside<br/>the guard rails?}
D -- No --> E[Investigate: leak or<br/>legitimate growth?]
E --> C
D -- Yes --> F[5. Compare against the current manifest]
F --> G{6. Difference > 20%?}
G -- No --> H[Do nothing. Come back in a month]
G -- Yes --> I[7. Pull request with the new values<br/>and the recommendation in the description]
I --> J[8. Team review]
J --> K[9. Merge, deploy to pre, validate]
K --> L[10. Deploy to pro]
L --> M[11. Check in Grafana:<br/>less throttling? fewer OOMs?]
M --> B
Step 2, two weeks and why. The recommender's histogram has a 24-hour half-life, so two or three days already give you signal. But two weeks cover two full weekly cycles, weekends included. At Rutas Norte this matters a lot: Saturday's traffic looks nothing like Wednesday's. And if there is a monthly close or a periodic event, that has to be covered too.
Step 6, the 20% threshold. Changing a Deployment's resources means deploying it. It is not worth doing for 5%. The 20% threshold is a reasonable convention: below it, noise outweighs signal.
Step 7, the pull request. Here is the heart of the method. The change is documented:
Title: Adjust bookings-api resources per VPA recommendation
VPA recommendation (14 days of observation, 2026-04-27 to 2026-05-11):
Container Current Recommended Change
api / cpu 500m 412m -17.6%
api / memory 512Mi 792Mi +54.7%
exporter / cpu 20m 6m -70.0%
exporter / memory 32Mi 21Mi -34.4%
Rationale:
- The memory of "api" was BELOW the lowerBound (573Mi). Real risk of
eviction under node memory pressure. This is the important fix.
- The CPU of "api" drops by 17.6%. Careful: this affects the HPA, which
computes against requests. See the note below.
- The exporter was oversized x3. With 30 replicas at peak that is
420m of CPU freed up for the scheduler.
Note on the HPA: lowering requests.cpu from 500m to 412m moves the HPA's
absolute target from 325m to 268m per pod. The HPA will scale EARLIER than it
does now under the same load. That is the desired effect (the pods were
oversized), but we must watch the replica count at peak during the first week.
Validated on rutas-norte-pre with the k6 load test (09-06): no
throttling, no OOM, p95 latency the same or better.That note about the HPA is exactly the kind of reasoning that gets lost when you let a controller change the numbers on its own.
The VPA in Off mode for the whole platform
A very profitable practice: a VPA in Off mode over every Deployment, StatefulSet and DaemonSet on the platform, even if you never apply them automatically. The cost is one object per controller and a little memory in the recommender. The benefit is having, at any moment, the answer to "is this properly sized?".
# Generate a VPA in Off mode for every Deployment in the namespace
for d in $(kubectl get deploy -n rutas-norte-pro -o name | cut -d/ -f2); do
cat <<EOF | kubectl apply -f -
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: $d
namespace: rutas-norte-pro
labels:
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: $d
updatePolicy:
updateMode: "Off"
EOF
doneAnd a weekly report comparing recommendation against manifest. That report, reviewed every Monday in fifteen minutes, is probably the highest-return-per-minute activity in operating a cluster.
- The classic incompatibility: VPA and HPA over the same metric
This is the trap you absolutely must know about.
The conflict
The VPA and the HPA cannot govern the same resource (CPU or memory) on the same workload. If you try, they feed back on each other destructively.
Let's see why, with bookings-api and concrete numbers. Suppose an HPA on CPU with averageUtilization: 65 and a VPA in Recreate mode controlling CPU.
Initial state: 4 replicas, requests.cpu 500m, HPA target 325m/pod.
Total load: 1600m.
t=0 Consumption: 400m/pod (1600 / 4). Ratio 400/325 = 1.23.
HPA -> ceil(4 x 1.23) = 5 replicas.
t=1min 5 replicas, consumption 320m/pod. HPA satisfied (ratio 0.98).
But the VPA observes 320m of consumption and recommends requests.cpu = 368m
(320m x 1.15 margin).
t=15m The VPA applies 368m. NOW the HPA target is 65% of 368m = 239m.
Real consumption is still 320m. Ratio = 320/239 = 1.34.
HPA -> ceil(5 x 1.34) = 7 replicas.
t=16m 7 replicas, consumption 229m/pod. HPA satisfied.
The VPA observes 229m and recommends requests.cpu = 263m.
t=30m The VPA applies 263m. HPA target = 171m. Consumption 229m.
Ratio 1.34. HPA -> 10 replicas.
t=31m 10 replicas, consumption 160m/pod. The VPA recommends 184m...The loop is obvious: the HPA lowers per-pod consumption by adding replicas; the VPA reads that lower consumption as the pods being too big and lowers requests; lowering requests lowers the HPA's absolute target, and the HPA scales again. The total load has not changed by a single watt, but we have gone from 4 replicas of 500m to 10 replicas of 263m, and the process continues.
Each iteration means recreating pods (VPA) and creating/destroying pods (HPA). It is a spiral of instability that is also very hard to diagnose, because each component in isolation appears to be behaving correctly.
flowchart LR
A[Constant load] --> B[HPA adds replicas]
B --> C[PER-POD consumption drops]
C --> D[VPA lowers requests]
D --> E[HPA absolute target drops]
E --> B
style B fill:#fdd,stroke:#c00
style D fill:#fdd,stroke:#c00
The valid combinations
| HPA scales on | VPA controls | Valid? | Comment |
|---|---|---|---|
| CPU | CPU | NO | The loop described above |
| Memory | Memory | NO | The same loop |
| CPU | Memory only | YES | The recommended combination. Independent axes |
| Memory | CPU only | Yes, technically | But scaling on memory is already a bad idea (09-01) |
| Custom / external metric | CPU and memory | YES | The best combination of all |
| — (no HPA) | CPU and memory | YES | The simple case |
The recommended configuration for bookings-api, if you did want an active VPA:
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: bookings-api-memory
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: bookings-api
updatePolicy:
updateMode: "Recreate"
minReplicas: 2
resourcePolicy:
containerPolicies:
- containerName: api
# KEY: memory only. CPU is governed by the HPA of 09-01.
controlledResources: ["memory"]
minAllowed:
memory: 512Mi
maxAllowed:
memory: 2Gi
- containerName: metrics-exporter
controlledResources: ["memory"]
minAllowed:
memory: 16Mi
maxAllowed:
memory: 128MicontrolledResources: ["memory"] is the line that makes coexistence possible. Without it, you get the loop.
The optimal combination: HPA on a business metric + full VPA
The most elegant configuration appears when the HPA scales on a metric that is neither CPU nor memory: requests per second, queue length. Then:
- The HPA decides how many pods, according to business demand.
- The VPA decides how big, according to observed consumption.
- There is no feedback: the business metric does not depend on
requests.
That is exactly what KEDA enables in 09-04. When notifications-worker scales on queue length, a full VPA over it will be perfectly safe and very useful.
The VPA in Off mode is always safe
And this is worth underlining: the conflict only exists when the VPA acts. In Off mode the VPA changes nothing, so it can coexist with any HPA on any metric with no risk whatsoever. Its CPU recommendation will be somewhat pessimistic (it measures pods whose load the HPA has already relieved), but it will still be valid information.
This is another argument in favour of Off mode: it wipes out a whole class of problems in one stroke.
- Applying it to Rutas Norte, component by component
Concrete, justified decisions for each piece of the platform.
bookings-api and web-store: VPA in Off mode
Both have a CPU-driven HPA from 09-01. We put a VPA in Off mode, as advisers.
# k8s/environments/pro/vpa-bookings-api.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: bookings-api
updatePolicy:
# OFF MODE. bookings-api has a CPU-driven HPA (09-01). An active VPA on
# CPU would trigger the feedback loop. And even if we confined the VPA to
# memory, we do not want unplanned restarts on the component that holds
# up the sale. Here the VPA is an ADVISER: we review its recommendation
# every Monday and take it to the manifest in a pull request.
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: api
minAllowed:
cpu: 200m
memory: 256Mi
maxAllowed:
cpu: "2"
memory: 2Gi
- containerName: metrics-exporter
minAllowed:
cpu: 5m
memory: 16Mi
maxAllowed:
cpu: 100m
memory: 128Mi# k8s/environments/pro/vpa-web-store.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: web-store
namespace: rutas-norte-pro
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: web-store
updatePolicy:
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: nginx
minAllowed:
cpu: 50m
memory: 64Mi
maxAllowed:
cpu: 500m
memory: 512MiWith the measurements from section 1, web-store is a clear candidate to come down from 200m to around 70m of CPU. With 15 replicas at peak, that frees almost two cores: fewer nodes to start (09-03) and a smaller bill.
notifications-worker: VPA in Recreate mode
Here we do switch it on, and it is the best candidate on the platform.
# k8s/environments/pro/vpa-notifications-worker.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: notifications-worker
namespace: rutas-norte-pro
labels:
app: notifications-worker
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: notifications-worker
updatePolicy:
# RECREATE MODE. This component consumes messages from a queue: a restart
# loses no work (the queue redelivers the in-flight message) and there is no
# user waiting for a response. It is the workload that BEST tolerates an active VPA.
updateMode: "Recreate"
# Never evict if fewer than 2 replicas would remain: the queue cannot be left
# without consumers for even a minute during the May bank holiday.
minReplicas: 2
resourcePolicy:
containerPolicies:
- containerName: worker
minAllowed:
cpu: 50m
memory: 128Mi
maxAllowed:
cpu: "1"
memory: 1Gi
# CPU and memory, both: in 09-04 this component will move to scaling on
# QUEUE LENGTH with KEDA, which is an external metric. There is no conflict
# with CPU, so the VPA can govern it without trouble.
controlledResources: ["cpu", "memory"]
- containerName: metrics-exporter
mode: "Off"occupancy-reports: VPA over the CronJob
The most profitable case of all. Recall: it asks for 500m and needs 1750m. It gets throttled, takes five times longer than it should and some nights does not finish.
# k8s/environments/pro/vpa-occupancy-reports.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: occupancy-reports
namespace: rutas-norte-pro
labels:
app: occupancy-reports
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: batch/v1
kind: CronJob # The VPA supports CronJob directly
name: occupancy-reports
updatePolicy:
# INITIAL MODE, not Recreate. A Job is ephemeral: there is no sense in evicting
# it halfway through a run (we would lose the work done and it would start over).
# With Initial, every NEW run is born with the right resources.
updateMode: "Initial"
resourcePolicy:
containerPolicies:
- containerName: generator
minAllowed:
cpu: 200m
memory: 256Mi
maxAllowed:
cpu: "4"
memory: 4Gi # Generous ceiling: it is a batch process that
# benefits from everything you give it, and it runs
# in the small hours when the cluster is empty
controlledResources: ["cpu", "memory"]Initial is the right choice for batch jobs, and it is worth understanding why: a Job evicted ten minutes into a thirty-minute run loses all the work and starts again. With Initial, each overnight run is born with the resources the recommender has learnt from previous runs. The VPA learns from Monday night for Tuesday night.
Expected effect: from 40 minutes down to about 9. And since it runs at 3 in the morning, with the cluster empty, those extra resources take nothing from anybody.
bookings-postgres: the difficult decision
Here we have to think. The facts:
- It is a StatefulSet with a single primary replica.
- It has
GuaranteedQoS (requests=limits), a deliberate decision from module 3 so that it is the last eviction candidate. - A restart means cutting every connection, losing PostgreSQL's page cache and a few seconds of total platform unavailability.
- It holds customers' personal data: any risk of corruption is unacceptable.
Decision: VPA in Off mode, with controlledValues: RequestsAndLimits.
# k8s/environments/pro/vpa-bookings-postgres.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: bookings-postgres
namespace: rutas-norte-pro
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: apps/v1
kind: StatefulSet
name: bookings-postgres
updatePolicy:
# OFF MODE, no argument. Reasons:
#
# 1. A SINGLE PRIMARY REPLICA. An eviction is a total platform outage.
# Not even minReplicas protects us: with 1 replica the updater would
# not act, but we do not want to depend on that.
#
# 2. GUARANTEED QoS. With controlledValues: RequestsAndLimits the VPA
# preserves the requests:limits ratio, which here is 1:1, so the QoS
# would be preserved. But it is too important a property to leave in
# the hands of a controller.
#
# 3. THE RESTART IS NOT FREE. PostgreSQL loses its page cache and the
# first queries after start-up go to disk. Latency gets worse for
# minutes.
#
# 4. THE SIZING OF A DATABASE CANNOT BE DEDUCED FROM CONSUMPTION. The
# shared_buffers, work_mem and effective_cache_size are configured
# in harmony with the container's memory. Changing the memory without
# touching postgresql.conf improves nothing, or makes it worse.
#
# Even so the VPA is VERY useful here: it will warn us if we are running
# short before it shows up as an incident.
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: postgres
minAllowed:
cpu: "1"
memory: 2Gi
maxAllowed:
cpu: "4"
memory: 8Gi
# RequestsAndLimits preserves the 1:1 ratio and therefore the Guaranteed
# QoS, for the day we decide to apply the recommendation by hand.
controlledValues: RequestsAndLimits
- containerName: postgres-exporter
minAllowed:
cpu: 10m
memory: 32Mi
maxAllowed:
cpu: 100m
memory: 128MiPoint 4 of the comment deserves emphasis, because it is the underlying argument: sizing a database is a configuration decision, not an observational one. Raising the container's memory without adjusting shared_buffers in harmony with it does not improve performance: it only wastes memory. The VPA knows nothing about postgresql.conf. A PostgreSQL operator (06-07) does, and that is the right path for scaling a database.
Summary table of the decisions
| Component | Mode | controlledResources |
Rationale |
|---|---|---|---|
bookings-api |
Off |
both (adviser) | CPU-driven HPA; no unplanned restarts on the critical component |
web-store |
Off |
both (adviser) | CPU-driven HPA; clear saving pending a manual application |
notifications-worker |
Recreate |
cpu, memory | Tolerates restarts; will scale on the queue (09-04), no conflict |
occupancy-reports |
Initial |
cpu, memory | Ephemeral Job; do not evict halfway; the time saving is enormous |
bookings-postgres |
Off |
both (adviser) | One replica, Guaranteed QoS, expensive restart, sizing tied to configuration |
redis-cache |
Off |
both (adviser) | Stateful cache; a restart empties it and dumps all the load on PostgreSQL |
- Interaction with LimitRange and ResourceQuota
The VPA does not live alone. In module 3 we put namespace guard rails in place that interact with it in ways worth anticipating.
LimitRange
Let's recall the rutas-norte-pro LimitRange:
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: rutas-norte-pro
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
min:
cpu: 50m
memory: 64Mi
max:
cpu: "4"
memory: 8GiInteractions:
- The LimitRange's
maxwins over the VPA'smaxAllowed. If the VPA recommends 6 cores and the LimitRange caps at 4, the mutated pod will be rejected in admission. The VPA is aware of LimitRanges and adjusts its recommendation, but if you change them after creating the VPA, there can be a lag. - The
mincan raise a low recommendation. If the VPA recommends 20m and the LimitRange demands a minimum of 50m, the pod ends up with 50m. - The
defaultvalues drop out of the equation when the VPA mutates the pod, because the pod already arrives with explicit values.
A rule of thumb: keep the VPA's maxAllowed below the LimitRange's max. That way the clipping shows up in uncappedTarget (useful information) instead of producing an admission rejection (a confusing failure).
ResourceQuota
The ResourceQuota is more treacherous, because the failure appears late and somewhere else.
apiVersion: v1
kind: ResourceQuota
metadata:
name: quota-pro
namespace: rutas-norte-pro
spec:
hard:
requests.cpu: "40"
requests.memory: 80Gi
limits.cpu: "80"
limits.memory: 160GiThe problem arises from the interaction between HPA and VPA over the quota:
Situation: bookings-api, requests.cpu 500m, HPA with maxReplicas 30.
Maximum quota consumption: 30 x 500m = 15 cores. Fits in the quota's 40.
The VPA recommends raising it to 800m (the application has grown).
It is applied.
Maximum consumption NOW: 30 x 800m = 24 cores.
Adding web-store, notifications-worker, postgres and redis: 43 cores.
THE QUOTA IS 40.
Result: during the May bank holiday, the HPA tries to create replica number 26
and the ReplicaSet receives:
Error creating: pods "bookings-api-..." is forbidden:
exceeded quota: quota-pro, requested: requests.cpu=800m,
used: requests.cpu=39.4, limited: requests.cpu=40
The HPA reports ScalingLimited. The platform runs short AT THE PEAK.The failure does not appear when the VPA is applied: it appears weeks later, at the worst possible moment, and the error message is in the ReplicaSet's events, not in the HPA nor in the VPA. It is a deferred, badly signposted failure.
A mandatory check when applying any VPA recommendation:
The 0.8 factor leaves room for ephemeral pods, jobs and deployments (which during a rolling update consume quota for both the old and the new version at once).
The calculation for Rutas Norte with the recommendation:
| Component | rec. requests.cpu |
maxReplicas |
Maximum CPU |
|---|---|---|---|
bookings-api (api) |
412m | 30 | 12.36 |
bookings-api (exporter) |
6m | 30 | 0.18 |
web-store |
70m | 15 | 1.05 |
notifications-worker |
120m | 20 | 2.40 |
bookings-postgres |
2000m | 1 | 2.00 |
redis-cache |
300m | 1 | 0.30 |
| Total | 18.29 |
18.29 against a quota of 40, with an 80% margin (32). It fits comfortably. And notice that the VPA's recommendation reduces the maximum consumption compared with the current scenario (which would be 15 + 3 + ... ≈ 21), because web-store and the exporter were oversized. Getting requests right does not only save money: it gives you room to scale further.
A warning about controlledValues: RequestsAndLimits
As we saw, the VPA preserves the requests:limits ratio. If your ratio is 1:2 and the VPA raises requests by 50%, the limit goes up by 50% too. And the ResourceQuota has a separate limits.cpu line that can also run out. Watch both.
- The limits of the VPA
We finish with what the VPA cannot do. Knowing the limitations stops you expecting from it what it does not give.
It needs history
A freshly created VPA knows nothing. Its first recommendations, in the first hours, are unreliable: the upperBound will be enormous and the target will reflect only what it has seen. Rule: do not take decisions with less than a week of data, and prefer two.
An uncomfortable corollary: for a new service, the VPA does not help. You have to estimate by hand, deploy, and let the VPA correct you afterwards.
It reacts slowly
With a 24-hour half-life, a real profile change takes days to be fully reflected. This is deliberate and correct — we do not want a five-minute spike to change the resources of the whole platform — but it means the VPA is no use for responding to events:
- It is no use for the May bank holiday. By the time the VPA notices that more CPU is needed, the bank holiday will be over.
- It is no use for a deployment that radically changes the consumption profile. There you have to estimate by hand and let the VPA validate afterwards.
The VPA is for the steady state, not for the transient. The transients are covered by the HPA (09-01) and KEDA (09-04).
Recreating a stateful pod is not free
In Recreate and Auto modes, every adjustment is a restart. The cost depends on the component:
| Component | Cost of the restart |
|---|---|
web-store (nginx) |
Almost nil: one second, stateless |
bookings-api |
Moderate: 25 s of start-up, a new connection pool, a cold cache |
notifications-worker |
Low: the queue redelivers the in-flight message |
redis-cache |
High: the whole cache is lost, and all that load falls on PostgreSQL |
bookings-postgres |
Very high: every connection cut, page cache lost |
The VPA respects PDBs (09-05), which mitigates the risk, but does not eliminate it: a PDB guarantees that everything does not go down at once, not that the restart is painless.
It does not know the business context
The VPA sees CPU and memory consumption. It does not see:
- That the May bank holiday starts on Friday.
- That this pod serves the payment process and cannot restart mid-transaction.
- That last night's high memory was a maintenance run and not the normal regime.
- That PostgreSQL's
shared_buffersmust change in harmony with the container's memory.
All of that a person supplies. And that is, summed up in one sentence, the justification for Off mode.
Other limitations to keep in mind
- It does not manage resources other than CPU and memory. Ephemeral storage, GPUs and extended resources are out of scope.
- With an active
updateMode, it should not be used alongside aggressive continuous deployment: the VPA's evictions and rolling updates overlap unpredictably. - One VPA per workload. Two VPA objects with a
targetRefto the same Deployment produce undefined behaviour. Kubernetes does not prevent it; you must.
Common Mistakes and Tips
Mistake 1: putting the VPA in Auto mode over CPU when there is already a CPU-driven HPA. The catastrophic mistake of this lesson. The feedback loop of section 11 takes hours to manifest and is hard to diagnose because each component looks fine in isolation. If you have a CPU-driven HPA: controlledResources: ["memory"] or Off mode. No exceptions.
Mistake 2: applying the recommendation of a two-day-old VPA. The outsized upperBound gives away the lack of history. Wait two weeks. If you are in a hurry, look at the lowerBound: if your current value is below it, that is a reliable signal even with little data, because it means the container has already run short in real observations.
Mistake 3: not setting maxAllowed. With no ceiling, a memory leak makes the recommendation grow without end. In active mode, you will end up with a pod asking for 30Gi that fits on no node and stays Pending forever. maxAllowed turns that leak into a visible signal (uncappedTarget drifting away from target) instead of into an outage.
Mistake 4: believing that the VPA modifies the Deployment. It does not. kubectl get deployment will keep showing the manifest's values. The mutation happens on the pod, in admission. To see reality you have to look at the pods, not the controller. This confusion generates a huge number of "the VPA does not work" tickets.
Mistake 5: an active VPA over a single-replica workload. An eviction is a total outage. The default minReplicas: 2 protects you, but if you lower it to 1 "so it works", you are asking for service interruptions. If you have a single replica, either make it two or use Off mode.
Mistake 6: forgetting the ResourceQuota. Section 13. Raising requests without recomputing requests × maxReplicas against the quota produces a deferred failure that shows up at the traffic peak, weeks later, with an error message in an unexpected place.
Tip 1: start with the CronJobs. They are the most profitable case and the lowest risk. Nobody is waiting for a response, Initial mode is safe, and the runtime saving is usually spectacular. occupancy-reports going from 40 to 9 minutes is a visible win that buys credibility for the rest.
Tip 2: export the recommendations to Prometheus. The VPA exposes metrics, and kube-state-metrics publishes kube_verticalpodautoscaler_status_recommendation_containerrecommendations_target. With that you can build a "recommended versus configured" dashboard in Grafana (07-04) for the whole platform, and an alert when the deviation exceeds 30%:
abs(
kube_verticalpodautoscaler_status_recommendation_containerrecommendations_target{resource="memory"}
-
kube_pod_container_resource_requests{resource="memory"}
)
/ kube_pod_container_resource_requests{resource="memory"} > 0.3Tip 3: a VPA in Off mode over absolutely everything. Almost no cost, permanent information. Include it in the base template of every new component.
Tip 4: use lowerBound as a risk alert. A container whose requests are below the lowerBound is in real danger of eviction or OOMKilled. It is the most actionable signal the VPA gives, and it deserves an alert.
Tip 5: review uncappedTarget monthly. If it grows month after month while target stays pinned to maxAllowed, you have a memory leak. The VPA detects it before it becomes an incident.
Tip 6: document in the manifest where the numbers come from. A comment like # requests.memory: 800Mi -- VPA recommendation 2026-05-11, real p95 690Mi turns a magic number into a traceable decision. Whoever reads it next will know whether they can touch it.
Exercises
Exercise 1: interpreting a recommendation
This is the describe of the redis-cache VPA after three weeks in Off mode:
Status:
Recommendation:
Container Recommendations:
Container Name: redis
Lower Bound:
Cpu: 180m
Memory: 1420Mi
Target:
Cpu: 245m
Memory: 1980Mi
Uncapped Target:
Cpu: 245m
Memory: 3410Mi
Upper Bound:
Cpu: 390m
Memory: 2048MiThe current manifest says:
And the VPA's resourcePolicy:
resourcePolicy:
containerPolicies:
- containerName: redis
minAllowed:
cpu: 100m
memory: 512Mi
maxAllowed:
cpu: "1"
memory: 2GiAnswer: (a) what is going on with the CPU; (b) what is going on with the memory and why target and uncappedTarget differ; (c) what it means that upperBound is exactly 2048Mi; (d) what QoS the pod has now and what that implies; (e) what you would do, in what order of priority, and what you would check first.
Exercise 2: diagnosing a conflict
web-store has been behaving strangely for three days. The team reports:
- The replica count oscillates between 3 and 12 several times a day with no traffic to justify it.
- The pods restart frequently, with reason
Evicted. - The pods'
requests.cpudo not match the manifest and change on their own.
The configuration is:
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web-store
minReplicas: 3
maxReplicas: 15
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
---
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: web-store
updatePolicy:
updateMode: "Auto"Explain exactly what is happening, with the timeline, and give two alternative solutions with their manifests and their trade-offs.
Exercise 3: designing the strategy for a new component
Rutas Norte is adding accounting-exporter, a component that generates the invoicing files for the accountancy firm. Profile:
- It is a CronJob that runs on the 1st of each month at 2:00.
- Each run takes between 20 and 90 minutes, depending on the month's booking volume.
- It uses a lot of memory: it loads all of the month's bookings into memory to aggregate them.
- If it fails, it has to be relaunched by hand and the administration team goes without the files that day.
- It has been
OOMKilledin two of the last four months. - Currently:
requests.memory: 1Gi,limits.memory: 2Gi,requests.cpu: 500m,limits.cpu: "1". - The namespace has a ResourceQuota of 40 cores and 80Gi.
Design the complete strategy: VPA yes or no? Which mode? What resourcePolicy? How much observation time do you need and why is this a special case? What would you do in addition to the VPA? Write the manifest and justify every decision.
Solutions
Solution 1
(a) The CPU is oversized.
The manifest asks for 500m; the upperBound is 390m. The current value is above the upperBound, which means the recommender is confident there is CPU to spare. The target is 245m: less than half of what is configured. Since redis-cache is a single replica, the absolute saving is modest (255m), but it frees scheduling capacity on a node.
(b) The memory: target 1980Mi, uncappedTarget 3410Mi.
This is the critical observation of the exercise. maxAllowed.memory is 2Gi = 2048Mi, and the uncappedTarget of 3410Mi is far above it. The recommender wants 3410Mi and the guard rails are clipping it to 1980Mi.
On top of that, the current requests (1Gi = 1024Mi) is below the lowerBound (1420Mi). A double alarm: reserved memory is short AND the recommender wants far more than we allow it.
Memory leak or legitimate growth? With Redis there is a third, very specific possibility, and it is the most likely one: the cache has grown until it filled the available space. Redis, with no eviction policy configured, keeps accepting keys until memory runs out. A growing uncappedTarget does not indicate a software fault: it indicates that maxmemory is not configured in Redis and the cache is growing unchecked.
Check:
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli CONFIG GET maxmemory
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli CONFIG GET maxmemory-policy
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO memoryIf maxmemory is 0, that is the underlying problem, and no requests adjustment solves it: it only delays the next OOMKilled.
(c) upperBound exactly 2048Mi.
It is no coincidence: it is the maxAllowed. The recommendation's bounds are clipped by the guard rails too. When you see an upperBound that matches your maxAllowed exactly, the recommender is telling you its confidence interval is truncated, and that the real recommendation is outside what you allow it.
(d) The pod's QoS.
Since not every resource has requests equal to limits, the QoS is Burstable, not Guaranteed (module 3).
A serious implication: under node memory pressure, a Burstable pod consuming above its requests is a priority eviction candidate. And redis-cache is consuming 1980Mi with a requests of 1024Mi: nearly twice what it reserved. It is among the first to fall when the node fills up, and its fall dumps all the load on bookings-postgres exactly when the node is already under pressure. A textbook domino effect.
(e) Action plan, by priority.
Priority 1 (today): configure maxmemory in Redis. This is the root-cause fix.
# redis-cache ConfigMap
maxmemory 1500mb
maxmemory-policy allkeys-lru # Evicts the least-used keys on reaching the capWith that, Redis stops growing unchecked and starts behaving like a real cache. The uncappedTarget should settle over the following weeks.
Priority 2 (this week): raise requests.memory to 2Gi and match it to the limit.
resources:
requests:
cpu: 300m # Down from 500m: the target is 245m, with margin
memory: 2Gi # Up from 1Gi: it was below the lowerBound
limits:
cpu: 300m # Same as requests
memory: 2Gi # Same as requests -> Guaranteed QoSThis achieves three things: it covers the lowerBound, promotes the pod to Guaranteed QoS (it stops being a priority eviction candidate, consistent with its role as a buffer in front of PostgreSQL), and frees 200m of CPU.
Priority 3 (in a month): review again. With maxmemory in place, check that the uncappedTarget has settled below 2Gi. If it keeps growing, there is something else.
Checks before applying:
# 1. Does it fit in the ResourceQuota? (redis-cache is 1 replica, small impact)
kubectl describe resourcequota quota-pro -n rutas-norte-pro
# 2. Is there a node with 2Gi genuinely free?
kubectl describe nodes | grep -A5 "Allocated resources"
# 3. How long does the cache take to recover after the restart?
# (changing requests forces the pod to be recreated)
# Schedule it in a low-traffic window, NEVER near the May bank holiday.That last point is the one always forgotten: changing redis-cache's resources means restarting it, and restarting it empties the cache. For the following minutes, all the availability query load goes straight to bookings-postgres. It has to be done on a Tuesday at 4 in the morning, not on a Friday at 10.
Solution 2
What is happening: the HPA-VPA feedback loop of section 11, in its purest form.
The HPA scales web-store on CPU. The VPA is in Auto mode with no controlledResources, which means it controls CPU and memory. Both govern the CPU.
Timeline:
t=0 3 replicas, requests.cpu 200m, HPA target = 50% of 200m = 100m.
Total load: 480m -> 160m per pod. Ratio 1.6.
HPA -> ceil(3 x 1.6) = 5 replicas.
t=2min 5 replicas, 96m per pod. HPA satisfied (ratio 0.96, within tolerance).
t=20min The VPA has been observing ~96m of consumption for a while. It recommends
requests.cpu = 110m (96 x 1.15). The updater EVICTS the pods
one by one. <-- The "Evicted" RESTARTS the team reports
t=25min Pods recreated with requests.cpu 110m.
The HPA target is NOW 50% of 110m = 55m.
Real consumption is still 96m. Ratio = 96/55 = 1.75.
HPA -> ceil(5 x 1.75) = 9 replicas. <-- The OSCILLATION the team reports
t=27min 9 replicas, 53m per pod. HPA satisfied.
The VPA observes 53m, recommends 61m...
t=45min The VPA applies 61m. HPA target = 30m. Consumption 53m. Ratio 1.77.
HPA -> 16 replicas, capped by maxReplicas at 15.
t=50min 15 replicas, 32m per pod. The VPA recommends 37m...
And the cycle continues, now limited by the ceiling.
When load drops at night, the process reverses: the VPA raises
requests (because the pods are at the edge of a tiny
recommendation), the HPA target rises, the HPA removes replicas, and
round we go again. Hence the 3 <-> 12 oscillation "several times a day".All three symptoms are explained: the replica oscillation (the HPA reacting to moving targets), the Evicted events (the VPA updater evicting), and the requests that do not match the manifest and change on their own (the admission controller mutating the pods).
Commands that would confirm it:
# The HPA events show frequent rescales with no traffic cause
kubectl describe hpa web-store -n rutas-norte-pro
# The namespace events show EvictedByVPA
kubectl get events -n rutas-norte-pro --field-selector reason=EvictedByVPA \
--sort-by=.lastTimestamp
# The pods' real requests differ from the manifest
kubectl get pods -n rutas-norte-pro -l app=web-store \
-o custom-columns=NAME:.metadata.name,CPU:.spec.containers[0].resources.requests.cpu
# The VPA recommendation has been falling
kubectl describe vpa web-store -n rutas-norte-proSolution A (recommended): VPA in Off mode.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: web-store
updatePolicy:
# OFF. The HPA governs the CPU. The VPA stays as an adviser: we review its
# recommendation every Monday and apply it by hand in a pull request.
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: nginx
minAllowed:
cpu: 50m
memory: 64Mi
maxAllowed:
cpu: 500m
memory: 512MiAdvantages: it eliminates the conflict entirely, no surprise restarts, the manifest is still the truth, compatible with GitOps. Trade-off: somebody has to apply the recommendation by hand.
Solution B: an active VPA over memory only.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: web-store
updatePolicy:
updateMode: "Recreate"
minReplicas: 3
resourcePolicy:
containerPolicies:
- containerName: nginx
# THE KEY LINE: memory only. CPU is the HPA's territory.
controlledResources: ["memory"]
minAllowed:
memory: 64Mi
maxAllowed:
memory: 512MiAdvantages: memory adjusts itself, with no intervention.
Trade-offs: there are still unplanned restarts; requests.cpu is left unadjusted (you would have to do it by hand anyway); during the May bank holiday you would have to switch to Off to avoid restarts at the worst moment.
Which to choose. For web-store, A. The memory saving on nginx is small (we are talking tens of Mi per pod) and does not offset the risk of unplanned restarts on the platform's front door. Solution B would make sense on a workload with large, variable memory consumption that tolerates restarts: notifications-worker is a better candidate.
Immediate action while the decision is being made:
kubectl patch vpa web-store -n rutas-norte-pro --type merge \
-p '{"spec":{"updatePolicy":{"updateMode":"Off"}}}'This stops the oscillation on the updater's next cycle, without deleting anything.
Solution 3
VPA yes or no? Yes, emphatically. It is a textbook case: a batch job with variable consumption that has already been OOMKilled twice. The cost of a restart is zero (the Job is ephemeral) and the cost of the current failure is high (manual intervention, the accountancy firm left without files).
Which mode? Initial. Just like occupancy-reports:
Recreatemakes no sense: evicting a Job 40 minutes into a 90-minute run loses all the work and starts from scratch. It would be worse than theOOMKilled.Offwastes the opportunity: here we do want automatic application, because consumption varies each month with the booking volume and nobody is going to review the recommendation monthly.Initialapplies the recommendation on every new run. Perfect.
The special observation case: the monthly frequency.
This is the interesting point of the exercise. The recommender's histogram has a 24-hour half-life. A monthly run means that, when the next one comes round, the previous sample is thirty half-lives old: its weight is 2⁻³⁰, that is, zero.
The VPA, as it stands, is practically useless for a monthly job. Every run starts almost from scratch.
Three ways to solve it, from least to most advisable:
-
Adjust the recommender's half-life. The
vpa-recommenderaccepts--cpu-histogram-decay-half-lifeand--memory-histogram-decay-half-life. Raising them to720h(30 days) would make the previous month's sample weigh half. Trade-off: it is a global recommender parameter, it affects every VPA in the cluster and it would makebookings-api's recommendations glacially slow. Only viable with a second, dedicated recommender instance, which is serious complexity. -
Run the job more frequently in pre. Schedule it weekly on
rutas-norte-prewith representative data, let the VPA learn there, and take the recommendation to production by hand. It works, but it requires realistic test data. -
VPA in
Initial+ a generousminAllowed+ explicit measurement. The pragmatic option and the one I recommend.
Beyond the VPA, three measures that matter more:
# k8s/environments/pro/cronjob-accounting-exporter.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: accounting-exporter
namespace: rutas-norte-pro
labels:
app: accounting-exporter
app.kubernetes.io/part-of: rutas-norte
spec:
schedule: "0 2 1 * *" # 1st of each month at 2:00
concurrencyPolicy: Forbid
# MEASURE 1: automatic retries. If it fails, let it try again on its own before
# waking anybody up. With 3 attempts and backoff, a one-off OOM recovers.
jobTemplate:
spec:
backoffLimit: 3
# MEASURE 2: a deadline. If it has not finished in 4 hours, something is wrong
# and we want to know before it overlaps with the morning load.
activeDeadlineSeconds: 14400
template:
spec:
restartPolicy: OnFailure
containers:
- name: exporter
image: registry.rutasnorte.example/accounting-exporter:3.1.0
resources:
requests:
cpu: 500m
# MEASURE 3, THE IMPORTANT ONE: raise the memory NOW, without
# waiting for the VPA. Two OOMKilled in four months with a 2Gi
# limit mean 2Gi is not enough. We go straight to 4Gi.
memory: 4Gi
limits:
cpu: "2"
# requests == limits on memory -> Guaranteed QoS for the
# container. It runs in the small hours, the cluster is empty,
# we take nothing from anybody and we guarantee it is not
# evicted under node memory pressure.
memory: 4GiAnd the VPA:
# k8s/environments/pro/vpa-accounting-exporter.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: accounting-exporter
namespace: rutas-norte-pro
labels:
app: accounting-exporter
app.kubernetes.io/part-of: rutas-norte
spec:
targetRef:
apiVersion: batch/v1
kind: CronJob
name: accounting-exporter
updatePolicy:
# INITIAL: every new run is born with the learnt resources. We never
# evict a Job halfway: we would lose 40 minutes of work.
updateMode: "Initial"
resourcePolicy:
containerPolicies:
- containerName: exporter
minAllowed:
cpu: 500m
# A GENEROUS and deliberate minAllowed. With a monthly run, the
# recommender's histogram (24 h half-life) is practically
# empty when the next run comes round. Without this floor, the VPA
# would recommend tiny values based on almost no data and
# we would have a guaranteed OOMKilled. The floor is our insurance.
memory: 4Gi
maxAllowed:
cpu: "4"
# A high ceiling: the booking volume grows and December (with the
# annual close) can be much bigger than a normal month. 8Gi is what
# fits comfortably on a 16Gi node with the cluster empty in the
# small hours. If the uncappedTarget reaches here, we need to review
# the exporter's algorithm: it probably loads into memory what
# it should be processing in batches.
memory: 8Gi
controlledResources: ["cpu", "memory"]
# RequestsAndLimits preserves the 1:1 memory ratio and therefore
# the container's QoS.
controlledValues: RequestsAndLimitsResourceQuota check:
Namespace quota: 40 cores, 80Gi.
Worst case for the exporter: 4 cores and 8Gi (if the VPA reaches maxAllowed).
Since it runs at 2:00 on the 1st, the rest of the platform is at
minReplicas: bookings-api 4, web-store 3, worker 2.
Estimated overnight consumption: ~8 cores, ~14Gi.
With the exporter at maximum: 12 cores, 22Gi. Well below the quota.
CONCLUSION: it fits comfortably. And that is one more reason to run it at night.Follow-up plan:
| When | What to do |
|---|---|
| Immediately | Apply the 4Gi and the retries. Do not wait for the VPA |
| After the 1st run | kubectl describe vpa to see the first target, and measure the real peak with kubectl top pods during the run |
| After the 3rd run | Compare the three target values. If they are consistent, tune minAllowed to a finer value |
| After 6 months | Review the trend. If the uncappedTarget grows linearly with the booking volume, the exporter scales badly and it needs to process in batches rather than loading everything into memory |
The underlying lesson of the exercise: when the execution frequency is far lower than the histogram's half-life, the VPA loses almost all of its automatic usefulness. In those cases, the guard rail (minAllowed) matters more than the recommendation, and manually measuring the first few runs remains irreplaceable. The VPA does not exempt you from thinking: it speeds up thinking well.
Conclusion
The VerticalPodAutoscaler does not respond to load: it responds to ignorance. It turns requests and limits from numbers somebody copied off another manifest into values derived from real consumption observed over days.
The essentials:
- The VPA answers "how big?"; the HPA answers "how many?". Different axes, different time constants: days versus seconds.
- It is not in the core. You have to install it from
kubernetes/autoscaler, check version compatibility, and in the cloud look first at whether the provider offers it. - Three components: the recommender computes (percentiles with exponential decay, p90 for CPU, the peak for memory), the updater evicts while respecting PDBs, and the admission controller mutates the pods as they are born. The Deployment never changes.
Offis the mode of serious teams. The manifest is still the truth, there are no surprise restarts, it works with GitOps and every resource change goes through a human review.Initialfor batch jobs;Recreateonly for workloads that tolerate restarts.target,lowerBound,upperBound,uncappedTarget.targetis what you take to the manifest; being belowlowerBoundis an alarm; anuncappedTargetthat drifts away fromtargetmonth after month is a memory leak until proven otherwise.- VPA and HPA over the same metric destroy each other. The loop is real and takes hours to manifest.
controlledResources: ["memory"]when the HPA scales on CPU, orOffmode. The optimal combination is an HPA on a business metric (09-04) and a full VPA. - In-place resizing will arrive and change the rules, but today it is no basis for a production strategy.
- Beware of the ResourceQuota: recommended
requests× the HPA'smaxReplicasmust fit, or the failure will appear at the traffic peak weeks later.
At Rutas Norte we have left the VPA in Off mode over bookings-api, web-store, bookings-postgres and redis-cache, active in Recreate over notifications-worker and in Initial over occupancy-reports. And we have discovered, with data, that bookings-api had its memory below its lowerBound and that web-store was reserving three times the CPU it uses.
With that, each pod asks for what it needs and the number of pods adapts to traffic. But there is a hidden assumption in everything we have done: we have taken for granted that, when the HPA asks for thirty bookings-api replicas, there is somewhere to put them. The Rutas Norte cluster has four nodes. Thirty bookings-api pods plus fifteen of web-store plus the twenty of notifications-worker do not fit. The scheduler will leave them Pending and the HPA will be able to do nothing about it: it will have asked for the replicas, the Deployment will have created them, and there they will stay, queued, while the May bank-holiday sale falls over exactly as it did last year.
In the next lesson, Cluster Autoscaling, we climb the last step: how to make the cluster add nodes when the pods do not fit, why removing them is far more delicate than adding them, how long a node really takes to be ready (and why that does not save you from a sudden spike), and the over-provisioning trick with filler pods that we now have almost all the pieces to understand.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
