## 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 run and Docker Compose

- Detect and troubleshoot OOMKilled containers

- Read docker stats to 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:

- --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.

- Detect and diagnose OOMKilled

Symptoms:

- 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).

- 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_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.

- 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, 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 (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 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.