Kubernetes Troubleshooting: CPU, Memory, OOMKilled, Throttling, and Pod Evictions

Learn how to troubleshoot Kubernetes CPU, memory, OOMKilled, CPU throttling, and Pod evictions using kubectl, Prometheus metrics, resource limits, and node conditions.

This post is also available in: Spanish

A Pod can be Running and still have serious performance problems.

An application can become slow, continuously consume memory, restart because of OOMKilled, or experience CPU throttling even when the Kubernetes node appears to have resources available.

In this second article of our Kubernetes troubleshooting series, we will investigate:

  • high CPU usage;

  • high memory usage;

  • OOMKilled;

  • CPU throttling;

  • Pod evictions;

  • node-level resource pressure.

The objective is not simply to find "which Pod is using the most resources."

We want to answer:

Is the problem in the application, its requests and limits, or the capacity of the node?


Requests, limits, and actual usage

Before investigating CPU or memory, we need to distinguish three concepts:

Resources
|
+------------+------------+
| | |
Request Limit Usage
| | |
v v v
Scheduling Enforcement Actual

Requests

A request tells Kubernetes how much of a resource a workload asks for.

For example:

resources:
requests:
cpu: "250m"
memory: "256Mi"

The scheduler uses these values when deciding which node can run the Pod.

A container can use more than its request when resources are available and its limits allow it.


Limits

A limit places a constraint on resource consumption.

resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"

CPU and memory limits behave differently.

CPU limit
|
v
CPU throttling
Memory limit
|
v
Memory pressure / allocation
|
v
Possible OOM kill

A container is not terminated simply because it consumes too much CPU. The kernel constrains its available CPU time through cgroup mechanisms.

Memory works differently. When memory cannot be allocated within the applicable cgroup constraint, the kernel's OOM mechanisms can terminate a process.


1. High CPU usage

Suppose an alert reports increased latency and we find a Pod consuming a large amount of CPU.

A good first check is:

kubectl top pods -A

To see individual containers:

kubectl top pod <pod> \
-n <namespace> \
--containers

We can also sort Pods by CPU:

kubectl top pods \
-n <namespace> \
--sort-by=cpu

This gives us a recent snapshot of resource consumption.

However:

kubectl top is not a replacement for a complete monitoring system.

Its values normally come from Metrics Server and are intended to provide lightweight resource signals suitable for Kubernetes components such as autoscalers.

To understand when a problem started or how it evolved over hours or days, we need historical monitoring.


What should we ask when CPU increases?

Do not immediately assume that the workload needs more CPU.

Start here:

High CPU
|
+--- Did traffic increase?
|
+--- Was the application recently changed?
|
+--- Is one endpoint CPU intensive?
|
+--- Is a dependency responding slowly?
|
+--- Is there a loop or runaway process?
|
+--- Did background workload increase?
|
+--- Is the container being throttled?

Check recent revisions:

kubectl rollout history \
deployment/<deployment> \
-n <namespace>

Then inspect resource configuration:

kubectl get deployment <deployment> \
-n <namespace> \
-o yaml

Look for:

resources:
requests:
cpu:
limits:
cpu:

CPU usage vs CPU limit

Suppose we have:

CPU request: 250m
CPU limit: 500m
CPU usage: 490m

The container is operating close to its CPU limit.

Now suppose the node reports:

Node CPU usage: 35%

That may initially look contradictory.

It is not.

The container has its own CPU constraint.

Node
16 CPUs available
|
| Plenty of CPU available
|
+------------------------------+
|
Container |
CPU limit = 500m |
| |
v |
reaches limit |
| |
v |
CPU throttling <---------------------+

A node can therefore have available CPU while an individual workload is constrained by its CPU limit.


2. CPU throttling

CPU throttling deserves its own investigation.

A workload can show:

Pods: Running
Node CPU: Normal
Memory: Normal
Errors: Low
Latency: HIGH

and still be constrained by CPU.

CPU limits are enforced through kernel and cgroup mechanisms. Once a workload exhausts its permitted CPU quota for a scheduling period, its execution can be delayed until more CPU time becomes available.

For latency-sensitive workloads, this can matter significantly.


Prometheus metrics

Depending on your container runtime and metrics collection stack, you may have metrics such as:

container_cpu_usage_seconds_total
container_cpu_cfs_throttled_periods_total
container_cpu_cfs_periods_total
container_cpu_cfs_throttled_seconds_total

