Docker Resource Limits: CPU, Memory, and I/O Constraints Explained

Without resource limits, a single misbehaving container can consume all available memory and crash the host, starve other containers of CPU time, or saturate disk I/O to the point where the system becomes unresponsive. Resource limits are not optional in production โ€” they are the guardrails that keep your infrastructure stable when things go wrong.

Docker uses Linux control groups (cgroups) to enforce resource constraints on containers. This guide covers every dimension of resource limiting: memory, CPU, block I/O, and the underlying cgroups mechanisms that make it all work.

Memory Limits

Memory is the most critical resource to limit because the consequences of overconsumption are severe. When a container exhausts available system memory, the Linux OOM (Out-Of-Memory) killer starts terminating processes, potentially killing unrelated containers or even critical system processes.

Setting Memory Limits

# Hard memory limit of 512MB
docker run --memory=512m myapp

# Memory limit with swap
docker run --memory=512m --memory-swap=1g myapp

# Memory limit with no swap (memory-swap equals memory)
docker run --memory=512m --memory-swap=512m myapp

# Soft limit (memory reservation) โ€” the scheduler will try to
# keep the container at this level but allows bursting
docker run --memory=1g --memory-reservation=512m myapp

Understanding the relationship between --memory and --memory-swap:

Configuration RAM Available Swap Available Behavior
--memory=512m (no swap flag) 512 MB 512 MB Total 1 GB usable (default: swap = memory)
--memory=512m --memory-swap=1g 512 MB 512 MB Total 1 GB usable (swap = memory-swap minus memory)
--memory=512m --memory-swap=512m 512 MB 0 MB No swap usage allowed
--memory=512m --memory-swap=-1 512 MB Unlimited Can use all available host swap

The OOM Killer

When a container exceeds its memory limit, the Linux kernel's OOM killer terminates the process inside the container. Docker then records this as an OOMKilled event:

# Check if a container was killed due to OOM
docker inspect --format='{{.State.OOMKilled}}' mycontainer
# Returns: true

# View container exit details
docker inspect --format='{{json .State}}' mycontainer | jq .
# "ExitCode": 137 indicates SIGKILL from OOM

You can adjust the OOM killer priority for a container. A lower score makes the container less likely to be killed when the host runs low on memory:

# Protect this container from OOM killer (-1000 to 1000)
docker run --oom-score-adj=-500 --memory=1g critical-app

# Disable OOM killer entirely (dangerous โ€” can freeze the host)
docker run --oom-kill-disable --memory=1g myapp

Warning: Never use --oom-kill-disable without setting a memory limit. If the container can allocate unlimited memory and the OOM killer is disabled, the entire host can become unresponsive.

Memory Limits in Docker Compose

services:
  api:
    image: myapp:latest
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  # Legacy syntax (still works but deploy is preferred)
  worker:
    image: myworker:latest
    mem_limit: 256m
    mem_reservation: 128m
    memswap_limit: 512m

CPU Limits

CPU constraints work differently than memory constraints. Rather than hard caps that trigger a kill, CPU limits throttle the container's access to processor time. There are two primary mechanisms: CPU shares (relative weighting) and CPU quotas (absolute limits).

CPU Shares (Relative Weight)

CPU shares define the relative amount of CPU time a container receives when there is contention. The default value is 1024:

# Give this container double the default CPU priority
docker run --cpu-shares=2048 high-priority-app

# Give this container half the default CPU priority
docker run --cpu-shares=512 background-worker

CPU shares only matter when containers are competing for CPU. If a container is the only one using the CPU, it gets full access regardless of its share value. This makes shares a soft limit.

CPU Quotas (Hard Limit)

The --cpus flag sets a hard limit on how many CPU cores a container can use:

# Limit to 1.5 CPU cores
docker run --cpus=1.5 myapp

# Limit to exactly 1 CPU core
docker run --cpus=1 myapp

