Skip to content
Blog

Limiting CPU and Memory per Container: A Practical Guide

--memory, --memory-swap, --cpus, --cpu-shares: what each flag really enforces, how OOM kills show up as exit code 137, and how to size limits you can defend.

Published on • October 31, 2026

AI Assistant

By default a Docker container has no resource constraints. It can consume as much CPU and RAM as the host’s kernel scheduler allows — which is fine on your laptop and catastrophic on a shared server, where one runaway container can starve its neighbors or trigger the kernel’s out-of-memory killer. Limits are cheap to set and expensive to get wrong, so this guide walks through the flags that matter, what the kernel actually does with them, and the sizing mistakes that turn a limit into an outage.

Memory: -m / --memory

$ docker run -d --memory 512m --name api my-api:latest

Units are b, k, m, g. The minimum allowed value is 6m. This is a hard limit: the kernel refuses to let the container’s cgroup allocate beyond it. Related flags:

FlagWhat it does
-m, --memoryHard limit on physical memory
--memory-swapTotal memory plus swap the container may use (only meaningful with --memory)
--memory-reservationSoft limit, activated under host memory pressure; must be lower than --memory to matter
--memory-swappiness0–100, how aggressively anonymous pages are swapped; inherited from the host if unset
--oom-kill-disableStop the kernel from OOM-killing this container — only ever combined with -m, and rarely a good idea

The --memory-swap puzzle

--memory-swap is the combined budget, not an extra allowance on top:

  • --memory=300m --memory-swap=1g → 300 MB RAM + 700 MB swap.
  • --memory=300m with --memory-swap unset → the container may use as much swap as RAM, i.e. 600 MB total, if the host has swap.
  • --memory=300m --memory-swap=300m → no swap at all (the common way to say “RAM only”).
  • --memory-swap=-1 → unlimited swap up to what the host has.

Two traps: inside the container, free reports the host’s swap, not the container’s budget, so don’t size from it; and on hosts without swap configured (the modern default on many distros, and typical in cloud images), the distinction is moot — the container dies at the RAM limit.

CPU: --cpus, --cpu-quota, --cpu-shares

CPU limiting has two different mechanisms, and mixing them up is the classic mistake.

Hard ceilings (CFS quota). --cpus expresses a fractional number of cores:

$ docker run -d --cpus 1.5 --name api my-api:latest

On a host with 2 CPUs, the container may use at most 1.5 of them. It’s shorthand for the lower-level pair: --cpus="1.5" equals --cpu-period=100000 --cpu-quota=150000 — the scheduler period (default 100,000 µs = 100 ms) and how many microseconds of that period the container may consume before being throttled. When the container exceeds its quota within a period, the kernel stalls it until the next period starts, which shows up as latency spikes and a docker stats CPU% pinned at the ceiling.

Soft weights. --cpu-shares (default 1024) is a relative weight, not a cap. It only matters when the host is oversubscribed: a container with --cpu-shares 2048 gets twice the CPU time of a default neighbor while CPU cycles are contended, but may use 100% of a core when the host is idle. It never guarantees or reserves anything. Use it to make a batch job yield to your API, not to cap anything.

Pinning. --cpuset-cpus=0,3 restricts the container to specific cores — useful for NUMA-aware or latency-sensitive workloads, useless for limiting a noisy neighbor on its own.

Observing limits: docker stats

$ docker stats
CONTAINER ID   NAME   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O
a428c601d073   api    148.2%  402.1MiB / 512MiB   78.5%   1.2MB / 800kB

MEM USAGE / LIMIT is exactly the number you want to watch: usage against your configured ceiling, before it becomes an OOM kill. Useful variations:

$ docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" api db
$ docker stats --no-stream            # one shot, for scripts and CI

One caveat from the docs: on Linux the CLI reports memory usage minus page cache, so the number can look lower than what the kernel counts against the cgroup. Cache-heavy workloads (builds, databases) genuinely do reclaim it — but it also means docker stats is a floor, not a worst case.

When the limit is hit: the OOM kill (exit code 137)

Push past the memory limit and the kernel’s OOM killer terminates a process inside the container — usually PID 1. The container stops, and the exit code tells you what happened:

$ docker ps -a
a428c601d073   api   Exited (137) 4 seconds ago

$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}' api
OOMKilled=true Exit=137

137 = 128 + 9 (SIGKILL). It is the universal signature of an OOM kill — but also of a plain docker kill, so State.OOMKilled is the authoritative check. The kernel log has the details:

$ sudo dmesg | grep -i oom | tail
Memory cgroup out of memory: Killed process 42 (java)

If OOMKilled=false and you still see 137, something else sent SIGKILL (an orchestrator, a healthcheck that timed out, or a runaway docker stop grace period).

The fix is either a bigger limit or a smaller app. Common offenders: JVM heaps sized from host memory (-Xmx should sit comfortably under --memory, leaving room for metaspace, threads, and direct buffers), Node’s --max-old-space-size, and Go services that treat the container as if it had the whole node.

How the limits are enforced: cgroups v2

Docker doesn’t enforce anything itself — it writes your flags into files in the container’s control group and the kernel does the rest. On modern hosts that’s the cgroups v2 unified hierarchy at /sys/fs/cgroup: your --memory 512m becomes memory.max, and --cpus 1.5 becomes cpu.max (150000 100000). You can read them from inside the container:

$ docker exec api cat /sys/fs/cgroup/memory.max
536870912
$ docker exec api cat /sys/fs/cgroup/cpu.max
150000 100000

That also explains behavior people find surprising: memory limits are enforced reactively (the kernel kills when pressure hits), while CPU limits are enforced proactively (throttling every period). For the deeper mechanics — memory.high vs memory.max, PSI, why a container can still stall before being killed — see the companion post on cgroups v2.

Limits in Compose

services:
  api:
    image: my-api:latest
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M
          pids: 100
        reservations:
          cpus: '0.25'
          memory: 256M

deploy.resources is honored by docker compose up on a single host (not just Swarm). reservations is the soft guarantee — it influences scheduling, it doesn’t throttle. The pids limit deserves mention: it caps the number of processes in the container, which stops a fork bomb that would otherwise exhaust host PIDs.

Sizing mistakes to avoid

  • Limiting without measuring. Run unlimited first, watch docker stats through a normal day and a load spike, then set the limit at ~1.5× observed peak — not at the number you guessed.
  • Overcommitting the host. If four containers each get --memory 2g on an 8 GB host, you’ve guaranteed an OOM event under peak. Sum the limits (plus daemon and OS overhead) against real capacity.
  • Forgetting that --cpus throttles, not migrates. A --cpus 0.5 service on a busy host doesn’t get half a core consistently — it gets bursts of a full core each period, which can wreck tail latency for request-serving work.
  • Setting limits but no swap story. Decide deliberately: no swap (--memory-swap equal to --memory) means fast failure; swap means slower degradation. Both are better than an unbounded container.
  • Leaving limits at defaults on multi-tenant hosts. No limit is a valid choice for a single-purpose dev machine; it is not a choice for a shared server.

Further reading