Skip to main content
RunBook Academy

Docker & ContainersXXXI Β· TroubleshootingPerformance

Performance problems β€” slow application, container, host

Advanced⏱ ~34 mindocker

What you'll learn

  • Diagnose performance problems systematically
  • Distinguish application, container, and host bottlenecks
  • Use the right tools for each layer
  • Detect CFS throttling, which `docker stats` does not show
  • Read container memory correctly despite page cache accounting

Prerequisites

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-12

Not yet marked complete on this device.

The application is slow. The question is β€œwhere” β€” application, container, or host. The right tool depends on the layer.

Two of the layers lie to you if you use the obvious tool. docker stats reports CPU as a percentage averaged over a sampling interval, which hides the throttling that is the single most common containerised performance fault. And container memory in docker stats is a CLI-computed figure that subtracts cache β€” a reasonable choice that nonetheless means the number you see and the number the OOM killer uses are different numbers.

The methodology

flowchart TD
  S[App is slow] --> Q1{Application metrics?}
  Q1 -- latency high --> A[App-level issue]
  Q1 -- latency normal --> Q2{Container metrics?}
  Q2 -- CPU throttled --> C1[Increase quota or reduce usage]
  Q2 -- memory high --> C2[Memory leak, OOM risk]
  Q2 -- network saturated --> C3[Network config]
  Q2 -- normal --> Q3{Host metrics?}
  Q3 -- host CPU high --> H1[Host saturation]
  Q3 -- host memory high --> H2[Sum of containers > RAM]
  Q3 -- host disk I/O high --> H3[Disk contention]
  Q3 -- normal --> Q4{External dependency?}
  Q4 -- yes --> D[Database, cache, downstream]

The first measurement: is it throttled?

Before anything else, ask the cgroup whether the kernel has been stopping your process. This is a counter, not a sample, so it cannot be missed by bad timing, and it answers a question no other tool answers.

Read-only / SafeCFS throttling
CONTAINER=api

PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
CGROUP=$(awk -F: '!/^0:/{print $3}' /proc/"$PID"/cgroup | head -1)
CG=/sys/fs/cgroup"$CGROUP"

echo '--- limit ---'
cat "$CG"/cpu.max

echo '--- throttling, sample 1 ---'
cat "$CG"/cpu.stat
sleep 60
echo '--- throttling, sample 2 ---'
cat "$CG"/cpu.stat

Illustrative output:

--- limit ---
50000 100000

--- throttling, sample 1 ---
usage_usec 184203118
user_usec 141002331
system_usec 43200787
nr_periods 421866
nr_throttled 98412
throttled_usec 2914003221

cpu.max of 50000 100000 is half a core: 50 ms of CPU time allowed per 100 ms period. nr_throttled divided by nr_periods is 23% β€” nearly a quarter of all scheduling periods ended with the container frozen until the next one began. throttled_usec is the total wall-clock time spent stopped.

Now look at what docker stats reports for the same container:

CONTAINER   CPU %    MEM USAGE / LIMIT   MEM %   NET I/O       BLOCK I/O   PIDS
api         38.42%   412MiB / 1GiB       40.2%   1.2GB / 890MB  0B / 4.1MB  34

38% CPU. Plenty of headroom, apparently. Both numbers are correct and they describe different things: 38% is the average over the sampling interval, and the throttling happens inside each 100 ms period. The container burns its 50 ms in the first 20 ms of the period and then does nothing for 80 ms. Averaged, that is modest utilisation. Experienced by a request in flight, it is an 80 ms stall.

Memory: two numbers, and only one kills you

Read-only / Safethe memory numbers that matter
CONTAINER=api

PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
CGROUP=$(awk -F: '!/^0:/{print $3}' /proc/"$PID"/cgroup | head -1)
CG=/sys/fs/cgroup"$CGROUP"

