Сервис на FastAPI после запуска занимает 180 МБ и первые дни честно держит эту отметку. Потом на графике мониторинга появляется пологая лестница: 200, 260, 400 МБ. Через месяц процесс упирается в лимит контейнера, ядро завершает его по OOM, а балансировщик молча поднимает новый экземпляр. Перезапуск по расписанию лечит симптом и прячет причину: где-то в коде живёт маленькая ссылка, которая годами держит за руку большой объект и не отпускает его. В Python почти не бывает утечек в том смысле, в каком они знакомы там, где память освобождают вручную. Здесь память теряют иначе: объект официально жив, потому что на него кто-то ссылается. Найти этого "кого-то" можно только по следам аллокаций, и главный инструмент для такой охоты лежит в стандартной библиотеке начиная с версии 3.4. Ниже разбор реальных источников потерь в процессе, который живёт неделями, и рабочая методика поиска виновной строки через tracemalloc.

Почему память процесса не возвращается операционной системе даже после очистки объектов

Первый механизм CPython не сборщик, а счётчик ссылок. Каждый объект знает, сколько на него указателей: присвоили имя, плюс один, удалили переменную, минус один. Ноль означает немедленное освобождение. Проверить любопытно одной строкой:

import sys

box = []
print(sys.getrefcount(box))

Выведет 2, потому что одну ссылку держит имя box, а вторую временно забирает сам вызов. Счётчик честен, но вслепую: два объекта, ссылающиеся друг на друга, никогда не дойдут до нуля, даже если снаружи на них никто не смотрит. Для таких островков существует циклический сборщик из модуля gc, но он отслеживает только контейнерные объекты со словарём атрибутов или внутренними ссылками. Числа и строки сборщик не учитывает вовсе: мелкие целые от -5 до 256 вообще кэшируются интерпретатором и живут вечно, и это не поломка, а устройство машины.

Второй уровень непонимания начинается, когда объект уже освободили, а RSS процесса в мониторинге не падает. Аллокатор pymalloc нарезает память аренами по 256 КиБ и возвращает операционной системе только целиком свободную арену. Если внутри остался хотя бы один живой байт, арена остаётся у процесса, и график стоит на месте, хотя внутри почти пусто. Поэтому утраченной называют не память, потерянную физически, а растущее множество живых объектов: именно его нужно измерять и расследовать.

Циклические ссылки там где их не ждут от обратных указателей до объектов исключений

Классика жанра двусторонняя связь родителя и потомка:

class Node:
    def __init__(self):
        self.parent = None
        self.children = []

root = Node()
leaf = Node()
leaf.parent = root
root.children.append(leaf)

del root, leaf

Имена стёрты, а оба объекта живы и держат друг друга. До Python 3.4 цикл с финализатором __del__ был приговором: такие объекты складывались в список gc.garbage и оставались навсегда. PEP 442 научил сборщик безопасно вызывать финализаторы и разбирать большинство таких связок, но легаси-код со сломанными __del__ встречается до сих пор.

Самый дорогой вид цикла прячется в обработке ошибок. У объекта исключения есть traceback, traceback держит кадры стека, а в каждом кадре живут все локальные переменные всей цепочки вызовов. Обработали сбой, отложили объект исключения в словарь для повторных попыток и заодно удержали в памяти кадры, их переменные и всё, на что те ссылались, включая структуры на сотни мегабайт. Язык страхует: запись except ValueError as exc удаляет имя exc в конце блока и разрывает цикл автоматически. Страховка работает ровно до момента, когда исключение сохраняют в структуру целиком. Лекарство простое: хранить текст через traceback.format_exc(), а не сам объект.

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

Кэши без верхней границы как главный источник роста RSS в веб-сервисах

Если спросить у эксплуатационщиков, что чаще всего медленно съедает память долгоживущих сервисов, ответ почти всегда один: кэш, которому забыли назначить границу. functools.lru_cache по умолчанию скромен и держит 128 записей, но стоит передать maxsize=None, и декоратор превращается в бездонный словарь:

from functools import lru_cache

@lru_cache(maxsize=None)
def load_profile(user_id):
    return fetch_profile(user_id)

Миллион пользователей прошёл через эндпоинт, и миллион значений осел в памяти до перезапуска. Отдельная ловушка: кэширование метода. В ключ записи попадает self, и декоратор на уровне класса держит каждый экземпляр, через который прошёл вызов. Приложению объект давно не нужен, но кэш не отпускает его никогда. Для методов либо разворачивают хранилище в атрибуте экземпляра, либо берут weakref.WeakValueDictionary: записи в нём исчезают сами, как только на значение не остаётся сильных ссылок.

Для реестров и кэшей объектов по ключу слабые значения дают изящный каркас без ручной чистки:

import weakref

class ObjectCache:
    def __init__(self):
        self._items = weakref.WeakValueDictionary()

    def put(self, key, value):
        self._items[key] = value

    def get(self, key):
        return self._items.get(key)

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

Разница между кэшем и утечкой на удивление тонкая, и состоит она в политике вытеснения. Есть максимальный размер и правило, кого выбросить при переполнении, значит это кэш. Нет ни того ни другого, значит это утечка под красивым названием "оптимизация". Проверка простая: если после выхода на рабочую нагрузку лестница RSS сменяется плато, кэш ведёт себя как задумано. Если график ползёт линейно при ровной нагрузке, у хранилища нет дна.

Пошаговая методика поиска утечки через диффы снимков tracemalloc

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

import tracemalloc

tracemalloc.start(25)
snapshot1 = tracemalloc.take_snapshot()

serve_requests(5000)

snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, "lineno")[:10]:
    print(stat)

Каждая строка отчёта читается как протокол, и выглядит она примерно так:

