Skip to content
shellhacks.dev
Docker

How to Limit CPU and Memory for Docker Containers Properly

Limit CPU and memory for Docker containers with --memory, --cpus and Compose limits, read OOM kills, check usage with docker stats and avoid the swap trap.

6 min read
Summarize in AI
Illustration for "How to Limit CPU and Memory for Docker Containers Properly": a shell script in an editor, file permission bits, a process monitor.

Without limits, one container can take all of a host's memory and CPU, and a leak in one service then takes down its neighbours. Limits turn a host-wide outage into one container restart. They need to be set from measurement, and the symptoms of getting it wrong are specific. The memory tooling is in measuring memory use, and the examples assume Docker 27 with cgroup v2.

Set the limits

Shell
docker run -d --name app --memory 512m --memory-swap 512m --cpus 1.5 myapp
docker stats --no-stream app
Text
CONTAINER ID   NAME   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O
3f9a2b1c8d7e   app    3.20%   148MiB / 512MiB     28.9%   1.2kB / 0B

--memory sets the ceiling. By default --memory-swap allows as much swap again as the memory limit, so setting it to the same value disables swap for the container and makes a leak fail fast rather than grinding the disk. --cpus 1.5 means one and a half cores of CPU time per scheduling period. --cpuset-cpus 0,1 pins to specific cores if you need that.

Recognise an OOM kill

Shell
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' app
Text
exit=137 oom=true

Exit code 137 is 128 plus signal 9, SIGKILL. With OOMKilled true, the memory cgroup killed it; with false, something else did, such as a manual kill. The kernel log has the detail: dmesg | grep -i 'out of memory'. The meaning of such statuses is in exit codes 126 to 137. A restart policy of unless-stopped hides the kill, so check the container's restart count.

CPU throttling looks like slowness

Shell
docker exec app cat /sys/fs/cgroup/cpu.stat
Text
usage_usec 8421337
nr_periods 4210
nr_throttled 612
throttled_usec 3101822

A growing nr_throttled means the container wanted more CPU than allowed and was paused. Latency spikes with low average CPU often come from this. Raise the limit, or reduce concurrency in the application. Many runtimes read the host's core count rather than the limit; set thread pools explicitly, for example GOMAXPROCS for Go and worker counts for Gunicorn.

Compose and runtimes

YAML
services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
        reservations:
          memory: 256M

Compose v2 applies deploy.resources.limits outside Swarm. For the JVM, use -XX:MaxRAMPercentage=75 so the heap follows the container limit; Node.js needs --max-old-space-size set below it. Leave room for non-heap memory, because a heap that equals the limit is killed by the first thread stack that grows.

Requests, limits and what the scheduler does

On a single Docker host there is no scheduler, so a limit is a hard cap and nothing more. Under an orchestrator the picture splits in two. A request tells the scheduler how much to reserve when placing the workload, and a limit tells the kernel what to enforce at run time. Setting only limits can leave the workload placed on a node that is already full of other requests, while setting requests far below real use causes overcommit and evictions. Measure the real use first, over a representative period including peaks, then set the request near the typical figure and the limit above the highest observed value with some headroom.

For memory this is critical, because exceeding the limit kills the process, while for CPU exceeding the limit only slows it down. That asymmetry is why teams are generous with memory limits and sometimes remove CPU limits altogether.

How the kernel enforces them

Memory limits use the cgroup memory controller. When a container's usage reaches its limit and the kernel cannot reclaim pages from the page cache, the OOM killer terminates a process in that cgroup, and the container usually exits with code 137. The key point is that the counted memory includes the page cache the container used, though cache is reclaimable and rarely causes a kill. CPU limits use the completely fair scheduler's bandwidth control: the container gets a quota of CPU time in each period, and when the quota is used up the container is paused until the next period, even if other cores are idle.

This is why a multi-threaded service with a CPU limit can show high latency while the node looks idle. The threads burn the quota in the first part of the period, and every thread then waits. Raising or removing the limit, or reducing the thread count to match it, removes the stalls.

Runtimes that read the wrong numbers

Many language runtimes decide their own sizes from what they see. A runtime that reads the host's total memory sets a heap larger than the container's limit and is killed when it grows. Modern JVMs read the cgroup limits and size the heap by a percentage, controlled by flags such as MaxRAMPercentage. Node.js has the --max-old-space-size option and Go has the GOMEMLIMIT variable to cap heap growth below the container limit. The worker count of a server often derives from the number of visible CPUs; a runtime that sees 64 cores in a container limited to two creates 64 workers and spends the quota on context switches. Set pool sizes explicitly.

Check these settings after changing a limit. A change in the container does nothing for a runtime whose configuration still refers to a fixed number chosen for a different size.

Checking the effect

Use docker stats for a live view and the cgroup files for detail: memory.current, memory.max and memory.events, which counts OOM kills, show what happened inside the limit, and cpu.stat shows throttled periods. A rising count of throttled periods is the direct evidence of CPU limits hurting. Export these through a metrics agent so you can graph them, set alerts on restarts caused by OOM and review limits when traffic changes. Test a limit under load before shipping it: start the container with the proposed value, replay realistic traffic and watch for kills and throttling. A limit chosen by guesswork is wrong in one direction or the other, and the cost shows up as either wasted money or an outage at the busiest hour.

Do not forget swap. A memory limit without a swap limit lets a container use swap up to the same amount on some setups, which hides pressure until the host becomes slow. Set the memory-swap value equal to the memory limit to disable swap for the container, so exceeding the limit produces a clean kill and a restart, which is easier to detect and fix than a machine that crawls. On hosts where swap is disabled globally, the setting has no effect, but writing it down keeps the intent visible.

Revisit limits when the application changes. A new feature, a larger dataset or a library upgrade can raise memory use by a quarter without anyone noticing until the first kill. A graph of memory against the limit, with an alert at eighty percent, gives early warning.

Where this does not apply

Limits protect the host; they do not fix a leak or a slow query. Setting them too tightly causes restarts under normal peaks, so start from observed maximums over a week plus 25 percent, and use leak diagnosis for the cause. On Docker Desktop the VM has its own ceiling that applies first. See the Docker resource constraints guide and the cgroup v2 documentation.

Taggeddocker
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.