All posts
8 min read

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.

dockerinfrastructureisolationarchitecture

--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.weight instead 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 — SIGKILL can't be caught.
  • docker inspect shows "OOMKilled": true for the container's main process.
  • The group's memory.events file 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.current look alarmingly high.
  • tmpfs — files written to /dev/shm or any tmpfs mount live in memory, count against the limit, and can't be dropped like cache. Writing a large file to /tmp on 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:

  1. CPU: run a busy loop on more threads than the limit allows. Total usage should plateau at the limit, and nr_throttled should climb.
  2. Memory: allocate past the limit. Only the allocating process should die, with 137, and oom_kill should increment. Nothing outside the container should notice.
  3. Processes: spawn processes in a loop. Creation should start failing at pids.max, and the host should stay responsive.
  4. 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. anon in memory.stat is 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 from nproc and free.

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