Привет, Хабр! Я Антон Пронькин, разработчик в команде развития сервисов продаж для юридических лиц в Т-Банке.

Расскажу, как мы переносили конфигурационные данные в файлы и наткнулись на OOM-крэш, вызванный всего одним методом расширения. Добавление YAML-файла на 4 КБ съедало больше 2 ГБ RAM. При этом локально все работало, а падало только при прогоне тестов на пайплайне.

В статье — расследование с профайлингом, экспоненциальный рост памяти, фикс в одну строку и разбор особенности работы библиотеки StackExchange.Utils.

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

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

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

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

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

var configNames = new []
{
    "reason-groups",
    // ...
    "pushing-descriptions",  // Единственная новая строчка
};

foreach (var configName in configNames)
{
    builder.BuildBusinessConfigs(Path.Combine(contentRoot, configName), $"{configName}.yml");
}

// ...

private static void BuildBusinessConfigs(this IConfigurationBuilder builder, string contentRoot, string configFileName)
{
   builder.WithSubstitution(configBuilder => configBuilder.AddYamlFile(configFileName, optional: false, reloadOnChange: true));
}

Видимо, проблема в файле. Возможно, неверная кодировка, большой размер или ошибочная структура YAML — возникла мысль, которая вдребезги разрушилась после перепроверки. Структура была корректная, а файл занимал около 4 КБ памяти (~120 строк). Конфигурация корректно подтягивалась локально и после деплоя на фича-ветку, включая монтирование через k8s ConfigMaps.

Поиск причины недоступности сервиса

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

Доработав пайплайн и джобы тестирования, я добавил информационные логи с приложения. Это стало ключевым моментом: на черном фоне консоли я увидел четыре страшных слова — Out Of Memory Exception. 

Unhandled exception. System.IO.InvalidDataException: Failed to load configuration from file '/pushing-descriptions/pushing-descriptions.yml'.
---> System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.

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

Поиск быстрого решения

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

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

Большой раннер дает около 14 ГБ RAM — достаточно много, даже учитывая, что у нас поднимается несколько сервисов. Мне стало очевидно, что существуют какие-то ограничения со стороны инструментов для развертывания тестовых окружений в k8s из раннеров. Недолго думая, решил увеличить ресурсы контейнеров приложения по лимитам памяти. После настройки — о чудо! — приложение начало запускаться на раннере!

Все равно часть тестов продолжала падать. «Это из-за нехватки ресурсов для зависимых сервисов», — убедил я себя.

Неожиданный размер айсберга

Я нашел источник проблемы, теперь нужно было выделить сервисам минимально необходимые ресурсы и перераспределить RAM для зависимостей. Быстрые значения на глазок не помогли: сервис не поднимался, когда я выделял 128, 256 и даже 512 МБ.

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

Локальный профайлинг

Мысль, что добавление конфигурационного файла на 4 КБ заставляет приложение потреблять 500 МБ RAM, не давала покоя, поэтому я начал смотреть потребление памяти в профайлере.

Рост потребления памяти до 440 МБ в управляемой куче на старте
Рост потребления памяти до 440 МБ в управляемой куче на старте

По графику потребления памяти виден линейный рост в управляемой куче до 440 МБ на старте при сборке конфигурации. Я не мог не проверить потребление памяти до моих изменений — до добавления небольшого файла. Результаты удивили меня даже сильнее, чем я рассчитывал:

Потребление памяти до моих изменений
Потребление памяти до моих изменений

120 МБ при стартовой инициализации! Это в 3,5 раза меньше, чем с добавленной конфигурацией. Ради интереса скопировал файл и добавил повторно в список, чтобы найти зависимость потребления памяти:

Рост потребления памяти после повторного добавления файла в список конфигураций
Рост потребления памяти после повторного добавления файла в список конфигураций

Вот в этот момент я был благодарен, что у офисного стула была спинка, которая удержала меня от падения.

В рамках миграции на подходе должно было добавиться около четырех новых файлов. Экстраполируя результаты, можно назвать оценку требуемой памяти после добавления еще трех файлов — более 3 ТБ.

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

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

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

private static void BuildBusinessConfigs(this IConfigurationBuilder builder, string contentRoot, string configFileName)
{
   builder.AddYamlFile(configFileName, optional: false, reloadOnChange: true);
}

Запустил профайлер:

