Привет, Хабр! Меня зовут Владимир Захаров (@‌vzkhrv), я расскажу про утечки памяти в SSR. На самом деле, утечки могут случиться в JavaScript везде – и на сервер-сайде, и на клиенте, поэтому информация будет полезна даже тем, у кого пока нет SSR. Давайте чуть подробнее познакомимся. Я ведущий фронтэнд-разработчик, около 8 лет в отрасли. В Зарплате.ру больше не работаю, но основной опыт, о котором хочу рассказать, получен именно там. Я люблю плавающие баги, разговоры о техдолге и шутки про ненастоящих программистов. Поговорим про утечки памяти в SSR, про работу с памятью и про то, как всё это выглядит в браузере и в nodejs. Ну, и естественно, как со всем этим жить.

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

Зарплата.ру – это федеральный job board для работодателей и соискателей с тремя миллионами посещений в месяц. На клиенте изоморфный код. Его можно исполнять как в SSR, так и в браузере. Мы использовали ReactJS, Redux, TS, nodejs, webpack. В server-side контейнере работал Express, а на бэкенде зоопарк микросервисов, в том числе контейнеры с нодой.

В какой-то момент нам от бизнеса поступила крупная задача по интеграции с внешним сервисом. Важным условием был релиз всех изменений одним днём. При этом на SSR-контейнере, как мы позднее узнали, отсутствовало нагрузочное тестирование. Это сыграло злую шутку.

Сначала всё шло отлично! Мы выполнили задачу в срок и действительно релизнулись одним днём. Работали почти по waterfall. Уже открыли шампанское, разлили его по бокалам, но тут пришел человек из сопровождения и обозначил проблему с потреблением памяти в SSR-контейнере. Мы посмотрели графики и увидели непрерывный рост потребления оперативной памяти.

график расхода памяти внутри контейнера SSR
график расхода памяти внутри контейнера SSR

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

Чтобы понять почему это большая проблема, давайте немного углубимся в детали. Фронтендеры пишут код, который исполняется в браузере. Браузер – это индивидуальная среда исполнения, где у пользователя свои ресурсы. В рамках одной сессии у него небольшое количество действий: десятки, редко – сотни. Под такой нагрузкой утечки себя практически не проявляют. Они могут возникнуть только при устаревшем оборудовании или в старом браузере. А это обычно меньше 1% пользователей.

Но утечки памяти – это скорее проблема на клиенте, потому что они приводят к видимой потере фреймрейта и зависанию UI. Из-за них повышается энергопотребление, а значит, батарейка на ноутбуке или телефоне пользователя садится быстрее, чем обычно. Всё это может приводить к блокированию потока и крешу вкладки или браузера. Из-за повышенной нагрузки на процессор пользователь начинает придумывать себе всякие «ужасы». Например, что в клиент встроен майнер. А это точно не тот use-кейс, который хочет получить компания. 

Если на клиенте утечка – это проблема, то на SSR утечка – это огромная проблема. Из-за большой нагрузки утечки растут лавинообразно. Они тратят общие ресурсы всего сервера и остальных сервисов. Из-за утечек появляются проблемы одновременно у всех пользователей. Кроме того, плохо работающий SSR-контейнер оказывает сайд-эффекты.

Как устроена память в JavaScript и в движке V8?

Память в V8 условно разделяется на две части – на Stack и Heap memory. В Stack хранятся примитивы, ссылки, фреймы функций и глобальные исполнения. В Heap memory — объекты и прочие динамические данные.

Garbage collector V8 автоматический. Он запускается, когда движок сам решит, что пора освободить место. Кроме того, он асинхронный и непредсказуемый. При его работе не блокируется поток или блокируется, но всего на 10 миллисекунд.  Непредсказуемость в том, что неизвестно, когда он будет запущен и когда будет закончена его работа в текущем цикле.

Было бы здорово, если бы сборщик мусора мог сам предсказывать, какие данные больше никогда не понадобятся до конца работы программы. Но, к сожалению, алгоритмически такая задача неразрешима. Поэтому автоматические сборщики мусора подменяют понятие необходимости – понятием достижимости. Это значит, что все ресурсы, на которые есть внешние ссылки, считаются необходимыми. Ссылки бывают слабые и сильные. Слабые создаются структурами WeakMap, WeakSet, WeakRef и не препятствуют работе garbage collector.

Garbage collector работает по алгоритму Mark Sweep Compact в три шага.

1 шаг. Mark garbage collector отмечает все достижимые области памяти и помечает их как необходимые:

Фактически это обход графа в глубину, где узлы – это данные, а рёбра – это ссылки. Слабая ссылка обозначена пунктирной линией. Она не будет учитываться.

