Intro
If your containers run fine locally but crash or throttle under real traffic, you likely need explicit CPU and memory limits plus a repeatable way to validate them. This guide shows how to:
- Set CPU and memory limits with
docker runand Docker Compose - Detect and troubleshoot
OOMKilledcontainers - Read
docker statsto compare real usage against configured 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 (for example, 256 MiB) and CPU budget (for example, 0.5 CPUs).
- Define pass/fail: the 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:
--memorysets the RAM hard limit.--memory-swapsets RAM + swap. Set it equal to--memoryto disable swap for the container. Set-1to 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.0at 100% is fully saturated. - MEM USAGE / LIMIT and MEM %: current memory and the configured cap.
- PIDS: number of processes/threads in the container.
- Detect and diagnose
OOMKilled
Symptoms:
- Container exits unexpectedly, often with exit code 137 (killed by SIGKILL).
docker psordocker inspectshowsOOMKilled=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).
- Encode limits in Docker Compose
There are two common paths, depending on your target.
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_limitandcpusare honored locally with v2.x files.cpu_sharessets 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.resourcesare enforced by Swarm. Do not rely on them for plain localdocker composeunless you also validate actual enforcement.
- Add runtime-level memory caps in your Dockerfile
Container limits cap the whole process tree. Many runtimes also need app-level caps so they do not allocate beyond your budget.
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.
- Load test locally in a controlled way
You can generate simple CPU or memory pressure without extra tools:
# 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'
a=['x'*10_000_000 for _ in range(100)]
import time; time.sleep(60)
PY"
Watch docker stats and your service metrics during the test. Adjust limits and runtime caps until your goals hold.
- Document budgets and tradeoffs
Record for each service:
- 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,
OOMKilledwhen 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 (for example, a public API or a 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
curlloop at 5 rps for 10 minutes). - Observe with
docker stats. Save a snapshot at minute 1, 5, and 10. - Check for restarts or
OOMKilledwithdocker psanddocker inspect. - Bump load by a small step (for example, +2 rps) and repeat.
Pass criteria
- No
OOMKilledevents. - 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
cpusor scale replicas.
Next iteration
- If memory is tight, lower runtime heap or raise
mem_limitmodestly (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-swapequal 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 statsshows 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/cpusfor local tests; treatdeploy.resourcesas 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.