Skip to main content
RunBook Academy

Docker & ContainersXV · Resource ControlsCPU

CPU shares, quotas, and pinning

Intermediate⏱ ~30 min

What you'll learn

  • Configure CPU limits correctly
  • Use CPU pinning for performance-critical workloads
  • Diagnose CPU throttling from cpu.stat
  • Explain why a share-based limit appears to do nothing in testing

Prerequisites

None — start here.

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-09

Not yet marked complete on this device.

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
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
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
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
$ CID=api; PID=$(docker inspect --format '{{.State.Pid}}' "$CID"); CG=$(awk -F: '/^0::/{print $3}' "/proc/$PID/cgroup"); cat /sys/fs/cgroup"$CG"/cpu.stat
usage_usec 918273645
user_usec 701122334
system_usec 217151311
nr_periods 864000
nr_throttled 212904
throttled_usec 4471928000

Illustrative output

The number that matters is the ratio:

  • 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
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
CID=api
docker exec "$CID" nproc
docker exec "$CID" cat /sys/fs/cgroup/cpu.max

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

GoalControlWhy
Cap a workload so capacity planning is predictable--cpusabsolute, applies on an idle host
Stop a batch job disturbing an interactive one--cpu-shares on bothrelative; only acts when it needs to
Guarantee tail latency for one service--cpuset-cpus plus a quotaremoves migration and contention
Keep memory access NUMA-local--cpuset-cpus + --cpuset-memspin cores and memory to one socket
Reduce throttle stall length without changing throughput--cpu-period + --cpu-quotashorter 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

  1. Q1. `--cpus 1.5` translates into which cgroup v2 file and value?

  2. 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?

  3. 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?

  4. Q4. Which of these read the container CPU quota rather than the host core count? Select all that apply.

  5. Q5. CPU limits can be changed on a running container without recreating it.

  6. 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.