2 шаг. Sweep – все данные, которые не пометили на первом шаге, считаются ненужными, а их области памяти – свободными для записи. Они фактически удаляются garbage collector:

3 шаг. Дефрагментация памяти, чтобы последующее аллоцирование для новых данных происходило быстрее:

Получается, когда пользователь исполняет код в браузере, у него есть собственный Heap и Stack, но количество исполнений кода невелико. Все синглтоны, кэши, подписки и прочие API методы у него индивидуальные. Когда Зарплата.ру запускает свой код в SSR, у всех пользователей в рамках одного контейнера появляется один общий Heap и Stack. Кратно растёт количество исполнений кода, и все кэши, подписки, API становятся общими на всех.

Стандартный цикл потребления памяти состоит из трёх этапов: выделение, потребление и освобождение. Утечка памяти – это ситуация, в которой третий шаг в цикле не происходит. Потому что где-то до сих пор хранится ссылка на эти данные несмотря на то, что для кода и use-кейса они могут быть не актуальны. Вот как это выглядит на графиках:

При нормальном потреблении памяти есть только горизонтальное развитие тренда. Вертикальные пики вверх – это выделение памяти и её использование. Вертикальные пики вниз – работа garbage collector, освобождение памяти. Рост потребления со временем отсутствует, garbage collector работает не постоянно и асинхронно.

Работа под нагрузкой – это не то же самое, что утечка. Появляется растущий тренд, но не на всём участке работы. Конкретно в кейсе Зарплата.ру пользователи, которые ищут вакансии, приходят на работу в 8 утра и примерно в 8.01 им уже надоедает работать. Они открывают Зарплату и начинают искать новые классные вакансии. Но кто-то с утра всё-таки работает, поэтому пик нагрузки достигается ближе ко второй половине дня. Вертикальный сброс вниз происходит в шесть вечера, когда все уходят домой – жизнь наладилась, новую работу искать не надо.

Дальше присутствует только горизонтальное развитие, но при утечке его нет вообще. Есть только рост потребления.

Вертикальные сбросы – это перезагрузка контейнера. А после перезагрузки вообще нет горизонтального этапа. Garbage collector работает, пики действительно есть, но он не удаляет всё. Потребление памяти продолжает расти.

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

Как получить дамп с SSR?

Первый и самый простой способ – запустить node с флагом inspect. В продакшене важно получать дампы именно под нагрузкой. Если вы находитесь в каком-то внутреннем контуре, то нужно попросить devops’а настроить проброс портов, чтобы по connection string был доступ до вашего контейнера.

Это простой и хороший способ. Мы получаем доступ к Chrome DevTools, и во вкладке Memory с помощью голубой кнопки внизу снимаем снапшоты. А если получили их каким-то другим способом, то загружаем кнопкой Load:

Преимущества этого метода ещё и в том, что при наличии resource map в этой вкладке видны пути до тех участков кода, в которых создаётся структура:

Это проще, чем искать по всей кодовой базе, но, к сожалению, запустить node с inspect можно не всегда. Это debug-флаг. Скорее всего, ваша служба безопасности не одобрит такое решение. В таком случае, на помощь придет пара npm-пакетов.

Первый из них – heapdump. У него максимально простой API. Write Snapshot сохраняет дамп в файловую систему по указанному пути. Проблема в том, что последний раз heapdump релизился три года назад. Он использует нативный Node JS модуль. Скорее всего, ваш Webpack не умеет импортировать нативные модули. Чтобы решить эту проблему, вам понадобится доработать config и добавить loader. Для билда этого нативного модуля понадобится Python. Для каждой версии node модуль нужно будет билдить отдельно. Кроме того, он не работает из коробки, есть ошибки в исходниках.

У нас был именно такой heapdump, поэтому нам пришлось завендорить код и исправить пару багов.

Начиная с версии 11.13, в ноду уже включён модуль V8. Он позволяет делать то же самое, только работает из коробки, всегда актуален для версии ноды и там ещё куча дополнительных инструментов для работы с памятью.

Как снимать снапшоты?

Вариант 1

setTimeout(() ⇒ {

v8.writeHeapSnapshot(`./${Date.now()}.heapsnapshot`);

}, ONE_HOUR);

Вариант 2

Снять дамп вручную.

kill -USR2 <pid nodejs>

process.on(‘SIGUSR2’, () ⇒ {

v8.writeHeapSnapshot(`./${Date.now()}.heapsnapshot`);

});

Вы можете настроить таймер или интервал и снимать их с каким-то периодом времени. Либо добавить серверный код – слушатель для POSIX-сигналов и вызывать их прямо из консоли, из ssh-соединения с нашим контейнером.

Мы выбрали второй вариант, потому что не доверяли таймерам с детства)

