Metrics Server and Resource Pressure
The same node reads 1% CPU in kubectl top and 5% in describe node. Both are right, they measure different things, and confusing them is why clusters report Insufficient cpu while every dashboard shows idle machines.
Troubleshooting Guide 79 of 103 Intermediate
- Kubernetes1.36.4
- Cluster4 nodes
- Runtimecontainerd 2.2.6
- CNICalico v3.32.1
- TimeAbout 30 min
- Reviewed21 August 2026
Written against the versions above. metrics-server is not installed by default. Without it, kubectl top returns an error rather than zeros.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| CKA1001 | 192.168.0.175 | Ubuntu 26.04 LTS | Control Plane Node | 2 Core | 4 GB | 50 GB |
| CKA1001-NODE01 | 192.168.0.176 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA1001-NODE02 | 192.168.0.177 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA1001-NODE03 | 192.168.0.178 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
Before you start
- metrics-server installed. The four-node installation guide adds it.
- The requests, limits and QoS guide. The distinction this guide turns on is defined there.
- The Pod stuck Pending guide, for the failure this distinction explains.
-
What kubectl top measures
kubectl topreports actual consumption, sampled. metrics-server scrapes each kubelet's summary API every 15 seconds, keeps only the latest value in memory, and serves it through the Metrics API.Three properties follow from that design and each one matters:
- There is no history. metrics-server holds one sample.
kubectl topcannot tell you what happened an hour ago, and there is no flag to make it. For history you need Prometheus or equivalent. - It is a sample, not a peak. A container that spikes to its memory limit between scrapes is killed without ever appearing in
top, which is why the OOMKilled guide warns against trusting these numbers for limit-setting. - It is in memory only. Restart the Pod and the data starts again from nothing, so
kubectl topreturns errors for a minute or two after any metrics-server restart.
Reading the node output: CPU in millicores against the node's capacity, memory in bytes.
cka1001at 98m and 41% memory is the control plane carrying the API server and etcd; the workers are near idle.The Pod view sorted by memory shows where the control plane's memory actually goes: kube-apiserver at 484Mi, six times etcd's 79Mi. That ratio is normal and worth knowing, because the API server's memory scales with the number of objects and watchers rather than with your workload.
--containersbreaks a Pod down per container, which is what you need when a Pod has a sidecar and you are trying to work out which half is consuming.One caveat on the small numbers:
sidecar 0Midoes not mean zero.kubectl toprounds to whole mebibytes, so anything under about half a mebibyte displays as 0.bash Example session kubectl top nodesNAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) cka1001 98m 4% 1385Mi 41% cka1001-node01 29m 1% 972Mi 29% cka1001-node02 37m 1% 857Mi 26% cka1001-node03 29m 1% 598Mi 18% kubectl top pods -n kube-system --sort-by=memory | head -6NAME CPU(cores) MEMORY(bytes) kube-apiserver-cka1001 26m 484Mi etcd-cka1001 14m 79Mi coredns-589f44dc88-fdcml 1m 68Mi kube-controller-manager-cka1001 7m 62Mi kube-scheduler-cka1001 3m 26Mi kubectl top pod app -n dbg --containersPOD NAME CPU(cores) MEMORY(bytes) app sidecar 1m 0Mi app web 1m 3MiExpected resultLive usage for nodes, Pods and containers.
--sort-by=memoryis the flag that makes the Pod view useful; unsorted output on a busy namespace is unreadable.Success condition
kubectl top nodesreturns numbers rather than an error. - There is no history. metrics-server holds one sample.
-
The same node, a different 1%
This is the point of the guide.
kubectl topsaidcka1001-node01was using 1% CPU and 29% memory.describe nodesays:cpu 100m (5%) 0 (0%) memory 70Mi (2%) 170Mi (5%)5% CPU and 2% memory. Different numbers for the same node at the same moment, and both correct, because they measure different things:
kubectl top- what is being used right now.describe node- what has been requested and limited, summed across the node's Pods.
The scheduler uses the second one. Exclusively. It has no idea what anything is actually consuming; it packs Pods onto nodes by comparing requests against
allocatable.That resolves the contradiction that puzzles people most often:
Insufficient cpuon a cluster where every node looks idle. A node whose Pods request all its CPU is full, at any level of real utilisation. Adding nodes fixes it; adding load does not cause it.So when a Pod will not schedule,
kubectl topis the wrong command.kubectl describe nodeand itsAllocated resourcestable is the right one, and the percentages there are ofallocatable, notcapacity, which is why they can approach 100% while the machine has memory free.The header on that table is worth reading too:
(Total limits may be over 100 percent, i.e., overcommitted.). Limits are allowed to exceed the node, because they are ceilings rather than reservations. Requests are not: their sum cannot exceed allocatable, and that is the constraint the scheduler enforces.On this node, requests at 5% and usage at 1% is a modest gap. In production the gap is usually much larger and always in that direction, because people set requests from a guess and the guess is high. That over-request is the most common cause of a cluster that costs too much while looking empty.
bash Example session kubectl describe node cka1001-node01 | sed -n '/Allocated resources/,/Events/p' | head -12Allocated resources: (Total limits may be over 100 percent, i.e., overcommitted.) Resource Requests Limits -------- -------- ------ cpu 100m (5%) 0 (0%) memory 70Mi (2%) 170Mi (5%) ephemeral-storage 0 (0%) 0 (0%) hugepages-1Gi 0 (0%) 0 (0%) hugepages-2Mi 0 (0%) 0 (0%)Events: <none>Expected resultRequests and limits side by side.
cpu ... 0 (0%)in the Limits column means no Pod on this node sets a CPU limit, which is common and usually correct: a CPU limit throttles rather than kills.Success conditionYou can state the node's requested CPU and its used CPU as two different numbers.
-
The conditions that mean pressure
Usage and requests are both about capacity planning. The node's conditions are about whether the kubelet is currently in trouble, and they are what triggers eviction.
NetworkUnavailable=False MemoryPressure=False DiskPressure=False PIDPressure=False Ready=TrueFive conditions, and the polarity is mixed, which is a genuine source of misreading: for the four pressure conditions False is healthy, and for
Readyit is True.What each one means when it flips:
MemoryPressure=True- available memory has fallen below the eviction threshold. The kubelet starts evicting Pods, choosing by QoS class:BestEffortfirst, thenBurstableexceeding its requests, andGuaranteedlast. It also adds a taint that stops new Pods arriving.DiskPressure=True- the image or root filesystem is low. The kubelet first garbage-collects unused images, which is often enough, then evicts.PIDPressure=True- the node is near its process limit. Rare, and usually a runaway fork loop.Ready=False- the kubelet has stopped reporting or reports itself unhealthy. Covered in its own guide.
The important distinction from the previous step: eviction responds to real usage, scheduling responds to requests. A node can be perfectly schedulable and evicting Pods, or under memory pressure while its requests look modest. They are separate mechanisms and the same node can be in trouble on one and fine on the other.
And a note on where the eviction thresholds come from: they are the kubelet's, not the scheduler's, and they are set on the node (
--eviction-hard, default around 100Mi of available memory). That is whyallocatableis smaller thancapacity: the difference is what the kubelet reserves for the system and for its eviction headroom.bash Example session kubectl get node cka1001-node01 -o jsonpath="{range .status.conditions[*]}{.type}={.status}{\" \"}{end}{\"\n\"}"NetworkUnavailable=False MemoryPressure=False DiskPressure=False PIDPressure=False Ready=TrueExpected resultAll four pressure conditions False and
ReadyTrue, which is a healthy node. Scan this rather thankubectl topwhen Pods are being evicted.Success conditionYou can name which polarity is healthy for each condition.
Troubleshooting
kubectl topreturnsMetrics API not available.Why: metrics-server is not installed, or its APIService is not reachable. It is not part of a default cluster.
Fix:
kubectl get apiservice v1beta1.metrics.k8s.ioshould showTrue. If it is missing, install metrics-server. If it is False, read its Pod logs: on a kubeadm cluster the usual cause is the kubelet's serving certificate not being signed by the cluster CA, which metrics-server rejects. The common workaround is--kubelet-insecure-tls; the correct fix is enablingserverTLSBootstrapon the kubelets.A Pod will not schedule with
Insufficient cpuwhilekubectl topshows the nodes idle.Why: Scheduling is based on requests, not usage. The two are unrelated numbers.
Fix:Read the right table:
kubectl describe node <name>and look atAllocated resources. To find the culprits, list requests across Pods:kubectl get pods -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CPU:.spec.containers[*].resources.requests.cpu. The usual finding is a Pod requesting far more than it uses.kubectl topshows 0Mi for a container that is clearly using memory.Why: Rounding to whole mebibytes, or metrics-server has not scraped since the Pod started.
Fix:Wait 15 to 30 seconds after a Pod starts before trusting the numbers. For precision below a mebibyte,
kubectl get --raw /apis/metrics.k8s.io/v1beta1/namespaces/<ns>/pods/<pod>returns the raw values metrics-server holds.Pods are being evicted and the node's requests look fine.
Why: Eviction is driven by actual usage against the kubelet's thresholds, not by requests. A node whose Pods all stay within modest requests can still exhaust memory if they overshoot.
Fix:Check the conditions:
kubectl get nodes -o custom-columns=NAME:.metadata.name,MEM:.status.conditions[?(@.type=="MemoryPressure")].status. Evicted Pods show phaseFailedwith reasonEvicted, which distinguishes them from OOMKilled containers. To protect a workload, make itGuaranteedby setting requests equal to limits.allocatableis noticeably less thancapacityand you want the difference back.Why: The kubelet reserves memory and CPU for the system and for eviction headroom, and that reservation is subtracted from what the scheduler may spend.
Fix:Working as intended, and reducing it is usually a mistake: the reservation is what stops a busy node taking the kubelet and the runtime down with it. Compare the two with
kubectl get node <name> -o jsonpath='{.status.capacity}{"\n"}{.status.allocatable}'so you know the real budget when sizing requests.You need to know what usage was during an incident that has passed.
Why: metrics-server keeps one sample in memory and no history at all.
Fix:There is nothing to recover, so this has to be prepared for. metrics-server exists to serve the Metrics API for
kubectl topand for the HorizontalPodAutoscaler, and it is deliberately not a monitoring system. Run Prometheus or an equivalent alongside it for anything you will want to look back at.