Привет, Хабр! Меня зовут Вадим Королёв. Я руководитель команды разработки в X5 Tech. Очень люблю Next.js и решать проблемы, которые он приносит. С ним всегда происходит что-то интересное. Расскажу о причине утечки памяти в Node.js, которая оказалась глубже, чем можно было подумать.

В декабре, перед самым Новым годом, наше приложение начало вести себя так, будто вот-вот рухнет. С ростом пользователей посыпались алерты, вырос трафик, а из команды мониторинга сообщили, что поды в Kubernetes перезагружаются. Пока не падают, но выглядят плохо.В этот момент я занимался архитектурой и оптимизацией Node.js в музыкальном стриминге. Открыл графики и увидел явный рост памяти, который уходил в пик и приводил к перезапуску подов. Так началась «классическая предновогодняя история». Next.js в Kubernetes внезапно начал есть память так, будто у него внутри чёрная дыра.

Сейчас во фронтенде приходится разбираться не только с самим фреймворком, но и с кучей сопутствующих вещей. Фронтенд-разработчику нужно держать в голове слишком много: рендеринг, контейнеры, сборщики, кэши, файловую систему, мониторинг и даже особенности конкретного ядра Linux, на котором всё это работает. И чтобы не заблудиться в куче метрик, лучше всего смотреть на них наглядно.

После очередного релиза приложения стриминга у нас возникла примерно такая картина.

Потребление оперативной памяти в пределах 1 гигабайта даже для самых жирных приложений на Next.js — не совсем окей. Особенно когда бэкэнд, как было у нас, выделен в отдельный микросервис.

Если у вас в ��родакшене что-то ведёт себя странно, графики лучше всего показывают, что происходит. У нас как раз была настроена классическая связка: Prometheus собирал метрики, а Grafana их рисовала. Базовый дашборд ставится буквально одной кнопкой. И уже по нему видно, что происходит с процессом: CPU, память, запросы, пики. У меня такой дашборд стоит даже на домашнем роутере, чтобы отслеживать нагрузку.

Для Next.js, конечно, лучше ставить дашборд, который дополнительно показывает event loop lag, объём heap, количество активных хендлеров, потребление памяти самим процессом и общими библиотеками. В продакшене обычно видно красивую «лесенку», которая то растёт, то спускается вниз по мере нагрузки на сервис. На ней мы и увидели, что память ведёт себя  не так, как должна. Прибавили к этому странное поведение подов и пошли проверять классические сценарии утечек памяти в Node.js.

Классические утечки памяти

Глобальные переменные

Это самая распространённая утечка в Node.js. Если положить данные в глобальную область видимости и продолжать добавлять туда элементы, например в массив, объект будет расти, пока живёт процесс, и не будет очищаться. Потому что глобальный скоуп никогда не удаляет переменные, которые в себе хранит.

// Плохо - глобальная переменная
let globalData = [];

function addData() {
   for (let i = 0; i < 1000000; i++) {
      globalData.push(new Array(1000).fill('data'));
   }
}

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

// Хорошо - локальная переменная
function processData() {
   let localData = [];
   // обработка данных
   return result; // localData будет освобождена
}

Замыкания

Но с замыканиями тоже нужно быть осторожным. Даже если переменная внутри замыкания больше не нужна, она всё равно может удерживать память. Функция createHandler создаёт и возвращает другую функцию. Внешняя переменная largeData внутри хендлера не используется, но всё равно остаётся в памяти. Это происходит потому, что анонимную функцию создали внутри createHandler, и она тянет за собой всё своё окружение.

// Плохо — замыкание удерживает большие данные
function createHandler() {
   const largeData = new Array(1000000).fill('heavy data');

   return function(event) {
       // Обработчик удерживает ссылку на largeData
      console.log('Event processed');
   };
}

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

// Хорошо — освобождение ссылок
function createHandler() {
   return function(event) {
      const localData = new Array(1000).fill('light data');
      // localData освобождается после выполнения
      console.log('Event processed');
    };
}

Таймеры и интервалы

Ещё одна классическая история. Когда вы создаёте таймер, он появляется как отдельная задача и висит в памяти.