echo 'limit:      ' "$(cat "$CG"/memory.max)"
echo 'current:    ' "$(cat "$CG"/memory.current)"
echo 'peak:       ' "$(cat "$CG"/memory.peak 2>/dev/null || echo n/a)"
echo '--- events (oom_kill is the one that matters) ---'
cat "$CG"/memory.events
echo '--- breakdown: anon is unreclaimable, file is page cache ---'
grep -E '^(anon|file|slab|sock) ' "$CG"/memory.stat

memory.current includes page cache. docker stats deliberately does not: the CLI computes memory by subtracting cache from the total β€” inactive_file on cgroup v2 β€” while the API returns the raw figure. So the two disagree by design, and the disagreement is the page cache.

Which one predicts an OOM kill? Neither directly. The kernel enforces memory.max against memory.current, but page cache is reclaimable β€” it will be dropped under pressure rather than triggering a kill. The number that actually predicts trouble is anon from memory.stat: anonymous memory cannot be reclaimed, only swapped or killed.

ReadingMeaning
memory.current near memory.max, anon low, file highHealthy. The cgroup is using its allowance as cache and will give it back.
anon climbing steadily toward memory.maxA leak or an undersized limit. This one ends in a kill.
memory.events oom_kill non-zeroIt has already happened, at least once, whether or not you noticed.
memory.events high non-zeroReclaim pressure is being applied β€” the application is being slowed to stay under the limit.

memory.events deserves a place in monitoring precisely because it is cumulative. A container that was OOM-killed at 04:00 and restarted looks completely normal at 09:00 in every live metric; the counter still says it happened.

The tools per layer

Application

# Profiling
go tool pprof http://app:6060/debug/pprof/profile  # 30s CPU profile
py-spy dump --pid PID                              # Python
node --prof app.js                                 # Node.js

For compiled languages, pprof / async-profiler / perf show function-level hotspots. For interpreted, the runtime’s profiler.

The container-specific catch: these tools need to see the process, and the process is in another PID namespace. py-spy and perf run from the host need --pid with the host-side PID, which is docker inspect --format '{{.State.Pid}}', not the PID the container reports. Run from inside the container they need SYS_PTRACE, which the default seccomp and capability set does not grant.

Read-only / Safeprofile a containerised process
CONTAINER=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")

# From the host, using the host-side PID. No container change needed.
sudo py-spy dump --pid "$PID"
sudo perf top -p "$PID"

# Map host PIDs to in-container PIDs so the two views line up
docker top "$CONTAINER" -eo pid,ppid,lwp,pcpu,rss,args

# If you must attach from inside, add the capability to a NEW debug
# container sharing the target's namespaces rather than to the target
docker run --rm -it \
--pid "container:$CONTAINER" \
--network "container:$CONTAINER" \
--cap-add SYS_PTRACE \
nicolaka/netshoot

The second form is the pattern worth learning. A sidecar debug container joined to the target’s PID and network namespaces gets full visibility without restarting the target, without changing its security profile, and without leaving SYS_PTRACE granted to a production workload afterwards. It is also the only way to get tooling into a distroless image.

Container

docker stats --no-stream api
docker top api
docker exec api cat /proc/loadavg

docker stats for a quick look, the cgroup files for anything you intend to act on. docker top is ps executed on the host against the container’s processes, so it works even when the image contains no ps.

Host

top
vmstat 1
iostat -xz 1
ss -tnp
cat /proc/pressure/cpu /proc/pressure/io /proc/pressure/memory

Standard Linux performance toolset, with PSI added because it answers β€œis anything waiting?” which utilisation does not.

A worked example

Application latency is 800 ms (target: 200 ms). Walk the layers, and record what each one rules out.

# 1. Application metrics
#    Request duration: 800 ms p99, 190 ms p50
#    Database query duration: 750 ms p99, 12 ms p50
#    β†’ The tail is in the database call. p50 is fine, so this is not
#      a uniformly slow system; something is intermittent.

# 2. Container: is the API being throttled?
#    nr_throttled/nr_periods = 0.4%  β†’ no. Rules out the quota.

