
Привет, Хабр! Я Антон Пронькин, разработчик в команде развития сервисов продаж для юридических лиц в Т-Банке.
Расскажу, как мы переносили конфигурационные данные в файлы и наткнулись на 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 МБ на старте при сборке конфигурации. Я не мог не проверить потребление памяти до моих изменений — до добавления небольшого файла. Результаты удивили меня даже сильнее, чем я рассчитывал:

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, который повлек несколько дней анализа и локализации источника проблемы.
А если вы встречали похожие истории с неожиданным потреблением памяти из-за библиотечных оберток — расскажите в комментариях, интересно собрать коллекцию.
