RE:NODE

Ресурсы13 мин чтения

Лимиты памяти Node.js: heap, RSS и потолок в 2 ГБ

Почему приложение Node падает на 2 ГБ на тарифе с 4 ГБ: лимит heap V8 против лимита контейнера, как задать --max-old-space-size и как найти настоящую утечку.

Обновлено

0 прочтений

Процесс Node на тарифе с 4 ГБ, который умирает, пока график в панели показывает 2 ГБ свободными, - это не ошибка хостинга. У Node есть собственный потолок heap, определяемый при старте эвристикой, которая ваш тариф не читает, и он нередко заметно ниже того, за что вы платите. Зеркальный случай встречается так же часто и выглядит совсем иначе: heap, выставленный на весь размер контейнера, что гарантирует, что ядро убьёт процесс раньше, чем V8 успеет собрать мусор. Оба дают «out of memory», лечатся они противоположным образом, а отличить одно падение от другого просто, если знать, куда смотреть. Здесь разбирается, как понять, какой случай у вас, как правильно задать число и как выяснить, нужно ли вообще что-то поднимать.

Два потолка и какой из них вы заденете первым#

Лимит контейнера - это то, что хост выдаёт серверу. Это настройка cgroup, её обеспечивает ядро, и она учитывает весь процесс целиком: heap JavaScript, каждый Buffer, всё, что выделил нативный модуль, стеки потоков, скомпилированный JIT код и сам бинарник Node. Пересечёте его - ядро отправит SIGKILL. Ни предупреждения, ни stack trace, ни возможности записать что-либо в лог, потому что процесс не просят остановиться - его останавливают.

Лимит heap V8 - это то, сколько Node позволяет занимать собственным объектам JavaScript. Его обеспечивает V8, а не ядро. Когда V8 не может освободить достаточно для выполнения выделения, он сознательно сдаётся, печатает несколько абзацев и аварийно завершается.

убивают на линииV8 old space--max-old-space-sizeBuffer и нативнаявне heapПотоки и кодlibuv, JIT, бинарникRSS процессачто считает ядроЛимит контейнератариф, который вы купили
Два потолка над одним процессом Node

Полезное следствие: heap JavaScript - это подмножество процесса, и --max-old-space-size управляет только этим подмножеством. Приложение, которое стримит загрузки файлов, ресайзит картинки или держит большие объекты Buffer, может сидеть на 300 МБ heap и 2,5 ГБ RSS. Подбирать тариф только по heapUsed - так такое приложение и убивают на тарифе, который выглядит вдвое больше, чем нужно.

Что на самом деле внутри процесса Node#

process.memoryUsage() возвращает пять чисел, и каждое отвечает на свой вопрос:

javascript
{  rss: 215547904,        // 205 MB - the whole process in physical memory  heapTotal: 132513792,  // 126 MB - heap V8 has committed from the OS  heapUsed: 118426160,   // 113 MB - live objects plus uncollected garbage  external: 8451234,     //   8 MB - C++ objects tied to JS objects  arrayBuffers: 4210688  //   4 MB - ArrayBuffers and Buffers, part of external}
ПолеСчитается в лимит heapСчитается в лимит контейнера
heapUsedДаДа
heapTotalДаДа
externalНетДа
arrayBuffersНетДа
rssНеприменимоЭто число, за которым следит ядро

Раз в минуту записывайте в лог все пять на продакшене. Это ничего не стоит и превращает «оно снова упало» в картину, которую можно прочитать. Самый ценный шаблон, который она раскрывает, - rss растёт при плоском heapUsed: значит, утечка в буферах или нативном аддоне, и никакая настройка heap её не тронет.

То, что живёт в RSS и никогда не появляется в heapUsed: содержимое Buffer, всё, что выделяет нативный модуль, например библиотека для картинок или криптографии, четыре потока пула libuv (UV_THREADPOOL_SIZE, по умолчанию 4) и их стеки, скомпилированный вывод JIT, загруженный бинарник и фрагментация аллокатора. Последнее - реальная вещь, которую часто диагностируют неверно: malloc в glibc держит арены на каждый поток и не всегда возвращает освобождённые страницы ядру, так что RSS может выйти на плато выше того, что использует приложение. Установка MALLOC_ARENA_MAX=2 в окружении иногда его снижает; это стоит одного эксперимента, а не целого вечера.

Как узнать реальный лимит heap одной командой#

Не гадайте значение по умолчанию. Оно менялось между мажорными версиями Node, а начиная с Node 12 вычисляется из того, сколько памяти, по мнению Node, есть у машины, - что внутри контейнера обычно означает весь хост, а не ваш тариф. На контейнере в 1 ГБ, работающем на узле со 128 ГБ, Node может спокойно выбрать потолок heap в несколько раз выше вашего лимита, и тогда ядро убьёт вас задолго до того, как V8 что-либо предпримет.

Спросите у рантайма:

bash
$ node -p "require('v8').getHeapStatistics().heap_size_limit / 1048576"2048.0000152587891$ node --max-old-space-size=3072 -p "require('v8').getHeapStatistics().heap_size_limit / 1048576"3075.0000152587891

Второе число чуть выше флага, потому что heap_size_limit включает молодое поколение, а не только old space. Ещё две однострочные команды стоит знать:

bash
$ node -p "require('os').totalmem() / 1073741824"   # the machine, not your plan$ node -p "process.constrainedMemory()"             # the cgroup limit, in bytes

process.constrainedMemory() есть в Node 18.15 и новее и возвращает лимит памяти, о котором сообщает контейнер, либо 0, если лимита нет. В старых версиях он не определён, и доступен только os.totalmem() - это и есть ловушка, описанная выше. Если ваш менеджер процессов или Dockerfile принимает решения на основе os.totalmem(), проверьте их.

Как правильно задать лимит#

Сделать это можно в трёх местах, и они не эквивалентны:

bash
$ node --max-old-space-size=3072 dist/server.js
the panel Startup tab, or .env
NODE_OPTIONS=--max-old-space-size=3072
package.json
{  "scripts": {    "start": "node --max-old-space-size=3072 dist/server.js"  }}

NODE_OPTIONS применяется к каждому процессу Node, который запускает окружение, включая npm, next build, tsc и любой дочерний процесс. Часто это именно то, что нужно - сборка Next.js, которая падает на полпути, классический случай, - но помните, что тогда этап сборки и работающий сервер используют одно и то же число.

Ставьте heap примерно в 75% от лимита контейнера. Никогда не приравнивайте его к тарифу.

Лимит контейнера--max-old-space-sizeНа что идёт остальное
512 MB320Рантайм, стеки, небольшие буферы
1 GB768То же, плюс скромные кэши
2 GB1536Запас на несколько крупных запросов
4 GB3072Нормальный запас
8 GB6144Нормальный запас

Оставляйте больше, если вы обрабатываете загрузки, генерируете изображения или используете нативный модуль, который выделяет память вне heap. Сервису, который буферизует файл в 200 МБ на запрос, нужно умножать на параллелизм запас, а не среднее значение.

Два соседних флага изредка стоит трогать. --max-semi-space-size=32 увеличивает молодое поколение, что помогает приложениям с очень высокой скоростью создания короткоживущих объектов: меньше объектов переходит в old space и переживает сборку, которую не должно было пережить. Выделяются два semi-space, так что реальная цена - минимум вдвое больше заданного числа. Беритесь за него только после того, как --trace-gc покажет постоянные scavenge при растущей доле продвижения.

А если вы запускаете worker-потоки или cluster, у каждого из них свой heap:

javascript
new Worker("./job.js", {  resourceLimits: { maxOldGenerationSizeMb: 512, maxYoungGenerationSizeMb: 32 },});

Четыре worker-а cluster, наследующие --max-old-space-size=3072 из NODE_OPTIONS на тарифе с 4 ГБ, - гарантированная смерть, и эту ошибку очень легко сделать при переходе от одного процесса к нескольким. Разделите бюджет. Когда вообще нужно несколько процессов (на одноядерном тарифе обычно никогда), разбирается в статье PM2 против панели хостинга.

Две сигнатуры падения и как их различить#

Лимит heap V8 целиком:

code
<--- Last few GCs --->[18:0x5a3b0f0]   109238 ms: Mark-Compact 2010.4 (2078.3) -> 2009.1 (2080.0) MB[18:0x5a3b0f0]   110114 ms: Mark-Compact 2011.0 (2080.0) -> 2010.2 (2081.5) MB<--- JS stacktrace --->FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed -JavaScript heap out of memory

Лимит контейнера целиком:

code
2026-05-04T01:58:12.004Z  GET /api/reports/daily 200 1182ms

Это и есть вся сигнатура: вывод обрывается на полуслове, и процесса больше нет. Коды выхода решают вопрос, когда консоль уже прокрутилась:

Код выходаСигналЗначение
137SIGKILL (128 + 9)Убит снаружи. На контейнере почти всегда лимит памяти
134SIGABRT (128 + 6)V8 прервал сам себя. Лимит heap или проверка assertion
139SIGSEGV (128 + 11)Ошибка сегментации, обычно нативный модуль
143SIGTERM (128 + 15)Запрошена чистая остановка. Ваша кнопка перезапуска или деплой
1-Ваш код бросил исключение, и никто его не поймал

Ещё два сбоя выдают себя за эти, не будучи ни одним из потолков:

  • RangeError: Invalid string length - это максимальный размер строки в V8, а не исчерпание памяти. Реальное значение проверьте командой node -p "require('buffer').constants.MAX_STRING_LENGTH". Это значит, что вы склеили или прогнали через JSON.stringify то, что следовало стримить.
  • ERR_WORKER_OUT_OF_MEMORY - это один worker-поток, превысивший собственные resourceLimits, что вообще не затрагивает heap главного процесса.

На RE:NODE случай лимита контейнера устроен определённым и намеренным образом. На лимите памяти ядро останавливает контейнер, и он возвращается чистым, а не продолжает работать в свопе, как в upstream Pterodactyl, где сделано наоборот. Короткий перезапуск, после которого всё восстанавливается само, лучше медленной деградации, тянущей вниз всю машину. Следствие, которое нужно учитывать: приложение с утечкой превращается в цикл перезапусков, а наблюдатель раз в две минуты проверяет, не пошёл ли аптайм назад: три неожиданных перезапуска за час поднимают предупреждение на странице сервера и автоматически открывают тикет. Тот же механизм с другой стороны разбирает статья почему ваш игровой сервер постоянно перезапускается.

Ищем утечку вместо подъёма потолка#

Приложение, которое растёт, пока не упрётся в любой потолок, имеет утечку, и подъём потолка покупает часы, а не решает проблему. Если память стабильно растёт при постоянной нагрузке, число, которое надо менять, находится в вашем коде, а не в команде запуска.

Начните с --trace-gc, который почти бесплатен и может работать на продакшене:

bash
$ node --trace-gc --max-old-space-size=1536 dist/server.js
code
[42:0x5f8c0a0]     3021 ms: Scavenge 58.3 (79.5) -> 44.1 (80.5) MB, 2.1 / 0.0 ms[42:0x5f8c0a0]   184433 ms: Mark-Compact 512.9 (548.0) -> 498.2 (549.5) MB, 220.4 / 0.0 ms[42:0x5f8c0a0]   402117 ms: Mark-Compact 899.1 (932.0) -> 884.7 (933.5) MB, 388.9 / 0.0 ms

Игнорируйте строки Scavenge. Читайте число сразу после стрелки в последовательных строках Mark-Compact: это живой heap после полной сборки. Если оно каждый раз возвращается примерно к одному уровню, утечки нет, и вам действительно может понадобиться heap побольше. Если оно монотонно растёт часами - 498, 884, 1 340 - что-то удерживается, и никакая настройка этого не исправит.

Когда вы установили, что это утечка, делайте снимки heap:

bash
$ node --heapsnapshot-signal=SIGUSR2 dist/server.js$ kill -USR2 $(pgrep -f dist/server.js)

Каждый сигнал записывает файл .heapsnapshot в рабочий каталог. То же можно сделать изнутри процесса, что проще на панели, где отправка сигналов неудобна:

javascript
const v8 = require("node:v8");const path = v8.writeHeapSnapshot(`/home/container/heap-${Date.now()}.heapsnapshot`);console.log("snapshot written to", path);

Сделайте один после прогрева и второй через тридцать-шестьдесят минут при той же нагрузке. Скачайте оба, откройте Chrome DevTools, перейдите на панель Memory, загрузите оба файла, затем переключите вид на Comparison и отсортируйте по delta. Конструктор, у которого число объектов выросло на десятки тысяч, и есть утечка, а панель retainers покажет, что его удерживает. Два снимка - вся техника; один снимок почти ничего не говорит.

Куда обычно уходит память#

Примерно в том порядке, в каком это встречается в реальных приложениях:

  • Кэш без ограничения. Map или обычный объект на уровне модуля, в который только пишут. Задайте ему предельный размер и политику вытеснения; LRU с max - это двадцать строк или одна зависимость.
  • Обработчики событий, добавляемые на каждый запрос или соединение и никогда не снимаемые. MaxListenersExceededWarning в вашем логе - это Node сообщает, что так и происходит, и ложная тревога это почти никогда.
  • Таймеры, удерживающие замыкания. setInterval, созданный на каждое соединение и не очищенный, навсегда сохраняет всё, что замыкает его колбэк, включая тела запросов и строки из базы.
  • Карты ожидающих запросов без таймаута. Ключом служит correlation id, запись удаляется при ответе, но не удаляется, когда ответ не приходит.
  • Потоки, которые не читают или не уничтожают. Игнорирование backpressure - запись быстрее, чем принимает получатель, - буферизует разницу в памяти по построению.
  • Кэши Discord-ботов. discord.js держит в памяти гильдии, каналы, участников, сообщения и присутствия. Значения по умолчанию подходят для бота на нескольких серверах; кэши участников в частности растут с каждой гильдией, к которой вы присоединяетесь. Настройте makeCache и sweepers и запрашивайте только те gateway intents, которые действительно используете. О подборе размера рассказывают хостинг Discord-бота 24/7 и как выбрать тариф для Discord-бота.
  • Нативные модули. Обработка изображений, headless-браузеры и библиотеки кодирования выделяют память вне heap. Они проявляются ростом rss и external при плоском heapUsed, и им нужен собственный лимит параллелизма, а не больший heap.

Подбор тарифа на рабочем примере#

Приблизительные отправные точки для одного процесса, пока вы не измерили свои:

НагрузкаТипичный RSSРазумный тариф
Discord-бот, несколько гильдий80-150 MB1 GB
Discord-бот, десятки гильдий250-600 MB2 GB
API на Express или Fastify, умеренный трафик150-400 MB1-2 GB
Next.js в продакшене (next start)300-700 MB2 GB
next build или крупный запуск tsc1.5-4 GB на пикеСобирайте там, где хватает места
Обработка изображений или медиаот 500 MB, на каждое заданиеЦеликом зависит от размера входных данных

Теперь реальная картина. API на Express на тарифе 2 ГБ умирал примерно в 02:00 большую часть ночей. Консоль обрывалась посреди запроса, код выхода был 137, а в логе приложения не было ничего. NODE_OPTIONS был выставлен в --max-old-space-size=2048, точно по размеру тарифа, и это и есть ошибка: heap позволили заполнить весь контейнер, поэтому у V8 не было причин собирать мусор агрессивно, и ядро пришло первым.

Поминутный лог process.memoryUsage() показал heapUsed ровно на 180 МБ весь день, а затем вертикальный рост до 1,7 ГБ, начинавшийся в 01:55. В 01:55 плановое задание вытягивало четыреста тысяч строк, преобразовывало их в объекты и делало JSON.stringify результата в одну строку, чтобы записать отчёт.

Три изменения, и ни одно - не больший тариф. Heap опустили до 1536, чтобы V8 упирался в собственный потолок первым и завершался сообщением, которое можно прочитать, а не исчезал. Задание отчёта перевели на курсор и поток записи, так что строки никогда не лежат в памяти все сразу. И само задание вынесли из процесса API целиком - схемы см. в фоновые задачи на небольшом сервере. Пиковый RSS после этого был 620 МБ на том же тарифе 2 ГБ.

Общее правило, которое иллюстрирует пример: прежде чем покупать память, сделайте так, чтобы процесс падал с выводом. 134 со stack trace стоит больше, чем 137 с тишиной, хотя 137 выглядит дружелюбнее.

FAQ#

Что на самом деле задаёт --max-old-space-size?

Максимальный размер в мегабайтах old generation V8 - части heap JavaScript, где объекты живут после того, как переживут пару сборок молодого поколения. Он не ограничивает данные Buffer, нативные выделения и стеки потоков, так что процесс может и будет использовать больше памяти, чем заданное вами число.

Какое значение безопасно на тарифе с 4 ГБ?

Около 3072. Цель - чтобы V8 упёрся в собственный потолок раньше, чем ядро в ваш, и вы получили читаемую ошибку вместо тихого убийства. Если ваше приложение гоняет большие буферы, ставьте меньше и следите за rss, а не за heapUsed.

Моё приложение убито, а в логе ничего нет. Почему?

Потому что ядро убило его сигналом SIGKILL за превышение лимита памяти контейнера, а процесс, получивший SIGKILL, не может выполнить никакой код, включая логгер. Проверьте код выхода: 137 - это именно этот случай. Как выглядит резкий обрыв рядом с настоящим падением, разбирает чтение консоли.

Исправляет ли подъём лимита heap утечку памяти?

Нет. Он откладывает падение пропорционально тому, насколько вы его подняли. Если живой heap после каждой полной сборки мусора стабильно растёт при постоянной нагрузке, удерживаемые объекты - это баг, и единственное настоящее решение - найти, что их держит.

Делят ли worker-ы cluster лимит heap?

Нет. У каждого процесса свой, а NODE_OPTIONS применяет одно и то же значение ко всем. Четыре worker-а по 3 ГБ на тарифе 4 ГБ - это четыре тарифа. Разделите лимит контейнера на число процессов, прежде чем задавать флаг.

Почему RSS остаётся высоким после всплеска трафика?

Отчасти потому, что V8 не возвращает освобождённые страницы heap операционной системе сразу, а отчасти потому, что системный аллокатор держит арены. Плато - это нормально. Лестница, которая только растёт на протяжении дней, - нет: именно такую форму графика нужно разбирать, а об остальных рассказывает чтение графика нагрузки сервера.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000