Пара особенностей:

  1. У вашего контейнера должен быть запас оперативной памяти примерно в два раза больше, чем текущий размер heap. Синий пик вверх на картинке – это как раз момент съёма дампа.

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

  1. Чтобы загружать дампы в Chrome DevTools, у файлов должно быть расширение .heapsnapshot

Анализ и поиск утечек

Вкладка Memory в Chrome DevTools выглядит вот так:

У неё две основные части. Слева – список снятых или импортированных снапшотов. Там память занимает примерно 10 мегабайт.

В основной части таблица сущностей. Там содержатся не только сущности, которые создаются в коде, но и все системные сущности, которые создает движок. Потому что дамп памяти полный.

  • Первая колонка – список сущностей;

  • Вторая колонка – удалённость от корневого узла;

  • Третья колонка – shallow size, объём памяти, который структура занимает сама по себе;

  • Четвертая колонка – retained size, объём памяти, который структура занимает вместе со всеми объектами, ссылки на которые она содержит. То есть весь тот объём, который она фактически удерживает благодаря ссылкам.

Здесь хочу отметить, что какие-то замыкания, объекты, лексический контекст функций и строки занимают почти 8 мегабайт. Дамп в 9,6Мб, конечно, значительный, но судить об утечке по одному снапшоту не получится.

Если снять еще пару снапшотов через равные промежутки времени и проанализировать развитие, то видно, что к третьему появляется 24 мегабайта. Это уже не хилое развитие потребления. В таблице сущности те же самые структуры продолжают рост потребления, замыкание, объекты, контекст и строки теперь занимают 23 мегабайта.

Но даже это не говорит об утечке. Возможно, продолжают загружаться данные. Нужно определить, что данные не просто загружаются, но и накапливаются в памяти. Для этого три снапшота уже есть. Выбираем третий снапшот в строке фильтров, «показать те объекты, которые были созданы между первым и вторым снапшотом и до сих пор остаются в третьем», то есть накапливаются, не удалены garbage collector.

Посмотрим, что получилось: 13 мегабайт до сих пор остаются в третьем снапшоте. И это всё ещё объекты, замыкание, контекст и строки. Этот пример синтетический. Я специально сделал так, чтобы текли именно эти структуры.

Если, например, открыть (closure), то видно, что есть несколько десятков одинаковых замыканий, созданных какой-то функцией someMethod. При этом само замыкание занимает 64 байта, а вот количество удерживаемых данных – почти 13 мегабайт. И вот теперь с 99% вероятностью можно сказать, что есть утечка.

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

Паттерны утечек

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

Глобальные переменные никогда не удаляются garbage collector, поскольку всегда имеют прямую связь с корневым узлом. Не важно, созданы они специально или случайно.

Интервалы, таймеры и подписки

При создании интервалов, таймеров и подписок – мы передаём в них callback.

В callback’е обычно обрабатываются данные. Значит, здесь есть ссылки на них. До тех пор, пока подписка жива, callback будет хранить данные по ссылкам и удерживать их от удаления garbage collector.

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

Можно ограничить количество исполнений или срок жизни таймера. Просто представьте, что вы этот код поместили в серверную среду, и у тысяч или десятков тысяч пользователей в зависимости от вашей нагрузки теперь есть 10 000 таймеров, которые никогда не исполняются. А в едином окружении все ресурсы, которые удерживают callback’и, тратят общую память.

Кэш и мемоизация

Кэш и мемоизация – это кейс Зарплата.ру. Именно в нём у нас произошла утечка памяти. Давайте разберём в деталях:

Там была функция generate company url, которая создавала какую-то строку из объекта компании. Пользователь в ходе поиска вакансии обычно открывает одну и ту же компанию несколько раз. Сначала смотрит описание, потом контакты, потом ещё какие-то условия. Поэтому мы решили оптимизировать функцию, обернуть её в memoize, чтобы не считать каждый раз одно и то же.

Проблема в том, что этот код не работает:

  1. Memoize из lodash использует первый аргумент функции как ключ. В этом кейсе первый аргумент – это объект. То есть создают в кэше записи, у которых ключ – это объект. Потом ищут по кэшу записи, сопоставляя объекты. В JavaScript объекты не равны друг другу, значит мемоизация просто не работает.

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

К тому же, у lodash в memoize отсутствуют механизмы инвалидации кэша. Значит он растёт бесконечно. И ещё memoize использует для кэша объекты и map, которые генерируют сильные ссылки и препятствуют работе garbage collector.

Чтобы решить эту проблему, во-первых, не кэшируйте всё подряд. Скорее всего, функция создания строки из объекта не настолько тяжеловесная. Во-вторых, не используйте ключи как объекты. Конкретно у memoize есть второй аргумент – резолвер, где можно задавать кастомный ключ и высчитывать его.