app/payments.py:214: size=184.2 MiB (+96.1 MiB), count=1240 (+620)
app/cache.py:88: size=95.4 MiB (+42.7 MiB), count=48900 (+24450)

В первой колонке место аллокации, дальше текущий размер, прирост к первому снимку, число блоков и его прирост. compare_to показывает дельту между снимками, statistics группирует по файлам и по полным трейсбекам, а флаг cumulative складывает аллокации по всей цепочке вызовов. Шум импортов убирают фильтрами: tracemalloc.Filter(False, "<unknown>") выкидывает аллокации без читаемого источника, а исключение "<frozen importlib._bootstrap>" очищает отчёт от служебных модулей старта. Глубину кадров задают и без правок кода: переменной окружения PYTHONTRACEMALLOC или опцией интерпретатора -X tracemalloc=25.

Для контроля пиков есть пара get_traced_memory, она возвращает текущий и максимальный объём отслеживаемых блоков, а reset_peak из версии 3.9 сбрасывает пик к текущему значению перед новым замером. Расходы самого модуля смотрят через get_tracemalloc_memory: чем больше кадров хранит трейсбек, тем дороже обход, поэтому в продакшене трассировку включают на время расследования, а не навсегда. Ритм методики один и тот же: два снимка на равной нагрузке, дельта, спуск от файла к строке, от строки к трейсбеку, от трейсбека к удерживающей ссылке в коде.

Приёмы модуля gc для проверки гипотез о циклах и не поддающихся объектах

Иногда по диффу видно, что растут объекты, которые обязаны умирать: сессии, задачи, временные структуры. Значит, где-то цикл, и сборщик либо не доходит до него, либо не может разобрать связку. Статистику по поколениям выдаёт gc.get_stats: три словаря, в каждом счётчик сборок, число собранных объектов и число не поддающихся. Пороги по умолчанию 700, 10 и 10: сборка нулевого поколения запускается после 700 новых аллокаций минус освобождения, старшие поколения разбираются в разы реже. Полный проход инициируют вручную вызовом gc.collect, который возвращает число освобождённых объектов.

Для упорных случаев есть режим gc.set_debug(gc.DEBUG_LEAK): флаг DEBUG_SAVEALL складывает недостижимые объекты не в освобождение, а в список gc.garbage, где их можно рассмотреть глазами и разглядеть связь, которая мешает сборке. Отдельно полезен gc.freeze из версии 3.7: перед форком родительского процесса, как это делают префорк-серверы, заморозка переносит выжившие объекты в постоянное поколение, и дочерние процессы не копируют их при записи. С Python 3.13 циклический сборщик стал инкрементальным, и фазы полной сборки больше не дают тех заметных пауз, за которые его ругали в сервисах, чувствительных к задержкам. Отключать сборщик в надежде на скорость опасно и бессмысленно: счётчик ссылок всё равно чистит большинство объектов, а циклы начнут копиться молча.

Ситуации когда tracemalloc молчит и нужен взгляд со стороны процесса

Бывает и обратная картина: RSS растёт уверенно, а диффы tracemalloc показывают ровный ноль. Значит, память запрашивает не сам Python. Расширения на C, драйверы баз, клиенты брокеров сообщений и научные стеки берут память системным аллокатором мимо домена 0, и трассировщик их не видит. Для таких случаев существует memray, инструмент с открытым кодом, который пишет отчёты по нативным аллокациям, включая вызовы внутри расширений, и строит графики удержаний на временной шкале.

Ложных тревог в теме хватает. Память, которая уходит после gc.collect, не утечка, а отложенная работа. Рост RSS первые минуты после старта не утечка, а прогрев кэшей и импортов. Пила на графике от пары больших задач, держащих буферы одновременно, тоже не утечка. Поэтому первым делом фиксируют скорость роста в мегабайтах за час по замерам psutil через Process().memory_info().rss и только потом тратят часы на разбор кода. Хороший контрольный приём: снимать дифф tracemalloc сразу после полного прохода gc.collect, чтобы убрать отложенный мусор из картины и не гоняться за пустыми следами.

План обследования растущего процесса и выбор лечения под найденную причину

Когда лестница на графике уже видна, порядок действий почти не зависит от проекта:

  1. Замерить скорость роста RSS через psutil и отрезать версии с прогревом и нормальной работой кэшей, у которых рост закономерно заканчивается плато;
  2. Включить tracemalloc с запасом кадров от 15 до 25 и снять дифф между двумя равными порциями нагрузки;
  3. Сузить отчёт фильтрами до собственного кода, убрав шум импортов и служебных модулей;
  4. Найти в дельте строку или трейсбек с устойчивым приростом и прочитать этот участок кода глазами;
  5. Проверить гипотезу о цикле режимом gc.DEBUG_LEAK и списком gc.garbage, если объекты обязаны умирать, но живут;
  6. Применить точечное лечение: граница и политика вытеснения для кэша, слабые ссылки для подписок и наблюдателей, форматированный текст вместо объекта исключения, разрыв обратных указателей в структурах;
  7. Прогнать нагрузочный тест, убедиться, что лестница сменилась плато, и выключить трассировку за ненадобностью.

Инструмент в этой истории вторичен, первична привычка читать диффы, а не перезапускать процесс по расписанию. Циклические ссылки лечатся дисциплиной разрыва связей и слабыми ссылками там, где связь обязана быть недолговечной. Кэши требуют границы: как минимум maxsize, как максимум политика вытеснения по времени и стоимости. Нативные аллокации требуют отдельного инструментария, потому что стандартная библиотека их не видит. А растущий RSS в мониторинге нужен самому проекту: это честный сигнал, что объект держат дольше, чем он заслуживает, и лучше узнать об этом из графика, чем ночью из сообщения о завершении процесса по OOM.