Всем привет. Я занимаюсь фронтендом в небольшой команде сервиса бронирования отелей. Расскажу, как 8 дней ловил утечку памяти на проде, несколько раз думал, что починил, и каждый раз ошибался. Последний фикс был не в нашем коде, а в патче Vue, который через неделю апстрим откатил как регрессионный. В результате мы остались на одной патч-версии без утечки; обычный minor/patch update теперь для нас не безопасен без проверки heap-снапшотами.
Наш стек: Nuxt 3.18 + Vue 3.5.x + TypeScript, SSR, Pinia, PM2 cluster, nginx перед Node. Обычный каталог отелей с тысячами SEO-страниц вида /oteli-v-{город}/{подборка}.
Вкратце
-
В отчете Ahrefs тысячи 502 у ботов, у живых пользователей почти нет. Снаружи 502, изнутри 200 — смерть воркера в момент запроса.
-
Первая причина: SIGABRT от V8 по забытому —max-old-space-size от старого сервера. Лимит подняли, краши прекратились, память продолжала течь.
-
Дифф heap-снапшотов показал: в нашей связке Nuxt 3.18 + Vue 3.5.x watch() в setup() на SSR оседает в heap без очистки. Известные апстрим-issue Vue/Nuxt — задеть может не каждого, у нас совпало.
-
Обернул клиентские
watchвif (import.meta.client). Ошибки у пользователей почти исчезли, скорость утечки осталась прежней. Вотчи оказались главным источником GC-давления, но не объясняли основной рост RSS. -
Закрылось апгрейдом Vue до 3.5.31 (апстрим-фикс SSR scope cleanup) и снятием серверных
useFetch/useMediaQueryвотчеров. -
В 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 за день.
Что в итоге
-
В нашей связке Nuxt 3.18 + Vue 3.5.x watch() в setup() SSR-компонента оставался в heap. Клиентские вотчи лучше явно убирать из сервер-бандла через
if (import.meta.client).useFetchиuseMediaQueryтоже могут создавать вотчи под капотом — если heap diff ведет туда, заменяйте на ручной$fetchи статические SSR-ветки без реактивности. -
Heap снапшот в момент сериализации может занять не ~2x от heap как в доке, а 5x — снимайте при heap_used ≤ ~15% от лимита.
-
Сначала heap-diff + retainer chain, потом код. Гадать по исходникам — терять время.
ссылка на оригинал статьи https://habr.com/ru/articles/1040346/