# Limit to 0.5 of a CPU core (50% of one core)
docker run --cpus=0.5 myapp

Under the hood, --cpus=1.5 translates to --cpu-period=100000 --cpu-quota=150000. The container gets 150,000 microseconds of CPU time every 100,000 microsecond period. You can set these values directly for more precise control:

# Equivalent to --cpus=0.25
docker run --cpu-period=100000 --cpu-quota=25000 myapp

CPU Pinning

For latency-sensitive workloads, you can pin a container to specific CPU cores:

# Run on cores 0 and 1 only
docker run --cpuset-cpus="0,1" myapp

# Run on cores 0 through 3
docker run --cpuset-cpus="0-3" myapp

# Combine with CPU limit
docker run --cpuset-cpus="0,1" --cpus=1.5 myapp

CPU pinning is particularly useful for NUMA-aware applications or when you need predictable latency without interference from other workloads.

CPU Limits in Docker Compose

services:
  api:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 512M

  worker:
    image: myworker:latest
    cpuset: "0,1"
    cpu_shares: 512

Block I/O Limits

Disk I/O can be a significant bottleneck, especially when multiple containers share the same storage device. Docker provides I/O throttling to prevent any single container from monopolizing disk bandwidth.

# Limit read speed to 10 MB/s on a specific device
docker run --device-read-bps /dev/sda:10mb myapp

# Limit write speed to 5 MB/s
docker run --device-write-bps /dev/sda:5mb myapp

# Limit read IOPS to 1000 operations per second
docker run --device-read-iops /dev/sda:1000 myapp

# Limit write IOPS to 500 operations per second
docker run --device-write-iops /dev/sda:500 myapp

# Set relative I/O weight (10-1000, default 500)
docker run --blkio-weight 300 background-task

Tip: Block I/O limits require the blkio cgroup controller. On systems using cgroups v2, the I/O controller must be enabled. Check with: cat /sys/fs/cgroup/cgroup.controllers

Understanding Cgroups v1 vs v2

Docker's resource limits are implemented through Linux control groups. Modern distributions are migrating to cgroups v2, which changes how some resource controls work.

# Check which cgroup version your system uses
stat -fc %T /sys/fs/cgroup/
# "cgroup2fs" = cgroups v2
# "tmpfs" = cgroups v1 (or hybrid)

Key differences between cgroups v1 and v2:

Feature cgroups v1 cgroups v2
Hierarchy Multiple hierarchies Single unified hierarchy
Memory accounting Separate memory and memsw Unified memory tracking
I/O controller blkio (limited) io (supports buffered I/O)
CPU controller cpu, cpuacct (separate) cpu (unified)
Pressure stall info Not available PSI metrics available

Docker Engine 20.10+ supports cgroups v2. If you are running a recent Linux distribution (Ubuntu 22.04+, Fedora 31+, Debian 11+), you are likely already on cgroups v2. Docker detects the cgroup version automatically and adjusts its behavior.

Real-World Sizing Guidelines

Setting appropriate limits requires understanding your application's actual resource usage. Here are starting points for common workloads:

Workload Type Memory Limit CPU Limit Notes
Static file server (nginx) 128-256 MB 0.25-0.5 Primarily needs memory for connections
Node.js API 256-512 MB 0.5-1.0 Single-threaded; limit CPU to 1 unless using workers
Python/Django API 256-512 MB 0.5-1.0 Per-worker process; multiply by worker count
Java/Spring Boot 512 MB-2 GB 1.0-2.0 Set -Xmx to ~75% of container memory limit
PostgreSQL 1-4 GB 1.0-4.0 shared_buffers = 25% of memory limit
Redis (cache) 256 MB-1 GB 0.5-1.0 Set maxmemory to 75% of container limit
Background worker 128-512 MB 0.25-0.5 Depends heavily on task type

The Sizing Workflow

Don't guess at resource limits. Measure first, then set limits with headroom:

