Skip to main content
RunBook Academy

← All runbooks in Docker & Containers

medium riskservice affecting~30 min

Runbook: Sustained high CPU — attribute it before you act

1 · Prerequisites

Confirm every item is in place before any state change.

  • Shell access on the Docker host; sudo is needed only for reading another user process detail and for perf
  • The host runs cgroup v2 (confirm with stat -fc %T /sys/fs/cgroup, which should print cgroup2fs)
  • You know the normal CPU baseline for this host, because high is a comparison and without a baseline it is a guess
  • You can state whether the complaint is CPU usage on a dashboard or latency reported by users; they lead to different steps
  • A second terminal is available, because most of these measurements are deltas taken over an interval

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · uptime
  • · nproc
  • · docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.PIDs}}'
  • · docker ps --format '{{.Names}} {{.Status}}'
  • · CONTAINER=web
  • · docker inspect -f '{{.HostConfig.NanoCpus}} {{.HostConfig.CpuQuota}} {{.HostConfig.CpuPeriod}} {{.HostConfig.CpuShares}} {{.HostConfig.CpusetCpus}}' "$CONTAINER"
  • · CID=$(docker inspect -f '{{.Id}}' "$CONTAINER"); cat /sys/fs/cgroup/system.slice/docker-"$CID".scope/cpu.stat

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Record the host baseline with uptime and nproc; expect the 1-minute load average and the CPU count. A load average below the CPU count is not a saturated host, whatever a percentage graph shows.
  2. 2Run docker stats --no-stream and sort by CPUPerc; expect one container to stand out. If every container is modest and the host is still busy, the consumer is on the host outside Docker and this runbook is the wrong one.
  3. 3Take a second docker stats --no-stream sample 30 seconds later; expect the same container to lead. A single sample catches startup spikes and health checks and attributes them as though they were sustained.
  4. 4Confirm the attribution from the cgroup rather than the summary - read usage_usec in /sys/fs/cgroup/system.slice/docker-"$CID".scope/cpu.stat twice, 10 seconds apart; expect the delta divided by 10 million to equal the number of CPUs consumed.
  5. 5Break the container down per process with docker top "$CONTAINER" -eo pid,pcpu,etime,comm --sort=-pcpu; expect one process or thread group to dominate. These are host PIDs, which is what makes the next step possible.
  6. 6For an unattributed host process, map its PID to a container with cat /proc/PID/cgroup; expect a line containing docker- followed by the full 64-character container ID, or no docker path at all if the process is not in a container.
  7. 7Read cpu.stat for nr_throttled and throttled_usec; expect both to be near zero. A throttled_usec that climbs while CPUPerc sits below the limit is throttling, and that is a latency incident rather than a capacity one.
  8. 8Compare the limit with the demand - read cpu.max in the same cgroup; expect quota then period, for example 100000 100000, which is one CPU. Then decide whether the limit is too low or the workload is too high.
  9. 9Apply one change - raise the quota with docker update --cpus, pin with --cpuset-cpus, or fix the workload - and record the before and after values of nr_throttled and the application latency.
  10. 10Re-measure over a full traffic cycle rather than for 30 seconds, because CPU problems are periodic far more often than they are constant.

4 · Verification

Confirm the procedure actually fixed the problem.

  • A 10-second delta of usage_usec in the container cgroup corresponds to the expected number of CPUs and no longer to the whole host
  • nr_throttled in /sys/fs/cgroup/system.slice/docker-"$CID".scope/cpu.stat stops increasing over a five-minute observation window under representative load
  • throttled_usec is flat across two samples taken ten minutes apart
  • The 1-minute load average from uptime is below the CPU count reported by nproc
  • Application p99 latency has returned to its recorded baseline - the number that made this an incident, not the CPU graph
  • docker stats --no-stream shows no single container above its intended share across three samples

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • If docker update --cpus made throughput worse, restore the previous value with docker update --cpus and the number recorded in step 8; the change takes effect without a restart
  • If --cpuset-cpus was used to pin a container, remove the pinning with docker update --cpuset-cpus and the full CPU range, for example 0-7
  • If the limit was changed in a Compose file, revert the cpus or deploy.resources.limits.cpus value and run docker compose up -d for that service only
  • If a container was stopped to relieve the host, start it again and confirm docker ps shows it running before closing the incident
  • Reading measurements changes nothing, so steps 1 to 7 need no rollback. Only steps 8 and 9 alter state

