Поймай меня, если сможешь — Утечки памяти

Dec 01 2022
Ретроспектива утечки памяти
Введение Утечки памяти — это одна из тех вещей, которые, когда они случаются, действительно могут забросить вас в тупик. Поначалу их диагностика кажется сложной задачей.
Инженеры Elli против утечки памяти (иллюстрация Джейн Ким)

Введение

Утечки памяти — это одна из тех вещей, которые, когда они случаются, действительно могут забросить вас в тупик. Поначалу их диагностика кажется сложной задачей. Они требуют глубокого изучения инструментов и компонентов, на которые опирается ваш сервис. Это тщательное изучение не только углубит ваше понимание ландшафта вашего обслуживания, но и даст представление о том, как все работает внутри. Несмотря на пугающую на первый взгляд утечку памяти, по сути, это скрытое благословение.

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

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

Контекст

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

Элли, помимо прочего, является оператором пункта зарядки. Мы отвечаем за подключение зарядных станций (CS) к нашему серверу и управление ими по протоколу OCPP . Следовательно, наши клиенты могут заряжать свои электромобили на частных или общественных станциях. CS подключены к нашим системам через WebSockets. Что касается аутентификации, мы поддерживаем соединения через TLS или Mutual TLS (mTLS). Во время TLS CS проверит сертификат нашего сервера и убедится, что он подключается к серверной части Elli. С mTLS мы также проверяем наличие у CS выданного нами клиентского сертификата.

Что касается подключения, сервер, написанный на Node.js, отвечает за логику UPGRADE с HTTP на WebSockets и за сохранение состояния соединений. Он развертывается в кластере Kubernetes и управляется автомасштабированием Horizontal Pod (HPA). В идеале HPA следует за нагрузкой трафика и соответствующим образом увеличивает или уменьшает количество модулей.

Мы одновременно поддерживаем десятки тысяч постоянных и долгоживущих TCP-соединений от зарядных станций . Это усложняет работу и существенно отличается от типичных служб RESTful. Прокси-метрика, которая отслеживает нагрузку, — это использование памяти, поскольку она отражает количество установленных соединений, а логика приложения не требует большого количества вычислений. Наши поды долгоживущие, и масштабирование через память привело нас к наблюдению, что количество подов медленно увеличивается при постоянном количестве подключений. Короче говоря, мы обнаружили утечку памяти.

Оценка воздействия на

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

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

Когда модуль не может обработать дополнительный трафик, благодаря проверке готовности Kubernetes он перестает получать дополнительный трафик, но продолжает обслуживать установленные соединения. Модуль, который будет обслуживать X соединений, может в конечном итоге обслуживать только часть своих возможностей из-за утечки, не вызывая каких-либо сбоев на стороне клиента. Это означает, что мы можем легко поглотить удар, просто раскрутив больше стручков.

Расследование

А теперь собственно техническое погружение в утечку памяти.

Здесь мы объясняем инструменты и методы, которые мы использовали для обнаружения источника утечки памяти, что мы ожидали увидеть в нашем эксперименте и что мы действительно наблюдали. Мы включили ссылки на ресурсы, которые мы использовали в нашем расследовании для вашей справки.

Краткое введение в память JS

Переменные в JavaScript (и большинстве других языков программирования) хранятся в двух местах: в стеке и в куче. Стек обычно представляет собой непрерывную область памяти, выделяющую локальный контекст для каждой выполняемой функции. Куча — это гораздо большая область, в которой хранится все, что выделяется динамически. Это разделение полезно, чтобы сделать выполнение более безопасным от повреждения (стек более защищен) и более быстрым (нет необходимости в динамической сборке мусора кадров стека, быстрое выделение новых кадров).

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

Источник:https://glebbahmutov.com/blog/javascript-stack-size/

Создание моментального снимка динамической памяти из производственного модуля | Снимки кучи и профилирование

Ожидания

Мы регулярно собирали снимки кучи нашего приложения, чтобы увидеть накопление объектов с течением времени. Из-за характера приложения, в основном поддерживающего подключения WebSocket, мы ожидали, что объекты TLSSocket будут соответствовать количеству подключений в приложении. Мы предположили, что когда станция отключается, на объект каким-то образом все еще ссылаются. Сборка мусора работает путем очистки недоступных объектов, поэтому в этом случае объекты останутся нетронутыми.

Полученные результаты

Получение дампа кучи из модуля, загруженного на 90%, привело к диапазону 100 МБ. Каждый модуль запрашивает около 1,5 ГБ ОЗУ, а куча занимает менее 10% выделенной памяти. Это выглядело подозрительно…

Где была выделена остальная память? Тем не менее, мы продолжили анализ. Делая три снимка с интервалами и наблюдая за изменением памяти с течением времени, ничего не было обнаружено. Мы не заметили скопления объектов и проблем со сборкой мусора. Дамп кучи выглядел довольно здоровым.

Изображение 1. Создание моментальных снимков кучи с помощью инструментов разработки Chrome из производственного модуля. Количество объектов TLSSocket совпадает с текущим подключением модуля вопреки ожидаемым результатам.

Объекты TLSSocket соответствовали состоянию приложения. Возвращаясь к первому наблюдению, дамп кучи на порядок меньше, чем использование памяти. Мы подумали: « Этого не может быть. Мы ищем не в том месте. Нам нужно сделать шаг назад».

Кроме того, мы профилировали приложение с помощью Cloud Profiler , предлагаемого GCP. Нам было интересно посмотреть, как объекты распределяются с течением времени, и потенциально выявить утечку памяти.

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