The exact metrics and labels available can differ between environments, so verify what your monitoring stack actually exposes before relying on a query.

For CPU consumption, a query might look like:

rate(
container_cpu_usage_seconds_total{
namespace="production",
pod=~"api-.*",
container!="POD",
container!=""
}[5m]
)

When the CFS period metrics are available, a throttled-period ratio can be calculated with a query such as:

sum by (namespace, pod, container) (
rate(container_cpu_cfs_throttled_periods_total[5m])
)
/
sum by (namespace, pod, container) (
rate(container_cpu_cfs_periods_total[5m])
)

Do not interpret throttling in isolation.

Correlate:

CPU throttling
+
CPU usage
+
request rate
+
latency
+
application behavior
|
v
Better diagnosis

How should CPU throttling be resolved?

There is no universal answer.

Depending on the cause, we might:

  • optimize CPU-intensive code;

  • increase CPU requests;

  • modify CPU limits;

  • increase replicas;

  • adjust autoscaling;

  • separate background workloads;

  • investigate noisy neighbors;

  • increase node capacity.

Removing every CPU limit is not automatically the correct answer either.

Limits can help protect shared environments from workloads that would otherwise consume excessive resources.

The appropriate configuration depends on the workload's behavior and the required balance between performance and isolation.


3. High memory usage

Start again with:

kubectl top pods -A

and:

kubectl top pod <pod> \
-n <namespace> \
--containers

However, a single measurement cannot tell us whether memory looks like this:

Normal high usage
Memory
^
| ____________
| /
|______/
time

or this:

Possible leak
Memory
^
| /
| /
| /
| /
|_____/
time

Historical metrics are particularly important for memory investigations.


Questions to ask about memory

High Memory
|
+--- Is usage continuously growing?
|
+--- Does it stabilize?
|
+--- Does it correlate with traffic?
|
+--- Was the application changed?
|
+--- Is caching involved?
|
+--- Could there be a memory leak?
|
+--- Is a queue growing?
|
+--- Does the runtime have a configured heap?
|
+--- Are we approaching the memory limit?

Inspect configured resources:

kubectl get pod <pod> \
-n <namespace> \
-o yaml

For example:

resources:
requests:
memory: "512Mi"
limits:
memory: "1Gi"

Then compare:

Memory usage
vs
Memory request
vs
Memory limit

4. OOMKilled

One of the clearest symptoms is:

Reason: OOMKilled

Start with:

kubectl describe pod <pod> \
-n <namespace>

We can also inspect the previous container state:

kubectl get pod <pod> \
-n <namespace> \
-o jsonpath='{.status.containerStatuses[*].lastState.terminated}'

And retrieve the previous container's logs:

kubectl logs <pod> \
-n <namespace> \
--previous

OOMKilled vs Exit Code 137

This distinction is important.

You may encounter:

Exit Code: 137

137 conventionally represents:

128 + 9 = 137
signal 9 = SIGKILL

However:

Exit code 137 by itself does not prove that the process was killed because of an out-of-memory condition.

A process can receive SIGKILL for other reasons.

Look for Kubernetes reporting:

Reason: OOMKilled

and correlate it with:

  • container state;

  • Events;

  • memory metrics;

  • logs;

  • node conditions.


Container OOM vs node memory pressure

Another important distinction is:

Memory problem
|
+-------+-------+
| |
v v
Container OOM Node Pressure
| |
Memory/cgroup Node running
constraint low on memory
| |
v v
OOMKilled Possible eviction

These are not the same incident.

Container OOM

For example:

limits:
memory: "512Mi"

If the workload cannot allocate the memory it needs within its applicable constraint, the kernel's OOM mechanism can kill a process.

Node memory pressure

Multiple workloads may instead consume enough memory that the node itself becomes resource constrained.

Check:

kubectl describe node <node>

Look for:

Conditions:
MemoryPressure:

And inspect current usage:

kubectl top node <node>

How should OOMKilled be resolved?

Increasing the memory limit may be correct.

But first answer:

Why does the application need more memory?

Possible explanations include:

OOMKilled
|
+--- Memory leak
|
+--- Limit too low
|
+--- Higher traffic
|
+--- Unbounded cache
|
+--- Incorrect heap configuration
|
+--- Large query / processing job
|
+--- Excessive worker concurrency
|
+--- Memory-backed volume usage

Changing:

resources:
requests:
memory: "512Mi"
limits:
memory: "1Gi"

