Chapter 22Lesson 02~120 minutes

CPU, Memory, PIDs, Devices, ulimits, cgroups v2, Reservations, and Container Resource Governance: Guided Hands-On Workflow and Core Operations

Run safe bounded CPU, memory, PID, ulimit, and optional device experiments and verify each control with Docker and cgroup evidence.

Resource governancecgroups v2CPU & memoryPIDs & ulimitsEvidence-first

Learning objectives

  • Create bounded disposable CPU, memory, PID, and ulimit experiments.
  • Change one control at a time and prove the resulting Docker and cgroup state.
  • Trigger a safe memory-limit failure and preserve the exit/OOM evidence.
  • Treat device access as a narrow host-authority grant rather than a reason to use privileged mode.
Safety boundary. Every resource in this lesson is disposable and label-scoped. CPU loops are bounded by container limits, the memory experiment is capped at 64 MiB with no additional swap, PID creation is capped, and device access is optional. Never use these experiments on a production daemon or replace them with a fork bomb, host-wide stress test, or privileged container.

1. Preflight: record the execution boundary before creating load

The same command has different consequences on native Linux and Docker Desktop. On Desktop, the Linux daemon, cgroups, and Linux devices live inside the Docker VM. Begin with read-only evidence and stop if the active context points to a shared or production host.

docker context show
docker version
docker info --format 'os={{.OSType}} cgroup={{.CgroupVersion}} driver={{.CgroupDriver}} cpus={{.NCPU}} memory={{.MemTotal}}'
docker compose version || true
docker buildx version || true

docker pull alpine:3.22
docker image inspect alpine:3.22 --format 'image={{.Id}} repoDigests={{json .RepoDigests}}'

2. CPU lab: ceiling first, then evidence

Create one named CPU worker with a half-CPU quota. The shell loop is intentionally simple and disposable; --cpus 0.50 changes the container CPU quota rather than pinning it to a particular core. The label makes cleanup auditable.

docker rm -f dkr22-cpu 2>/dev/null || true
docker run -d --name dkr22-cpu --label academy=docker-ch22   --cpus 0.50 alpine:3.22 sh -c 'while :; do :; done'

docker inspect dkr22-cpu --format 'NanoCpus={{.HostConfig.NanoCpus}} CpuQuota={{.HostConfig.CpuQuota}} CpuPeriod={{.HostConfig.CpuPeriod}}'
docker stats --no-stream dkr22-cpu

docker exec dkr22-cpu sh -c '
  if [ -f /sys/fs/cgroup/cpu.max ]; then
    printf "cpu.max="; cat /sys/fs/cgroup/cpu.max
    printf "cpu.weight="; cat /sys/fs/cgroup/cpu.weight
  fi
'
Expected evidence. On cgroups v2, cpu.max should show a quota/period pair rather than max. docker stats is observational; brief samples and VM scheduling can vary, so verify the cgroup setting instead of demanding one exact percentage.

3. Memory lab: trigger a bounded failure and preserve the cause

The goal is not to “crash Docker”; it is to prove how a process behaves when its memory cgroup has a 64 MiB ceiling and no extra swap allowance. A small Python image makes allocation behavior explicit. Depending on allocator and kernel behavior, the process may be killed by the memory cgroup or may terminate after allocation failure; preserve both OOMKilled and the exit code.

docker rm -f dkr22-mem 2>/dev/null || true
docker pull python:3.13-alpine

docker run --name dkr22-mem --label academy=docker-ch22   --memory 64m --memory-swap 64m   python:3.13-alpine   python -c 'blocks=[]
while True: blocks.append(bytearray(8*1024*1024))'

# The command above is expected to fail; inspect before removing it.
docker inspect dkr22-mem \
  --format 'status={{.State.Status}} exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} memory={{.HostConfig.Memory}} memorySwap={{.HostConfig.MemorySwap}}'
docker logs dkr22-mem 2>&1 | tail -40

docker start dkr22-mem >/dev/null 2>&1 || true
sleep 1
docker exec dkr22-mem sh -c 'test -f /sys/fs/cgroup/memory.events && cat /sys/fs/cgroup/memory.events || true' 2>/dev/null || true
Interpretation. If the container exits too quickly to inspect cgroup files with exec, that is expected. Preserve docker inspect first; on a native Linux host you may also inspect the container cgroup directory read-only. Do not increase the limit until you have identified whether the failure is genuine workload demand, a leak, or an unrealistic test envelope.

