← Debugging Guides

阅读中文版本

PM2 — max_memory_restart Causes a Restart Loop

Investigate PM2 restart loops where RSS crosses max_memory_restart, separate leaks from baseline growth, and size the limit against real memory profiles.

intermediatedebuggingnodejspm2memory

Symptoms

  • pm2 logs show the process restarting every few minutes with restart permission denied by memory limit messages
  • pm2 describe app reports a restart count climbing into the hundreds with uptime resetting each time
  • In-flight requests are dropped on every restart, producing periodic 502 windows

Possible causes

  • The max_memory_restart threshold sits below the legitimate steady-state RSS of the workload (V8 heap plus buffers plus native addons)
  • A slow leak pushes RSS upward until it crosses the limit, after which every restart starts from the leaked baseline of the last run
  • Traffic spikes inflate RSS temporarily (large request bodies, big cache warmups) and the threshold has no headroom
  • The limit counts all process memory including native allocations that V8 flags like --max-old-space-size do not govern

Diagnosis steps

Read the restart pattern

pm2 describe app | grep -E "restarts|uptime|memory" && pm2 logs app --lines 50 | grep -i restart

Interpretation

Restarts at a fixed memory threshold with regular intervals indicate the limit, not crashes: crash loops show error stacks, memory loops show clean exits.

Profile the real memory shape

pm2 monit # watch RSS over a full traffic cycle including peak

Interpretation

Steady-state RSS must sit meaningfully below the limit; a baseline that already brushes the limit means the threshold is wrong, not the app.

Distinguish leak from baseline

curl -s localhost:9229/... # heap snapshot pair, or log process.memoryUsage() on an interval

Interpretation

Flat RSS after warmup followed by a plateau means sizing; monotonic growth without plateau means a leak to fix at the code level.

Root cause

PM2 kills the process when RSS crosses max_memory_restart and restarts it. If the threshold is below the workload legitimate steady-state footprint, or a leak resets to an already-high baseline, the process cycles through kill-restart indefinitely, dropping in-flight requests every cycle.

Fix

First classify: size the limit from measured peak RSS plus 30 percent headroom if memory is flat; fix the leak (detached listeners, growing caches, unbounded buffers) if growth is monotonic. Keep --max-old-space-size for the V8 heap below max_memory_restart so the GC acts before PM2 kills.

Risk note

Disabling max_memory_restart removes the last defense against a leak and lets the OOM killer decide. Fix sizing or the leak instead.

Verify the fix

After the change, run a full traffic cycle and assert restart count stays at zero, RSS plateaus below the limit, and no 502 windows appear in the balancer logs.

Symptom walkthrough

PM2 leaves a clear signature in its own telemetry:

$ pm2 describe app
│ status         │ online              │
│ restarts       │ 217                  │
│ uptime         │ 4m                   │

A restart count in the hundreds with uptime always a few minutes, and logs containing restart permission denied by memory limit, is the loop made visible. The decisive comparison is between steady-state RSS and the configured threshold. An app whose honest working set is 400 MB against a 300 MB limit will loop forever while being perfectly healthy — the kill is correct given the config, and the config is wrong given the workload.

Fix in practice

Classify before changing anything. Flat RSS after warmup means a sizing problem:

// log the shape on an interval
setInterval(() => {
  const m = process.memoryUsage();
  console.log({ rss: MB(m.rss), heap: MB(m.heapUsed), ext: MB(m.external) });
}, 60_000);

Monotonic growth without plateau means a leak — fix the code (event listeners per request, an LRU that is not actually LRU, buffers accumulating in a transform). Then set the numbers in a coherent order: V8 heap flag first, PM2 limit second:

{
  "apps": [{
    "name": "app",
    "script": "server.js",
    "max_memory_restart": "600M",
    "node_args": "--max-old-space-size=450"
  }]
}

The PM2 kill must be strictly larger than the V8 ceiling so garbage collection gets its chance before the process is terminated, and both must derive from measured peak RSS plus about 30 percent headroom.

Verification

  Drive a full traffic cycle that includes your real peak (cache warmup plus busy hour replay). Assert restart count stays at zero through the cycle, RSS plateaus visibly below max_memory_restart, and the balancer logs contain no 502 windows. Then prove the guard still works: in a staging copy, inject a deliberate leak and confirm PM2 restarts it before RSS threatens the host — the mechanism must remain armed, correctly sized, with in-flight request draining configured if your framework supports graceful restart.