// Плохо — таймер не очищается
function startProcess() {
    const data = new Array(100000).fill('data');

    setInterval(() => {
        // data удерживается в памяти
     console.log('Processing...');
     }, 1000);
}

Чтобы он очистился, нужно вызвать clearTimeout (или clearInterval) и убрать ссылку. Если этого не сделать, таймер отработает, но сама структура продолжит висеть в процессе.

// Хорошо — очистка таймера
function startProcess() {
    const data = new Array(100000).fill('data');

    const timer = setInterval() => {
        console.log( 'Processing...');
    }, 1000);

   // Очистка при необходимости
   setTimeout(() = {
       clearInterval(timer);
   }, 10000);
}

Event Listeners

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

// Плохо — обработчики не удаляются
const EventEmitter = require('events');
const emitter = new EventEmitter();

function addListener() {

    const data = new Array(100000).fill('data');

    emitter.on('event', () => {
         // data удерживается в памяти
         console.log('Event received');
    });
}

Чтобы обработчик очистился, его нужно снять по ссылке. Для этого есть метод removeListener (или off в новых версиях). Я передаю ту же функцию, и память освобождается.

Неконтролируемый кеш

В Node.js есть структуры Map и Set. Они удобные, но в контексте утечек про них легко забыть. Это те же объекты, где данные лежат по ключу. Что положили, то и останется в памяти, пока не удалите.

// Плохо — неограниченный кэш
const cache = new Map();

function getData(key) {
    if (cache.has(key)) {
       return cache.get(key);
  }

  const data = expensiveOperation(key);
  cache.set(key, data); // Кэш растет бесконечно
  return data;
}

Если ключ в Map или Set больше не нужен, его придётся очищать вручную. А если вы используете такие кэши на глобальном уровне, стоит заранее ограничивать их рост. Я обычно задаю максимальный размер и выбираю алгоритм очистки, который подходит под конкретную бизнес-задачу.

Потоки и файловые дескрипторы

Есть ещё один тип утечек, с которым редко сталкиваются ��о фронтенде. В Node.js при открытии файла создаётся дескриптор, который удерживает выделенную под чтение память. Если файл не закрыть, дескриптор останется висеть и удерживать ресурсы.

const fs = require('fs');

// * Плохо поток не закрывается
function readFile() {
   const stream = fs.createReadStream('large-file.txt');

   stream.on('data', (chunk) => {
       // обработка данных
   });
// Поток не закрывается явно
}

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

// Хорошо — правильное закрытие
function readFile() {
    const stream = fs.createReadStream('large-file.txt');
 
   stream.on('data', (chunk) => {
       // обработка данных
   });
   stream.on('end', () => {
   stream.close(); // Явное закрытие
   });
   stream.on('error', (err) => {
   stream.close(); // Закрытие при ошибке
   });
}

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

Как снимать Snapshots

Начать поиск утечек в Node.js проще всего с Chrome DevTools. В браузере есть встроенный Node Inspector.

chrome://inspect/

Он позволяет делать Snapshot прямо с работающего процесса. Для этого заходите во вкладку Memory, нажимаете Take Snapshot и получаете состояние памяти в моменте.

Но одного снимка обычно недостаточно. Утечку важно рассматривать в динамике. Поэтому удобнее делать несколько снимков.

Техника трёх снапшотов

  1. Первый снимок сразу после запуска приложения. На нём будут только инициализированные модули и минимальный набор объектов.

  2. Второй снимок через некоторое время после начала работы. На продакшене обычно ждут минут 10. За это время успевают подгрузиться модули по lazy loading, заполниться кэш, а приложение – выйти в рабочий режим.

  3. Третий снимок перед остановкой процесса. Его удобнее всего сравнивать со вторым.

После снятия нескольких снимков появится не самое неочевидное окно.

В верхней части список объектов: функции, строки, массивы и другие структуры, которые были созданы в процессе.

Нас интересует колонка Size delta. В режиме сравнения выбираем второй и третий снимок и сортируем по убыванию. На первом месте окажется самый «тяжёлый» объект, который рос во времени. В примере для демки это просто накапливающийся массив.

Внизу окна есть дерево ссылок. По нему можно спуститься до места накопления в коде. В новых версиях инспектор показывает больше метаданных, вплоть до файла и имени функции.

