CPU control is more nuanced than memory, because CPU is not
consumable in the same way. Memory a container holds is memory
nobody else can have. CPU time a container does not use is simply
given to somebody else, and reappears the instant it is wanted.
That single difference is why the most common CPU-limit bug is not
“the limit was wrong” but “the limit did nothing until the day it
did everything”.
There are three controls, and they are not interchangeable.
Quota — a hard ceiling
Configuration changecpu quota— Limit the container to the equivalent of 1.5 cores. Enforced by the kernel every 100 ms regardless of how idle the host is.
docker run -d --name api --cpus 1.5 myorg/api:1.0.0
--cpus 1.5 is shorthand for --cpu-quota=150000 --cpu-period=100000
and lands in the cgroup as cpu.max = "150000 100000": 150 ms of
CPU time per 100 ms wall-clock period, which across multiple threads
is 1.5 cores’ worth.
A quota is absolute. It applies on a completely idle host. This
is the control you want for a hard cap, for capacity planning, and
for making a workload’s performance reproducible.
Shares — a relative weight, and only under contention
Configuration changecpu shares— Relative priority. On an idle host these two containers behave identically.
docker run -d --name priority-app --cpu-shares 2048 myorg/api:1.0.0
docker run -d --name batch --cpu-shares 512 myorg/batch:1.0.0
--cpu-shares sets a weight. If both containers want CPU at the
same instant and there is not enough to go round, the scheduler
divides the contested time in proportion to the weights. If only one
wants CPU, it gets all of it, share value irrelevant.
There is no --cpu-weight flag on docker run. The Docker flag is
--cpu-shares (or -c); cpu.weight is the cgroup v2 file it
writes to, and the two numbers are not the same value.
Pinning — which cores, not how many
Configuration changecpu pinning— Restrict the container to CPUs 0 and 1. Also constrains which NUMA node its memory allocations are local to.
docker run -d --name db --cpuset-cpus="0,1" postgres:16
--cpuset-cpus is orthogonal to the other two: it chooses which
cores, while quota chooses how much time. Use it for:
Latency-sensitive workloads that suffer from cache eviction
when the scheduler migrates them between cores.
NUMA locality. Pin the container to the cores on the socket
whose memory it is using; pair with --cpuset-mems for the
matching NUMA node. Check the topology with lscpu | grep NUMA
before choosing numbers.
Segregating a noisy workload onto cores that nothing
latency-sensitive uses.
Pinning is easy to get wrong at scale. Two containers pinned to
0,1 contend with each other while cores 2–15 sit idle, and no
quota is being exceeded, so nothing in the metrics says “throttled”.
Treat a cpuset as an allocation that needs a registry, the same way
static port assignments do.
Diagnosing throttling
A throttled container is slow in a way that looks like an
application problem, a database problem, or a network problem. The
counter that settles it is in the cgroup.
Read-only / Safecpu.stat— nr_periods is how many enforcement windows have passed; nr_throttled is how many ended with the container cut off.
nr_throttled / nr_periods — here 212904/864000 ≈ 25%. One
period in four ended with the container stopped. That is severe.
throttled_usec / nr_throttled gives the average stall length —
here about 21 ms per throttled period, which is a latency budget
destroyed.
Under 1% is noise. Over 5% sustained means the quota is too low for
the workload’s shape, which is not the same as too low for its
average usage.
Read-only / Safethrottle ratio across every container— One pass over the host. Anything above a few percent deserves a look.
for c in $(docker ps -q); do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d /)
pid=$(docker inspect --format '{{.State.Pid}}' "$c")
cg=$(awk -F: '/^0::/{print $3}' "/proc/$pid/cgroup")
awk -v n="$name" '/nr_periods/{p=$2} /nr_throttled/{t=$2}
END {if (p>0) printf "%-24s %6.2f%% throttled\n", n, 100*t/p}' \
/sys/fs/cgroup"$cg"/cpu.stat
done
Runtimes that read the limit — and the ones that do not
Point 1 above only works if the application knows what its limit is.
JVM: container-aware since JDK 10. availableProcessors()
reflects cpu.max, and thread pools sized from it come out right.
-XX:ActiveProcessorCount=N overrides it if you need to.
Go: container-aware since Go 1.25, which reads cpu.max
and sets GOMAXPROCS from it, re-checking periodically in case
the limit changes. Anything built with Go 1.24 or earlier defaults
GOMAXPROCS to the host’s core count — 64 threads in a
one-core container on a large host — unless the program uses a
library such as automaxprocs or sets the variable explicitly.
Node.js: the libuv thread pool defaults to 4 regardless, but
cluster-mode workers sized from os.cpus().length see the host.
Python / gunicorn: multiprocessing.cpu_count() sees the
host. A workers = 2 * cpu_count() + 1 line in a config file is a
very common cause of exactly the trap above.
Read-only / Safewhat the application thinks it has— nproc reads the CPU affinity mask, so it reflects --cpuset-cpus but NOT --cpus. That gap is the bug.
nproc inside a container with --cpus 1 on a 32-core host prints
32. It is not lying — the affinity mask really does include all 32
cores; the quota just limits how much of them the container may
use. Any code that sizes a pool from nproc is reading the wrong
number.
Choosing between them
Goal
Control
Why
Cap a workload so capacity planning is predictable
--cpus
absolute, applies on an idle host
Stop a batch job disturbing an interactive one
--cpu-shares on both
relative; only acts when it needs to
Guarantee tail latency for one service
--cpuset-cpus plus a quota
removes migration and contention
Keep memory access NUMA-local
--cpuset-cpus + --cpuset-mems
pin cores and memory to one socket
Reduce throttle stall length without changing throughput
--cpu-period + --cpu-quota
shorter enforcement window
A useful default for a mixed production host is --cpus on
everything (so nothing can run away) plus --cpu-shares within a
tier (so the important thing wins when they collide). Pinning is a
specialist tool; reach for it when you have measured a problem it
solves.
Knowledge check
Knowledge check · 6 questions
Q1. `--cpus 1.5` translates into which cgroup v2 file and value?
Q2. A sixteen-thread service with `--cpus 1` shows 40% average CPU and normal p50 latency, but p99 spikes to around 100 ms. What is happening?
Q3. On a cgroup v2 host, what cpu.weight does a container started with `--cpu-shares 1024` end up with, relative to a container started with no share setting at all?
Q4. Which of these read the container CPU quota rather than the host core count? Select all that apply.
Q5. CPU limits can be changed on a running container without recreating it.
Q6. Which two fields in cpu.stat give you the throttling ratio, and roughly what value is a problem?
Passing score: 75%. Answers are checked in this browser.