Снижение потребления памяти после ухода от использования подстановок
Снижение потребления памяти после ухода от использования подстановок

30 МБ после сборки конфигов (часть графика после первого увеличения) вместо 440 МБ! Это именно то, что нужно.

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

Потребление памяти контейнером на фича-ветке: увеличение слева — деплой изменений, уменьшение справа — применение фикса
Потребление памяти контейнером на фича-ветке: увеличение слева — деплой изменений, уменьшение справа — применение фикса

Поиск источника протечки

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

Вероятно, проблема не в самом StackExchange.Utils, а в его связке с YAML-провайдером из NetEscapades.Configuration.Yaml вместо JSON. Но нет — мигрировал файлы на JSON и стандартную библиотеку, проблема сохранилась.

Оставалось самое достоверное решение — анализ исходного кода библиотеки. Объем кода оказался совсем небольшим, и бросился в глаза блок кода при билде конфигураций:

var valueBuilder = new ConfigurationBuilder();
for (var i = 0; i < builder.Sources.Count; i++)
{
    var source = builder.Sources[i];
    if (!ReferenceEquals(source, this))
    {
        valueBuilder.Add(source);
    }
}

var valueRoot = valueBuilder.Build();

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

В этом случае можно продолжить использование метода WithSubstitution со всеми его возможностями, однако регистрация должна вызываться единожды, а все добавляемые источники — внутри обертки билдера. Это позволит добавлять источники один раз.

Анализ серьезности проблемы

Я решил рассмотреть масштаб проблемы на простом приложении, которое собирает конфигурации и определяет фактически занимаемую память. Конфиги будут представлять собой простой in-memory список из 100 ключей.

Вариант 1. Добавление конфигов в нескольких вызовах WithSubstitution.

using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using StackExchange.Utils;

const int sourcesCount = 5;   

var settings = Enumerable.Range(1, 100).Select(i => new KeyValuePair<string, string>(
    i.ToString(),
    Random.Shared.Next().ToString()
)).ToArray();

var configurationBuilder = new ConfigurationBuilder();
for (var i = 0; i < sourcesCount; i++)
{
    configurationBuilder.WithSubstitution(builder => builder.AddInMemoryCollection(settings));
}

configurationBuilder.Build();

Console.WriteLine(Process.GetCurrentProcess().WorkingSet64 / (1024 * 1024));

Количество источников (sourcesCount)

Занимаемый объем памяти, МБ

1

42

2

42

3

43

4

49

5

77

6

261

7

1 762

8

10 348

9

OOM

Память росла не линейно, а взрывным образом: шесть источников — сотни мегабайт, восемь — уже больше 10 ГБ, девятый — OOM.

Экспоненциальный рост потребления памяти при добавлении новых источников
Экспоненциальный рост потребления памяти при добавлении новых источников

Вариант 2. Добавление конфигов в одном вызове WithSubstitution.

using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using StackExchange.Utils;

const int sourcesCount = 5;

var settings = Enumerable.Range(1, 100).Select(i => new KeyValuePair<string, string>(
    i.ToString(),
    Random.Shared.Next().ToString()
)).ToArray();

var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.WithSubstitution(builder =>
{
    for (var i = 0; i < sourcesCount; i++)
    {
        builder.AddInMemoryCollection(settings);
    }
});

configurationBuilder.Build();

Console.WriteLine(Process.GetCurrentProcess().WorkingSet64 / (1024 * 1024));

Количество источников (sourcesCount)

Занимаемый объем памяти, МБ

1

42

2

42

3

42

4

42

5

42

6

42

7

42

8

42

9

42

Потребление памяти держалось на 42 МБ независимо от количества источников. 

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

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

Выводы и дальнейшие шаги

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

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

Из варианта реализации с добавлением конфигураций в общий WithSubstitution видно, что утечки памяти нет, поэтому нужно применять именно этот подход. Для предотвращения подобных ситуаций у других пользователей занес issue с описанием кейса мейнтейнерам библиотеки — буду рад вашим предложениям и обсуждениям!

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

Я календарь переверну — и снова 5 апреля: CPU-утилизация во имя интернационализации
Привет, Хабр! Я Антон Пронькин, разработчик в команде развития сервисов продаж для юридических лиц. ...
habr.com

А если вы встречали похожие истории с неожиданным потреблением памяти из-за библиотечных оберток — расскажите в комментариях, интересно собрать коллекцию.