Это тяжело читать на минифицированном коде, но для Next.js можно использовать неминифицированную сборку.

Инспектор без минификации

Чтобы инспектор показывал читабельные названия файлов и функций, можно собрать Next.js без минификации. Для этого нужно:

  1. Откатиться с Turbopack на Webpack.

  2. Выключить минификацию при сборке.

module.exports = {
      experimental: {
          // Отключаем Turbopack, возвращаемся к Webpack
          turbopack: false
       },
       webpack: (config, { isServer }) => {
            if (!isServer) {
              // Попытка отключить минификацию
              config.optimization.minimize = false;
            }
            return config;
       },
};
  1. Договориться с командами OPS/SRE, чтобы один из подов в Deployment был поднят на такой сборке. Потеря производительности будет небольшой, особенно если реплик больше четырёх.

Снятия по флагу

Node Inspector на продакшене не всегда подключается без дополнительных флагов. Можно поймать интересный баг, который до сих пор не решили. Инспектор просто не подключится к процессу на продакшене. Поэтому Node.js нужно запускать с параметром inspect и портом для подключения инспектора:

// package.json
{
   "scripts": {
       "start": NODE_OPTIONS='--inspect=0.0.0.0:9229' next start
    }
}

Снятие по сигналу

Если нельзя подключиться к продакшену, можно снимать Snapshot при получении сигнала.

# Запускаем процесс с ожиданием сигнала SIGUSR2
$ node --heapsnapshot-signal-SIGUSR2 index.js &
$ ps aux
USER PID %CPU %MEM       VSZ      RSS   TTY    STAT  START TIME COMMAND
node  1  5.5  6.1     787252   247004    ?     Ssl   16:43 0:02  node 
-heapsnapshot-signal-SIGUSR2 index.js

# убиваем процесс с сигналом SIGUSR2
$ kill -USR2 1

# В директории появится snapshot
$ls
Heap.20190718.133405.15554.0.001.heapsnapshot

Когда в процесс прилетит сигнал SIGUSR2, рядом с исполняемым файлом появится дамп памяти.

Снятие командой

Начиная с Node.js 16, можно вызвать writeHeapSnapshot прямо из кода.
В этом случае snapshot создаётся во время выполнения, без остановки приложения. Это удобно, когда нужно зафиксировать состояние памяти в конкретный момент, например, в середине бизнес-логики.

require( ‘v8’ ).writeHeapSnapshot();

Но это альтернатива для особых случаев, когда все предыдущие способы не подходят.

Локализация проблемы

Но вернёмся к нашему новогоднему переполоху. Когда мы сняли snapshots, нарисовалась интересная картина. По логам утечки нет. По snapshots тоже нет. В динамике память не растёт, а на продакшене растёт. Значит, дело в поведении самого окружения, а не кода. Поэтому я решил натравить нагрузочное тестирование на стейдж.

Самый простой способ создать эмуляцию запросов пользователей в ваше приложение — утилита Autocannon. Она запускается в одну команду и позволяет выдавать тысячи RPS с локальной машины. Единственное, перед запуском стоит предупредить команду безопасности, чтобы вас не приняли за человека, который DDoSит собственную инфраструктуру.

//load-test.js
const autocannon = require('autocannon');

async function main() {
    const result = await autocannon({
        url: 'http://stage-env-example.com',
        connections: 10, // параллельные соединения
        pipelining: 1,       // HTTP pipelining
        duration: 10        // длительность в секундах
     });

     console.log('Requests/sec:', result.requests.average);
     console.log('Latency (ms):', result. latency.average);
     console.log('2xx responses: ', result['2xx']);
}
main().catch(console.error);

Я прогнал нагрузку и получил результат, потому что запрашивал html-документы, а не сами файлы, которые запускали сжатия. Поэтому пошёл искать похожие кейсы на GitHub. Чаще всего проблему с нашим фреймворком уже кто-то решил, и остаётся сделать Ctrl-C, Ctrl-V.

Я нашёл похожее поведение в двух issue и попробовал там поковырять. Несколько пользователей указали, что избежать роста потребления оперативной памяти можно, если отключить оптимизацию изображений в конфигурации.

