Всем привет! Меня зовут Виктор, и я программист. Восемь лет работаю в команде Т-Банка и все это время вместе с коллегами занимаюсь проектом «Т-Телефония». Моя команда разрабатывает сервисы, которые обеспечивают голосовую коммуникацию внутри и вне банка. 

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

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

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

Краткое устройство Т-Телефонии

Малая часть инфраструктуры Т-Телефонии представлена на рисунке. Есть еще около 45 основных и вспомогательных сервисов.

Схема Т-Телефонии
Схема Т-Телефонии

Т-Телефония — высоконагруженная система, и сбой в любом из сервисов моментально влияет на ее работу. 

За всем семейством сервисов нужно как-то наблюдать. В рамках импортозамещения коллеги сделали внутренний инструмент Sage с метриками и графиками в Grafana, логами, алертами и интеграцией в Time — наш внутренний мессенджер.

Что такое дамп и где его взять

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

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

Дамп помогает решить проблемы, если: 

  • утекает память, когда объекты копятся и не собираются GC, что может привести к ООМ;

  • блокируются потоки, например когда поток остановился в ожидании и не дает завершиться приложению;

  • истощается пул подключений, растет количество потоков и появляются исключения на таймауты ожидания подключения из пула;

  • есть множественные подключения, которые негативно влияют и на приложение, и на сервис.

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

Посмотрим, как получить дамп в зависимости от ОС, то есть как сохранить содержимое рабочей памяти процесса.

Утилиты для Windows: 

UI:

  1. Диспетчер задач для любителей графического интерфейса.

  2. Process Explorer от компании Sysinternals. 

CLI:

  1. ProcDump от Sysinternals — утилита, приближенная к Microsoft.

  2. Dotnet-Dump от Microsoft.

Утилиты для Unix/Mac:

  • dotnet-dump;

  • dotnet-trace;

  • dotnet-stack;

  • dotnet-gcdump.

Утилиты dotnet кроссплатформенные, ими можно пользоваться везде, где запущено .NET-приложение. А вот с docker-системами все интереснее и проще.

Утилита для Docker — целый один инструмент: Dotnet-monitor — средство мониторинга .NET-приложений в рабочих средах и способ сбора диагностических артефактов по запросу. Утилита создана разработчиками Microsoft, более подробно можно посмотреть у них на сайте.

Dotnet-monitor можно использовать не только в Docker, но и для других систем. Основное его преимущество в том, что он предоставляет API для команд.

Команды, которые для нас реализовали коллеги из DevOps
Команды, которые для нас реализовали коллеги из DevOps

Наши Devops развернули Dotnet-monitor на хосте с docker-контейнерами (sidecar), предоставив доступ в каждый из них. Далее через CI/CD по запросу раннером вызывается API, возвращающее дамп, который сохраняется во внешнее хранилище. По окончании операции в специальный канал внутреннего мессенджера приходит ссылка на скачивание.

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

Инструменты для работы с дампами

Мы поняли, как можем снять дамп. Осталось рассмотреть инструменты, с помощью которых можно его открыть. 

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

С этим инструментом можно свернуть горы и сделать что-то невероятное, если уметь пользоваться.

Внешний вид WinDBG
Внешний вид WinDBG

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

Основное преимущество DotMemory — его наглядность представления данных: поиск по типам, отображение экземпляров классов, построение дерева зависимостей, визуализация структуры объекта с возможностью перейти в любой вложенный. Но нет возможности провести анализ потоков, стеков и неуправляемой памяти — возможно, я плохо искал.

Внешний вид DotMemory
Внешний вид DotMemory

Microsoft Diagnostic Runtime инструмент для тех, кто хочет сделать что-то свое. О нем я узнал на конференции .NEXT в далеком 2018 году. Это библиотека, которую можно подключить через Nuget в своей любимой IDE к проекту и проанализировать память.

Пример поиска Event Processor. Можно проанализировать внутренние объекты, поискать tasks в определенном состоянии и посчитать всех родителей. А в черной рамке — вывод всех счетчиков
Пример поиска Event Processor. Можно проанализировать внутренние объекты, поискать tasks в определенном состоянии и посчитать всех родителей. А в черной рамке — вывод всех счетчиков

В Visual Studio тоже можно открыть дампы. Он позволяет посмотреть статистику по объектам, визуализирует структуру объекта, предоставляет доступ к потокам и tasks. А еще Visual Studio — единственный, кто умеет красиво отображать даты и время.

Пример дампа в VS
Пример дампа в VS

Visual Studio вобрал в себя плюсы DotMemory: красивую визуализацию, удобный просмотр, все можно перетаскивать. Но он не такой интуитивно понятный, как WinDBG.

Например, чтобы посмотреть объект, его нужно перетащить в окно Watch. А чтобы запустить анализ дампа, нужно найти окно Actions, нажать в нем Run Diagnostic Analysis с зеленой стрелкой, прощелкать чек-боксы, которые нужно проанализировать, и запустить. Но в целом инструмент довольно хороший.

Внешний вид окна Actions
Внешний вид окна Actions

