147
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

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

Время чтения 28 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
147
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

На собеседованиях по .NET нас часто спрашивают о сборщике мусора, его поколениях, устройстве стека и кучи, братьях LOH и POH. А мы с вами уверенно (ну, почти всегда) отвечаем на них и довольные читаем офер. А что же на самом деле? Как решать реальную задачу по поиску утечек памяти в приложении?

Меня зовут Денис Смирнов, я ведущий .NET-разработчик с опытом более пяти лет. В этой статье разберу реальную проблему утечки памяти, с которой мне пришлось столкнуться на работе. Это был долгий, трудный путь, и сегодня мы пройдем его вместе с вами. Приятного чтения!

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

Постановка проблемы

Недавно для решения одной задачи я начал писать микросервис: всё было хорошо, стори двигались, и кодовая база плавно, но верно росла. И в один прекрасный день мы начали ловить ошибки. Метод на один и тот же запрос отвечал или 200, или 502, что значило «Микросервис в данный момент не отвечает». Ничего страшного в этом не было: в сервисе еще только тестировали первые задачи. Это была отличная возможность потренироваться в поиске утечек памяти без вреда для нервной системы.

На тот момент мы использовали .NET 8.

Расследование: изучаем метрики

В сыром микросервисе у нас еще не было экспорта метрик в Grafana или чего-то подобного. Именно поэтому первым делом я смотрел метрики пода через kubectl.

1. Смотрим метрики всех подов

Команды kubectl top pod или kubectl top pod -n <namespace>, если у вас несколько неймспейсов. Если не знаете название неймспейса, то посмотрите их список командой kubectl get namecpace. Параметр -n можно передавать во все команды, далее о нём не говорим. Получаем подобный вывод:

Так можно увидеть, сколько каждый под занимает памяти (в мегабайтах) и CPU (в ядрах, в данном случае это millicore, равный 0,001 core).

Можно фильтровать выборку по конкретному поду: kubectl top pod hh-service-backend- 34yt13idgfe. В ответе получим то же самое для одного пода.

Если у вас в одном поде запущено несколько контейнеров, то можно посмотреть разбивку на ресурсы внутри пода командой kubectl top pod hh-service-backend-34yt13idgfe --containers.

2. Проверяем лимиты

Если вы вдруг не знаете, сколько ваш под запрашивает ресурсов в кубере при старте, и его лимиты, то проверяем их командой kubectl describe pod hh-service-backend-34yt13idgfe.

Ответ довольно большой, и весь нам не нужен, интересны только следующие строки:

Такой вывод означает, что под запрашивает на старте 0,1 ядра и 200 МБ памяти, а его лимит — 400 МБ.

Кроме того, в контексте задачи нас интересует поле Restart Count: 10 — количество рестартов пода. В хорошем случае оно должно быть равно 0.

3. Собираем метрику с пода по времени

Итак, в микросервисе не было сбора метрик, поэтому первым делом я анализировал ситуацию почти вручную. Себе в помощь написал команду для сбора значения памяти и вывода в файл get-memory.bat. Код ниже собирает данные из вывода команды kubectl в csv-файл.

@echo off
 
set POD=sourcing-integrator-backend-5797ccb6bf-4d4fx set NAMESPACE=dev
set OUTPUT_FILE=memory.csv set INTERVAL_SECONDS=5
 
:loop
for /f "tokens=3" %%m in ('kubectl top pod %POD% -n %NAMESPACE% --no-headers') do (
echo %date% %time%; %%m >> %OUTPUT_FILE%
)
 
echo Memory value written to %OUTPUT_FILE%
timeout /t %INTERVAL_SECONDS% /nobreak >nul goto loop

 

По полученным данным строим график и получаем следующую картину:

На этом этапе стало понятно, что память растет константно и постоянно вверх до перезапуска микросервиса.

Расследование: кастомный сбор метрик с вызовов методов API

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

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

В первую очередь нужен класс, который инкапсулирует всю логику сбора метрик внутри себя, у меня это ResourcesUsageAsyncWatcher. Основная идея в том, чтобы захватить AsyncLocal маркер на время запроса. Каждая смена контекста записывает использованную память в текущем потоке, а при срабатывании .Stop() останавливаем таймер и записываем время, прошедшее от инициализации экземпляра класса.

Полный код ResourcesUsageAsyncWatcher.


