Прямой ответ
Сначала диагностика, потом охота: «память растёт» ≠ «утечка» — Python-процесс не обязан возвращать освобождённую память ОС (аллокатор держит пулы для повторного использования: пик нагрузки поднял RSS, трафик спал — RSS остался — это норма, не утечка); признак настоящей утечки — монотонный рост между сопоставимыми состояниями (после каждого цикла нагрузки база выше; за неделю — гигабайты — системная диагностика показывает форму кривой). Локализация: tracemalloc (снапшоты «кто allocate'ит» — сравнение двух точек во времени показывает строки-накопители — stdlib, нулевая инфраструктура), memray (полная запись аллокаций с флеймграфом — тяжелее, точнее), objgraph (графы ссылок — «почему объект не собирается: кто держит»). Типовые виновники в порядке частоты: кеши без границ (@lru_cache на методе (self в ключе — каждый экземпляр навсегда), самописные dict-кеши без maxsize/TTL), глобальные коллекции (списки-логгеры/подписчики, растущие всю жизнь процесса), незакрытые ресурсы (курсоры/сессии/файлы — держат буферы и ссылки), циклические ссылки с __del__ (gc справляется с циклами, но финализаторы в циклах — исторический источник задержек), native-утечки в C-расширениях (Python-инструменты не видят — valgrind/ASAN территория). И страховка для прода, пока ищется причина: max_requests ротация воркеров (gunicorn) и MemoryMax systemd — утечка превращается из аварии в незаметную ротацию.
tracemalloc: снапшоты и сравнения
Встроенный инструмент для «кто виноват»: tracemalloc.start(10) (глубина стека 10) в начале процесса/теста → снапшот A → прогон нагрузки → снапшот B → B.compare_to(A, 'lineno')[:15] — топ строк с дельтой аллокаций (строка кода + сколько добавилось — локализация за минуты). Режимы работы: в dev/тесте (нагрузочный сценарий повторяется — locust даёт воспроизводимость); в проде — включение по сигналу/эндпоинту диагностики (tracemalloc замедляет и ест память на учёт — постоянно держать включённым нельзя; «снимок по кнопке» при растущем RSS — приемлемо); статистика по доменам ('filename', 'traceback') — укрупнение, когда lineno-вывод шумит. Интерпретация: топ-строка — не всегда корень (аллокация «здесь», а не освобождается «там» — держит ссылка из другого места; следующий шаг — objgraph по подозрительному типу: objgraph.show_backrefs(objgraph.by_type('Session')[:3]) — граф «кто держит» показывает владельца). memray — когда tracemalloc недостаточно: нативные аллокации, точные стеки в C-расширениях, флеймграфы (memray run --follow-fork app.py → memray flamegraph) — профилирование-контекст тот же, предмет — память.
Типовые причины и их устранение
Кеши без границ — причина №1: functools.lru_cache на методе класса (ключ включает self — экземпляр никогда не освобождается, кеш растёт с числом экземпляров) — лечится weakref-ключами, кешем на функции от данных (не от self), или явным кеш-объектом с maxsize; самописный cache = {} без очистки — дисциплина кешей: maxsize + TTL, метрика размера кеша наружу. Глобальные списки/реестры: модуль-логгер, копящий записи в памяти; паттерн Observer без отписки (объект умер, ссылка в списке подписчиков жива — и объект не умирает) — weakref-подписчики или явная отписка в контекст-менеджере. Незакрытые ресурсы: DB-сессии/курсоры без close держат результат-буферы (сессия SQLAlchemy с незакоммиченной загрузкой — тысячи объектов в identity map — сессии: expire_on_commit, скоуп на запрос); файлы/HTTP-ответы без with — то же. Большие данные в замыканиях/дефолтах: def f(data=big_list) (дефолт живёт вечно), замыкание, захватившее кадр с мегабайтами — явная передача нужного. Потоки/таски-зомби: каждый незавершённый поток/таск держит стек и ссылки (утечка «по воркеру в минуту» — async-дисциплина); native: numpy/torch-баферы, расширения — Python-сборщик не при чём (RSS растёт, tracemalloc чист — сигнал смотреть в C-слой: malloc_trim, обновления библиотек, ASAN-сборки).
Диагностика в проде и профилактика
Прод-набор: метрика RSS по воркерам во времени (Prometheus-экспорт) — форма кривой отличает утечку (линейный/ступенчатый рост без плато) от рабочего набора (плато после прогрева) и фрагментации (пила); алерт на рост тренда (алерты — predict_linear «кончится память за 6h»); реплика-жертва: один воркер/под с включённым tracemalloc и снапшотами по расписанию (сравнение часовых снапшотов в проде — улика, невоспроизводимая локально); профилировщик по требованию (py-spy dump живого процесса — что держит стек; memray attach — аллокации) — без рестарта. Профилактика/смягчение: max_requests=N, max_requests_jitter gunicorn — ротация воркеров обнуляет накопленное (для медленных утечек — достаточная мера «навсегда»: цикл ротации короче времени накопления); MemoryMax systemd/OOM-лимиты контейнера + Restart=always — худший случай ограничен; пул соединений/ресурсов с явным lifecycle вместо ручного создания. И процесс: найденная утечка → тест-регрессия (нагрузочный сценарий + assert на рост RSS/число объектов — тесты ловят возвращение бага).
Практический сценарий: воркер раздувается за смену
Симптом: Celery-воркер обработки документов вырастает с 300 МБ до 2 ГБ за 6 часов, OOMKill, рестарт, снова (Celery-контекст). Шаг 1 — форма: метрика RSS — линейный рост, коррелирует с числом обработанных документов (не время, не трафик) — «что-то накапливается на документ». Шаг 2 — воспроизведение: скрипт обрабатывает 200 фикстурных документов, tracemalloc-снапшоты каждые 50: дельта-топ — строка в модуле парсинга: _CACHE[key] = parsed — самописный кеш разобранных шаблонов без ограничения (ключей столько же, сколько разновидностей документов × варианты — растёт бесконечно). Шаг 3 — фикс: LRU на 256 записей + метрика размера; повторный прогон — RSS плато на 420 МБ (рост прекратился); шаг 4 — проверка «второго дна»: ротация всё равно включена (max_tasks_per_child=500 — страховка от native-утечек парсера-библиотеки, которые Python-инструменты не увидят); шаг 5 — регрессия: тест-сценарий «500 документов — RSS +число объектов в кеше» в CI. Время разбора — полдня; без метрик и воспроизводимого сценария та же утечка ищется неделями «по ощущениям» — разница в подготовке, не в сложности инструмента.
FAQ
Сборщик мусора не справляется с циклами ссылок?
Справляется: gc находит циклы (refcount — нет, gc — да); исключения исторические — объекты с __del__ в циклах (до Python 3.4 не собирались; сейчас собираются, но финализаторы могут откладывать) и «логические утечки» (живая ссылка из глобальной структуры — для сборщика это не мусор); поэтому «gc.collect() не помог» — почти всегда про живые ссылки, не про циклы.
Почему память не возвращается ОС после пика?
CPython-аллокатор (pymalloc) и glibc-malloc держат освобождённые блоки в пулах (возврат — по порогам/фрагментации): RSS — «взято у ОС», не «занято данными»; смотреть нужно tracemalloc.get_traced_memory()/метрики объектов для «занято Python'ом» и RSS для системной картины; malloc_trim(0) через ctypes иногда возвращает — костыль, не лечение.
Утечка или фрагментация — как различить? Утечка: рост монотонный, снапшоты tracemalloc показывают накопление конкретных строк/типов, ротация «лечит» до следующего накопления; фрагментация: RSS выше занятого (tracemalloc), рост платообразный, зависит от паттерна аллокаций (мелкие/крупные вперемешку) — лечится выравниванием размеров/пулами, не поиском ссылок.
Стоит ли просто уменьшить max_requests и забыть про утечку? Как тактика — да (ротация короче времени накопления — инциденты прекращаются немедленно); как стратегия — нет: ротация маскирует рост (дорогие ресурсы/файлы утекают вне Python-кучи и могут переживать ротацию; «утечка» может быть симптомом неправильного lifecycle ресурсов — закрытие вопроса без диагноза оставляет мину).
Источники: документация tracemalloc/gc (Python), memray (Bloomberg) — ссылки в шапке.