6 · Escalation

When the runbook isn't enough, contact:

  • · CPU is consumed by kernel time rather than user time, or by a process outside every container: escalate to the platform team with the /proc/PID/cgroup output showing no docker path
  • · One container saturates its quota with no change in request volume: escalate to the application team as a suspected hot loop or a regression, attaching the per-process docker top output
  • · Throttling persists after the quota is raised to a value well above measured demand: escalate to the platform team, because the container may be contending for a specific CPU set or fighting an interrupt-heavy neighbour
  • · The host is a database or any latency-critical service: escalate before pinning CPUs, because cpuset changes interact with NUMA layout and can make latency worse

There are two CPU incidents that look alike on a dashboard and need opposite responses. In the first, a container is genuinely consuming the host. In the second, a container is being throttled — held below its quota by the scheduler — and reports modest CPU usage while every request it serves is slow. The second is the one that gets misdiagnosed, because the graph that would show it is not the CPU graph.

Attribute first. Every action in this runbook is wrong if applied to the wrong container.

Symptoms

  • Host load average above the CPU count, sustained.
  • One container at a high percentage in docker stats.
  • Or: normal CPU everywhere, and latency that has doubled.
  • Requests time out in bursts rather than continuously.

Step 1: Establish the baseline

Read-only / Safehost baseline
uptime
nproc
cat /proc/loadavg

# Pressure stall information: how much time was lost waiting for CPU
cat /proc/pressure/cpu

Load average is a count of runnable tasks, not a percentage. On a host with 8 CPUs, a load of 4 is half idle. Comparing load to nproc is the fastest way to find out whether there is an incident at all — and /proc/pressure/cpu is the honest signal for whether anything is actually waiting.

Step 2: Attribute to a container

Read-only / Safedocker stats
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.PIDs}}'

# Take a second sample. One sample is an anecdote.
sleep 30
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.PIDs}}'

docker stats reads the container’s cgroup CPU accounting over a short interval and reports the result as a percentage in which 100% is one full CPU. On an 8-CPU host a container using four cores reads roughly 400%, and a container pegged at 100% is using one core, not the whole machine. Reading it as a share of the host is the most common misreading of this output.

Read-only / Safecgroup CPU accounting
CONTAINER=web
CID=$(docker inspect -f '{{.Id}}' "$CONTAINER")
CG=/sys/fs/cgroup/system.slice/docker-"$CID".scope

cat "$CG"/cpu.stat
cat "$CG"/cpu.max       # quota and period, or the word max for unlimited
cat "$CG"/cpu.weight    # relative weight, the cgroup v2 form of cpu-shares
cat "$CG"/cpu.pressure  # time tasks in this cgroup spent waiting for CPU

# Delta over ten seconds
grep usage_usec "$CG"/cpu.stat; sleep 10; grep usage_usec "$CG"/cpu.stat

Step 3: Map a host PID to a container

Sometimes the evidence arrives the other way round: top on the host shows a PID burning a core and no obvious owner. Every containerised process carries its cgroup path in /proc.

Read-only / SafePID to container
# From the host's process table
ps -eo pid,pcpu,etime,comm --sort=-pcpu | head -10

# Which cgroup, and therefore which container, owns PID 2192?
cat /proc/2192/cgroup
# prints: 0::/system.slice/docker-CONTAINERID.scope, where CONTAINERID
# is the full 64-character container ID

# Turn that ID back into a name
docker inspect -f '{{.Name}} {{.Config.Image}}' 25fb9c8b983b

# The reverse direction: a container's main process on the host
docker inspect -f '{{.State.Pid}}' "$CONTAINER"

What this rules in or out. A /proc/PID/cgroup line with no docker- component means the process is not in a container at all — a host agent, a backup job, a kernel thread — and no amount of container tuning will touch it. That single check ends a surprising number of these incidents.

Step 4: Break it down inside the namespace

