lenec ru

← все посты

JavaScript heap out of memory в Node: что делать

16K

Эта ошибка приходит на самом неподходящем моменте. Деплой собирается полчаса, и в самом конце выпадает:

<--- Last few GCs --->
[1234:0x55c1abcd]   189345 ms: Mark-sweep 2046.5 (2058.4) -> 2046.4 (2058.4) MB

FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory

Это Node сообщает, что сборщику мусора уже некуда расти, и виртуальная машина V8 завершает процесс. По умолчанию у Node лимит heap'а — порядка 4 ГБ на 64-битных системах (раньше было около 1.5 ГБ, но в современных версиях лимит подняли). Когда сборка большая или приложение держит много данных в памяти — упираешься.

Первый рефлекс — поднять лимит

Через переменную окружения:

NODE_OPTIONS="--max-old-space-size=8192" pnpm build

Здесь 8192 — это размер старой генерации в мегабайтах, грубо говоря, верхняя граница heap'а. Иногда это правильное лекарство: сборка крупного Next-проекта или большой TypeScript-компиляции реально требует больше памяти, особенно с source maps.

В CI я добавляю эту переменную в job-окружение, чтобы не редактировать каждый package.json:

env:
  NODE_OPTIONS: --max-old-space-size=8192

На сервере с 4 ГБ ОЗУ ставить 8 ГБ heap'а бессмысленно — Node будет свопиться и работать ещё медленнее. Сначала проверь, сколько памяти физически есть.

Когда это симптом, а не причина

Если процесс рантайма (а не сборка) падает с heap out of memory — это почти всегда утечка. Поднимешь лимит — упадёт через два часа вместо одного. Лечить надо находя источник.

Признаки утечки

  • память приложения растёт линейно при равномерной нагрузке;
  • после каждого деплоя ОК, через сутки — плохо;
  • график RSS в Grafana похож на пилу с растущей вершиной.

На таких симптомах поднимать --max-old-space-size — самообман. Идти искать.

Как снять heap snapshot

Node может сделать дамп heap'а на диск — потом открываешь его в Chrome DevTools, ходишь по объектам и ищешь, кого слишком много.

Самый простой способ — сигнал:

kill -USR2 <pid>

В свежих Node это создаёт файл Heap.<timestamp>.heapsnapshot. Скачиваешь его на свой ноут, открываешь в DevTools (Performance > Memory > Load), смотришь, какой класс/конструктор внезапно занимает половину памяти.

Можно делать snapshots программно:

import { writeHeapSnapshot } from 'v8';

process.on('SIGUSR2', () => {
  const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
  writeHeapSnapshot(file);
  console.log(`Heap dumped: ${file}`);
});

Делаешь два снимка с разрывом 5–10 минут под нагрузкой и сравниваешь — что приросло.

Типичные источники утечек

Подписки на эмиттеры без отписок

Сервис вешает обработчик на event emitter и забывает отписаться при завершении запроса. Через сутки в emitter висят 10000 closures, держащих ссылки на запросы.

// плохо
function handle(req, res) {
  bus.on('event', (e) => res.write(e));
}

// хорошо
function handle(req, res) {
  const onEvent = (e) => res.write(e);
  bus.on('event', onEvent);
  res.on('close', () => bus.off('event', onEvent));
}

Глобальные кэши без TTL

Простой const cache = new Map(), в который кладут результаты запросов «чтобы быстрее». Никто не чистит. К концу месяца там пол-базы. Лечится либо TTL-кэшем (lru-cache), либо размером (max items).

Замыкания в Promise.all над большими коллекциями

Я видел это много раз: кто-то делает Promise.all(rows.map(processRow)) над 200 000 строк. Все 200 000 promise'ов и их замыкания живут в памяти, пока последний не завершится.

Лекарство — обрабатывать пачками:

for (const batch of chunk(rows, 100)) {
  await Promise.all(batch.map(processRow));
}

Стримы без backpressure

Читаешь файл стримом, парсишь, пишешь в БД. Если БД медленнее, чем чтение, очередь задач между ними растёт. К моменту OOM в очереди уже миллион записей. Помогает pipeline с правильной обработкой backpressure или явные паузы стрима.

Что делать прямо сейчас, если падает на проде

В порядке от быстрого к серьёзному:

  • Перезапустить, чтобы вернуть сервис в строй. Это не починка, это пожарный шланг.
  • Увеличить лимит на heap до того, что физически есть на машине, — выиграть время, чтобы спокойно искать причину.
  • Снять snapshot до перезапуска, если успеваешь. Это золото для разбора.
  • Включить мониторинг RSS и heap — Prometheus exporter в Node умеет это из коробки.
  • Найти и починить утечку.

Особенности сборки

Если ошибка приходит именно во время next build или tsc, причина обычно не в утечке, а в реальной потребности. Длинные компиляции с большими типами и source maps жрут много. Я в Next-проектах с тяжёлым TypeScript уже не удивляюсь, когда нужно 6–8 ГБ.

В package.json:

{
  "scripts": {
    "build": "NODE_OPTIONS='--max-old-space-size=8192' next build"
  }
}

На Windows такая запись не подхватится, в кросс-платформенных проектах удобно через cross-env:

{
  "scripts": {
    "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build"
  }
}

Что забрать с собой

  • OOM на сборке — обычно лечится повышением лимита, если памяти на машине достаточно.
  • OOM на рантайме — это утечка, и лимит просто отодвигает падение во времени.
  • Heap snapshots — твой главный инструмент, не игнорируй их.
  • Метрики RSS и heap живут в Node бесплатно, ставь их в мониторинг сразу.

OOM-сообщение не страшное, если научиться читать симптомы. Большинство инцидентов у меня в практике решались за пару часов работы со снимками heap'а — без сложных инструментов и без чёрной магии.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.