Root Cause Diagnosis
Exit code 137 is 128 + 9, indicating the container process received SIGKILL — on low-memory ARM instances this is almost always the Linux kernel OOM killer terminating the container because it exceeded available memory, not a Docker-internal failure. ARM instances (particularly AWS Graviton, Raspberry Pi, and budget ARM VPS tiers) compound this because many base images and pre-built binaries default to memory allocation patterns tuned for x86 JIT/runtime behavior, and ARM builds of runtimes like Node.js, Java, and Python often carry higher baseline memory overhead per process on identical workloads, pushing containers over unset or misconfigured memory limits faster than on equivalent x86 hardware.
Quick Fix (1-Minute Override)
Check if this was actually an OOM kill (not a crash) and confirm immediately:
docker inspect <container_id> --format='{{.State.OOMKilled}}'
If true, raise the container’s memory limit as an immediate stop-gap:
docker update --memory=512m --memory-swap=1g <container_id>
This buys headroom while you diagnose the actual memory consumer in Step 2 below — it is not a permanent fix if the underlying process has a real memory leak.
Step-by-Step Resolution
Step 1: Confirm OOM Kill via System Logs
Cross-check the kernel log to confirm the OOM killer specifically targeted this container’s process (not an unrelated system process, which would point to system-wide memory pressure instead):
dmesg -T | grep -i "killed process"
Look for oom-kill entries with a pid matching the container’s process:
docker inspect <container_id> --format='{{.State.Pid}}'
Step 2: Check Actual Memory Usage Pattern
Before setting arbitrary limits, profile actual consumption to size limits correctly:
docker stats <container_id> --no-stream
For a fuller picture over time (catches spikes that --no-stream misses):
docker stats <container_id>
Watch MEM USAGE / LIMIT during the workload that triggers the crash — if usage climbs steadily rather than spiking once, this indicates a memory leak in the application rather than an undersized limit.
Step 3: Set Explicit Memory Limits in docker-compose.yml
Avoid relying on defaults (no limit = container can consume all host memory, triggering system-wide OOM behavior that kills unpredictably). Set explicit bounds:
services:
app:
image: your-app:latest
deploy:
resources:
limits:
memory: 384M
reservations:
memory: 128M
mem_swappiness: 0
For plain docker run (non-compose):
docker run --memory=384m --memory-swap=384m --oom-kill-disable=false your-app:latest
Note: --memory-swap equal to --memory disables swap for the container entirely — appropriate on ARM instances with no swap configured (common default on cloud ARM tiers).
Step 4: Configure or Verify Host-Level Swap
Many ARM cloud instances (particularly AWS Graviton t4g/a1 tiers) ship with zero swap by default. Without swap, any memory spike beyond the physical RAM ceiling triggers immediate OOM kill rather than degraded performance:
free -h
If swap shows 0 across the board, add a swap file:
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Verify:
swapon --show
Step 5: Tune Application-Level Memory Ceilings to Match Container Limits
If the container runs a JVM, Node.js, or similar runtime with its own heap management, an unset runtime memory ceiling can exceed the Docker container limit before the runtime’s own GC triggers, causing the kernel OOM killer to act first. Explicitly cap the runtime below the container limit:
Node.js (Dockerfile or entrypoint):
CMD ["node", "--max-old-space-size=256", "server.js"]
Java:
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
Set the runtime ceiling to roughly 75-80% of the container’s --memory limit, leaving headroom for non-heap overhead (thread stacks, native buffers).
Step 6: Adjust Kernel OOM Score to Deprioritize Non-Critical Containers
On multi-container hosts where a low-priority container should be killed before a critical one under memory pressure, set oom_score_adj per container:
services:
low-priority-worker:
image: worker:latest
oom_score_adj: 500
critical-api:
image: api:latest
oom_score_adj: -500
Lower values (down to -1000) make a container less likely to be killed; higher values (up to 1000) make it a preferred kill target.
Why This Fails for Some Users
- cgroup v1 vs v2 memory accounting differences: Some older ARM-based distributions (Raspberry Pi OS pre-Bullseye, older Ubuntu ARM builds) default to cgroup v1, which handles memory limit enforcement differently than cgroup v2 — specifically, v1 can undercount memory used by kernel-side caches attributed to the container, causing limits to be hit earlier than the configured value suggests. Check with:
mount | grep cgroupMigrating to cgroup v2 (default on newer kernels, 5.8+) resolves the discrepancy but requires a full OS/kernel upgrade path, not a Docker-level fix. - QEMU emulation overhead on cross-architecture builds: If the image being run was built for x86_64 and is running under QEMU emulation on an ARM host (rather than a native ARM image), emulation overhead roughly doubles effective memory usage per process, causing containers that fit comfortably on native ARM to OOM under emulation. Confirm architecture match with:
docker inspect <image_name> --format='{{.Architecture}}' uname -mThese must match, or the image must be an explicit multi-arch manifest with a nativearm64variant. - Raspberry Pi GPU memory split reducing available system RAM: On Raspberry Pi-based Docker hosts, the
gpu_memsetting in /boot/config.txt reserves RAM for GPU use, which is invisible tofree -has available memory but still reduces the pool Docker containers can draw from. On headless server deployments, reduce this to the minimum:gpu_mem=16 - Missing swap accounting kernel parameter on ARM64 kernels: Some ARM64 kernel builds ship without
swapaccount=1enabled at boot, which silently disables Docker’s ability to enforce--memory-swaplimits correctly, causing containers to consume unconstrained swap before an OOM kill occurs inconsistently. Check and add to boot parameters if using GRUB (uncommon on ARM but present on some ARM server boards):cat /proc/cmdline | grep swapaccount - Docker Desktop resource limits on ARM Macs (Apple Silicon) misapplied to remote ARM deployments: Developers testing locally on M1/M2 Macs sometimes assume local success guarantees production behavior on ARM cloud instances, but Docker Desktop’s VM-level memory allocation (configurable in Docker Desktop settings, often generously set to 4GB+ by default) masks limits that a production ARM instance with genuinely constrained physical RAM (512MB-1GB tiers) will hit immediately — always test against a resource-limited container locally before deploying to a low-memory ARM target.