Read-only / Safeper-process inside the container
# docker top accepts ps options and runs on the HOST, so it works
# even for a distroless image with no shell and no ps of its own
docker top "$CONTAINER" -eo pid,pcpu,etime,comm --sort=-pcpu

# Thread-level, when one thread of a multi-threaded process is hot
docker top "$CONTAINER" -eLo pid,tid,pcpu,comm --sort=-pcpu | head -15

# If the image does have a shell and ps
docker exec "$CONTAINER" top -b -n 1 2>/dev/null | head -20

The PIDs docker top prints are host PIDs, which is exactly what you want: you can feed them straight to /proc, to ps, or to a profiler on the host without entering the namespace.

Step 5: Throttling — the diagnosis people miss

Read-only / Safethrottling counters
cat "$CG"/cpu.stat
# nr_periods      scheduling periods elapsed
# nr_throttled    periods in which the cgroup was throttled
# throttled_usec  total time tasks were held off the CPU

# Sample twice; only the delta means anything
grep -E 'nr_periods|nr_throttled|throttled_usec' "$CG"/cpu.stat
sleep 60
grep -E 'nr_periods|nr_throttled|throttled_usec' "$CG"/cpu.stat

Multi-threaded runtimes hit this hardest. A process with 16 threads on a host with 16 CPUs, limited to --cpus 2, can consume its entire period budget in an eighth of a period and then stall for the rest of it. Sizing the runtime’s thread pool to the CPU limit rather than to the host’s core count usually helps more than raising the quota.

Step 6: Understand which limit you actually set

This distinction decides whether your limit does anything at all on an otherwise idle host.

Flagcgroup v2 fileEnforcedBehaviour
--cpus 1.5cpu.maxAlwaysHard quota. Throttles at the limit even when the host is idle
--cpu-quota and --cpu-periodcpu.maxAlwaysThe same mechanism, expressed in raw microseconds
--cpu-shares 512cpu.weightOnly under contentionA relative weight, not a cap
--cpuset-cpus 0-3cpuset.cpusAlwaysRestricts which CPUs may be used at all

Docker’s documentation says of --cpu-shares: “This is only enforced when CPU cycles are constrained. When plenty of CPU cycles are available, all containers use as much CPU as they need. In that way, this is a soft limit.” And it “doesn’t guarantee or reserve any specific CPU access.”

Docker documents the equivalence directly: --cpus 1.5 is “the equivalent of setting --cpu-period 100000 and --cpu-quota 150000” — which is what you will see in cpu.max.

Step 7: Change one thing

Configuration changeadjust CPU allocation
# Record the current values first
docker inspect -f '{{.HostConfig.NanoCpus}} {{.HostConfig.CpuShares}} {{.HostConfig.CpusetCpus}}' "$CONTAINER"

# Raise the quota
docker update --cpus 2 "$CONTAINER"

# Or restrict which CPUs it may use
docker update --cpuset-cpus 0-3 "$CONTAINER"

# Confirm the kernel agrees with the daemon
cat "$CG"/cpu.max

docker update changes a running container’s limits without recreating it, which is what makes it safe to try during an incident and easy to revert. For a Compose-managed service, make the same change in the Compose file afterwards, or the next docker compose up will quietly restore the old value.

Common patterns

EvidenceDiagnosisAction
One container far above the rest, load above nprocGenuine saturationFind the process, then raise the limit or reduce the work
CPU at the limit, nr_throttled climbing, latency upCFS throttlingRaise --cpus, or size the runtime’s threads to the limit
Low CPU everywhere, high latency, cpu.pressure lowNot a CPU incidentCheck IO and memory pressure instead
Host busy, every container quietConsumer is outside DockerCheck /proc/PID/cgroup for the top host PIDs
Container uses every core despite --cpu-sharesShares are a weight, not a capUse --cpus if a ceiling is required
Throttled on an idle hostQuota is absolute by designExpected. Raise the quota if the limit is wrong
One thread at 100%, others idleSingle-threaded bottleneckMore quota will not help; escalate to the application team
Spikes every few minutesHealth check, cron, or GCCorrelate the interval with the health check interval first

References

  1. Runtime options with Memory, CPUs, and GPUs
  2. docker container stats
  3. docker container top
  4. docker container update
  5. Control Group v2 - cpu.max and cpu.stat