to:

resources:
requests:
memory: "1Gi"
limits:
memory: "2Gi"

may be appropriate when measurements demonstrate that the additional memory is legitimate.

If the workload has a memory leak, increasing the limit may simply delay the next failure.


5. Pod evictions

Now suppose we find:

STATUS: Evicted

Start with:

kubectl describe pod <pod> \
-n <namespace>

Then investigate its node:

kubectl describe node <node>

Kubernetes supports node-pressure eviction, where the kubelet can terminate Pods to reclaim constrained node resources.

The kubelet monitors signals associated with resources such as:

Memory
Disk
Filesystem inodes

When configured thresholds are crossed, Pods can be selected for eviction so the node can reclaim resources.


Node conditions

A good starting point is:

kubectl get nodes

followed by:

kubectl describe node <node>

Look for conditions such as:

MemoryPressure
DiskPressure
PIDPressure

Then inspect current usage:

kubectl top node <node>

List workloads assigned to that node:

kubectl get pods -A \
--field-selector spec.nodeName=<node> \
-o wide

This helps identify which workloads were colocated on the affected node.


Evicted is not OOMKilled

These symptoms are easy to confuse.

OOMKilled
|
A process/container is killed
as part of an OOM condition
!=
Evicted
|
kubelet terminates a Pod
to reclaim constrained
node resources

The investigation therefore differs.

For OOMKilled, the container and its memory behavior are primary investigation targets.

For Evicted, the node and its resource pressure become especially important.


6. QoS Classes

Kubernetes assigns a Quality of Service class to Pods based on their CPU and memory resource configuration.

Check it with:

kubectl get pod <pod> \
-n <namespace> \
-o jsonpath='{.status.qosClass}'

The classes are:

Guaranteed
Burstable
BestEffort

Guaranteed

With traditional per-container resource configuration, a Pod qualifies as Guaranteed when every container has CPU and memory requests and limits, with the request equal to the limit for each resource.

For example:

resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "500m"
memory: "512Mi"

Burstable

A Pod with at least some CPU or memory resource configuration that does not meet the Guaranteed criteria is generally classified as Burstable.

BestEffort

A Pod without CPU or memory requests or limits is normally:

BestEffort

For example:

resources: {}

QoS and eviction

QoS matters when Kubernetes selects workloads during node resource pressure, but it is not the only consideration.

Pod priority and whether resource consumption exceeds requests can also affect eviction decisions.

Therefore, avoid reducing every eviction decision to:

BestEffort -> Burstable -> Guaranteed

as if QoS class alone completely determined the result.

The actual node-pressure eviction process considers additional factors.


7. Requests that are too low

Consider:

resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "2"
memory: "2Gi"

while the workload normally consumes:

CPU: 800m
Memory: 900Mi

The Pod may run successfully.

However, its requests tell the scheduler that the workload needs considerably fewer resources than it typically consumes.

Across many workloads, this can contribute to:

Node appears to have capacity
|
Scheduler places more Pods
|
Actual usage grows
|
Node becomes saturated
|
Resource pressure

Requests should therefore not be selected solely to make Pods easier to schedule.

They should reasonably reflect the workload's resource requirements.


8. What if requests are too high?

The opposite problem also exists.

Suppose we configure:

requests:
cpu: "4"
memory: "8Gi"

for a workload that typically consumes:

CPU: 200m
Memory: 300Mi

This can waste schedulable capacity.

Even when:

kubectl top nodes

shows low actual utilization, the scheduler may refuse additional Pods because Pod placement is based on requested resources.

Inspect:

kubectl describe node <node>

and find:

Allocated resources:

This helps us compare:

Actual utilization
vs
Requested resources

9. Useful Prometheus metrics

kubectl top is useful for a quick snapshot.

During a real incident, we usually need to understand how resource behavior changed over time.

Metrics commonly available from kubelet/cAdvisor pipelines can include:

container_cpu_usage_seconds_total
container_memory_working_set_bytes
container_cpu_cfs_throttled_periods_total
container_cpu_cfs_periods_total
container_cpu_cfs_throttled_seconds_total

kube-state-metrics can separately expose Kubernetes object state such as configured requests and limits.

Exact metric availability and labels depend on the monitoring architecture.


CPU usage

For example:

sum by (namespace, pod) (
rate(
container_cpu_usage_seconds_total{
container!="",
container!="POD"
}[5m]
)
)