// next.config.js
{
    images: { unoptimized: true }
}

Сначала всё вроде бы получилось: графики выровнились. Но появилась другая проблема: память пошла вниз, а потребление CPU — вверх.

Next.js у нас использовался как оптимизатор картинок. Так не нужно держать отдельный CDN под это дело. Мы отключили оптимизацию, но сам процесс сжатия картинок остался. А в стриминговых сервисах картинки – это половина нагрузки. Они создают запросы, нагружают библиотеку Sharp, пишут кэши. И пока браузер не запрашивает картинки, это никак не проявляется.

Мы сделали инъекцию в код Next.js и решили временно отложить проблему, на неё уже и так ушло слишком много сил и времени.

Как понять природу проблемы

Но история не давала мне покоя, и чтобы понять, почему какие-то картинки увеличивают потребление памяти, я решил разобраться, как работает оптимизатор кэша в Next.js.

И тут меня озарило! Когда я нагружал запросы к HTML-документам, то не подумал, что картинки обрабатываются только после того, как браузер распарсит HTML и сделает отдельный запрос к оптимизатору. Получается, я нагружал совсем не то, что было нужно.

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

Наконец-то удалось воспроизвести проблему в условиях, где её можно спокойно изучить. И сразу стало ясно, что мало смотреть только на Node.js. Нужно разбираться, как работает операционная система, на которой запускается мое приложение.

Как Linux работает с памятью

Linux делит оперативную память на несколько частей.

  • SWAP живёт на диске и работает медленно. В неё отправляются страницы, которые система давно не трогала. Это спящие процессы, старые данные и всё, что не нужно прямо сейчас.

  • Физическая память — это реальные модули с чипами. Здесь Linux держит данные для быстрого доступа: активные страницы процессов и кэши, которые ускоряют работу системы.

Когда мы работаем с картинками или «трогаем файлы», система начинает создавать несколько типов кэшей.

В Slab-кэшах живут сетевые объекты, сокеты и другие элементы, без которых Linux просто не работает. Это базовая служебная часть системы.

В буферных кэшах хранятся метаданные файлов. По сути, информация об inode: о том, когда файл был создан, когда обновлялся и где лежит. Для быстрого доступа Linux держит эти данные в оперативной памяти.

В Page-кэши попадают общие библиотеки. В нашем случае Sharp. Она написана на плюсах и подключается каждый раз, когда Next.js оптимизирует картинки. Пока к ним идут запросы, Sharp активно работает и кладёт свои данные в эти кэши.

Как посмотреть реальное потребление памяти

Реальное потребление памяти процессом можно посмотреть через htop. Утилита запускается прямо из терминала и показывает подробное состояние системы.

Нас интересует столбец Resident Set Size. Он отражает суммарный объём памяти, который занимает процесс вместе с общими библиотеками, подключенными во время работы.

Чтобы понять, сколько буферов создаёт именно наше приложение, вспоминаем, что в типичной продовой конфигурации Kubernetes контейнер разворачивают в минимальном окружении. Вы запускаете приложение, поднимается Next.js, и кроме системных библиотек базового образа Linux ничего нет.

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

Для этого можно воспользоваться утилитой free с флагами -hm. Флаг -h выводит значения в удобном для чтения виде, а флаг -m показывает их в мебибайтах.

Нас интересует столбец, где указаны буферы и кэши.

Анализ через pmap

Теперь, когда мы поняли, что процесс сам порождает кэши, это нужно доказать. С помощью утилиты pmap -px передаём ID процесса и получаем список участков памяти, которые были выделены во время работы процесса.

Для демонстрации я сделал однострочный скрипт на Python, который по таймеру создаёт один и тот же файл. У Next.js-сервера таблица будет такая же, только намного больше.

Столбцы в таблице показывают:

  • адреса (неинтересно, если вы не администратор Linux);

  • объём памяти, который приходится на каждый участок;

  • Resident Set Size

  • Dirty – кэши, выделенные под inode;

  • Mode – какие права имеет участок памяти (например, r и w для чтения и записи, а также доступ на выполнение кода – то же самое, что мы привыкли видеть в обычных файловых скриптах);

  • Mapping, где указано, с чем связан участок памяти: подпроцессом или библиотекой.