4. PIDs lab: bounded process creation, never a fork bomb

--pids-limit protects the host from a container that creates too many processes or threads. The following loop attempts only a small fixed number of child processes and uses short sleeps, so it demonstrates denial without uncontrolled growth.

docker rm -f dkr22-pids 2>/dev/null || true
docker run --name dkr22-pids --label academy=docker-ch22   --pids-limit 16 alpine:3.22 sh -c '
    i=1
    while [ "$i" -le 24 ]; do
      sleep 8 &
      echo "attempt=$i status=$?"
      i=$((i+1))
    done
    wait
  ' || true

docker inspect dkr22-pids --format 'exit={{.State.ExitCode}} pidsLimit={{.HostConfig.PidsLimit}}'
docker logs dkr22-pids 2>&1 | tail -40

5. ulimit lab: prove the process-level ceiling

Rlimits are distinct from cgroups. This example sets both soft and hard RLIMIT_NOFILE to 64 and reads it from the process. Engine 29's runtime/packaging changes make this especially important: do not assume the old high Docker default.

docker run --rm --name dkr22-ulimit --label academy=docker-ch22   --ulimit nofile=64:64 alpine:3.22 sh -c '
    printf "nofile="; ulimit -n
  '

6. Optional device lab: narrow mapping only

Device nodes belong to the daemon host. Only on an authorized local Linux daemon, and only if the source device is known harmless and present, you can demonstrate a narrow mapping such as read access to /dev/null. This is optional because remote daemons and Docker Desktop VM boundaries change what “host device” means.

# OPTIONAL: authorized local Linux daemon only.
docker run \
  --rm \
  --name dkr22-device \
  --label academy=docker-ch22 \
  --device /dev/null:/dev/labnull:r   alpine:3.22 sh -c 'ls -l /dev/labnull; test -c /dev/labnull'
Do not substitute --privileged. If the mapping is unavailable, record that platform limitation. A failed narrow device lab is safer evidence than a successful over-privileged container.

7. Compare declared policy with observed state

Layer Before/after evidence What it proves
Docker config docker inspect .HostConfig What the daemon recorded: memory, NanoCpus/quota, PidsLimit, Ulimits, Devices.
Kernel/cgroup cpu.max, memory.max, pids.max What the Linux controller exposes to the workload.
Runtime metrics docker stats --no-stream Observed use at a point in time.
Failure state .State.ExitCode, .State.OOMKilled, logs How the main process actually terminated.
Platform capacity docker info, Desktop Resources The denominator that constrains every per-container policy.

8. Challenge: choose the right layer

A service is slow. docker stats shows low CPU but memory usage is at the configured limit; the container restarts with exit code 137 and OOMKilled=true. Which layer should you investigate first?

Answer strategy: preserve the failed container evidence, inspect the memory limit and memory.events, then profile application memory. Increasing CPU quota or changing DNS cannot explain the observed OOM evidence.

9. Exact cleanup

Remove only named lab containers. Do not run broad prune commands; keep pulled images if later lessons need them, or remove exact tags only after verifying no other lab depends on them.

docker rm -f dkr22-cpu dkr22-mem dkr22-pids dkr22-ulimit dkr22-device 2>/dev/null || true
docker ps -a --filter label=academy=docker-ch22
Next lesson

Next: turn these mechanics into design decisions—hard versus soft controls, quota versus affinity, swap policy, host-level capacity, and least-privilege device access.

Knowledge check

Why does the CPU lab inspect cpu.max in addition to docker stats?

Why set --memory-swap equal to --memory in the bounded OOM lab?

Is a PID-limit experiment allowed to use a fork bomb?

What does --ulimit nofile=64:64 change?

A harmless device mapping fails on Docker Desktop. Should you retry with --privileged?

Official references and version notes

Version baseline, verified 2026-09-21.

Docker Engine 29.8.1 is the current Engine baseline used here. Earlier Engine 29 packaging moved to containerd 2.x; one important consequence documented in Engine 29 release notes is that the default container nofile limit can follow systemd's default instead of older very-high Docker defaults. Docker Desktop runs Linux containers inside a VM, so host capacity, cgroup paths, devices, and swap evidence belong to that VM boundary. Record docker version, docker info, and the actual cgroup mode on your host rather than assuming these values.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.