javascript

Как я 8 дней ловил утечку памяти в Nuxt 3 SSR, и несколько раз думал, что починил

  • пятница, 29 мая 2026 г. в 00:00:13
https://habr.com/ru/articles/1040346/

Всем привет. Я занимаюсь фронтендом в небольшой команде сервиса бронирования отелей. Расскажу, как 8 дней ловил утечку памяти на проде, несколько раз думал, что починил, и каждый раз ошибался. Последний фикс был не в нашем коде, а в патче Vue, который через неделю апстрим откатил как регрессионный. В результате мы остались на одной патч-версии без утечки; обычный minor/patch update теперь для нас не безопасен без проверки heap-снапшотами.

Наш стек: Nuxt 3.18 + Vue 3.5.x + TypeScript, SSR, Pinia, PM2 cluster, nginx перед Node. Обычный каталог отелей с тысячами SEO-страниц вида /oteli-v-{город}/{подборка}.

Вкратце

  1. В отчете Ahrefs тысячи 502 у ботов, у живых пользователей почти нет. Снаружи 502, изнутри 200 — смерть воркера в момент запроса.

  2. Первая причина: SIGABRT от V8 по забытому --max-old-space-size от старого сервера. Лимит подняли, краши прекратились, память продолжала течь.

  3. Дифф heap-снапшотов показал: в нашей связке Nuxt 3.18 + Vue 3.5.x watch() в setup() на SSR оседает в heap без очистки. Известные апстрим-issue Vue/Nuxt — задеть может не каждого, у нас совпало.

  4. Обернул клиентские watch в if (import.meta.client). Ошибки у пользователей почти исчезли, скорость утечки осталась прежней. Вотчи оказались главным источником GC-давления, но не объясняли основной рост RSS.

  5. Закрылось апгрейдом Vue до 3.5.31 (апстрим-фикс SSR scope cleanup) и снятием серверных useFetch/useMediaQuery вотчеров.

  6. В Vue 3.5.32 фикс откатили как регрессионный. Сидим на 3.5.31; следующий апгрейд Vue — только с повторной проверкой heap-снапшотами.

До и после

До, пик

После, прошло 2 недели

502 в час

2444

0-1

502/504 за день

50 000 за 3-4 дня

6

RSS воркера

до 2907 МБ, ротация ~50 мин

плоские 350 мб, аптайм 14+ ч

Рост RSS

65 мб/мин

пила GC, дрейф ±2 мб/мин

CPU в простое

27% (GC трешинг)

2%

Что видят боты

Началось всё с отчета в Ahrefs: 1670 ссылок на наш сайт с кодом ответа 502 Bad Gateway. Боты сканируют сериями и попадают в 5-10 секундное окно недоступности воркера; реальный пользователь обновляет страницу через пару секунд и получает 200.

Масштаб: 51 467 ошибок и забытый PM2-конфиг

Первая теория: тупо мало RAM. В dmesg запись OOM-киллера:

Killed process 1364988 (node) total-vm:36643296kB, anon-rss:1395632kB

Каждый воркер на прогретом приложении ест ~1 гб RSS, два воркера + nginx + система = потолок. На скорую руку поправил кластер до instances: 1 и max_memory_restart: 900M, заказал апгрейд сервера.

Через пару дней сервер апгрейднули до 4 CPU / 8 гб RAM. Сел разбирать прод заново — масштаб оказался другим. В nginx access.log: 51 467 ответов HTTP 502 за 3-4 дня, около 2100 ошибок в час. Топ URL — неожиданный: статический JS-чанк /_nuxt/Bwfv1ZSS.js (1835 хитов), /favicon.ico (490), / (485). Статика /_nuxt/ исторически проксировалась через Node, а не отдавалась с диска. Падает Node — отваливается вся статика.

pm2 describe показал стек падения:

node::OOMErrorHandler
  → v8::Utils::ReportOOMFailure
  → v8::internal::V8::FatalProcessOutOfMemory
  → Heap::PerformGarbageCollection
  → Runtime_StringBuilderConcat

Умирал процесс по SIGABRT: V8 в FatalProcessOutOfMemory по --max-old-space-size. Не SIGKILL от ядра (в dmesg/journalctl пусто), не SIGTERM от PM2.

Открываю ecosystem.config.cjs: сервер апгрейднули, а PM2-конфиг остался от 2-гигабайтной эпохи. Прогретое приложение ест ~800 мб, на параллельные SSR-рендеры остается ~400 — под нагрузкой кончаются. max_memory_restart: 1700M не успевал, V8 умирал по внутреннему лимиту раньше.