В моём примере со скриптом видно несколько строк, помеченных как anonymous с режимом rw. Через эти области процесс работает с файловой системой.

Для сравнения, на рабочем Next.js сервисе pmap показывал ~40 000 сгенерированных картинок в час, и для каждой создавался собственный анонимный mapping.

Чтобы увидеть закономерности, я загрузил лог утилиты pmap в LLM и получил ожидаемый результат.

Цель анализа:

Определить причину роста потребления оперативной памяти в сервисе web-optimizer и выявить возможные утечки или аномалии в работе приложения.

1. Краткое резюме

• Рост RЕЅ с 450 МБ до 1,8 ГБ за 2 часа.

Отсутствие увеличения hеар по данным V8 утечка в пользовательском коде не подтверждена.

• Рост Shmem и Slab в /proc/meminfo→ признаки накопления кэша в ядре (tmpfs + inode cache).

• Пиковая нагрузка на CPU коррелировала с операциями записи в кеш-директорию.

2. Ключевые наблюдения

• Логи показали регулярные операции writeToCacheDir (модуль Image Optimizer).

• Падение RAM при ручной очистке каталога. next/cache/images.

htop: RSS процесса Next.js умеренный, но shared сегменты растут.

• ртар: большое количество [апоп] маппингов, связанные с файловым кэшем.

• slabtop: рост dentry_cache и inode_cache.

3. Выводы

• Утечки в коде приложения не обнаружены.

• Накопление памяти связано с tmpfs и кешированием файлов в RAM.

• Память освобождается при удалении кэшированных файлов или после простоя.

4. Рекомендации

1. Перенести кеш изображений на диск или настроить лимит tmpfs (size=...).

2. Использовать периодическую очистку устаревших файлов (cron/сайдкар).

3. Настроить мониторинг Shmem и Slab в Grafana для раннего обнаружения роста.

Почему память не течёт на локалке

Я решил копать дальше и спросил, почему утечка не воспроизводится на локальной машине. Там то рост, то падение, то снова рост, снова падение и никаких устойчивых трендов.

LLM ответило, что Kubernetes работает иначе. Кластер поднимает поды и запускает внутри них приложения, полностью изолированные друг от друга. У них своя сеть, процессы и минимальный набор системных зависимостей. А главное, наши контейнеры с Node.js-приложением для хост-системы выглядят как временная память внутри оперативки. Поэтому всё, что происходит в контейнере: создание файлов или работа самого процесса — отражается в хосте как раздутие RAM.

Разгадка утечек

Получается, при запросе на оптимизацию изображения в Next.js сначала растёт JS heap, потом подключается Sharp и выделяет память под обработку изображения. Затем создаются временные файлы и метаданные, которые добавляют новые буферы.

В результате на один простой запрос вы получаете сразу три источника роста потребления памяти.

Заключение

В итоге мы поняли, что алертим непрозрачные показатели. Из-за этого мониторинг реагирует слишком рано и слишком шумно, особенно перед новогодними праздниками. Пора пересматривать подход. 

Наблюдайте за метриками, которые отражают реальную работу процесса. Разбирайтесь в инструментах, с которыми работаете. Если нужно, копайте теорию. Сегодня это намного проще, чем несколько лет назад. Для Node.js стоит отслеживать именно heap и всё, что связано с работой самого процесса. А статику и файловые операции лучше вынести в отдельный набор метрик и ограничить для них квоты.

Расследование подсветило, что Next.js в Kubernetes использует антипаттерн серверлесс-процессов и создаёт дополнительные файлы. Для инфраструктуры это не лучший сценарий, но сам фреймворк работает корректно. Его можно продолжать использовать, а возникающие проблемы допиливать по мере появления.

Я написал небольшую Node.js-утилиту. Она запускается как сайдкар рядом с Next.js. Вы указываете ей путь к папке с кэшем, и она удаляет устаревшие файлы по TTL или заданному лимиту. Это не полноценная замена системы управления кэшами, а гарантированный способ удерживать горячие кэши в памяти в ограниченном объёме и не давать старым данным бесконтрольно разрастаться. Если изображение однажды оптимизируется повторно, пользователь этого даже не заметит.

Спасибо, что дочитали до конца.