Konrad Kowalski (rootsher)Principal Platform & Reliability Architect101111111010000111010001011110111101110010010100

Anatomy of a CPU problem: from an understated request to p99 latency

date
category
Capacity & Performance
also in
Containers · Observability
reading
5 min / 944 words

Assume a simple HTTP service running in Kubernetes.

Configuration:

yaml
resources:
  requests:
    cpu: 500m

No CPU limit.

The application usually consumes:

text
300-400m

so a 500m request looks reasonable.

p99 latency:

text
35 ms

For most of the day everything works correctly.

Then peak traffic arrives.

The Pod's CPU usage rises to:

text
900m

and p99 suddenly jumps:

text
35 ms -> 180 ms

The first reaction:

text
CPU still does not look dramatic.
There is no limit.
Why did the application slow down?

To answer that, you have to walk the whole way from the Kubernetes scheduler to the Linux scheduler.


Step 1: the scheduler believed the request

The node has:

text
16 CPU

There are 20 Pods running on it.

The sum of their requests:

text
10 CPU

For kube-scheduler the situation looks healthy:

text
requested capacity = 10 CPU
node capacity      = 16 CPU

Our service declares:

text
500m

so the scheduler treats each of its replicas as a workload needing half a CPU.

The trouble is that under peak traffic the real demand of a single replica is no longer:

text
500m

but, say:

text
1.5 CPU

A request does not cap the workload, so the application may try to use more.

But during placement the scheduler did not know that several workloads would simultaneously need far more than they declared.


Step 2: real demand exceeds capacity

During normal traffic the node looks like this:

text
capacity = 16 CPU
demand   = 8 CPU

Plenty of spare power.

During the peak:

text
API pods       -> 10 CPU
batch jobs     -> 4 CPU
other services -> 7 CPU

In total:

text
demand = 21 CPU

on:

text
capacity = 16 CPU

We are short of:

text
5 CPU

Linux cannot execute 21 CPU of work in parallel on 16 logical CPUs.

There is no hidden reserve.

Some tasks have to wait.


Step 3: there is no throttling

Our Pod does not have:

yaml
limits:
  cpu:

so it has no finite CPU bandwidth limit.

In cgroup v2 that corresponds to a situation like:

text
cpu.max = max ...

We check cpu.stat.

We do not observe a growing:

text
nr_throttled

Conclusion:

text
the kernel is not stopping the application
because of exhausted quota

That matters.

If at this point we automatically raised the CPU limit, we would fix nothing.

There is no limit, after all.

The problem is somewhere else.


Step 4: the task becomes runnable

An HTTP request arrives.

The application's worker gets work:

text
request
   |
   v
thread becomes runnable

Normally the scheduler gives it a CPU very quickly:

text
runnable
   |
   v
running

Assume handling the request needs a total of:

text
6 ms CPU time

On a healthy node the execution may look like this:

text
0 ms             6 ms
|-----------------|
      running

CPU-related latency:

text
~6 ms

Under contention the situation looks different.


Step 5: runnable does not mean running

The node has many competing tasks.

Our thread becomes runnable:

text
t = 0 ms

but the scheduler does not give it a CPU right away.

We get:

text
0 ms       8 ms
|-----------|
 runnable
 waiting

Then the thread executes:

text
8 ms -> 10 ms

that is:

text
2 ms CPU time

Another switch happens.

The thread waits again:

text
10 ms -> 17 ms

Then it executes another slice of work.

The whole thing may look like this:

text
0------8--10-------17----21
| WAIT |RUN| WAIT  | RUN |

The application's CPU time:

text
6 ms

Wall-clock time:

text
21 ms

The application's code did not become three times more expensive.

Executing the same 6 ms of CPU was simply stretched out by scheduler wait.


Step 6: CPU usage does not show the missing time

This is the most important moment of the whole incident.

Monitoring shows:

text
Pod CPU usage = 900m

You might think:

text
it is not even using a whole CPU

But usage says:

text
how much CPU time the workload actually got

It does not say:

text
how much CPU the workload wanted

If the application wanted on average:

text
1.5 CPU

but through contention received:

text
0.9 CPU

the usage metric will show:

text
0.9 CPU

The missing:

text
0.6 CPU

will not appear as usage.

It is work not done, waiting for the scheduler.


Step 7: PSI shows the pressure

We look at CPU PSI.