internal sealed class ResourcesUsageAsyncWatcher
{
     private readonly ThreadLocal<(int ThreadId, long AllocatedBytes)?> contextStart = new();
     private readonly Stopwatch timer = Stopwatch.StartNew(); private volatile bool isRunning = true;
     public ResourcesUsageAsyncWatcher()
     {
         _ = new AsyncLocal<object>(OnContextChanged) { Value = true };
     }
     public ContextSwitchMetrics Totals { get; } = new(); public void Stop()
     {
         isRunning = false; timer.Stop();
         Totals.AddElapsedMilliseconds(timer.ElapsedMilliseconds);
         Totals.SetTotalBytes(GC.GetTotalAllocatedBytes());
         contextStart.Dispose();
     }
     private void OnContextChanged(AsyncLocalValueChangedArgs<object> args)
     {
         if (!isRunning)
             {
                 return;
             }
         var threadId = Environment.CurrentManagedThreadId; if (args.PreviousValue == null)
         {
             contextStart.Value = (threadId, GC.GetAllocatedBytesForCurrentThread());
             return;
         }
         if (args.CurrentValue != null)
         {
             return;
         }
         var start = contextStart.Value; contextStart.Value = null;
// Выходим, если поток не тот или отсутствует контекст if (start == null || start.Value.ThreadId != threadId)
         {
             return;
         }
         Totals.AddAllocatedBytes(
         GC.GetAllocatedBytesForCurrentThread() - start.Value.AllocatedBytes);
     }
     public sealed class ContextSwitchMetrics
     {
         private long allocatedBytes;
         private long elapsedMilliseconds; private long totalBytes;
 
         public long AllocatedBytes => allocatedBytes;
         public long ElapsedMilliseconds => elapsedMilliseconds; public long TotalBytes => totalBytes;
         internal void AddAllocatedBytes(long value) => Interlocked.Add(ref allocatedBytes, value);
         internal void AddElapsedMilliseconds(long value) => Interlocked.Add(ref elapsedMilliseconds, value);
         public void SetTotalBytes(long totalBytesNow) =>
         {
             Interlocked.Add(ref totalBytes, totalBytesNow);
         }
     }
}

 

Теперь можно использовать полученный класс там, где требуется измерять утечки памяти. Я написал Middleware и с его помощью ловил метрики на http-запросы в сервис.


internal class GcMetricsMiddleware : IMiddleware
     {
         public async Task InvokeAsync(HttpContext context, RequestDelegate next)
         {
             var resourceLogger = context.RequestServices.GetRequiredService<ILogger>
             ();
             // создаём
             var resourceWatcher = new ResourcesUsageAsyncWatcher();
             // даём методу выполнится 
             await next(context);
             // останавливаем сбор и логгируем информацию 
             resourceWatcher.Stop();
             var totals = resourceWatcher.Totals; resourceLogger.Info(
             "request {requestPath} resources: elapsedMilliseconds
             {elapsedMilliseconds} allocatedBytes {allocatedBytes} totalBytes {totalBytes}",
             context.Request.Path,totals.ElapsedMilliseconds, totals.AllocatedBytes,totals.TotalBytes);
         }
     }

 

Из таких логов можно сложить вполне неплохие метрики в Elastic search.

Если у вас лог в Elastic сразу разбивается на параметры, то удобно искать по ним. Для этого надо пройти по пути Elastic -> Dashboards -> Create dashboard -> Create visualization.

В фильтр ввести что-то вроде messageTemplate : "request {requestPath} resources: elapsedMilliseconds {elapsedMilliseconds} allocatedBytes {allocatedBytes} totalBytes {totalBytes}" and requestPath : "api/test".

 

Создать простой график с такими параметрами сначала для значения allocatedBytes, затем для totalBytes:

Получится график по выделяемой памяти на вызов метода:

И график изменения общей памяти, которую занимает микросервис:

График подтверждает результаты, полученные запросом в kubectl: память растет линейно и не сбрасывается. А каждый вызов метода создает еще больше долгоживущих объектов, которые сохраняются вплоть до рестарта сервиса.

Расследование: локальный запуск

Дальше я использовал встроенный профилировщик debug режима Jet Brains Rider и получил следующие результаты:

Как видно на графике, объекты коротко живут в 0-м и 1-м поколении и почти сразу отправляются во 2-е поколение GC. Это значит, что при работе приложения накапливается множество долгоживущих больших объектов. Эта память с высокой вероятностью не будет очищена до конца работы приложения.

Такую проблему решает переключение сборщика мусора .NET в серверный режим <ServerGarbageCollection>true</ServerGarbageCollection>. Одна строка в главном .csproj-файле исправляет проблему резервирования лишней памяти. Подробнее о настройках сборщика мусора можно почитать в официальной документации, например здесь, здесь и здесь

После переключения режима GC картина значительно меняется. Большую часть памяти занимает поколение 0, а поколение 2 заполняется незначительно только после сборки мусора. Скорость потребления памяти частично снижается.

Немного эндорфинов к нашему столу! Наконец, картина улучшается, и микросервис ест существенно меньше памяти из пода. Но всё еще делает это постоянно и со временем перезапускается. Немного отпразднуем и идем дальше.

Расследование: проверка внешних зависимостей

После очередной неудачи я решил подойти к проблеме с другой стороны. Задался вопросом «Что объединяет методы, которые я вызывал при поиске проблемы?» и стал проверять их детальнее.

Покажу упрощенную версию — так будет понятнее. Допустим, есть два метода в контроллере:

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

Есть две точки:

  • Точка 1 — вызов api/test/2. Он незначительно влияет на память.
  • Точка 2 — вызов /{id}. А тут после вызова поколение 0 увеличивается значительнее и далее продолжает расти.

Я предположил, что может быть виноват вызов монги, и идем с ним дальше.

Расследование: разбор библиотеки монги

