Живой тест на PM2 7.0.4: короткий всплеск памяти в 100 МБ, который длится 2 секунды и опускается обратно, PM2 не замечает вообще - а устойчивый рост убивает процесс на следующей проверке, что подтверждено логом демона. Разбираем источник кода PM2 (Worker.js, God/Reload.js), разницу между RSS и
Обновлено: 18 сентября 2026.
Вы поставили max_memory_restart в конфиге PM2, ожидая, что это закроет вопрос с памятью раз и навсегда - а он либо не срабатывает вовремя, когда процесс явно раздулся, либо срабатывает неожиданно и роняет соединения без видимой причины. Оба поведения объясняются одним и тем же: max_memory_restart устроен не так, как о нём привыкли думать. Он меряет не то, что вы, скорее всего, ожидаете, проверяет это не постоянно, а по таймеру, и в fork-режиме перезапускает процесс тем же грубым способом, что и обычный pm2 restart. Ниже - что он делает на самом деле, подтверждено живым тестом на PM2 7.0.4, и что стоит поставить рядом, если вам нужен настоящий жёсткий предел.
Коротко.
max_memory_restartследит за RSS (resident set size) - полным физическим объёмом памяти процесса, а не за V8-хипом, который ограничивает--max-old-space-size. Проверка идёт не постоянно, а по таймеру - раз в 30 секунд по умолчанию (подтверждено в исходном коде PM2 7.0.4), поэтому короткий всплеск памяти, который поднялся и опустился между двумя проверками, PM2 просто не видит - живой тест показал это буквально: всплеск на 100 МБ, который держался 2 секунды, прошёл незамеченным. А устойчивый рост, который не опускается, убивает процесс на следующей же проверке. В fork-режиме этот рестарт - обычный цикл «стоп-старт», тот же самый, что уже дал ~секунду простоя в статье про zero-downtime деплой; в cluster-режиме - по одному воркеру за раз, без общего разрыва, но с оговорками из той же статьи. Сам по себе лимит не лечит утечку, а просто переносит зависание на предсказуемый интервал вместо непредсказуемого. Более жёсткий и по-настоящему мгновенный предел -MemoryMaxв systemd: ядро останавливает процесс cgroup'ом в момент самой попытки выделить лишнюю память, без ожидания какого-либо таймера - и это подтверждено настоящим OOM-киллом на живом тесте.
Если вы раньше ограничивали память Node.js флагом --max-old-space-size, у вас уже есть мысленная модель: есть один лимит, он про память V8, и достаточно держаться ниже него. max_memory_restart в PM2 - это лимит совсем другой природы, и путаница между ними - самая частая причина, почему поведение кажется случайным.
--max-old-space-size ограничивает old space - один из сегментов кучи (хипа) V8, движка JavaScript внутри Node.js, где живут долгоживущие объекты. max_memory_restart сравнивается с RSS (resident set size) - всем физическим объёмом памяти, который процесс реально занимает в оперативной памяти прямо сейчас: сюда входит V8-хип целиком, но также буферы вне хипа (например, объекты Buffer из статьи про утечку памяти - они не живут в V8-хипе), память нативных модулей, стеки потоков и служебные структуры самого Node.js. PM2 получает это число через npm-пакет pidusage, который на Linux читает /proc/<PID>/status - подтверждено чтением исходников ActionMethods.js в установленной на тестовом боксе версии PM2.
На живом тесте разница была наглядной уже на пустом процессе: тестовое Node-приложение в простое показывало rss: 63332352 (63,3 МБ), а heapUsed при этом - всего 6 040 128 (5,8 МБ). Если бы вы ориентировались только на размер хипа, эти 63 МБ RSS выглядели бы необъяснимо большими - но они и не про хип вовсе, а про весь процесс целиком, включая сам движок Node.js и его служебные структуры.
Практическое следствие: значение --max-old-space-size вообще не защищает от того, что убьёт max_memory_restart, и наоборот. Процесс с утечкой в основном через Buffer-объекты (как в примере из статьи про диагностику утечки на VPS) может упереться в лимит RSS, пока V8-хип остаётся почти пустым - для --max-old-space-size такой процесс выглядел бы полностью здоровым.
Естественное ожидание от «лимита памяти» - что он сработает в момент превышения, как автомат защиты по току. На деле проверка встроена в фоновый цикл PM2, который называется Worker: он раз за разом опрашивает все запущенные процессы и, помимо прочего, сравнивает их RSS с max_memory_restart. Между двумя такими опросами PM2 попросту не смотрит на память вообще.
Интервал этого цикла задаёт константа WORKER_INTERVAL в файле constants.js установленной версии PM2 (проверено на 7.0.4): process.env.PM2_WORKER_INTERVAL || 30000 - то есть по умолчанию 30 000 миллисекунд, 30 секунд, с возможностью переопределить переменной окружения при запуске демона.
Чтобы увидеть эффект на практике за разумное время, а не ждать полчаса между попытками, для теста интервал временно сократили до 5 секунд. Логика от этого не меняется - меняется только скорость, с которой можно её наблюдать.
Короткий всплеск. Тестовое приложение запущено под PM2 с max_memory_restart: '80M'. Запрос к эндпоинту /spike заставил его выделить 100 МБ и удерживать их ровно 2 секунды - заведомо меньше интервала проверки. RSS процесса за это время реально пересёк порог: с 62 232 КБ (подтверждено одновременно тремя источниками - process.memoryUsage().rss внутри самого приложения, ps -o rss и VmRSS из /proc/<PID>/status) до 164 552 КБ во время всплеска и обратно до тех же 62 232 КБ через 2 секунды. PM2 не отреагировал вообще: счётчик рестартов остался на нуле, PID процесса не поменялся ни разу за время теста.
Устойчивый рост. Тот же процесс, но память теперь не отпускается - буфер остаётся в памяти навсегда, как при реальной утечке. Уже на следующей проверке (при пятисекундном интервале - в пределах пары секунд после запроса) в логе демона PM2 появилась строка:
[PM2][WORKER] Process 2 restarted because it exceeds --max-memory-restart value (current_memory=141594624 max_memory_limit=83886080 [octets])
Следом в том же логе - Stopping app:memtest id:2, затем App [memtest:2] exited with code [0] via signal [SIGINT], и только после этого starting in -fork mode- и online.
Сценарий | RSS пересёк порог? | Что сделал PM2 |
|---|---|---|
Всплеск 100 МБ, держится 2 секунды, интервал проверки 5 секунд | Да, подтверждено через /proc в момент всплеска | Ничего. restart_time не изменился, PID тот же |
Удержанный рост той же величины, не опускается | Да, стабильно | Реальный рестарт на следующей проверке, подтверждено логом демона |
Вывод простой: max_memory_restart не пропускает превышение лимита - он пропускает превышение, которое закончилось раньше следующего тика таймера. Для процесса, который держит утечку часами, 30-секундная задержка не имеет значения. Для процесса, у которого RSS кратковременно подскакивает под нагрузкой и тут же опускается - например, обработка большого файла или залпа запросов, - тот же самый интервал означает, что легитимный, безопасный скачок памяти тоже может остаться незамеченным, а может и попасть точно на момент проверки и вызвать ненужный рестарт здорового процесса. Оба исхода - следствие одной и той же архитектуры проверки, не баг и не непредсказуемость.
Что именно происходит в момент, когда лимит сработал, зависит от режима, в котором запущен процесс - и здесь стоит вернуться к разбору из статьи про zero-downtime деплой, где эта разница уже подробно измерена для обычного pm2 restart/reload. Хорошая новость: срабатывание по памяти идёт через тот же самый код, так что измерения оттуда напрямую применимы и сюда - тратить время на их повторение не нужно.
В исходном коде PM2 обработка превышения лимита вызывает функцию God.reloadProcessId. Внутри неё есть развилка по режиму запуска: если exec_mode процесса не равен cluster_mode (то есть в fork-режиме, который используется во всей этой серии статей), вызов немедленно передаётся дальше в God.restartProcessId - тот же самый путь, что и у обычной команды pm2 restart: полная остановка процесса и запуск нового. Именно это подтвердил лог демона выше - «exited... via signal SIGINT», а следом «starting». Если у вас на этом сервере всего один процесс без переключения портов, как в базовой настройке из первой статьи серии, это означает ту же секунду простоя, что и при наивном pm2 reload - 11 из 57 запросов с connection refused, уже измеренную в статье про zero-downtime деплой. Единственный способ избежать этого конкретно для срабатывания по памяти - тот же blue-green с двумя портами и переключением nginx, что описан там, потому что PM2 в этот момент не спрашивает вас, готовы ли вы к рестарту - он просто перезапускает процесс.
Только для cluster_mode вызывается другая функция, hardReload: она сначала поднимает нового воркера и ждёт от него события listening, и лишь после этого убирает старого - воркеры сменяются по одному, а не все разом. Это тот же механизм, который уже честно разобран со своими компромиссами (двойная память постоянно, отдельный ISR-кеш на процесс) в статье про zero-downtime деплой - здесь достаточно зафиксировать, что срабатывание max_memory_restart в cluster-режиме пользуется тем же самым механизмом, а не каким-то отдельным, более грубым.
Если у вашего Next.js-приложения на самом деле есть утечка - например, тот тяжёлый объект на каждый запрос из статьи про диагностику утечки памяти, - max_memory_restart её не найдёт и не исправит. Он умеет ровно одно: сравнивать число с числом раз в 30 секунд и убивать процесс, если число больше. Причина утечки как была в коде, так и останется.
Практический эффект - зависание становится регулярным вместо случайного. Без лимита процесс копил бы память часами и вставал колом непредсказуемо, как описано в самом начале серии. С лимитом он будет упираться в потолок памяти на более-менее одинаковом интервале - и каждый раз, когда это происходит в fork-режиме, вы получаете ту же самую секунду простоя, которую остальная часть этой серии как раз и старалась убрать: внешний healthcheck, чтобы ловить зависания, которые лимит по памяти вообще не видит, периметр через nginx и ufw, чтобы наружу торчал только один порт, и способ перезапускать процесс без разрыва соединений. Лимит по памяти, который тихо перезапускает процесс раз в несколько часов без единого способа сгладить этот момент, обнуляет часть этой работы, если не встроен в ту же схему переключения портов.
Если вам действительно нужен предохранитель, который не зависит от того, успеет ли PM2 опросить процесс до следующего тика, - это MemoryMax, лимит cgroup (control group - механизм ядра Linux, который ограничивает ресурсы для группы процессов), который задаётся прямо в unit-файле systemd. Разница принципиальная: PM2 узнаёт о превышении лимита на следующей плановой проверке, а ядро останавливает процесс cgroup'ом в момент самой попытки выделить память сверх лимита - без всякого интервала опроса.
Это подтверждено не документацией, а настоящим убийством процесса. Тестовое приложение запущено напрямую через systemd, без PM2, с таким unit-файлом:
[Service]
Type=simple
ExecStart=/usr/bin/node app.js
Restart=on-failure
RestartSec=2
MemoryMax=100M
MemoryAccounting=yes
После того как удерживаемая память процесса перешла границу 100 МБ, systemctl status показал Active: failed (Result: oom-kill) и Main PID ... code=killed, signal=KILL. В журнале ядра (journalctl -k) нашлась точная причина:
Memory cgroup out of memory: Killed process 356408 (node) total-vm:1106424kB, anon-rss:101224kB, file-rss:41728kB, shmem-rss:0kB, UID:0 pgtables:808kB oom_score_adj:0
MemoryMax=100M - жёсткий потолок для cgroup этого сервиса; превышение приводит к тому, что ядро само выбирает и убивает процесс внутри этой группы (oom_memcg в логе ядра указывает именно на unit сервиса, а не на систему целиком).MemoryAccounting=yes - включает учёт памяти для cgroup этого юнита. На современном systemd (232+, то есть любой актуальный Ubuntu/Debian) учёт включён по умолчанию, так что строка избыточна на таком хосте - но явное указание не помешает и подстрахует на старых системах, где дефолт может быть другим.Restart=on-failure и RestartSec=2 - без этой пары процесс, убитый OOM-киллером, так и останется в статусе failed навсегда. С ними - проверено вживую - systemd поднял процесс заново ровно через 2 секунды после убийства, с новым PID.У этого подхода есть цена, которую стоит проговорить честно: это убийство сигналом SIGKILL, без единого шанса на аккуратное завершение - процессу не дают времени ни закрыть соединения, ни доделать текущий запрос. Это предохранитель на крайний случай, который защищает сервер целиком от одного разросшегося процесса, а не штатный механизм управления памятью. Ставьте MemoryMax заметно выше нормального рабочего объёма вашего процесса - как страховку от катастрофы, а не как рабочий лимит, который будет срабатывать регулярно. Если процесс упирается в него часто, это тот же сигнал, что и с max_memory_restart: где-то в коде реальная утечка, и её место - в статье про диагностику утечки памяти, а не в очередном лимите.
ps -o rss,cmd -p <PID> и сравните с тем, что показывает node --inspect в Chrome DevTools для самого хипа - это два разных числа.pm2 --version, и совпадает ли интервал проверки с 30 секундами по умолчанию, если вы явно не переопределяли PM2_WORKER_INTERVAL.max_memory_restart, и готовы ли вы к секунде простоя в fork-режиме при каждом срабатывании.max_memory_restart ловит только рост RSS, а не зависший событийный цикл и не зависший запрос без таймаута.MemoryMax в systemd для сервиса, из-под которого запускается PM2, или для всей его cgroup.Чаще всего по двум причинам. PM2 проверяет память не постоянно, а раз в 30 секунд по умолчанию (константа WORKER_INTERVAL в исходном коде, подтверждено на версии 7.0.4) - короткий всплеск памяти, который поднялся и опустился между двумя проверками, PM2 просто не увидит: на живом тесте всплеск на 100 МБ длиной в 2 секунды прошёл полностью незамеченным. И второе - лимит считается от RSS, полного объёма процесса, а не от V8-хипа, поэтому если вы ориентируетесь на --max-old-space-size, ожидания и реальность легко расходятся.
RSS (resident set size) - весь физический объём памяти процесса: V8-хип целиком, буферы вне хипа, память нативных модулей, стек потоков. PM2 получает это число через npm-пакет pidusage, который на Linux читает /proc/<PID>/status - подтверждено чтением исходного кода PM2. Это не то же самое, что V8-хип, который ограничивает --max-old-space-size.
По умолчанию раз в 30 секунд - подтверждено в исходном коде PM2 7.0.4 (constants.js, WORKER_INTERVAL: process.env.PM2_WORKER_INTERVAL || 30000). Интервал можно изменить одноимённой переменной окружения при запуске демона PM2. Между двумя проверками PM2 не видит, что происходит с памятью процесса вообще.
Нет - он не находит и не устраняет причину утечки, а просто убивает процесс при превышении RSS. Если утечка реальна, лимит будет срабатывать снова и снова на предсказуемом интервале вместо непредсказуемого, а каждый такой рестарт в fork-режиме проходит через тот же цикл остановки и запуска, что и обычный pm2 restart, с той же паузой в обслуживании.
MemoryMax - жёсткий предел памяти cgroup в unit-файле systemd. В отличие от PM2, который узнаёт о превышении на следующей плановой проверке, ядро Linux останавливает процесс cgroup'ом в момент самой попытки выделить лишнюю память - без ожидания таймера. Проверено вживую: процесс с MemoryMax=100M был убит cgroup OOM killer'ом сразу при попытке выделить память сверх лимита, что подтвердилось и статусом systemctl, и точной строкой в журнале ядра.
max_memory_restart меряет RSS - весь физический объём памяти процесса, а не V8-хип, который ограничивает --max-old-space-size. Источник числа - npm-пакет pidusage, читающий /proc/<PID>/status.pm2 restart, - полная остановка и запуск, та же секунда простоя, что уже измерена в статье про zero-downtime деплой. В cluster-режиме - по одному воркеру за раз, тот же механизм, что и у pm2 reload.MemoryMax в systemd - настоящий жёсткий предел без ожидания таймера, подтверждён живым OOM-киллом cgroup. Это аварийный предохранитель, а не штатный механизм: он убивает процесс сигналом SIGKILL без шанса на аккуратное завершение, поэтому ставить его стоит заметно выше рабочего объёма процесса, а не как замену настоящему поиску утечки.Этой статьёй серия про надёжный Next.js на маленьком VPS закрыта: от диагностики самой утечки до внешнего healthcheck, периметра через nginx и ufw, деплоя без простоя и, наконец, честного разбора, что на самом деле делает и не делает max_memory_restart. Весь путь по порядку: