Утечка памяти в StackExchange.Utils: 4 КБ конфигурации съели 2 ГБ RAM

При переносе конфигурационных данных в YAML-файлы команда развития сервисов продаж для юридических лиц Т-Банка столкнулась с неожиданным OOM-крэшем (out-of-memory). Локально всё работало без нареканий, но при прогоне тестов на пайплайне приложение падало с ошибкой нехватки памяти. Расследование пока

Утечка памяти в StackExchange.Utils: 4 КБ конфигурации съели 2 ГБ RAM

При переносе конфигурационных данных в YAML-файлы команда развития сервисов продаж для юридических лиц Т-Банка столкнулась с неожиданным OOM-крэшем (out-of-memory). Локально всё работало без нареканий, но при прогоне тестов на пайплайне приложение падало с ошибкой нехватки памяти. Расследование показало, что виновником стал метод расширения из популярной библиотеки StackExchange.Utils, а объём потребления RAM рос экспоненциально с каждым вызовом. Эта проблема могла затронуть тысячи .NET-разработчиков, использующих библиотеку для управления конфигурацией.

Как 4 КБ конфигурации привели к 2 ГБ RAM

Антон Пронькин, разработчик в Т-Банке, рассказал, что при добавлении YAML-файла размером всего 4 КБ в проект на платформе .NET использование оперативной памяти превышало 2 ГБ. Проблема проявлялась только в среде CI/CD, где нагрузка на память была выше. Локально же приложение запускалось и работало стабильно, что затрудняло диагностику. Ключевым моментом стало то, что утечка возникала не сразу, а накапливалась при многократном обращении к конфигурации в тестах.

Почему утечка памяти возникает только в CI/CD?

Разница между локальной средой и пайплайном объясняется поведением сборщика мусора .NET. На локальной машине с достаточными ресурсами сборщик мусора успевал освобождать неиспользуемые объекты до того, как память заканчивалась. Однако на CI/CD-серверах, где одновременно выполняются несколько процессов, сборка мусора происходила реже, и объекты накапливались экспоненциально. В результате после нескольких десятков обращений к конфигурации приложение падало с OutOfMemoryException. Это типичный сценарий, когда проблемы с памятью проявляются только под нагрузкой.

Предыстория и контекст

Команда переходила от хранения конфигурации в коде к внешним файлам формата YAML. Такой подход упрощает управление настройками и позволяет менять их без перекомпиляции. Однако при интеграции с библиотекой StackExchange.Utils, которая предоставляет удобные методы для работы с конфигурацией, возникла аномалия. Библиотека широко используется в .NET-сообществе, и подобная проблема могла затронуть многих разработчиков. Интересно, что утечка была связана не с самим YAML-парсером, а с одним из методов расширения, который неправильно управлял кэшированием.

Что именно вызывало утечку?

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

Технические подробности: экспоненциальный рост и фикс в одну строку

Антон провёл детальный профайлинг с помощью инструментов .NET, чтобы выявить источник утечки. Оказалось, что метод расширения сохранял ссылки на предыдущие версии конфигурации, создавая всё новые и новые экземпляры. Исправление заняло буквально одну строку: добавление вызова Clear() для очистки кэша перед повторным чтением. После этого потребление памяти стабилизировалось на уровне нескольких мегабайт. Интересно, что локально проблема не проявлялась, так как сборщик мусора успевал очищать объекты до того, как память заканчивалась, но на нагруженном пайплайне этого не происходило.

Как воспроизвести проблему?

Чтобы воспроизвести утечку, достаточно создать .NET-проект, подключить библиотеку StackExchange.Utils и в цикле многократно читать конфигурацию из YAML-файла. После 50–100 итераций потребление памяти начнёт расти экспоненциально. Например, на тестовом проекте с файлом в 4 КБ через 100 обращений память достигала 500 МБ, а через 200 — уже 2 ГБ. Без фикса это приводило к падению приложения. Разработчики могут проверить свои проекты с помощью профайлера памяти, такого как dotMemory или PerfView.

Кого затронет и как

Проблема актуальна для всех разработчиков на .NET, использующих библиотеку StackExchange.Utils для работы с конфигурацией. Особенно это касается проектов с частым чтением конфигурационных файлов в тестовых сценариях или при высокой нагрузке. В российских компаниях, где эта библиотека также популярна, возможны аналогичные инциденты. Разработчикам рекомендуется обновить библиотеку до последней версии или применить патч, очищающий кэш. Также стоит обратить внимание на мониторинг памяти в CI/CD-пайплайнах, чтобы вовремя выявлять подобные аномалии.

Какие альтернативы существуют?

Если обновление библиотеки невозможно, можно временно отказаться от использования метода расширения и читать конфигурацию напрямую через стандартный YAML-парсер, например YamlDotNet. Это потребует небольших изменений в коде, но полностью устранит утечку. Другой вариант — вручную очищать кэш после каждого чтения, как это сделали в Т-Банке. В долгосрочной перспективе стоит рассмотреть миграцию на более стабильные библиотеки для работы с конфигурацией, такие как Microsoft.Extensions.Configuration.

Что будет дальше

Команда Т-Банка уже отправила отчёт об ошибке в репозиторий StackExchange.Utils. Ожидается, что в ближайших версиях библиотеки проблема будет исправлена на уровне исходного кода. Разработчикам рекомендуется временно использовать обходной путь — явно очищать кэш после каждого чтения конфигурации. В более широком смысле этот случай напоминает о важности профилирования памяти даже для, казалось бы, простых операций, особенно при переходе на новые форматы данных.

Как предотвратить утечки памяти в будущем?

Чтобы избежать подобных инцидентов, внедрите профилирование памяти в процесс CI/CD. Например, добавьте в пайплайн шаг, который запускает тесты с ограничением памяти и анализирует дампы при падении. Также полезно регулярно обновлять зависимости и следить за их changelog'ами. Используйте анализаторы кода, такие как Roslyn analyzers, для выявления потенциальных утечек на этапе компиляции. И, конечно, не забывайте про нагрузочное тестирование с мониторингом памяти.

Итог

Утечка памяти в StackExchange.Utils из-за 4 КБ YAML-файла привела к потреблению 2 ГБ RAM, но была исправлена одной строкой кода. Этот кейс — отличная иллюстрация того, как небольшие изменения в конфигурации могут вызвать серьёзные проблемы, если не учитывать особенности используемых библиотек. Разработчикам стоит быть внимательными при выборе методов расширения и регулярно проверять потребление памяти в тестовых средах.