Нам, к сожалению, эти способы не подходили, потому что отсутствовал механизм инвалидации кэша.  Ссылки loadash все равно бы хранились вечно. Поэтому мы пошли другим путём.

Если реализовывать кэширование самостоятельно, можно использовать WeakMap и WeakSet, а не объекты или массивы. WeakMap и WeakRef создают слабые ссылки и не препятствуют работе сборщика мусора.

Замыкания

Давайте разберём следующий код.

Есть функция – replaceData. Внутри неё, в локальную переменную мы записываем ссылку на какой-то внешний объект – previosData. Есть функция unusedMethod, в которой мы ничего не делаем, только проверяем, пустая previosData или нет. В блоке нет кода. В ходе работы функции во внешнюю переменную Data мы записываем объект. В объекте есть id, длинная строка, чтобы мы увидели утечки быстрее. И пустая функция someMethod без тела и входящих аргументов.

Так создаётся замыкание. В сам Method не поступают никакие аргументы, то есть ссылок он не хранит. В ходе работы мы перезаписываем данные, но не создаём массив и не накапливаем их. Data – это объект, то есть по идее, замыкания плодиться не должны.

Посмотрим, что в нём. Лексический контекст функции занимает основную память:

В нём есть ссылка на previosData:

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

С этим не получится никак бороться, это замыкание. Такие особенности нужно учитывать при написании кода. Не используемый метод обрабатывает какие-то данные, такой кейс важен при рефакторинге. Если вы видите методы, которые не используются, не стесняйтесь их удалять. Возможно из-за них течёт память.

Подстроки

Есть функция, генерирующая очень длинную строку – примерно 10 мегабайт. В нашем случае опять же для того, чтобы текло быстрее. От этой длинной строки мы будем брать подстроку длиной всего 12 символов. Например, такой кейс может использоваться для того, чтобы генерировать id.

Исполняем функцию, получаем подстроку длиной 12 символов, храним её где-нибудь в массиве. Интервал нужен, только чтобы имитировать нагрузку. В памяти будут подстройки длиной 12 символов.

Теперь меняем 12 на 13. Больше ничего не меняем, не добавляем никакого нового кода под капотом. Запускаем, смотрим в память:

Видим, что появилась структура sliced string:

А ещё sliced string хранит ссылку на родительскую строку, но ведь строки – это примитивы. 

Факт на лицо: утечка, создаваемая этой ситуацией, ссылки на родительские строки существует с 2018 года и обсуждается до сих пор. Разработчики движка говорят, что это вариант оптимизации. Движок до конца не уверен, когда ему лучше хранить ссылку, а когда пересоздавать строку при создании подстроки. Движок не уверен, а мы уверены, когда было 12 символов, а стало 13.

Решение у этого странного кейса такое же странное. Если подстроку разбить на символы, а потом с join’нить обратно, то мы получим нормальный примитив. Ссылка на родителя сотрётся, и он будет удалён garbage collector.

Как жить дальше?

Мы искали эту утечку три недели. Это было увлекательное путешествие. Из него можно сделать простой вывод из двух пунктов:

  1. О проблеме здорово знать. Должен быть настроен мониторинг ресурсов. Если в компании есть девопс, скорее всего, мониторинг ресурсов настроен. Придите к нему, получите доступ и время от времени туда заглядывайте.

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

Эти два пункта не очень-то зависят от фронтенда. А вот что зависит, так это понимание принципа общих ресурсов на server-side и своевременное обновление пакетов. В SSR Зарплата.ру мы использовали ноду 10 версии, поэтому даже если бы вовремя знали про модуль V8 — это никак бы не помогло, пришлось бы сначала обновляться.

Преждевременная оптимизация – известное зло. Скорее всего, ваш фронтэнд не настолько сложен, чтобы там что-то кэшировать. Удостоверьтесь, что это действительно необходимо.

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

Вот видео моего выступления на конференции Frontend Conf, которое легко в основу статьи.

Комментарии (3)


  1. ruslan_astratov
    14.09.2023 12:33
    +1

    Спасибо. Было полезно и интересно


  1. rqdkmndh
    14.09.2023 12:33
    +1

    Если реализовывать кэширование самостоятельно, можно использовать WeakMap и WeakSet, а не объекты или массивы. WeakMap и WeakRef создают слабые ссылки и не препятствуют работе сборщика мусора.

    ох, видели бы вы лица людей, которым советуешь в конкретном случае использовать WeakMap или WeakSet. Как будто, предлагаешь прогуляться пешком до Владивостока. Ну теперь, буду отсылать на эту статью. Очень полезный разбор.


  1. nrdvmn
    14.09.2023 12:33

    Интересная тема и приятный слог! Спасибо!