# Step 1: Run without limits and observe actual usage
docker stats mycontainer

# Output shows real-time resource consumption:
# CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O   BLOCK I/O
# mycontainer 2.34%   187.4MiB / 15.5GiB  1.18%   1.2kB/0B  0B/0B

# Step 2: Check peak memory usage over time
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}" mycontainer

# Step 3: Set limits at 1.5-2x the observed peak
docker run --memory=384m --cpus=0.5 myapp

Tip: usulnet's dashboard provides real-time resource monitoring across all your containers. Instead of running docker stats on individual hosts, you can view memory and CPU usage trends from a single interface, making it much easier to set appropriate limits based on actual consumption patterns.

Java Applications: A Special Case

Java applications require special attention because the JVM has its own memory management. Before Java 10, the JVM did not recognize container memory limits and would try to allocate based on host memory, frequently leading to OOM kills.

# Modern Java (10+) is container-aware by default
docker run --memory=1g myapp-java

# Set JVM heap to 75% of container limit
docker run --memory=1g -e JAVA_OPTS="-Xmx768m -Xms384m" myapp-java

# For older Java (8u191+), enable container awareness explicitly
docker run --memory=1g \
  -e JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" \
  myapp-java

Remember that JVM memory includes more than just the heap. Account for metaspace, thread stacks, native memory, and direct buffers. A good rule of thumb: set the container memory limit to at least 1.5x your max heap size.

Monitoring Resource Usage

Once limits are in place, continuous monitoring ensures they remain appropriate as workloads change.

Docker Stats

# Real-time stats for all containers
docker stats

# Stats for specific containers, formatted
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.BlockIO}}"

# One-shot stats (good for scripting)
docker stats --no-stream --format '{{.Name}},{{.CPUPerc}},{{.MemUsage}}' > stats.csv

Checking Cgroup Files Directly

For detailed information, you can inspect the cgroup files that Docker creates:

# Find the container's cgroup path
CGROUP_PATH=$(docker inspect --format='{{.HostConfig.CgroupParent}}' mycontainer)

# cgroups v2: Memory current usage
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format='{{.Id}}' mycontainer).scope/memory.current

# cgroups v2: Memory limit
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format='{{.Id}}' mycontainer).scope/memory.max

# cgroups v2: CPU stats
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect --format='{{.Id}}' mycontainer).scope/cpu.stat

Setting Up Alerts

Combine resource monitoring with alerting to catch problems before they impact users:

# Example: Alert when container uses more than 80% of memory limit
docker stats --no-stream --format '{{.Name}} {{.MemPerc}}' | while read name pct; do
  pct_num=$(echo "$pct" | tr -d '%')
  if (( $(echo "$pct_num > 80" | bc -l) )); then
    echo "WARNING: $name memory usage at $pct"
  fi
done

Common Pitfalls

Not Setting Any Limits

The most common mistake is deploying containers without resource limits. In development this is fine, but in production, a single memory leak can take down every container on the host. Always set at least memory limits for production workloads.

Setting Limits Too Low

Overly aggressive limits cause constant OOM kills and CPU throttling, degrading application performance. If your container is frequently restarting with exit code 137, your memory limit is too low. If response times spike periodically, check for CPU throttling.

Ignoring Swap

By default, Docker allows containers to use as much swap as the memory limit. This can make a memory-constrained container appear to work while actually performing terribly due to swap thrashing. For latency-sensitive applications, disable swap: --memory-swap equal to --memory.

Forgetting Child Processes

Resource limits apply to all processes in the container's cgroup, including child processes. A container running nginx with 10 worker processes needs memory for all workers combined, not just the master process.

Resource limits are one of the most important aspects of running containers in production. Start by measuring actual usage, set limits with appropriate headroom, and monitor continuously. Tools like usulnet make this process significantly easier by providing real-time visibility into resource consumption across your entire container fleet.