Cloud Profiler — это инструмент непрерывного профилирования, разработанный для приложений, работающих в Google Cloud. Это статистический или выборочный профилировщик с низкими издержками, который подходит для производственных сред.

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

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

Статистика использования памяти

Нам нужно было больше информации об использовании памяти. Мы создали информационные панели для всех показателей, которые может предложить process.memoryUsage() .

HeapTotal и heapUsed относятся к использованию памяти V8.

Внешний относится к использованию памяти объектами C++, связанными с объектами JavaScript , управляемыми V8.

rss , размер резидентного набора , представляет собой объем пространства, занимаемого в основном запоминающем устройстве (то есть подмножество общей выделенной памяти) для процесса, включая все объекты и код C++ и JavaScript.

arrayBuffers относится к памяти, выделенной для ArrayBuffers и SharedArrayBuffers , включая все буферы Node.js. Это также входит во внешнюю стоимость. Когда Node.js используется в качестве встроенной библиотеки, это значение может быть равно 0, поскольку в этом случае выделения для ArrayBuffers могут не отслеживаться.

Изображение 2: Визуализация RSS-контента. Официальной актуальной модели памяти V8 нет, так как она довольно часто меняется. Мы делаем все возможное, чтобы изобразить, что находится под RSS, чтобы мы могли получить более четкое представление о потенциальных компонентах памяти, из-за которых происходит утечка памяти. Если вы хотите узнать больше о сборщике мусора, мы рекомендуем https://v8.dev/blog/trash-talk. Спасибо @mlippautz за разъяснения.

Как мы видели ранее, мы получали моментальные снимки кучи ~100 МБ из контейнера, который использовал более 1 ГБ памяти. Где выделена остальная память? Давайте посмотрим.

Изображение 3: Использование памяти на модуль (95-й процентиль). Он растет со временем. Здесь ничего нового, мы знаем об утечке памяти.
Изображение 4: количество подключений на модуль с течением времени (95-й процентиль); модули обрабатывают все меньше и меньше соединений.
Изображение 5: Куча используемой памяти (95-й процентиль). Куча соответствует размеру собранных нами снимков и остается стабильной с течением времени.
Изображение 6: Внешняя память (95-й процентиль): небольшая по размеру и стабильная.
Изображение 7: Использование памяти и размер резидентного набора (RSS) (95-й процентиль). Корреляция есть — RSS следует шаблону.

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

Распределитель памяти

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

Мы решили протестировать другой распределитель памяти и переключились с musl на jemalloc . Мы не нашли значимых результатов. В этот момент мы поняли, что нам нужно сделать перерыв. Нам пришлось полностью пересмотреть подход.

Может ли быть так, что утечка появляется только на mTLS-соединениях?

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

Мы не учли, что наши станции могут подключаться через TLS или mTLS. Наш первый тест включал станции TLS, но не mTLS, и причина этого проста: мы не могли легко создавать станции mTLS и соответствующие клиентские сертификаты. Недавний инцидент побудил нас свести к минимуму радиус поражения и разделить обязанности приложения, чтобы каждое развертывание обрабатывало трафик TLS и mTLS отдельно. Эврика! Утечка памяти появляется только на наших модулях mTLS, тогда как на TLS память стабильна.

Куда мы отправимся отсюда?

Мы решили, что есть два варианта: (1) перейти к нашим следующим подозреваемым — библиотеке, которая обрабатывает все задачи инфраструктуры открытых ключей, а также потенциальную рекурсию где-то в этом пути кода, (2) или жить с ней, пока мы не переработаем наш сервис полностью.

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

Резюме

Улучшение нашего масштабирования показало, что у нас есть утечка памяти, которую можно было бы оставить незамеченной в течение неопределенного периода времени. Приоритизация клиента и оценка влияния утечки были в первую очередь. Только тогда мы смогли задать темп нашему расследованию, поскольку поняли, что это не повлияло на клиентов. Мы начали с самого очевидного места для поиска утечки памяти — кучи. Однако анализ кучи показал нам, что мы смотрим не туда. Нужны были дополнительные подсказки, и процессный API V8 дал нам именно это. В первых результатах, которые мы получили, утечка памяти появилась в RSS. Наконец, проанализировав всю собранную информацию, мы заподозрили фрагментацию памяти.

Изменение распределителя памяти ситуацию не улучшило. Скорее, изменение нашего подхода и разделение рабочей нагрузки между TLS и mTLS помогло нам сузить затронутый путь кода.

Каковы окончательные результаты нашего расследования?

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

Устранили ли мы утечку памяти?

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

Ключевые моменты и уроки

  • Хотя инженерные задачи объединяют людей; мы играли в пинг-понг идей с инженерами, не входившими в нашу команду.
  • Дал нам мотивацию переосмыслить сервис, что привело к более масштабируемой архитектуре.
  • Если оно болит, оно требует вашего внимания; не игнорируйте это.
  • https://nodejs.org/en/docs/guides/diagnostics/memory/using-heap-snapshot/
  • Включите удаленную отладку в модуле через переадресацию портов:https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/
  • https://developers.google.com/cast/docs/debugging/remote_debugger
Следите за нами на Medium! (иллюстрация Джейн Ким)

Если вам интересно узнать больше о том, как мы работаем, подпишитесь на блог Elli Medium и посетите веб-сайт нашей компании по адресу elli.eco ! Увидимся в следующий раз!

Об авторе

Thanos Amoutzias — инженер-программист, он разрабатывает систему управления зарядными станциями Elli и занимается темами SRE. Он увлечен созданием надежных услуг и поставкой эффективных продуктов. Вы можете найти его на LinkedIn и в ️.

Кредиты: Спасибо всем моим коллегам, которые просматривали и давали отзывы о статье!