Анамнез собран, пораженный участок почти найден.

Что может увеличивать память при работе с монгой? А давайте спросим у нейросети!

Дело в том, что для микросервисов мы в команде используем отдельный фреймворк, который объединяет все используемые инструменты, среди них и обертка над Mongo.Driver. Она инкапсулирует в себе регистрацию драйвера, резолв коллекций, парсинг BsonDocument в типы .net, контексты и т. п.

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


internal static class MongoClientStorage
     {
         private static readonly ConcurrentDictionary<MongoClientSettings, Lazy<MongoClient>> Clients =          
         new();
         internal static MongoClient GetOrCreateMongoClient(MongoClientSettings settings)
         {
             return Clients.GetOrAdd(settings, static newSettings =>
             new Lazy<MongoClient>(() => new MongoClient(newSettings))).Value;
         }
     }

 

Чтобы корректно представлять ключ, мы должны уметь их сравнивать, а для этого в C# присутствует интерфейс IEquatable<T> с одним-единственным методом Equals. «И в чём тут проблема?» — спросите вы. А дело в том, что при дебаге видно, что на КАЖДЫЙ вызов в монгу создаётся отдельный клиент. Значит, дело может быть в сравнении ключей, поэтому зайдем еще дальше — в сам фреймворк монги.

Как видим, класс MongoClientSettings реализует интерфейс IEquatable<MongoClientSettings>.

public class MongoClientSettings : IEquatable<MongoClientSettings>, IInheritableMongoClientSettings

Спускаемся ниже, к имплементации метода Equals (большая часть метода вырезана).

Тут сравниваются все без исключения поля методом Equals или ==, выглядит не очень Clean Code. Даже сложные вложенные поля сравниваются таким же образом.


public override bool Equals(object obj)
{
     if (object.ReferenceEquals(obj, null) || GetType() != obj.GetType()) { return false; }
     var rhs = (MongoClientSettings)obj; return
     _allowInsecureTls == rhs._allowInsecureTls &&
     _applicationName == rhs._applicationName &&
     ...
     object.Equals(_libraryInfo, rhs._libraryInfo) &&
     _replicaSetName == rhs._replicaSetName &&
     _retryReads == rhs._retryReads &&
     ...
     _serverMonitoringMode == rhs._serverMonitoringMode &&
     _serverSelectionTimeout == rhs._serverSelectionTimeout &&
     _socketTimeout == rhs._socketTimeout &&
     ...
     object.Equals(_writeConcern, rhs._writeConcern)
}

 

Всё бы ничего, но дальше сравниваются делегаты Action<T> по ссылке:


private Action<ClusterBuilder> _clusterConfigurator;
...
public override bool Equals(object obj)
    {
    ...
    object.ReferenceEquals(_clusterConfigurator, rhs._clusterConfigurator)
    ...
    }

 

А перед запросом MongoClient мы каждый раз заново заполняем MongoClientSettings значениями из строки подключения из appsettings.

Получается, код написан в целом неплохо, с оглядкой на то, что драйвер монги должен быть Singleton-объектом. Но сама реализация Equals в библиотеке Mongo.Driver ломает наш код: каждый раз когда мы делаем запрос на чтение/запись, нам нужно получить существующий в памяти драйвер по настройкам. Но сервис, отдающий драйвер, зарегистрирован как Scoped-сервис и создается на каждый запрос. Именно поэтому сложные поля Action<ClusterBuilder> ClusterConfigurator и LocalCertificateSelectionCallback ClientCertificateSelectionCallback тоже создаются каждый раз и имеют собственную ссылку в памяти. Значит, Equals возвращает False, и вместо получения существующего драйвера мы создаем новый, а старый продолжает храниться в памяти до перезагрузки приложения.

Исправление проблемы

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

  1. Изменить подход к хранению настроек драйвера монги: хранить их в одном месте, а не создавать снова каждый раз.
  2. Изменить ключ, по которому хранится драйвер.

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

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

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


internal class MongoDbClientHolder : IMongoDbClientHolder
    {
        private readonly ConcurrentDictionary<string, Lazy<MongoClient>> _mongoClients
= new();
        public MongoClient GetOrCreateMongoClient(string cluster, MongoClientSettings settings)
        {
            return _mongoClients.GetOrAdd( cluster,
            _ => new Lazy<MongoClient>(() => new MongoClient(settings)))
            .Value;
        }
    }

 

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

var client = _clientHolder.GetOrCreateMongoClient("Main", mainClientSettings);

На этом всё.

Заключение

Это было несколько тяжелых дней непростого расследования ошибки (с которыми в разработке приходится нередко встречаться). Мы с командой нашли брешь в коде собственного фреймворка, разобрались в нюансах реализации интерфейса IEquatable<T>, а также потренировались считать метрики и строить дашборды.

Какие выводы я сделал:

  • не скупитесь на логи и метрики;
  • пишите тесты всех уровней на библиотеки;
  • проводите ревью кода, лучше, если это будут делать несколько человек;
  • не бойтесь нейросетей;
  • пейте магний (так спокойнее). 

До новых встреч!

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники