Утечка, которая не умирает: Как сборщик мусора Python предал нас
· 4 min read
Ускорять парсинг и внедрять кэширование — это благородное занятие. Но вся эта архитектура не имеет значения, если в один прекрасный день CI-раннеры и серверы падают замертво с банальной ошибкой: No space left on device.

Мы сталкивались с этой проблемой трижды. Свободное место на дисках таяло до абсолютного нуля, полностью парализуя работу пайплайнов. Расследование каждый раз указывало на один и тот же компонент — легаси-коллектор для Doxygen.
Анатомия утечки
Чтобы Doxygen не конфликтовал сам с собой при параллельных запусках, наш коллектор создавал для каждого вызова изолированные временные директории через классический метод tempfile.mkdtemp(). Туда складывались сгенерированные Doxyfile, кэш-файлы и гигантские порции промежуточного XML.
По задумке, в конце работы оркестратор должен был вызвать метод cleanup(), который через shutil.rmtree() удалял эти временные папки. Но реальный мир суров. Если внутри парсера вылетало исключение (exception), скрипт падал, cleanup() не вызывался, и папка весом в несколько сотен мегабайт навсегда оставалась лежать в /tmp.
Мы дважды латали конкретные места, где терялись исключения (это были так называемые “Рецидив №1” и “Рецидив №2”). Но это была борьба с ветряными мельницами. Кто-то в другом модуле забывал обернуть вызов в try/finally, и диск снова начинал медленно истекать кровью. Нам нужен был фундаментальный архитектурный фикс.
Иллюзия спасения: Делегируем всё GC
Как решить проблему очистки ресурсов в Python? “Давайте просто доверим это сборщику мусора (GC)!” — решили мы.
Вместо сырого mkdtemp мы переписали логику на объект tempfile.TemporaryDirectory. По задумке, этот объект умнее: когда он удаляется из оперативной памяти, его деструктор (__del__) автоматически рекурсивно очищает папку на диске.

Чтобы папка жила ровно столько, сколько нужно, мы привязали объект к жизненному циклу самого коллектора:
temp_dir_obj = tempfile.TemporaryDirectory(prefix="ude_xml_")
self._active_temp_dirs.append(temp_dir_obj)
Звучало как элегантное, “pythonic” решение. Сборщик мусора должен был сделать всю грязную работу за нас.
Твист: Структурный фикс сам оказался утечкой
Мы раскатили этот структурный патч. И вскоре поймали Рецидив №3. Сервер снова лег из-за пустого диска.
Наш идеальный “архитектурный фикс” утекал как решето. Оказалось, что надеяться на Garbage Collector при управлении диском — фатальная ошибка.
В сложной микросервисной архитектуре Flude инстанс оркестратора мог кэшироваться, жить дольше ожидаемого или попадать в циклические ссылки (например, перехватываться замыканиями или декораторами телеметрии). Сборщик мусора просто не вызывал деструктор инстанса, а значит, _active_temp_dirs продолжал жить в оперативной памяти.
В написанном нами репродьюс-тесте всё стало предельно ясно. Пять вызовов collect() подряд без явного cleanup() создавали пять мертвых директорий на диске, которые GC полностью игнорировал. Памяти нашему приложению хватало, поэтому агрессивный цикл сборки мусора даже не запускался, в то время как диск задыхался от мусора.
Грубая сила вместо магии
Стало очевидно, что неявный “ленивый” cleanup через сборщик мусора — это антипаттерн для тяжелых ресурсов. Мы полностью отказались от слепой веры в GC и внедрили параноидальный, жесткий контроль.
В самое начало метода collect() мы добавили принудительную зачистку “сирот”. Теперь, когда коллектор запускается, он первым делом смотрит в свой собственный массив _active_temp_dirs. Если там лежат директории от предыдущего вызова — значит, вызывающий код был написан криво и забыл за собой убрать.
Коллектор принудительно убивает эти старые директории прямо на старте нового прогона, сопровождая это гневным предупреждением в логи:
logger.warning(
f"Orphaned temporary directory found: {td.name}. "
"Caller failed to invoke cleanup(). Force-cleaning now."
)
Итог
В тесте с пятью вызовами без очистки количество утекших директорий упало с 5 до 1 (на диске оставалась только последняя папка текущего прогона). Диск был навсегда спасен.
Этот инцидент стал для нас болезненным, но важным уроком. Сборщик мусора в Python создан для управления оперативной памятью. Делегировать ему файловую систему, сетевые сокеты или соединения с БД — это заложенная бомба. Только явные вызовы cleanup(), контекстные менеджеры with и параноидальные защитные проверки могут гарантировать стабильную работу системы в облаке.
Но утечка диска была не единственной проблемой нашей слепой веры в инструменты. Вскоре мы столкнулись с багом парсера, которого… вообще не существовало. О том, как обычный grep по кодовой базе чуть не заставил нас переписывать ядро из-за “призрака” Doxygen — читайте в следующем эпизоде.