> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Your Container Has Two CPUs and Is Using One Point Three
- URL: https://debugly.dev/container-cpu-throttling-cgroup-cfs/
- Published: 2026-09-16T03:30:00.000Z
- Updated: 2026-10-17T05:57:51.000Z
- Description: The container's CPU limit is two cores and its usage never exceeds one point three, yet requests slow down. CFS quota throttles in hundred millisecond…
- Author: Rohit Bhadani
- Tags: Deep Dive, Docker, DevOps

The dashboard said the container was using 65 percent of its two CPU limit, comfortably under, and yet p99 latency climbed whenever traffic arrived in bursts. The team kept raising the limit and the throttling followed, because the limit was never the constraint. The constraint was how the limit is enforced, in short windows, and bursts that fit under the average still exceed the window.

This is the least intuitive mechanism in containerised compute, and it explains a large share of "the container is slow but CPU looks fine" incidents.

This was Docker 27.5 on Linux 6.8 with cgroup v2, and Kubernetes 1.32 expressing the same limits. The enforcement is the kernel's CFS bandwidth control either way.

## The enforcement model

A CPU limit is not a speed governor. It is a budget per period. With a limit of two CPUs, the cgroup gets two hundred milliseconds of CPU time per one hundred millisecond period, to spend across its threads. Spend the budget early and the cgroup is throttled for the rest of the period, its runnable threads parked even though the host has idle CPUs.

So a workload that needs four cores for fifty milliseconds, then idles, uses an average of two cores and is throttled every period, because the first fifty milliseconds consumed the entire budget. The average never shows the stall. The stall shows up as latency.

This is why usage reads 1.3 of 2 while requests wait. The 1.3 is the average. The throttle is the shape inside each period, and the shape is what users feel.

## Seeing the throttle

The kernel counts it for you. In cgroup v2:

```bash
cat /sys/fs/cgroup/cpu.stat

```

The fields `nr_throttled` and `throttled_usec` count how many times and for how long the cgroup was throttled. A rising `throttled_usec` while average usage sits under the limit is the diagnosis, plain and unambiguous.

Kubernetes exposes the same numbers through the container metrics, and the ratio of throttled time to elapsed time is the headline number. Above a few percent on a latency sensitive service, the limit is actively hurting you even though it is never "exceeded".

A process level view helps too. If your application's own profiling shows runnable threads waiting while the host is idle, that gap is the throttle, because nothing else parks runnable threads on an idle machine.

## Why raising the limit is a partial fix

Raising the limit raises the per period budget, so bursts fit more often, and the throttling reduces. That is why the team saw improvement each time, and why it kept coming back, because the next traffic spike produced a bigger burst and the same dynamic returned.

The honest options are three, and they trade differently.

**Raise the limit well above the burst.** If bursts are the workload, budget for the burst, not the average. A limit set to the peak burst headroom removes the throttle at the cost of reserving more.

**Remove the limit, keep the request.** In cgroup v2 with a request but no limit, the pod gets its guaranteed share and can use idle host CPU when available, never throttled by an artificial ceiling. This trades a hard ceiling for burstability, and it is the right call for bursty latency sensitive services on nodes you control. The eviction side of that trade is the QoS story in [requests, limits and the QoS class](https://debugly.dev/kubernetes-requests-limits-and-qos-classes/).

**Smooth the bursts.** If the burst is self inflicted, for example a cron that wakes all workers at once, or a batch that fans out, smoothing the arrival removes the throttle without touching limits. The burst is often a scheduling artefact rather than genuine demand.

## The multi thread multiplier

A subtlety that bites Go and Java services: the budget is per cgroup, not per thread. A service with two CPUs of budget and forty runnable goroutines or threads can burn the two hundred milliseconds in an instant, because all forty consume simultaneously. The more parallelism inside the container, the faster the budget burns, so highly threaded services throttle at lower apparent usage.

This is why the same binary throttles in a container and not on a laptop. The laptop has no period budget. The container does, and forty threads spend it forty times faster than one.

Tuning the runtime's parallelism to the limit, such as capping GOMAXPROCS to the CPU limit, aligns the burn rate with the budget and is one of the few genuinely effective mitigations short of raising the limit. Libraries that detect the cgroup and set parallelism exist precisely because this trap is so common.

## The dashboard that hides it, precisely

It is worth naming exactly why the usual graphs mislead, because the mismatch is structural. Utilisation graphs average CPU over the scrape interval, typically fifteen to sixty seconds, which is hundreds of CFS periods. Throttling happens inside a single period and is over before the average can show it. So the graph and the stall are both true, at different resolutions, and the graph's resolution is simply too coarse to contain the fact that matters. The fix is to plot the throttled time series from cpu.stat, which is measured at the period resolution where the pain actually lives, next to the utilisation graph, so the two resolutions sit side by side and the disagreement is visible instead of hidden.

## The rule I keep

A CPU limit is a budget per hundred millisecond period, not an average speed, and throttling is the kernel parking your runnable threads once the period's budget is spent. Average usage under the limit tells you nothing about the shape inside the period, so read `cpu.stat` throttled time, not the utilisation graph.

Budget for the burst, or remove the ceiling and keep the floor, or smooth the arrival. And align the runtime's parallelism with the budget, because forty threads spend a two CPU allowance before the first request finishes thinking.

The latency tail this produces is the queueing version of [p99 and why averages lie](https://debugly.dev/p99-latency-and-why-averages-lie/), and the memory sibling, where the limit bites as a kill rather than a stall, is [OOMKilled and the container memory limit](https://debugly.dev/oomkilled-container-memory-limit-debugging/).

---

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)