Before the incident:

text
some avg10=1.20

During the problem:

text
some avg10=34.00

That is completely different information from:

text
CPU usage = 900m

PSI tells us that for a significant part of the time runnable work is experiencing CPU stall.

That is:

text
the application wants to execute
+
the scheduler cannot give it CPU right away

We already have strong evidence for:

text
CPU contention

Step 8: runqueue latency confirms the mechanism

If we need a more precise confirmation, we look at the moment of:

text
sched_wakeup

and:

text
sched_switch

Example:

text
thread runnable:
14:00:00.100

thread running:
14:00:00.108

Scheduler delay:

text
8 ms

For an HTTP request whose own CPU work is a few milliseconds, an extra:

text
5-10 ms

of waiting across many consecutive wakeups can increase the final latency dramatically.


Why p99 in particular goes up

Not every request hits the same situation.

One request may get a CPU almost immediately:

text
scheduler wait = 0.2 ms

Another:

text
scheduler wait = 3 ms

Yet another hits the worst possible moment:

text
scheduler wait = 15 ms

So average latency may rise moderately.

But the tail:

text
p95
p99
p99.9

starts rising much faster.

Example:

text
before:

p50 = 15 ms
p95 = 25 ms
p99 = 35 ms

during contention:

text
p50 = 22 ms
p95 = 80 ms
p99 = 180 ms

That is the typical character of queueing problems.

The more overloaded a resource is, the more uneven the waiting time of individual requests becomes.


Where was the CPU request in all this

Back to the beginning:

yaml
requests:
  cpu: 500m

The request did not directly cause:

text
p99 = 180 ms

But it influenced two important elements of the system.

Placement

The scheduler counted the workload as:

text
0.5 CPU

If the real steady-state or peak demand was much larger, the node may have been filled more aggressively than the actual characteristics of the workloads suggested.

CPU weight

Under contention a request also influences the workload's relative weight.

An understated request may therefore mean:

text
more aggressive placement
+
a weaker position during competition

That is not a guarantee of a problem.

But both effects work in an unfavourable direction once CPU starts running short.


A noisy neighbor can trigger the whole incident

Assume the API itself did not change.

The problem starts at 14:00 because on the same node a batch workload goes from:

text
200m

to:

text
6 CPU

Batch has no limit.

It is perfectly entitled to try to use the available CPU.

Before 14:00:

text
node demand < capacity

After 14:00:

text
node demand > capacity

The API starts waiting in the runqueue.

From its point of view:

text
same code
similar traffic
similar CPU usage
p99 explodes

The problem does not live only "inside the Pod".

It lives in the shared resource.


What the full diagnosis looks like

We have the symptom:

text
p99 latency up

We check usage:

text
CPU usage = 900m

Not enough for a diagnosis.

We check the limit:

text
none

We check throttling:

text
nr_throttled is not growing

So:

text
this is not quota

We check PSI:

text
CPU pressure up

So:

text
runnable work is waiting

We check the node:

text
other workloads are bursting

We check scheduler latency:

text
runnable -> running delay up

Diagnosis:

text
CPU contention
caused by overloading the shared node

Not:

text
the CPU limit is too low

Not:

text
the application suddenly executes more instructions

Not:

text
900m CPU is too much

Just a specific mechanism.


The whole path

The incident can be reduced to:

text
understated / aggressive requests
        |
        v
dense bin packing
        |
        v
simultaneous burst of workloads
        |
        v
CPU demand > CPU capacity
        |
        v
more runnable tasks than available CPU
        |
        v
runqueue
        |
        v
scheduler latency
        |
        v
request execution stretched over time
        |
        v
p99 latency up

And the observability of that same path:

text
CPU usage
    |
    | does not show the whole demand
    v
cpu.stat
    |
    | no throttling
    v
CPU PSI
    |
    | pressure rises
    v
scheduler / runqueue latency
    |
    v
confirmed CPU contention

This is the most important conclusion of the whole series:

text
"a CPU problem"

is not one thing.

It may mean:

text
too little CPU capacity
a bad request
CPU throttling
CPU contention
a noisy neighbor
bad placement on the hardware

That is why debugging CPU does not start with the question:

text
what percentage of CPU do I see?

but with:

text
did the workload get CPU when
it wanted to do work?

If not, the next question is:

text
what exactly prevented it?

And only then do we have a diagnosis.