Если же нет Windows, Rider, Visual Studio и вы любите Linux, компания Microsoft о вас тоже позаботилась. Она создала набор утилит для кроссплатформенной разработки с подробной информацией в разделе Learn.

Проблемы с утечкой памяти

График, который показывает проблемы с утечкой памяти
График, который показывает проблемы с утечкой памяти

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

Проблемы с памятью могут еще выглядеть вот так.

Монотонно возрастающее потребление памяти, когда объекты в памяти вообще никогда не собираются сборщиком мусора
Монотонно возрастающее потребление памяти, когда объекты в памяти вообще никогда не собираются сборщиком мусора

Монотонно возрастающее потребление памяти может привести к out of memory и краху приложения. Такое может происходить быстро или медленно, могут быть ложно положительные росты. Например, когда прилетает запрос с большой выдачей, которая кэшируется.

К чему приводят забытые объекты

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

Внешний вид дампа в DotMemory
Внешний вид дампа в DotMemory

Мы сняли дамп и открыли его в DotMemory. На первой странице видна проблема: некий объект MqListenerService занимает более 3,5 ГБ. Мне кажется, это много и что-то здесь не так.

Если нажать на ссылку на круговой диаграмме, мы провалимся в объект. Там видим 3,5 ГБ и то, что внутри лежат какие-то хабы в большом количестве.

Всего 343 страницы, а на одной странице 500 объектов. Перемножаем — получаем большое количество экземпляров класса UserSingleConnectionHub
Всего 343 страницы, а на одной странице 500 объектов. Перемножаем — получаем большое количество экземпляров класса UserSingleConnectionHub

Хаб в SignalR создается при подключении клиента, вызывая его конструктор. Когда клиент закончил свою сессию, он закрывается, разрывается подключение и хаб удаляется.

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

Пример кода подписки, когда к внешнему объекту, полученному через конструктор, подписываемся для обработки сообщений
Пример кода подписки, когда к внешнему объекту, полученному через конструктор, подписываемся для обработки сообщений

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

А если мы забудем про объекты типа «файл», «сокет» или «семафор», будут проблемы еще и с неуправляемой памятью. А неуправляемую память диагностировать намного сложнее.

Зачем ограничивать пользовательские запросы

Есть сервис, предоставляющий API к базе данных. К сожалению, скриншотов не сохранилось, и я построил примерный график.

Примерное поведение приложения
Примерное поведение приложения

У нас копились объекты, а потом Garbage Collector собрал их, освобождая память. Нехорошее поведение, алерты срабатывают, сервис тормозит. Мы успели снять дамп, открыли его и увидели много-много небольших объектов: DAO по одной таблице.

Посмотрели, что все объекты принадлежат одному контроллеру. Залезли в логи и увидели запрос с такими параметрами:

Проблема в том, что почти все параметры — NULL. Когда прилетает запрос с такими параметрами, сервис идет в базу данных и начинает выгребать всю таблицу с зависимостями в память. Таблица большая, объектов много, есть зависимости, и объем занятой процессом памяти начинает расти. Как только запрос закончит выдачу, Garbage Collector все соберет. 

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

Мы можем ограничить поле Count, указать там 10, 20, 150 — какое-то приемлемое количество возвращаемых объектов. Еще можно требовать идентификатор, просить заполнить хоть что-нибудь, потому что в противном случае мы ничего не отдадим. А можно поступить еще проще: увидели, что ничего не заполнено, отдаем 400 bad request — разбирайтесь сами.

Токен отмены сохраняет память

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

График растущей памяти
График растущей памяти

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

Количество сервисов, у которых стабильно утекала память, возрастало, особенно было заметно на сервисах c большим аптаймом. Сняли дамп, а там занято всего 1—2 ГБ из 5 ГБ (размер дампа).

Долгое время наблюдали за неуправляемой памятью сервисов, и ее размер не давал нам покоя: он мог достигать 8—10 ГБ. Нужно было понять, откуда такой размер потребляемой памяти. Мы использовали VMMAP — еще один инструмент от Марка Русиновича, компании Sysinternals. В нем есть возможность посмотреть всю занимаемую память в разрезе.

Открытый дамп
Открытый дамп

Мы увидели 8,5 ГБ неуправляемой памяти и только 1,5 ГБ управляемой. У нас тогда был только один неуправляемый пакет — Oracle-клиент. Но разве может такой популярный клиент течь? Не должно быть такого.

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

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

CancellationToken, 17 экземпляров и занимают более 4 ГБ
CancellationToken, 17 экземпляров и занимают более 4 ГБ

Полезли внутрь, нашли один единственный CancellationToken размером почти 3,5 ГБ. Если в цикле вызывать CancellationToken, он привязывает к себе вот эти лямбды.

Внутреннее содержимое Cancellation Token
Внутреннее содержимое Cancellation Token

Мы стали создавать внутренние токены в каждой итерации цикла, и у нас все перестало течь. Почему мы не замечали этого сразу? Потому что цикл обращался в базу где-то раз в 5 минут. Вычитывал из нее какую-то конфигурацию, сравнивал с текущей и применял. Раз в 5 минут — это довольно редко. В перспективе нескольких недель становится заметно. Сами объекты тоже небольшие. Вот вам и непонятная утечка. 

Исправление было простым: CancellationTokenSource.CreateLinkedTokenSource. Позволяет создавать связный токен, который будет отменен при отмене исходного.

Условие перезапуска таймера

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

Левые графики — это память, правые — потоки
Левые графики — это память, правые — потоки

На графиках с памятью видно, что она потихоньку утекает. На графиках с потоками видно, что их количество очень и очень большое: 13 и 16 тысяч. 

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

Стек одного из потоков
Стек одного из потоков

Сняли дамп. Видим по последним вызываемым методам, что потоки стоят на методе GetServer от Redis-клиента. Таких потоков очень много, почти все.

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

Switch Master внутри себя вызывает метод getServer, который проверяет подключение к серверу. Если в кластере будет много серверов, то и разрываться будут соединения для всех. Так и получилось, что количество потоков росло.

Реализация в библиотеке Redis-клиента
Реализация в библиотеке Redis-клиента

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

Решение простое: запускать таймер на одно срабатывание, обернув выполняемый метод в блок try..finally, и уже по результату в блоке Finally запускать таймер повторно.

Мы обновились, и все у нас заработало. Вывод: не забывать обновлять библиотеки, особенно если там изменили код.

Почему важно помнить про KeepAlive

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

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

Экран в DotMemory — ничего криминального
Экран в DotMemory — ничего криминального

DotMemory нам ничем не помог, поэтому пошли в WinDBG. Там открылся совершенно другой мир — мир потоков, где их 2 500.

Список потоков
Список потоков

Запустили команду !Threads, она показывает список всех потоков в виде текстовой таблицы, в которой отображаются сами потоки, их тип и номер. А если щелкнуть по номеру в столбце OSID, поток станет текущим и можно посмотреть его стек. 

Команда ~*e!clrstack позволяет сделать вывод стеков всех потоков. Эта команда выполнялась долго — минут 15, но мне нужна была статистика: посмотреть, чем же они заняты. Я дождался, открыл, полистал стеки нескольких потоков и увидел, что все они стоят где-то в одном месте GetValue.

Сделал поиск по всей выдаче и получил более тысячи вхождений — большая часть этих потоков ожидает GetValue. Мы поняли, что все потоки висят в ожидании подключения к базе данных. Но непонятно почему.

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

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

Увидели, что одно из 10 подключений как будто зависло. По всем подключениям меняется количество принятых, отправленных пакетов, а здесь нет. Нашли базистов, попросили их посмотреть со стороны базы данных. Оказалось, что со стороны базы всего 9 подключений, со стороны клиента — 10. Что происходит — непонятно, хотя это TCP-подключение.

Дело было в механизме Keep Alive, который позволяет определить, мертво подключение или нет. Есть разные реализации в разных клиентах, например через ping-pong: раз в какой-то интервал времени один участник отправляет другому некоторое сообщение и ждет определенного ответа. Если ответ не приходит в указанный промежуток, подключение считается мертвым.

Почему-то Oracle в своем клиенте сделал Keep Alive выключенным. Мы его включили для решения найденной проблемы.

Чем еще плох Deadlock

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

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

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

Мы поснимали дампы с разных инстансов, начали их смотреть и заметили одну особенность: клиент, наша обертка разрушена. Но при этом внутри подключение живо.

Вот так выглядит разрушенная обертка и живое подключение
Вот так выглядит разрушенная обертка и живое подключение

Клиент ZooKeeper полностью асинхронный, что накладывает некоторые ограничения. Если у нас, например, меняется состояние ноды, состояние подключения или состав потомков у этой ноды, через Watcher к нам приходит оповещение. Мы подписываемся на события и ждем изменений.

Диаграмма последовательности проблемы
Диаграмма последовательности проблемы

Внутри нашей обертки в Dispose под Lock ожидали остановки всех наблюдателей. Когда произошел разрыв подключения, сработало событие OnDisconnected для наблюдателя, обработчик которого внутри себя ожидал освобождения этого же Lock. Ну а дальше Deadlock.

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

Рекомендации и советы

WinDBG — довольно сложный инструмент. Но я немножечко позаботился и составил небольшой список рекомендаций, команд и лайфхаков, которые помогут начать работать с WinDBG. Их не то чтобы много, но самые базовые, чтобы было с чего начать. Все хранится на GitHub — залетайте!

Несколько советов, которые я собрал, пройдя весь путь:

  1. Настроить мониторинг и уведомления, чтобы можно было вовремя узнать о проблеме. Нам нужен запас времени, чтобы что-нибудь предпринять.

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

  3. Проводить нагрузочное тестирование лучше с анализом памяти потоков до, во время и после нагрузки. А еще нужно анализировать их.

  4. Пользоваться BenchmarkDotNet. С помощью этого инструмента можно не только мериться попугаями и сравнивать качество алгоритмов, но и сравнивать критичные участки между версиями на скорость выполнения и прожорливость.

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

Если остались вопросы или хотите поделиться своими историями, добро пожаловать в комментарии!

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