Фикс занял несколько минут:

instances: 2,
max_memory_restart: '4G',
node_args: [
  '--max-old-space-size=5120',                // 1200 → 5120 (почему так много — дальше)
  '--heapsnapshot-signal=SIGUSR2',
  '--env-file=/var/www/website/shared/.env',
],

Плюс /_nuxt/ в nginx переключил на отдачу с диска. Закешированные URL переживали краш через proxy_cache_use_stale, страдал только первый-холодный запрос на каждый уникальный путь — а их у ботов как раз тысячи.

SIGABRT-краши прекратились. 502/час упал до 0-1. Победа?

Память все еще течет

Снял бейслайн через 37 секунд после прогрева — 64 мб. Через 3.5 часа воркеры доходили до RSS 1.7 гб / heap_used 1.25 гб. Утечка в районе 400 мб/час на воркер. Кластер + поднятый лимит просто превратили краш каждые 15 минут в медленный рост — симптом спрятан, причина осталась.

Решил снять второй снапшот для диффа. kill -SIGUSR2 <pid> — и оба воркера падают по SIGABRT во время сериализации дампа (RSS 1.7 → 3.5 гб → OOM). По V8-блогу снапшот занимает ~2х от heap. У меня на конкретном окружении (Node 20, Nuxt SSR, heap_used ≈ 1.25 гб) пиковый RSS уходил в 4.63 гб — то есть ~3.7-5х.

По моим наблюдениям снимать дамп безопасно при heap_used ≤ 15% от лимита (на других heap-формах картина может быть другой). Поэтому --max-old-space-size и поднят с 1200 до 5120.

Дампы шли по 50-600 мб, Chrome DevTools такие не вывозит — написал два Node-скрипта: diff-heap.mjs (агрегирует ноды по constructor name) и who-retains.mjs (обратный обход графа ссылок до глубины 4).

Сигнатура утечки и retainer chains

=== TOP GROWERS BY constructor (delta_size desc) ===
delta_size   delta_count   key
 +12.83 MB   +152,919      object/Dep
  +4.59 MB    +37,605      object/ComputedRefImpl
  +2.01 MB    +32,850      object/RefImpl
+760032 B      +7,917      object/EffectScope
+448800 B       +5,100     object/ReactiveEffect
+285600 B       +5,100     closure/watchHandle

На 3 датапоинтах (64 → 154 → 234 мб) считаю не приросты, а отношения счетчиков. В окнах 0-15 и 15-40 мин они получились идентичные — нормирую на «единицу источника» (один утекший композабл/стор с 2 вотчами):

Счетчик

прирост на 1 инстанс источника

watchHandle

2

ReactiveEffect

2

RefImpl

~4.2

ComputedRefImpl

~3.4

EffectScope

~0.55

Dep

~11.6

Стабильные отношения = один и тот же источник штампует одну и ту же структуру. Это не случайный набор объектов, а связанный граф реактивности Vue — watch() без очистки scope. Эвристика, не доказательство, но повторения заметны.

who-retains.mjs для object/EffectScope (3318 нод): 49% через Object.scope <- Object.component (Vue-компоненты, не размонтированные после SSR-рендера), 15% через EffectScope.prevScope chain, 5% через nuxtApp.ssrContext.__watcherHandles.

Диагноз

В нашей связке Nuxt 3.18 + Vue 3.5.x SSR-teardown не диспозит EffectScope: вотчи в setup() компонента / composable / Pinia-стора оседают в heap. На сервере нет «размонтирования» — компонент отрендерился в строку, но watch держит его живым. Для любой Vue 3 + Nuxt 3-конфигурации это утверждать не буду, проверять стоит heap диффом.

Это известный апстрим-баг: nuxt#33705 (цифры репортера почти совпадают с моими, heap с 80 до 600 мб за 4 ч при 60 rpm), vuejs/core#5208 и др. по теме.

И тут трафик ботов перестает быть фоновой деталью и становится множителем утечки: тысячи уникальных URL (типа /oteli-v-sochi) = тысячи SSR-рендеров = тысячи неубранных вотчей.

Шаблон фикса и 6 волн правок

// Было:
watch(source, handler);

// Стало:
if (import.meta.client) {
  watch(source, handler);
}

В серверном бандле вотча после этого нет, семантика не теряется — это всё клиентские вещи (аналитика, скролл, document.title, login/logout).

Убрал все вотчи — а память течет ровно так же