Memory working set

sum by (namespace, pod) (
container_memory_working_set_bytes{
container!="",
container!="POD"
}
)

Instead of only asking for the current value, inspect:

30 minutes before
|
incident begins
|
resource growth
|
failure
|
recovery

The trend is often more useful than the snapshot.


10. Correlating resources with application behavior

Consider the following timeline:

14:00 CPU normal
14:05 Deployment
14:10 CPU increases
14:12 Latency increases
14:15 CPU throttling increases
14:20 5xx increases

We now have a much stronger story than:

"CPU is high."

Our hypothesis becomes:

Deployment
|
v
CPU growth
|
v
Throttling
|
v
Latency
|
v
Errors

The next investigation might use application logs and distributed traces to discover which new execution path is consuming additional CPU.


11. Investigating the node

When the evidence suggests that the problem affects an entire node:

kubectl describe node <node>

and:

kubectl top node <node>

are useful starting points.

If deeper investigation is required and permissions allow it:

kubectl debug node/<node> \
-it \
--image=ubuntu

From the debugging environment, host resources can be inspected according to the permissions and environment available.

For example:

free -h
df -h
df -i

And in environments where administrators have direct host access, kubelet logs can also be investigated:

journalctl -u kubelet

Troubleshooting flow

We can summarize the investigation as:

Performance issue
|
v
Define impact
|
v
kubectl top / metrics
|
+---------+---------+
| |
v v
CPU Memory
| |
+------+------+ +-----+------+
| | | |
High usage Throttling Growth OOMKilled
| | | |
+------+------+ +-----+------+
| |
v v
Requests/limits Requests/limits
| |
+---------+---------+
|
v
Check node
|
+------------+-------------+
| | |
CPU usage MemoryPressure DiskPressure
| | |
+------------+-------------+
|
v
Correlate application
|
v
Logs / Metrics / Traces
|
v
Root cause

Command reference

# Current usage
kubectl top pods -A
kubectl top nodes
# Container-level usage
kubectl top pod <pod> \
-n <namespace> \
--containers
# Sort by usage
kubectl top pods \
-n <namespace> \
--sort-by=cpu
kubectl top pods \
-n <namespace> \
--sort-by=memory
# Pod state
kubectl describe pod <pod> \
-n <namespace>
# Previous container state
kubectl get pod <pod> \
-n <namespace> \
-o jsonpath='{.status.containerStatuses[*].lastState.terminated}'
# Previous logs
kubectl logs <pod> \
-n <namespace> \
--previous
# Resource configuration
kubectl get deployment <deployment> \
-n <namespace> \
-o yaml
# Node investigation
kubectl describe node <node>
kubectl top node <node>
# Pods running on a node
kubectl get pods -A \
--field-selector spec.nodeName=<node> \
-o wide
# QoS class
kubectl get pod <pod> \
-n <namespace> \
-o jsonpath='{.status.qosClass}'
# Node debugging
kubectl debug node/<node> \
-it \
--image=ubuntu

What not to do

During an incident, avoid automatic responses such as:

OOMKilled
-> increase memory
High CPU
-> increase CPU
Evicted
-> add another node

Those actions might temporarily mitigate the problem without resolving its cause.

Instead:

Symptom
|
v
Measure
|
v
Correlate
|
v
Find constraint
|
v
Understand workload
|
v
Change
|
v
Validate

After making a change, verify:

  • CPU;

  • memory;

  • throttling;

  • restart count;

  • latency;

  • error rate;

  • node pressure;

  • application behavior.


Conclusion

Resource problems in Kubernetes can occur at multiple levels:

Application
|
Container
|
Pod
|
Node
|
Cluster

An OOMKilled container does not automatically mean the node ran out of memory.

High CPU does not automatically mean we need more CPUs.

And an evicted Pod is not the same as a process being terminated by the OOM killer.

To investigate these incidents correctly, connect:

Requests + Limits
+
Actual Usage
+
Container State
+
Node Conditions
+
Historical Metrics
+
Application behavior
|
v
Root Cause

In the next article in this series, we will troubleshoot Kubernetes networking:

  • Services;

  • EndpointSlices;

  • DNS;

  • Ingress and Gateway API;

  • NetworkPolicy;

  • CNI;

  • Pod-to-Pod connectivity;

  • outbound connectivity.

We will follow the traffic path layer by layer to determine exactly where communication is failing.

Comments