Утечки памяти в долгоживущем Python-процессе: как найти
Воркер Celery за сутки разросся с 200 МБ до 3 ГБ, OOM-killer убил процесс на пике нагрузки. Разбираем, как найти, что течёт, чем мерить и где Python обманывает ожидания.

Типичная картина: фоновый воркер стартует с 200 МБ RSS, через двенадцать часов занимает 1,5 ГБ, к утру ловит OOM-killer и перезапускается по MemoryError. Перезапуск маскирует проблему, пока не совпадёт с пиком нагрузки — тогда падает всё разом. Ниже — как локализовать источник роста в процессе, который нельзя просто перезапустить и забыть, чем это мерить и почему gc.collect() не всегда помогает.
Проверяем на CPython 3.11+. Инструменты tracemalloc и gc входят в стандартную библиотеку, objgraph и pympler ставятся отдельно. Замеры делаем на Linux, где RSS виден через /proc и ps.
Сначала — что именно растёт
Прежде чем искать «утечку», убедитесь, что это утечка, а не законное поведение аллокатора. CPython не всегда возвращает освобождённую память ОС: пулы pymalloc для мелких объектов удерживаются, а фрагментация арен приводит к тому, что RSS остаётся высоким даже после того, как объекты собраны. Рост RSS сам по себе — ещё не диагноз.
Разделите два вопроса:
- Растёт ли число живых объектов Python. Это настоящая утечка на уровне кода: где-то копятся ссылки, и сборщик их не трогает.
- Растёт ли RSS при стабильном числе объектов. Это фрагментация или C-расширение, которое аллоцирует память в обход интерпретатора (NumPy, драйверы БД, парсеры).
Первый вопрос закрывает tracemalloc, второй виден по расхождению между суммой размеров Python-объектов и RSS процесса.
Замер номер один: динамика RSS во времени
Без графика роста вы гадаете. Снимайте RSS процесса раз в минуту и стройте кривую. Линейный рост — копятся ссылки. Ступеньки — растёт на конкретных задачах. Пила с медленным подъёмом основания — фрагментация плюс небольшая утечка.
# CPython 3.11, снимок RSS текущего процесса
import os
def rss_bytes() -> int:
# VmRSS в /proc/self/status — резидентная память в килобайтах
with open("/proc/self/status", encoding="ascii") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1]) * 1024
raise RuntimeError("VmRSS не найден")
print(rss_bytes()) # например, 214_958_080
В долгоживущем процессе повесьте этот замер на таймер или в конец обработки каждой задачи и пишите в лог с меткой времени. Пять минут наблюдения дадут больше, чем час чтения кода наугад.
Замер номер два: tracemalloc и снимки-дельты
tracemalloc отслеживает, из какой строки кода аллоцирована каждая область памяти. Сила инструмента — в сравнении двух снимков: он показывает, какие места выросли между точками, а не абсолютную картину, где шумит вообще всё.
# CPython 3.11
import tracemalloc
tracemalloc.start(25) # хранить до 25 кадров стека на аллокацию
snapshot_before = tracemalloc.take_snapshot()
# ... прогоняем несколько циклов работы воркера ...
for _ in range(1000):
handle_task()
snapshot_after = tracemalloc.take_snapshot()
# сравниваем по строкам кода, сортируем по приросту размера
stats = snapshot_after.compare_to(snapshot_before, "lineno")
for stat in stats[:10]:
print(stat)
Вывод покажет строки вида myapp/cache.py:42: size=48.2 MiB (+48.1 MiB), count=310000 (+309900). Растущий count — прямой указатель: объекты не освобождаются. Если size растёт, а count почти не меняется — объекты становятся тяжелее, ищите разрастающиеся списки или словари внутри долгоживущего объекта.
Абсолютный снимок памяти почти бесполезен: в нём тонет всё сразу. Дельта между двумя снимками после одинаковой работы — вот что показывает пальцем на утечку.
Аргумент 25 в start() — глубина сохраняемого стека. Чем больше, тем точнее видно, кто вызвал аллокацию, но тем выше накладные расходы. Для локализации ставьте побольше; в проде — минимум или выключено, tracemalloc заметно замедляет процесс и сам ест память.
Классика утечек в Python
CPython освобождает объект, когда счётчик ссылок падает до нуля. Сборщик циклов подбирает то, что ссылается само на себя. Утечка почти всегда — это лишняя ссылка, которая не даёт счётчику дойти до нуля.
- Глобальный кеш без ограничения. Словарь на уровне модуля, куда складывают результаты по ключу и никогда не чистят. Растёт линейно с числом уникальных ключей.
- Накопление в атрибуте синглтона. Список
self.history.append(...)в объекте, который живёт всё время работы процесса. - Замыкания и
functools.lru_cacheна методах. Кеш держит ссылку наself, экземпляр не собирается никогда. Классическая ловушка — декоратор@lru_cacheпрямо над методом класса. - Незакрытые ресурсы. Файлы, соединения, буферы, которые копятся, пока не упрётесь в лимит дескрипторов или память драйвера.
- Циклы ссылок с
__del__. До Python 3.4 объекты с финализатором в цикле не собирались вовсе; сейчас собираются, но задержка сборки может держать память дольше, чем ожидаете.
Когда tracemalloc молчит, а RSS растёт
Значит, память уходит в C-расширение — tracemalloc видит только аллокации через аллокатор CPython. Здесь помогает подсчёт живых объектов Python и сопоставление с RSS.
# CPython 3.11
import gc
from collections import Counter
def top_types(limit: int = 15) -> None:
gc.collect() # сначала убираем недостижимые циклы
counter: Counter[str] = Counter()
for obj in gc.get_objects():
counter[type(obj).__name__] += 1
for type_name, count in counter.most_common(limit):
print(f"{count:>10} {type_name}")
top_types()
Снимите top_types() дважды с интервалом работы. Если число объектов Python стабильно, а RSS вырос на сотни мегабайт — течёт C-код. Для NumPy смотрите суммарный .nbytes живых массивов, для драйверов БД — курсоры и незакрытые результирующие наборы. Инструмент уровня Python здесь бессилен, нужен профилировщик нативной памяти вроде того, что предлагает tracemalloc-совместимый Memray от Bloomberg.
Кто держит ссылку: objgraph
Вы нашли тип, который копится, но не понимаете, почему объекты не умирают. objgraph строит граф обратных ссылок — показывает, кто именно держит объект живым.
# objgraph, установка: pip install objgraph
import objgraph
# сколько объектов каждого типа прибавилось с прошлого вызова
objgraph.show_growth(limit=10)
# ... прогон работы ...
objgraph.show_growth(limit=10)
# берём один экземпляр подозрительного типа и смотрим,
# какая цепочка ссылок ведёт к нему от корней
leaking = objgraph.by_type("MyCachedRow")[0]
objgraph.show_backrefs([leaking], max_depth=5, filename="backrefs.png")
Картинка backrefs.png покажет цепочку: от глобального словаря или замыкания до вашего объекта. В этой цепочке и сидит лишняя ссылка. Часто это оказывается тот самый модульный кеш или lru_cache на методе.
Сравнение инструментов
| Инструмент | Что показывает | Видит C-память | Накладные расходы |
|---|---|---|---|
tracemalloc | Строку кода, где аллоцировано; дельту между снимками | Нет | Высокие, замедляет процесс |
gc.get_objects | Число живых объектов по типам | Нет | Низкие, но collect блокирует |
objgraph | Кто держит ссылку на объект | Нет | Средние, граф строится долго |
RSS через /proc | Реальную память процесса в ОС | Да, косвенно | Минимальные |
Где подход ломается
- Многопроцессные воркеры. В Celery/Gunicorn с несколькими форками замер в одном процессе не отражает картину остальных. Снимайте RSS по каждому PID отдельно и не путайте общую разделяемую память после fork с настоящим ростом.
- Ложная тревога от pymalloc. RSS не падает после освобождения — это норма для CPython, а не утечка. Смотрите на тренд числа объектов, а не на разовое значение RSS.
- tracemalloc в проде. Он сам держит таблицу трасс, которая растёт, и заметно тормозит. Не оставляйте его включённым постоянно — используйте для локализации на стенде или короткими окнами.
- Гейзенбаг наблюдателя.
gc.get_objects()и снимкиtracemallocсами создают объекты и удерживают ссылки на время анализа. На маленьких утечках шум инструмента сопоставим с сигналом. - Отложенная сборка циклов. Объект в цикле ссылок жив до следующего прохода сборщика поколений. Если кажется, что память «висит», вызовите
gc.collect()перед замером — иначе примете задержку сборки за течь.
Что делать дальше
Порядок работы: сначала график RSS во времени, чтобы понять характер роста; затем дельта tracemalloc, чтобы найти строку; затем objgraph, чтобы понять, кто держит ссылку. В проде оставьте лёгкий постоянный замер — RSS и число объектов по ключевым типам в метрики (Prometheus, statsd), с алертом на устойчивый линейный рост между рестартами.
Инвалидация и настройка размера кешей — отдельная тема; здесь мы только находили источник. Если утечка в C-расширении, следующий инструмент — профилировщик нативной памяти (Memray) и трекинг живых буферов конкретной библиотеки. Точные флаги и режимы работы сверяйте с документацией инструмента вашей версии.
Гид по Госуслугам и 3-НДФЛ: Пособия, маткапитал, вычеты за лечение, ИИС и квартиру.
Спросить за 15 ₽Источники: tracemalloc — Trace memory allocations (документация Python), gc — Garbage Collector interface (документация Python)
Частые вопросы
У меня не CPython 3.11, а 3.9 или PyPy — сработает?
Сделал gc.collect(), а память не освободилась. Значит, утечка?
tracemalloc ничего подозрительного не показывает, а RSS всё равно растёт. Что дальше?
Можно ли включить tracemalloc в продакшене на постоянно?
Замер показывает рост, но найти лишнюю ссылку не могу. Как локализовать?
У меня несколько воркеров через fork (Celery, Gunicorn). Замер врёт?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.