By default a container can use all the memory and CPU of the host. That’s fine for a laptop and a liability on a shared server, where one service with a leak or a hot loop starves its neighbours. Docker’s --memory, --cpus and --pids-limit flags set Linux cgroup limits that the kernel enforces. Setting them is easy. Setting them so the application inside actually behaves well within them is the real skill, and it’s what this guide is about.
Numbers below come from node:22-bookworm on a Docker Desktop VM with 8 CPUs and about 4 GB of RAM, using cgroup v2.
What the flags write into the cgroup
docker run --rm --memory 256m --cpus 0.5 node:22-bookworm \
sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max; nproc'268435456
50000 100000
8memory.max is 256 MiB in bytes. cpu.max says “50,000 microseconds of CPU time per 100,000-microsecond period”, which is what half a CPU means. And nproc still says 8. A CPU limit isn’t a smaller machine; the process sees every core and can run threads on all of them at once, it just gets paused once the group has used its quota for the current period.
In Compose the same settings are mem_limit and cpus on a service, or deploy.resources.limits with memory and cpus. docker stats shows usage against the limit live:
NAME CPU % MEM USAGE / LIMIT PIDS
api 0.00% 1.379MiB / 256MiB 1Memory: the limit is a wall, not a warning
When the processes in a container try to use more memory than memory.max (and there’s no swap allowance; setting --memory-swap equal to --memory disables it), the kernel’s OOM killer picks a process and sends SIGKILL. No exception, no stack trace, often no log line at all. Here’s a script that allocates 16 MB buffers until something stops it:
$ docker run --name oom --memory 128m --memory-swap 128m node:22-bookworm \
node -e 'const a=[]; setInterval(()=>{a.push(Buffer.alloc(16*1024*1024,1)); console.log("rss MB", Math.round(process.memoryUsage().rss/1048576))},100)'
rss MB 124
rss MB 140
rss MB 156
$ docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' oom
exit=137 oom=trueExit 137 is 128 plus signal 9. OOMKilled: true is the bit that tells you it was memory and not a stop timeout, since both end in SIGKILL. The OOMKilled exit code 137 note covers the same failure under Kubernetes.
The limit counts more than your heap. Page cache from files the container reads and writes, tmpfs contents, thread stacks and native buffers all go into the cgroup. The kernel reclaims page cache under pressure, but an app that writes a lot of files can look close to its limit in docker stats without being in trouble, and an app whose native memory grows (image processing, compression, big Buffers) can die while its heap looks tiny.
When the runtime and the cgroup disagree
Modern runtimes read the cgroup limit to size themselves. Node 22 does:
$ docker run --rm node:22-bookworm node -e 'console.log(require("v8").getHeapStatistics().heap_size_limit/1048576)'
2006
$ docker run --rm --memory 256m node:22-bookworm node -e '...same...'
259That’s better than ignoring the limit, but look at the numbers: a 259 MB heap ceiling inside a 256 MB container. V8 will happily grow the heap toward that ceiling before collecting hard, and the rest of the process (code, stacks, buffers, the GC’s own working space) needs room too. In a test that filled an array with small objects under a 256 MB limit, the container was OOM-killed with the heap at about 165 MB. With the heap capped explicitly, the failure changed character:
$ docker run --name oom3 --memory 256m --memory-swap 256m node:22-bookworm \
node --max-old-space-size=128 -e '...same allocation loop...'
heapUsed MB 76 rss MB 132
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
$ docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' oom3
exit=133 oom=falseNow there’s an error message in the logs naming the cause, and a heap snapshot or --heapsnapshot-near-heap-limit could capture what filled it. That’s the general principle: give the runtime a budget below the container limit, so the runtime’s own out-of-memory handling fires before the kernel’s. For Node, a heap around 50 to 75 percent of the limit is a common starting point, tuned with real measurements. The JVM equivalent is -XX:MaxRAMPercentage=75. Python has no heap cap, so for Python services the job is measuring peak usage and leaving headroom.
--memory-reservation sets a soft target the kernel reclaims toward under host pressure; it doesn’t protect you from the hard limit.
CPU: throttling looks like latency
A CPU-bound loop under --cpus 0.5 for three seconds:
usage_usec 1661165
nr_periods 35
nr_throttled 31
throttled_usec 1543490The loop got about 1.66 seconds of CPU in roughly 3.5 seconds of wall time, and 31 of 35 scheduling periods hit the quota. Nothing failed. The process was simply frozen for the remainder of each period. For a web service that turns into tail latency: a request that needs 60 ms of CPU, arriving after other requests used the quota, waits until the next 100 ms period starts.
It gets worse when the runtime sizes itself from the core count it sees. A thread pool of 8 workers, all busy, burns a 0.5-CPU quota in about 6 ms and then the whole container sits idle for the remaining 94 ms of the period. Older JVMs, Go before 1.25, os.cpus().length in Node and multiprocessing.cpu_count() in Python all report host cores, not the quota (Node’s os.availableParallelism() also reported 8 here). Set worker counts from configuration rather than auto-detection, or use runtimes that read cpu.max.
--cpu-shares (a relative weight) is the alternative when you want fair sharing under contention but no hard ceiling when the host is idle. Many teams prefer weights for latency-sensitive services and hard limits for batch work.
Processes and the rest
--pids-limit caps how many processes and threads the container can create. It stops a fork bomb, or a bug that spawns a subprocess per request, from exhausting the host’s PID space:
$ docker run --rm --pids-limit 20 alpine:3.20 sh -c 'for i in $(seq 1 40); do sleep 5 & done'
sh: can't fork: Resource temporarily unavailableSet it generously (threads count too), but set it.
Interview practice
A Node.js API runs with --memory 512m --cpus 1. Under load it restarts a few times a day; docker inspect shows OOMKilled: true, but heap metrics in the dashboard never exceed 300 MB. P99 latency is also high even though average CPU usage is only 60 percent. Explain both observations and what you’d change.
Answer
The heap isn’t the whole process. RSS includes native memory (Buffers, compression, TLS), thread stacks and V8 overhead, and the cgroup also counts page cache and tmpfs. Node sizes its default heap ceiling from the 512 MB limit, so the heap can grow close to the limit before V8 works hard to collect, and heap plus everything else crosses 512 MB. The kernel kills the process without a JavaScript error. Check RSS and the container’s memory usage, not just heap, and set --max-old-space-size to around 300 to 350 MB so V8 collects earlier and, if the heap really does run out, fails with a readable error. If native memory is growing, that’s a leak to investigate separately.
Average CPU at 60 percent hides throttling. With a 1-CPU quota, bursts that need more than 100 ms of CPU in a 100 ms period get paused, and requests queue behind them. cpu.stat will show a high nr_throttled ratio. Options are raising the quota, using --cpu-shares instead of a hard cap, or reducing parallel CPU work (libuv’s thread pool, worker threads) so bursts fit the quota. Then load-test again and watch P99 and throttled_usec, not averages.
Keep going
- Docker interview questions
- Docker MCQ practice
- Kubernetes OOMKilled and exit code 137
- Docker tmpfs mounts
- Official reference: Resource constraints