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.
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.
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
'
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
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'
--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
Knowledge check
Why does the CPU lab inspect cpu.max in addition
to docker stats?
Because stats is observed usage, while cpu.max is
direct evidence of the cgroup v2 quota policy.
Why set --memory-swap equal to
--memory in the bounded OOM lab?
Docker documents that equal values disable additional swap allowance, making the memory ceiling easier to reason about.
Is a PID-limit experiment allowed to use a fork bomb?
No. The lesson uses a fixed small number of process-creation attempts so the demonstration remains bounded and safe.
What does --ulimit nofile=64:64 change?
The soft and hard per-process open-file descriptor rlimit; it is not a memory or PID cgroup limit.
A harmless device mapping fails on Docker Desktop. Should you
retry with --privileged?
No. Record the daemon/VM device boundary and use the conceptual evidence. Broad privilege is not a valid troubleshooting shortcut.
Official references and version notes
- Docker Docs — Resource constraints — CPU and memory hard/soft controls, swap semantics, and OOM guidance.
-
Docker Docs — Runtime metrics
—
docker stats, cgroup discovery, and cgroups v2 behavior. - Docker CLI — docker container run — CPU, memory, PID, ulimit, device, and cgroup options.
- Docker CLI — docker container stats — CPU, memory, PIDs, I/O, and one-shot JSON/template output.
- Compose Deploy Specification — resources — limits, reservations, PIDs, and device reservations.
- Docker Desktop — Resources — CPU/memory/swap capacity of the Docker Desktop Linux VM.
- Docker Engine 29 release notes — current Engine baseline and recent runtime/ulimit changes.
-
Linux kernel — Control Group v2
— controller semantics for
cpu.*,memory.*, andpids.*.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.