Боевой разбор зависшего next-server на 1-ядерном VPS: как залпом запросов и strace поймать растущий RSS, отличить утечку от обычного кэша по сигнатуре futex/mmap/madvise, и почему тяжёлый объект, который создаётся на каждый запрос вместо module-scope singleton, - самая частая причина. Плюс почему PM2 через npm start эту утечку не видит вообще.
Сайт на Next.js стоит на VPS с одним ядром и живёт нормально часами, иногда сутками, а потом без предупреждения садится в 100% CPU и перестаёт отвечать. Через nginx летят 499 и 504, pm2 status показывает процесс живым, но сайт не открывается. Помогает только pm2 restart - и через несколько часов повторяется. Это не «сервер слабый», это утечка памяти в Next.js: серверный код на каждый запрос создаёт что-то тяжёлое, что не освобождается, куча растёт, и в какой-то момент сборщик мусора V8 начинает бороться с ней сильнее, чем обслуживать запросы. Ниже - как поймать эту утечку двумя простыми инструментами, а не просто повесить рестарт по cron и забыть.
Коротко. Прогоните залп из 200-300 запросов подряд и сравните RSS процесса до и после: обычный кэш растёт и выходит на плато, утечка растёт почти линейно и не останавливается. Во время следующего зависания снимите
strace -p <PID> -cна 10-15 секунд - если вывод забитfutex,mmap,munmap,madvise, а не обычнымиepoll_wait/read/write, это сборщик мусора V8 воюет с разросшейся кучей, а не сервер ждёт сеть. Причина почти всегда одна и та же - в серверном коде (странице, layout, middleware) на каждый запрос создаётся объект, который должен жить один раз на процесс: инициализация библиотеки вроде i18next черезcreateInstance()/.use(), новый клиент, новый кэш. Лечится вынесением объекта в module-scopeMap.max_memory_restartв PM2 и cron-рестарт - это костыли, которые прячут симптом, а не чинят код; хуже того, если PM2 запущен черезnpm start, он вообще не видит зависший next-server и не перезапускает его вовремя.
Картина обычно такая. Сайт открывается нормально, отвечает быстро. Проходит несколько часов - иногда меньше, иногда сайт простаивает всю ночь без единого рестарта и садится только под утро, когда набегает первый настоящий трафик. Дальше top показывает одно ядро на 100%, процесс next-server (или просто node, смотря как запущен) занимает почти весь CPU, а новые запросы либо висят и отваливаются по таймауту, либо nginx сразу возвращает 504 Gateway Timeout, если клиент дождался ответа от апстрима, или 499, если клиент отвалился раньше. pm2 status при этом честно пишет online - процесс жив, просто не успевает ничего делать. Единственное, что помогает - pm2 restart. Через несколько минут после рестарта всё снова работает быстро, и цикл повторяется.
Это отличается от того, когда VPS просто тормозит из-за нехватки ресурсов вообще - переподписанного хоста, чужого процесса, забитого диска. Общую диагностику «сервер тормозит» разбирает отдельная статья про Linux. Здесь другой случай: нагрузка на сервере в среднем невысокая, но конкретно один процесс со временем разрастается по памяти сам по себе, вне зависимости от того, сколько реальных посетителей на сайте. Если оставить его в покое, он не восстановится - утечка не «рассасывается», куча продолжает расти до тех пор, пока сборщик мусора не начинает съедать всё ядро на попытки её обработать.
Первый инструмент - самый простой: устроить процессу маленькую нагрузку и посмотреть, что происходит с памятью. RSS (resident set size) - это объём физической памяти, которую процесс реально занимает прямо сейчас, в отличие от виртуальной адресации, которая почти ничего не говорит о фактическом потреблении.
Сначала смотрим стартовое значение, потом гоняем залп запросов, потом смотрим снова:
ps -o rss,cmd -C next-server
for i in $(seq 1 240); do curl -s -o /dev/null localhost:3000/; done
ps -o rss,cmd -C next-server
ps -o rss,cmd -C next-server - выводит RSS в килобайтах и командную строку для всех процессов с именем next-server. На живом тесте для этой статьи (Next 16.3.4) процесс так и назывался - next-server (v16.3.4), команда сработала без правок. В режиме output: 'standalone' имя иногда отличается - тогда ищите по ps aux | grep next и подставьте настоящее имя или PID.for i in $(seq 1 240); do curl -s -o /dev/null localhost:3000/; done - 240 запросов подряд к главной странице, тело ответа никуда не сохраняется (-o /dev/null), вывод curl подавлен (-s). Число 240 - ориентир: важно не конкретное количество, а сам факт устойчивой нагрузки в несколько минут. Если сайт требует авторизации или у него другой «тяжёлый» маршрут, где обычно и живёт утечка (страница с i18n, форма, API-роут) - гоняйте запросы именно по нему.Если удобнее следить за памятью через PM2, а не через ps, подойдёт pm2 jlist - он выводит полный список процессов в JSON, включая объект monit с текущей памятью и CPU:
pm2 jlist | grep -A2 '"memory"'
Что вы должны увидеть: два числа RSS, «до» и «после». У обычного, здорового процесса память после залпа обычно выше, чем в состоянии простоя - Next.js кэширует скомпилированные страницы и данные, - но рост останавливается и выходит на плато после первых десятков запросов. У процесса с утечкой память продолжает расти почти всю дорогу и не показывает признаков остановки. Вот как это выглядело на живом тесте для этой статьи - тестовый роут с намеренной утечкой, per-request Buffer.alloc(2MB), который пушится в module-level массив и никогда не чистится (та же форма бага, что разобрана ниже):
Запросов от старта | RSS | Прирост на этом интервале |
|---|---|---|
0 (старт) | 122 228 КБ (~119 МБ) | - |
240 | 161 232 КБ (~157 МБ) | ~163 КБ/запрос |
480 | 207 440 КБ (~203 МБ) | ~193 КБ/запрос |
720 | 281 168 КБ (~275 МБ) | ~307 КБ/запрос - прирост ускоряется, GC работает под растущим давлением |
~3700 (суммарно) | 959 724 КБ (~937 МБ) | - |
Рост монотонный и без единого плато на всём протяжении - ни на 240-м запросе, ни на 3700-м. Это и есть диагностический признак: у здорового процесса, который просто прогревает кэш, RSS поднимается на первых нескольких десятках запросов и дальше держится примерно на одном уровне, сколько запросов ни гоняй.
Параллельно с ростом RSS растёт и нагрузка на CPU. На том же тестовом боксе (2 vCPU / 4 ГБ) во время активного залпа top показывал next-server на 81.8% CPU одного ядра - нагрузка однопоточная и CPU-bound, второе ядро в ней почти не участвует, - при RES 296-710 МБ в зависимости от момента замера. Это и есть «одно ядро под завязку», о котором речь в начале статьи, снятое в процессе накопления утечки, а не в момент уже полного зависания.
Один прогон не всегда убедителен, особенно если между запусками процесс успел что-то досчитать в фоне. Стоит повторить залп два-три раза подряд без рестарта процесса между ними: если каждый следующий заход добавляет память, а не топчется около одного значения, - перед вами утечка, а не однократный прогрев кэша.
Нагрузочный тест хорош тем, что его можно устроить самому и когда угодно. Но настоящее зависание - редкое и незапланированное событие, и когда оно наступает, стоит успеть снять с процесса strace - утилиту, которая показывает, какие системные вызовы (обращения программы к ядру Linux) делает процесс и сколько времени на них тратит.
Узнаём PID зависшего процесса и снимаем сводку по вызовам за 10-15 секунд, затем останавливаем Ctrl+C:
pgrep -f next-server
sudo strace -p <PID> -c
pgrep -f next-server ищет PID процесса по подстроке в полной командной строке - надёжнее, чем полагаться на короткое имя comm, которое Linux обрезает до 15 символов.strace -p <PID> подключается к уже работающему процессу, не запускает новый. -c вместо построчного вывода каждого вызова копит статистику и печатает итоговую таблицу по Ctrl+C или когда процесс завершится: сколько времени и сколько раз вызван каждый системный вызов.strace почти всегда требует sudo, даже если вы владелец процесса - зависит от настройки ptrace_scope в ядре (см. пункт в чек-листе «Проверить»).Если снять статистику за весь стол зависания не успеваете, короткий именной захват с фильтром по памяти тоже покажет достаточно:
sudo strace -f -e trace=memory -p <PID> -o /tmp/strace-memory.log &
sleep 10
sudo kill %1
-f следит и за дочерними потоками/процессами, что важно для Node - V8 гоняет сборщик мусора и часть работы в дополнительных потоках.-e trace=memory сужает захват только до вызовов, связанных с памятью (mmap, munmap, brk, madvise и похожих), а не пишет вообще всё подряд.-o /tmp/strace-memory.log пишет вывод в файл, а не в терминал - удобно смотреть после того, как отпустит.Что вы должны увидеть в нормальном, не текущем процессе под нагрузкой: в топе списка - epoll_wait (процесс ждёт события на сокетах), read и write (читает запрос, пишет ответ). Он ждёт сеть, потому что и должен её ждать - на этом построена вся асинхронная модель Node.js.
По документированному поведению V8 в процессе, который дошёл до полного клина из-за утечки, почти весь вывод должны занимать futex (системный вызов синхронизации потоков - им активно пользуются внутренние потоки V8, включая сборщик мусора, во время долгих пауз), а также mmap, munmap и madvise - вызовы, которыми процесс запрашивает у ядра новые страницы памяти, возвращает их обратно или подсказывает ядру, как с ними обращаться, - а epoll_wait почти исчезать из статистики. На живом тесте для этой статьи до такого состояния не дошли, но тренд в его сторону подтверждён цифрами: при RSS около 280 МБ strace -p <PID> -c за 6-8 секунд активной нагрузки показывал долю futex около 9% времени; когда утечка накопилась сильнее и RSS поднялся до 600-950 МБ, доля futex выросла до 15%. При этом write, epoll_pwait, writev и read всё ещё оставались в топе вывода - это легитимный сетевой I/O под нагрузкой, а не сам клин. То есть подтверждён именно рост доли futex вместе с накоплением утечки, а не окончательный снимок полностью зависшего процесса - до него на этом прогоне не догнали.
Признак | Обычный next-server под нагрузкой | next-server, который завис из-за утечки |
|---|---|---|
Преобладающие вызовы в |
| ожидаемо - |
Что это означает | процесс ждёт события на сокетах, читает запросы, пишет ответы - штатная работа event loop | потоки V8 (включая сборщик мусора) синхронизируются и перекраивают память под растущую кучу |
CPU в это время | колеблется, есть простой между запросами | стремится к 100% на одном ядре, простой почти пропадает |
Что делать дальше | ничего, это норма | искать в коде объект, который создаётся заново на каждый запрос вместо singleton |
Правый столбец таблицы - ожидаемое крайнее состояние по документированному поведению V8. На живом тесте подтверждён рост доли futex с 9% до 15% по мере накопления утечки при RSS от ~280 до ~950 МБ; полного доминирования futex+mmap/munmap/madvise с почти исчезнувшим epoll_wait за отведённое время нагрузки не достигли - тестовый бокс был без свопа и с ограниченной памятью, до настоящего клина ему просто не хватило дистанции. Если у вас в продакшене процесс дошёл до полной остановки, ожидайте увидеть именно такую картину; если снимаете strace на процессе, который ещё отвечает, но уже тяжело дышит, ориентируйтесь на растущую, а не окончательную, долю futex.
В подавляющем большинстве случаев причина утечки в SSR-приложениях на Next.js одна и та же: где-то в серверном коде - на странице, в layout, в middleware или в API-роуте - вызывается конструктор или фабрика, которая должна отработать один раз на весь процесс, а не один раз на каждый запрос. Классический пример - инициализация библиотеки локализации в духе i18next: она создаёт внутри себя обработчики событий, кэши переводов, подписки, и всё это рассчитано жить одним долгоживущим экземпляром.
Вот как выглядит проблема в упрощённом виде - реальная библиотека может отличаться в деталях, суть от этого не меняется:
// app/[locale]/layout.tsx - ПЛОХО: новый экземпляр на каждый запрос
import i18nLib from 'some-i18n-lib';
export default async function LocaleLayout({ params, children }) {
const i18n = i18nLib.createInstance();
await i18n.use(reactBinding).init({
lng: params.locale,
resources: translations,
});
return <Provider i18n={i18n}>{children}</Provider>;
}
Каждый раз, когда этот layout рендерится - то есть на каждый запрос, - createInstance() создаёт новый объект, а .use(...).init(...) регистрирует у него внутренние обработчики. Старый экземпляр из предыдущего запроса никуда не делся: на него по-прежнему ссылаются подписки и таймеры, которые библиотека создала при инициализации, поэтому сборщик мусора не может его забрать. Каждый следующий запрос добавляет в кучу ещё один такой объект - и куча растёт бесконечно, пока её не станет слишком много для одного ядра.
Правильно - создать объект один раз на процесс и переиспользовать его, различая только то, что реально меняется между запросами (например, язык):
// lib/i18n.ts - module scope, выполняется один раз при старте процесса
import i18nLib from 'some-i18n-lib';
const instances = new Map();
export function getI18n(locale) {
if (!instances.has(locale)) {
const i18n = i18nLib.createInstance();
i18n.use(reactBinding).init({ lng: locale, resources: translations });
instances.set(locale, i18n);
}
return instances.get(locale);
}
const instances = new Map() объявлена на верхнем уровне модуля, а не внутри функции компонента - в Node.js модуль загружается один раз на процесс, значит и Map живёт всё время работы процесса.getI18n(locale) создаёт новый экземпляр, только если для этого языка его ещё не было, и дальше просто отдаёт сохранённый. Локалей на сайте обычно немного (2-5), поэтому в куче осядет несколько объектов, а не тысячи.layout.tsx вызов меняется на const i18n = getI18n(params.locale) - без await на инициализацию при каждом запросе, потому что инициализация уже случилась раньше.Тот же паттерн бьёт и без i18n-библиотек: new PrismaClient() внутри обработчика роута вместо модуля, свежий HTTP-клиент с собственным пулом соединений на каждый вызов API-роута, новый Redis- или S3-клиент внутри middleware. Общее правило простое - если объект не зависит от конкретного запроса (или зависит только от небольшого, конечного набора значений вроде locale), создавайте его в module scope один раз, а не заново при каждом обращении.
Есть отдельная ловушка, которая делает утечку ещё незаметнее: как именно PM2 запускает процесс. Если в конфиге ecosystem.config.js в поле script стоит npm, а в args - start, PM2 следит не за настоящим next-server, а за тонкой обёрткой npm, которая почти ничего не делает и держится на около-нулевом CPU и стабильной памяти. Реальный next-server запускается как дочерний процесс этой обёртки - и когда он подтекает по памяти или подвисает, PM2 этого не видит, потому что смотрит не туда.
max_memory_restart в этой конфигурации меряет память npm-обёртки, а не приложения, и никогда не срабатывает вовремя: обёртка себе спокойно висит на паре десятков мегабайт, пока дочерний next-server внутри неё разрастается до предела памяти сервера. Правильная настройка - указывать в script абсолютный путь к самому бинарнику Next и запускать его напрямую, без npm посередине. Этот момент разобран подробно в первой статье серии про деплой Next.js на VPS - если у вас сейчас в конфиге npm start, начните именно с этого исправления: только после него метрики PM2 вообще начнут отражать реальность.
max_memory_restart в PM2 - это страховка, а не лечение. Он перезапускает процесс, когда тот переваливает заданный лимит по RSS, и не даёт утечке довести дело до OOM (когда ядро начинает убивать процессы за нехватку памяти на всей системе). Это полезно как последний рубеж - но если утечка есть, лимит будет срабатывать снова и снова, просто на предсказуемом интервале вместо непредсказуемого.
Рестарт по расписанию через cron - тот же самый костыль в других словах: сайт перестаёт зависать надолго, но утечка никуда не девается, а причина остаётся ненайденной. Хуже того, регулярный рестарт по расписанию маскирует сам факт наличия утечки: если интервал подобран с запасом, вы можете месяцами не замечать, что процесс вообще течёт, пока однажды более тяжёлый трафик не приведёт к зависанию раньше, чем сработает cron.
Настоящий фикс - один: найти в коде место, где на каждый запрос создаётся тяжёлый объект, который должен жить один раз на процесс, и вынести его создание в module scope, как показано выше. После этого RSS под burst-тестом должен вести себя как у здорового процесса - расти на первых запросах и выходить на плато, а max_memory_restart и cron-рестарт становятся тем, чем и задумывались: страховкой на крайний случай, а не рабочим механизмом.
next start напрямую, а не через обёртку npm/yarn? Проверьте script в ecosystem.config.js или соответствующий unit systemd - без этого PM2 и мониторинг измеряют не тот процесс.strace во время зависания преобладание futex/mmap/munmap/madvise над обычными epoll_wait/read/write?.use(...), createInstance() или похожая тяжёлая инициализация прямо внутри страницы, layout или middleware - то есть в пути, который выполняется на каждый запрос?new SomeClient() (база данных, HTTP-клиент, кэш) внутри обработчика роута вместо того, чтобы создать клиент один раз на верхнем уровне модуля?С двух простых инструментов: нагрузочного залпа запросов с сэмплированием RSS до и после, и strace -p <PID> -c в момент, когда сайт уже подвис. Если RSS растёт почти линейно и не плато, а strace в основном показывает futex/mmap/munmap/madvise, у вас утечка, а не просто нехватка ресурсов сервера.
Почти всегда потому, что в серверном коде на каждый запрос создаётся объект, который должен жить один раз на весь процесс - типичный пример - инициализация библиотеки локализации через createInstance()/.use() внутри страницы или layout. Каждый вызов регистрирует новые внутренние обработчики, которые не освобождаются, и куча V8 растёт с каждым запросом без остановки.
Он срабатывает уже после того, как утечка случилась, то есть лечит симптом, а не причину, и просто переносит зависание на более предсказуемый интервал. Хуже, если PM2 настроен запускать npm start: тогда он следит за обёрткой npm, а не за настоящим next-server, и лимит по памяти вообще меряет не тот процесс.
Тот же подход работает не только для Next.js: нагрузочный тест с замером RSS до и после показывает сам факт утечки, а strace -c во время подвисания показывает, борется ли процесс за память или просто ждёт сеть или диск. Для точной локализации в коде дальше используют heap snapshot через node --inspect и вкладку Memory в Chrome DevTools, либо отдельные профилировщики вроде clinic.js.
Чаще всего это не полезная работа приложения, а сборщик мусора V8: он раз за разом проходит по разросшейся куче в поисках памяти для освобождения и не успевает расчистить её быстрее, чем утечка добавляет новую. На единственном ядре это же ядро обслуживает и запросы, поэтому event loop - механизм, которым Node.js по очереди обрабатывает задачи в одном потоке, - фактически останавливается, и сайт перестаёт отвечать.
futex - системный вызов синхронизации потоков; в Node.js его активно используют внутренние потоки V8, включая сборщик мусора, во время долгих пауз. mmap, munmap и madvise - вызовы, которыми процесс запрашивает у ядра новые страницы памяти, возвращает их или подсказывает, как с ними обращаться. Когда они забивают почти весь вывод strace -c, а обычных сетевых read/write/epoll_wait почти нет, процесс занят не запросами, а борьбой за память.
ps -o rss,cmd -C next-server до и после. Кэш растёт и выходит на плато, утечка растёт почти линейно и не останавливается: на живом тесте RSS шла 119 МБ → 157 МБ → 203 МБ → 275 МБ → 937 МБ без единого плато, с ускоряющимся приростом на запрос.strace -p <PID> -c во время зависания. Растущая доля futex (на тесте - 9% → 15% по мере накопления утечки) - подтверждённый признак; полное доминирование futex/mmap/munmap/madvise над epoll_wait/read/write - ожидаемая по V8 картина полного клина, которую стоит проверять по своему процессу, а не принимать как гарантированный слепок.Map по тому, что реально меняется (например, по locale), не создавать заново на каждый рендер.npm start вместо прямого пути к бинарнику Next, он следит за обёрткой, а не за приложением, и не видит ни зависания, ни утечки.max_memory_restart и cron-рестарт - страховка на крайний случай, не лечение. Они прячут симптом и тратят время, которое лучше потратить на поиск причины.