# EKS Underutilized Node

> A node group averaging 40% CPU or less with desired above its minimum can absorb one drained node's pods across the survivors, so ZopNight recommends shrinking DesiredSize by exactly 1 (priced at that node's on-demand rate × 730 hours) and re-measures before ever claiming a second.

Source: https://zop.dev/integrations/aws/recommendations/eks-underutilized-node
Updated: 2026-08-19

---

## One node per pass, on purpose

A 10-node group at 10% CPU could arguably lose several nodes at once. The rule still only
ever claims one: under-claiming is the safe direction for an action that drains live pods,
and successive passes converge on the right size with a fresh measurement between each
step. The gate is desired count above the scaling-config floor plus a 14-day CPU average at
or under 40%: headroom enough for the remaining nodes to absorb the drained node's pods
without tipping into pressure.

## Three vetoes stand between the average and the recommendation

Memory first: a CPU-idle group holding JVM heaps or in-memory caches must not shed a node,
so the memory rollup is required and must average at or under 50%. Peaks second: a group
that spikes toward saturation needs its headroom at exactly those moments, so a trusted
peak near the ceiling suppresses regardless of the average. Capacity type third: Spot
groups abstain entirely, because only [the on-demand instance rate](https://aws.amazon.com/ec2/pricing/) is visible here and pricing a
removed Spot node at on-demand would overstate the saving two to three times.

## Evidence requirements, stated

Both rollups come from [the Container Insights agent EKS documents](https://docs.aws.amazon.com/eks/latest/userguide/cloudwatch.html); a cluster without the agent produces
no series and the rule stays silent. A blind scale-down on an unmeasured group is the
failure mode it refuses. Unhealthy or transitioning node groups are skipped, and a missing
on-demand rate abstains rather than emitting an unpriced recommendation.

## Check a group's headroom

```bash
aws eks describe-nodegroup --cluster-name my-cluster --nodegroup-name my-ng \
  --query 'nodegroup.[scalingConfig,capacityType,instanceTypes]'
aws cloudwatch get-metric-statistics \
  --namespace ContainerInsights --metric-name node_cpu_utilization \
  --dimensions Name=ClusterName,Value=my-cluster Name=NodegroupName,Value=my-ng \
  --start-time "$(date -u -v-14d +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --period 86400 --statistics Average
```

## The PDB is your half of the contract

Applying the change sets DesiredSize down by one; AWS cordons and drains the surplus node,
honouring PodDisruptionBudgets as it evicts. That guarantee is only as good as the PDBs you
have defined, and a workload without one can be evicted abruptly. The pre-flight check is
therefore that every workload on the group carries a PDB, and the post-flight check is a day of
latency and error metrics on the smaller fleet.