Деплою новый фикс, снимаю замеры. Пик 502/504 в час — с 2444 до ~5. CPU воркера в простое — с 26-29% до 1.8-2.3%. SSR-ответ при heap 95% — раньше упирался в 60-секундный таймаут, теперь 4.3 с. И одна строка, которая все ломает: скорость роста RSS — была 65 мб/мин, и осталась 65 мб/мин.

Ошибок у пользователей стало в ~500 раз меньше, а скорость утечки не изменилась. Вотчи были не главным потребителем памяти, а главным источником GC-давления: тысячи живых ReactiveEffect/Dep заставляли V8 на каждом minor-GC обходить большой граф, 27% CPU воркера в простое уходило в сборку мусора, и под этим давлением SSR-склейка не укладывалась в 60-секундный таймаут nginx -> 504. Убрал вотчи -> граф маленький -> CPU 2% -> SSR в таймауте. А память по-прежнему текла: я закрыл последствие, не причину.


Серверные вотчеры и фикс в Vue 3.5.31

Новый дифф после волн показал, что прирост сместился. Топ растущих — не watchHandle, а:

+70.66 MB   array/
+34.05 MB   string
+12.83 MB   object/Dep
+10.19 MB   object/Link          <- внутренности реактивности Vue 3.5
 +7.25 MB   object/system / Context   <- SSR async context

Dep/Link — внутренняя doubly-linked-list реактивности Vue 3.5, system / Context — объекты асинхронного контекста SSR. Подозрение на фреймворк. Что попробовал:

1. useMediaQuery из VueUse на SSR заменил на статический shallowRef по ширине из user-agent.

2. useApi без useFetch. В asyncData.js:123 видно: useFetch строит key = computed(...) и передает watch: [...watchSources, _fetchOptions] в useAsyncData, плюс еще один watch(key, setImmediate, ...). Каждый useFetch на сервере = один-два вотча в том же паттерне утечки. На сервере хожу в API руками: написал createServerManualApi с ручным $fetch и shallowRef.

3. Бамп Vue до 3.5.31. В патчноуте фикс PR #14548: «fix(server-renderer): cleanup component effect scopes after SSR render». Из описания: scopes «skip the normal unmount path», в результате «scope-bound effects and cleanup callbacks persist beyond the request lifetime». Слово в слово мой диагноз. Бампнул — утечка закрылась. Точную долю каждого из трех поздних шагов (3.5.31 / useApi / useMedia) я не вычленял: снапшот после стабилизации уже не снимал.

А теперь поворот: в Vue 3.5.32 этот фикс откатили (PR #14674). #14548 звал scope.stop() на каждом EffectScope после SSR-рендера, а тот по контракту дергает onScopeDispose()-колбэки. В Vue есть негласный контракт «на SSR onScopeDispose не срабатывает», под него написана куча композаблов — после 3.5.31 клинап хуки начали стрелять на сервере. Узкая замена через unwatch context.__watcherHandles пока не вернулась.

И нюанс под наш случай: через __watcherHandles держалось только 5% утечки, основная часть (49%) через Object.scope <- Object.component. Узкий фикс для нас закроет малую долю. Нас спасала ровно «слишком грубая» #14548. Регрессия у нас не стреляет: своего onScopeDispose нет, VueUse-composables на сервере либо не создают ресурс, либо вычищены руками. Варианты при той же беде: остаться на 3.5.31, написать свой teardown в Nuxt-плагине или ждать узкий апстрим фикс с поправкой про неполноту для цепочек Object.scope <- Object.component.

Проверка через 2 недели

Утечка закрыта, прод спустя 2 недели: воркеры по 14 часов аптайма при ~350 мб RSS, рост за 60 секунд +5.9/−1.5 мб (пила GC), 502/504 за весь день — 6. Будь там утечка хоть на 30 мб/мин, давно уперся бы в max_memory_restart: 4G. С 2100 ошибок 502/час до 6 за день.

Что в итоге

  1. В нашей связке Nuxt 3.18 + Vue 3.5.x watch() в setup() SSR-компонента оставался в heap. Клиентские вотчи лучше явно убирать из сервер-бандла через if (import.meta.client). useFetch и useMediaQuery тоже могут создавать вотчи под капотом — если heap diff ведет туда, заменяйте на ручной $fetch и статические SSR-ветки без реактивности.

  2. Heap снапшот в момент сериализации может занять не ~2x от heap как в доке, а 5x — снимайте при heap_used ≤ ~15% от лимита.

  3. Сначала heap-diff + retainer chain, потом код. Гадать по исходникам — терять время.