How Container CPU and Memory Limits Actually Work
docker run --cpus 2 --memory 4g looks simple. Underneath, it's cgroup v2 files with behaviour that surprises people: CPU limits that throttle rather than slow, memory limits that count page cache, and tools inside the container that report the host's resources. How to read the real numbers.
--cpus 2 --memory 4g reads like a promise: this container gets two CPUs and four gigabytes. It's actually a set of rules the kernel enforces, and the rules behave differently from what the flags suggest. A CPU limit doesn't make a container slower, exactly — it makes it stop. A memory limit counts memory you didn't know you were using. And top inside the container will cheerfully tell you about resources you can't have.
If you run multi-tenant workloads, AI agents, or just production containers, these details decide whether a limit is protecting you or quietly causing your latency spikes. This is how it works on a modern Linux host with cgroup v2.
Where limits live
Container limits are implemented by control groups (cgroups): a kernel feature that groups processes and applies resource rules to the group as a whole. On cgroup v2 there's a single hierarchy mounted at /sys/fs/cgroup, and each container gets its own directory in it.
The limits are plain files. From inside a container you can usually read your own:
cat /sys/fs/cgroup/cpu.max # CPU limit
cat /sys/fs/cgroup/memory.max # memory limit
cat /sys/fs/cgroup/pids.max # process limit
Docker's flags are a friendly way of writing to these files:
| Docker flag | cgroup v2 file | What it controls |
|---|---|---|
--cpus |
cpu.max |
Hard CPU ceiling |
--cpu-shares |
cpu.weight |
Relative priority under contention |
--cpuset-cpus |
cpuset.cpus |
Which physical CPUs may be used |
--memory |
memory.max |
Hard memory ceiling |
--memory-swap |
memory.swap.max |
Swap allowance |
--pids-limit |
pids.max |
Maximum number of processes/threads |
CPU: quotas, not speed
--cpus 2 writes this to cpu.max:
200000 100000
Read it as: in every 100,000-microsecond (100 ms) period, this group may use 200,000 microseconds of CPU time. Across all of its threads, on any cores.
That's a quota, and it's the source of the most common surprise. The container isn't given two slower cores. It's given unrestricted access to every core until it has spent its budget for the period — and then it's stopped until the next period begins.
Consider a container limited to one CPU (100000 100000) running a request handler with eight busy threads on an eight-core host. All eight threads run in parallel and burn the whole 100 ms budget in about 12.5 ms. For the remaining 87.5 ms of the period, nothing in the container runs. Average CPU usage looks modest. A request that arrives during that window waits up to 87 ms before it even starts.
That's CPU throttling, and it shows up as tail latency that no CPU graph explains. Check for it directly:
cat /sys/fs/cgroup/cpu.stat
# usage_usec 81234567
# nr_periods 51200
# nr_throttled 9120 <- periods in which the group hit its quota
# throttled_usec 412345678 <- total time spent stopped
If nr_throttled is a significant fraction of nr_periods, the limit is actively hurting you. The fixes, in rough order:
- Match thread count to the limit. A runtime that starts one worker per host core inside a two-CPU container will throttle constantly. More on this below.
- Raise the limit if the workload genuinely needs it.
- Use
cpu.weightinstead of a hard cap where you only need fairness. Weight only matters under contention — when the host is idle, a weighted container can use everything.
(Newer kernels also support cpu.max.burst, which lets a group bank unused quota to absorb short spikes. Worth knowing about; not yet something to assume is available.)
CPU: weight is about fairness
cpu.weight (1–10000, default 100) is a completely different mechanism. It doesn't cap anything. When several groups compete for CPU, each gets time in proportion to its weight. When there's no competition, weight does nothing.
For multi-tenant hosts, the combination that works is usually both: a weight so tenants share fairly under load, and a cpu.max ceiling so no single tenant can monopolise the machine.
Memory: a hard wall with a killer behind it
--memory 4g sets memory.max. When the group's usage reaches it, the kernel first tries to reclaim memory from the group — dropping cached file data, swapping if allowed. If it can't get back under the limit, it invokes the OOM killer inside the group, which kills a process to free memory.
The important words are inside the group. The host and other containers are unaffected, which is exactly what you want from isolation. But from inside, it looks like your process died for no reason:
- The process exits with status 137 (128 + signal 9,
SIGKILL). No stack trace, no error log —SIGKILLcan't be caught. docker inspectshows"OOMKilled": truefor the container's main process.- The group's
memory.eventsfile records it:
cat /sys/fs/cgroup/memory.events
# low 0
# high 0
# max 214 <- times usage hit memory.max
# oom 3
# oom_kill 3 <- processes killed
An unexplained 137 is almost always this.
What counts toward the limit
memory.current is more than your process's heap. It includes:
- Anonymous memory — heaps, stacks, the memory you think of as "used."
- Page cache — file data the kernel has cached on the group's behalf. Reading a large file raises your usage. This part is reclaimable, so it's usually evicted before the OOM killer runs, but it makes
memory.currentlook alarmingly high. - tmpfs — files written to
/dev/shmor any tmpfs mount live in memory, count against the limit, and can't be dropped like cache. Writing a large file to/tmpon a tmpfs mount can OOM-kill a container. - Kernel memory attributed to the group, such as socket buffers.
memory.stat breaks the total down (anon, file, shmem, and so on). When investigating, look at anon first — that's the part that grows toward an OOM kill.
memory.high: the soft limit
cgroup v2 adds memory.high: above it, the kernel throttles the group and reclaims aggressively, but doesn't kill. Setting memory.high somewhat below memory.max turns a sudden OOM kill into a slowdown you can observe and alert on first.
Swap
Docker's --memory-swap is the total of memory plus swap. If you set --memory without it, and the host has swap, the container may use swap equal to its memory limit. Set --memory-swap equal to --memory to disable swap for the container, which gives more predictable behaviour.
Processes: the fork-bomb ceiling
pids.max caps the number of processes and threads in the group. It's the limit people forget, and the one that stops a fork bomb or a runaway worker pool from exhausting the host's process table and taking every tenant down with it. Set it on anything that runs untrusted or agent-generated code.
Threads count, so a JVM or a heavily threaded runtime needs a limit that accounts for them.
Why top inside the container is lying
Many tools inside a container read /proc/cpuinfo and /proc/meminfo, which describe the host, not the cgroup. So on a 64-core, 256 GB host, a container limited to 2 CPUs and 4 GB will see:
nproc # 64
free -g # total: 251
nproc changes only if you use --cpuset-cpus, which restricts the CPUs a container can run on; a --cpus quota doesn't.
This matters because runtimes size themselves from what they see:
- A server that starts one worker per core starts 64 workers in a 2-CPU quota. Result: constant throttling.
- A runtime that sizes its heap from total memory can plan for far more than the limit, and get OOM-killed before it ever collects garbage.
Modern runtimes have mostly fixed this. The JVM has been container-aware for years, and Go became cgroup-aware for GOMAXPROCS in version 1.25. But plenty of tools, scripts, and older runtimes haven't. The safe habit is to set sizing explicitly from the real limits: worker counts, --max-old-space-size for Node, -Xmx for the JVM, thread-pool sizes.
To read the real limits from inside a container, go to the source:
cat /sys/fs/cgroup/cpu.max # "200000 100000" = 2 CPUs
cat /sys/fs/cgroup/memory.max # bytes, or "max"
cat /sys/fs/cgroup/memory.current # what you're using now
Verify your limits are real
If limits are part of your isolation story — and on a multi-tenant host they must be — test them rather than trusting the config:
- CPU: run a busy loop on more threads than the limit allows. Total usage should plateau at the limit, and
nr_throttledshould climb. - Memory: allocate past the limit. Only the allocating process should die, with 137, and
oom_killshould increment. Nothing outside the container should notice. - Processes: spawn processes in a loop. Creation should start failing at
pids.max, and the host should stay responsive. - Neighbours: do all three while another container runs a latency-sensitive workload, and measure it. Isolation that only works when the host is idle isn't isolation.
We cover the broader isolation checklist — filesystem, network, privileges — in Docker vs Linux Users for Multi-Tenant Isolation.
The summary
- CPU limits are quotas per 100 ms period. Exceed the quota and the whole group stops until the next period. Watch
nr_throttled. - CPU weight is fairness, not a cap. Use it alongside a ceiling on shared hosts.
- Memory limits are enforced by an OOM killer scoped to the group. Exit code 137 with no logs is the signature.
- Page cache and tmpfs count toward memory usage.
anoninmemory.statis the number that predicts OOM kills. - Set
pids.max. It's what stops one tenant's fork bomb becoming everyone's outage. - Tools inside the container see the host. Size runtimes from
/sys/fs/cgroup, not fromnprocandfree.
EasySpawn gives every workspace enforced CPU, memory, and process limits through Docker and cgroups, so one project's runaway build hits its own ceiling instead of its neighbours'. See how it works or join the waitlist.
Related: Firecracker vs gVisor vs Containers · How to Run AI-Generated Code Safely · Writing a Production Dockerfile for a Node.js App
Keep reading
Firecracker vs gVisor vs Containers: Choosing Isolation for Untrusted Code
Containers share a kernel; gVisor intercepts it; Firecracker gives each workload its own. How the three isolation models actually work, what each costs in performance and compatibility, and how to match the boundary to the threat — for AI agents, multi-tenant platforms, and code execution.
Containers vs Virtual Machines: The Difference, Simply Explained
Both let one physical computer act like many. A virtual machine pretends to be a whole computer; a container is an isolated group of processes sharing one operating system. How each works, the trade-offs in speed, size, and isolation, and when to use which.