Intro
If your containers work locally but fail under traffic, you likely need clear CPU and memory limits and a repeatable way to test them. This guide shows how to:
- set CPU and memory limits with docker run and Docker Compose
- detect and troubleshoot OOMKilled containers
- read docker stats to see real usage vs limits
- run a safe local pilot before wider rollout
- avoid common production pitfalls and capacity planning mistakes
The goal is a simple workflow that gives production-like behavior on a laptop or a single host without surprises later.
Workflow Overview
Use this loop for each service.
- Define a small budget and success criteria
- Pick an initial memory limit (example: 256 MiB) and CPU budget (example: 0.5 CPUs).
- Define pass/fail: service stays healthy for N minutes under a defined load, p95 latency within target, no OOMKilled, CPU throttling acceptable for your SLO.
- Set limits for quick tests (docker run)
- Memory hard limit and swap policy:
docker run --rm \
--name web \
--memory=256m \
--memory-swap=256m \
-p 8080:80 nginx: alpine
Notes:
- --memory sets the RAM hard limit.
- --memory-swap sets RAM+swap. Set it equal to --memory to disable swap for the container. Set -1 to allow unlimited swap (if host swap is enabled).
- CPU limit and pinning:
# Limit to 0.5 CPUs (shares of a core over time)
docker run --rm --cpus=0.5 nginx: alpine
# Optionally pin to cores 0 and 1
docker run --rm --cpuset-cpus="0,1" --cpus=1.0 nginx: alpine
- Observe usage with docker stats
docker stats
Key fields:
- CPU %: relative to assigned CPUs. A container with --cpus=1.0 at 100% is fully saturated.
- MEM USAGE / LIMIT and MEM %: current memory and the configured cap.
- PIDS: number of processes/threads in the container.
Symptoms:
- Detect and diagnose OOMKilled
- Container exits unexpectedly, often with exit code 137 (killed by SIGKILL).
- docker ps or docker inspect shows OOMKilled=true.
Commands:
# Was it OOMKilled?
docker inspect -f '{{.State.OOMKilled}}' <container_id>
# What was the exit code?
docker inspect -f '{{.State.ExitCode}}' <container_id>
# Recent daemon events (look for oom)
docker events --since 10m | grep -i oom || true
To reproduce intentionally for learning:
# Allocate too much memory to trigger OOMKilled
docker run --rm --memory=128m --memory-swap=128m python:3.11-alpine \
python -c "a=['x'*10_000_000 for _ in range(100)]; import time; time.sleep(60)"
If you see OOMKilled, raise the limit, reduce the workload, or cap the runtime heap (see Dockerfile examples below).
There are two common paths, depending on your target.
- Encode limits in Docker Compose
- Local docker compose (non-Swarm): Prefer Compose file version 2.x resource keys for local enforcement.
version: "2.4"
services:
api:
image: myorg/api:1.0.0
mem_limit: 512m
mem_reservation: 128m
cpus: "1.0"
cpu_shares: 512
ports:
- "8080:8080"
Notes:
- mem_limit and cpus are honored locally with v2.x files.
- cpu_shares sets relative weight when the host is busy; it is not a hard cap by itself.
- Swarm deployments: Use deploy.resources (Compose v3+). These are for Swarm mode.
version: "3.8"
services:
api:
image: myorg/api:1.0.0
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
ports:
- "8080:8080"
Notes:
- deploy.resources are enforced by Swarm. Do not rely on them for plain local docker compose unless you also validate actual enforcement.
Container limits cap the whole process tree. Many runtimes also need app-level caps so they do not allocate beyond your budget.
- Add runtime-level memory caps in your Dockerfile
Examples:
# Node.js: cap V8 heap
FROM node:20-alpine
ENV NODE_OPTIONS=--max-old-space-size=256
# Java: set heap and container-aware ergonomics
FROM eclipse-temurin:21-jre
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -Xms128m -Xmx256m"
# Python: reduce glibc allocator arenas to limit RSS growth
FROM python:3.11-alpine
ENV MALLOC_ARENA_MAX=2
Keep these aligned with your container --memory. If the runtime can grow beyond the cap, you will see OOMKilled under load.
You can generate simple CPU or memory pressure without extra tools:
- Load test locally in a controlled way
# CPU burn (0.5 CPUs cap will throttle this)
docker run --rm --cpus=0.5 alpine sh -c "yes > /dev/null"
# Memory spike (expect OOMKilled at 128m)
docker run --rm --memory=128m --memory-swap=128m alpine \
sh -c "python3 - <<'PY'\na=['x'*10_000_000 for _ in range(100)]\nimport time; time.sleep(60)\nPY"
Watch docker stats and your service metrics during the test. Adjust limits and runtime caps until your goals hold.
Record for each service:
- Document budgets and tradeoffs
- steady-state memory, peak memory, and limit
- CPU budget and observed saturation under load
- when throttling is acceptable vs when to scale out
- known failure modes (for example, OOMKilled when batch size > N)
Local Pilot Plan
Make the first pilot narrow, measurable, and easy to inspect locally.
Scope
- One service that is important but simple (example: public API or worker).
Initial configuration
# Compose for local testing (v2.4)
version: "2.4"
services:
api:
image: myorg/api:1.0.0
mem_limit: 256m
mem_reservation: 128m
cpus: "0.5"
ports: ["8080:8080"]
Test steps
- Start the service and confirm readiness.
- Apply a small, consistent load (for example, a curl loop at 5 rps for 10 minutes).
- Observe with docker stats. Save a snapshot at minute 1, 5, and 10.
- Check for restarts or OOMKilled with docker ps and docker inspect.
- Bump load by a small step (for example, +2 rps) and repeat.
Pass criteria
- No OOMKilled events.
- p95 latency within target.
- MEM % stays below 80% at steady state; short spikes are allowed if they do not cause OOMKilled.
- CPU % under the cap produces acceptable latency. If not, either increase cpus or scale replicas.
Next iteration
- If memory is tight, lower runtime heap or raise mem_limit modestly (for example, +64m).
- If CPU is saturated and latency suffers, raise cpus (for example, 0.5 -> 1.0) or add a second replica.
- Encode changes in Compose and rerun the same steps to compare.
Troubleshooting quick reference
- Container restarts with exit code 137 and OOMKilled=true: reduce memory use or raise the limit; align runtime heap with container limit; consider disabling swap for the container by setting --memory-swap equal to --memory.
- CPU pegged at 100% with throttling: raise --cpus, reduce work per request, or add replicas. Pin to specific cores only if you have a reason (isolation, NUMA testing).
- docker stats shows MEM close to LIMIT while app heap seems small: account for native allocations, caches, JIT, threads, and page cache; reduce allocator arenas or JIT code cache where applicable.
- Compose resources not applying locally: use v2.x mem_limit/cpus for local tests; treat deploy.resources as Swarm-only unless verified otherwise.
- On macOS/Windows: Docker runs inside a VM. Ensure the VM itself has enough memory and CPUs, or all containers may contend or get killed at the VM level.
Capacity planning caveats
- Leave headroom. Do not set limits equal to peak observed use; allow for bursts, GC, JIT, and kernel buffers.
- Separate steady-state from spike behavior. If spikes cause OOMKilled, tune the app (batch size, heap, caching) before simply raising limits.
- Observe over time. Short tests can hide leaks and fragmentation that only appear after hours or days.
- Balance CPU caps with latency goals. Hard caps protect neighbors but can increase tail latency under bursty load; choose based on your SLOs.
- Document per-service budgets so future changes do not silently erase your guarantees.
Conclusion
Start small and deliberate: set conservative CPU and memory limits, verify with docker stats and container exit status, and encode settings in Compose appropriate for your environment. Add runtime-level memory caps to keep your app inside the container budget. Use the local pilot plan to expand coverage service by service. Over time, your limits become reliable, production-like defaults you can scale with confidence.