# 3. Host CPU and pressure
#    /proc/pressure/cpu:  some avg10=2.1   β†’ not CPU-starved
#    /proc/pressure/io:   some avg10=44.7  β†’ tasks blocked on I/O 45%
#      of the time. This is the finding.

# 4. Which device, and which container
#    iostat -xz 1: vda %util 96, await 38 ms
#    io.stat per cgroup: the db container owns most of the rbytes/wbytes

# 5. Inside the database
#    Long-running queries with sequential scans on a large table
#    β†’ the disk saturation is a symptom of a missing index

The bottleneck is the database’s disk, driven by unindexed queries. Note what the walk bought: step 2 eliminated the most commonly-blamed cause in one command, and step 3 named the resource before anyone had to guess.

The intermediate wrong answers this avoids: adding CPU to the API container (step 2 said no), adding memory to the host (nothing pointed there), and moving to host networking (network was never implicated).

Disk and network, per container

Read-only / Safeper-container I/O attribution
CONTAINER=db
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
CGROUP=$(awk -F: '!/^0:/{print $3}' /proc/"$PID"/cgroup | head -1)
CG=/sys/fs/cgroup"$CGROUP"

# Bytes and IOPS per block device, cumulative. Sample twice and subtract.
cat "$CG"/io.stat

# Is this cgroup stalling on I/O specifically?
cat "$CG"/io.pressure 2>/dev/null

# Map the major:minor numbers in io.stat to device names
lsblk -o NAME,MAJ:MIN,SIZE,MOUNTPOINT

# Network counters, from inside the container's netns
sudo nsenter -t "$PID" -n cat /proc/net/dev

io.stat keys are major:minor device numbers, which is why lsblk is in the block. Attributing device saturation to a container by reading iostat on the host and guessing is the step this replaces.

Verification

Read-only / Safeverify the performance fix
#!/usr/bin/env bash
set -euo pipefail
CONTAINER=api
WINDOW=120

PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
CGROUP=$(awk -F: '!/^0:/{print $3}' /proc/"$PID"/cgroup | head -1)
CG=/sys/fs/cgroup"$CGROUP"

read_stat() { awk -v k="$1" '$1==k {print $2}' "$CG"/cpu.stat; }

p0=$(read_stat nr_periods);   t0=$(read_stat nr_throttled)
sleep "$WINDOW"
p1=$(read_stat nr_periods);   t1=$(read_stat nr_throttled)

periods=$(( p1 - p0 )); throttled=$(( t1 - t0 ))
[ "$periods" -gt 0 ] || { echo 'FAIL: no scheduling periods observed' >&2; exit 1; }

pct=$(( throttled * 100 / periods ))
echo "throttled $throttled of $periods periods = $pct percent"
[ "$pct" -lt 1 ] || { echo "FAIL: still throttling at $pct percent" >&2; exit 1; }

oom=$(awk '$1=="oom_kill"{print $2}' "$CG"/memory.events)
[ "$oom" -eq 0 ] || { echo "FAIL: $oom OOM kills recorded" >&2; exit 1; }

echo OK

Sampling a delta rather than reading the cumulative value is what makes this a verification. nr_throttled never decreases, so a container that was throttled badly before the fix will still show a large total afterwards; only the rate over a fresh window tells you whether the fix worked.

Knowledge check

Knowledge check Β· 7 questions

  1. Q1. A user reports the API is slow. The first thing to investigate is:

  2. Q2. `docker stats` shows a container at 38% CPU, but p99 latency is five times p50. Which measurement would explain this?

  3. Q3. A container's `memory.current` sits at 98% of `memory.max` and has done for weeks with no OOM kills. What is the most likely reading?

  4. Q4. Which are true about pressure stall information (PSI)? Select all that apply.

  5. Q5. Before raising a container's `--cpus`, which checks are worth doing? Select all that apply.

  6. Q6. Restarting the container is the right first response to the API being slow.

  7. Q7. `docker stats` must be given `--no-stream` before it is safe to put in a capture script.

Passing score: 75%. Answers are checked in this browser.