Есть класс проблем, который проходит мимо всей остальной наблюдаемости. Тесты зелёные, логи чистые, ошибок нет — а воркер через три дня после выката перезапускается сам по лимиту памяти. Или отвечает всё медленнее, хотя запросов столько же.
Метрики этот случай видят, но объясняют плохо. График памяти показывает, что она растёт; график открытых дескрипторов — что их стало десять тысяч. На вопрос кто именно держит память и где именно тратится процессор метрика не отвечает по своей природе: она агрегат, в ней нет ни объектов, ни строк кода.
В Python на него отвечают два инструмента, и оба не требуют останавливать сервис: tracemalloc из стандартной библиотеки показывает, где выделена живая память, а py-spy снимает стек живого процесса снаружи, не трогая его код. Про них и статья.
Как отличить утечку от нормальной работы
Прежде чем что-то снимать, полезно убедиться, что проблема вообще есть. Растущий график памяти сам по себе ни о чём не говорит: интерпретатор берёт память у операционной системы охотно и возвращает неохотно, так что «занято много» — обычное состояние здорового воркера.
У Python нет метрики «живая куча после сборки», как у рантаймов с управляемой кучей: счётчик ссылок освобождает большинство объектов сразу, а сборщик мусора разбирает только циклы. Поэтому смотреть приходится на process_resident_memory_bytes под ровной нагрузкой. Здоровый сервис выходит на полку: первые минуты память растёт, пока прогреваются кэши и пулы, потом график становится горизонтальным. Утечка выглядит как полка, у которой нет конца: при той же нагрузке память прибавляет понемногу каждый час и через сутки упирается в лимит контейнера.
Два графика, которых нет у других рантаймов, стоит смотреть первыми. Открытые дескрипторы — process_open_fds: каждый незакрытый клиент, сессия или файл это сокет, и рост дескрипторов значит, что утекают не объекты, а соединения. И число задач цикла событий — метрики из коробки нет, её заводят сами:
from prometheus_client import Gauge
asyncio_tasks = Gauge("asyncio_tasks", "Задачи цикла событий")
asyncio_tasks.set_function(lambda: len(asyncio.all_tasks()))
Самая частая утечка в асинхронном сервисе — не объекты, а задачи, застрявшие на await: каждая держит свой стек, замыкания и всё, на что ссылается. Если asyncio_tasks растёт линейно со временем работы, дальше можно не гадать.
Посмотреть, что делает сборщик, можно, ничего не устанавливая:
import gc
gc.set_debug(gc.DEBUG_STATS)
Каждая сборка печатает несколько строк: gc: collecting generation 2..., gc: objects in each generation: 5386 3840 0 и gc: done, 133 unreachable, 0 uncollectable, 0.0010s elapsed. Число объектов в старшем поколении и есть долгоживущие данные; если оно растёт от сборки к сборке при ровной нагрузке, утечка в циклических структурах есть. Разбор остальных строк — в разделе «Глубже».
Первый взгляд: кто занимает память
tracemalloc запоминает место выделения каждого блока, но только с момента включения. Поэтому его включают при старте процесса — переменной среды, чтобы не трогать код:
PYTHONTRACEMALLOC=25 uvicorn app.main:app
Число — глубина сохраняемого стека. Цена заметная: каждое выделение дописывает запись о себе, и память процесса вырастает, а процессор замедляется на десятки процентов. Поэтому tracemalloc держат включённым не везде и не всегда, а на одном экземпляре под подозрением; включить его на живом процессе можно и вызовом tracemalloc.start(25) из служебного обработчика, но учитываться будет только то, что выделено после.
Дальше нужен служебный обработчик на management-порту — не на бизнес-портах, об этом статья про настройку наблюдаемости:
import tracemalloc
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
mgmt = FastAPI()
baseline: tracemalloc.Snapshot | None = None
@mgmt.post("/debug/tracemalloc/baseline")
async def take_baseline() -> dict:
global baseline
baseline = tracemalloc.take_snapshot()
return {"traced_kib": tracemalloc.get_traced_memory()[0] // 1024}
@mgmt.get("/debug/tracemalloc/top", response_class=PlainTextResponse)
async def top(limit: int = 15) -> str:
snapshot = tracemalloc.take_snapshot()
stats = snapshot.compare_to(baseline, "lineno") if baseline else snapshot.statistics("lineno")
return "\n".join(str(stat) for stat in stats[:limit])
Вывод — список строк кода, отсортированный по памяти, которую выделили они и которая жива сейчас:
app/pricing/cache.py:41: size=1812 MiB (+1790 MiB), count=9812331 (+9701004), average=194 B
sqlalchemy/engine/result.py:512: size=118 MiB (+2 MiB), count=402117 (+6800), average=307 B
pydantic/main.py:214: size=96 MiB (+1 MiB), count=1203400 (+12000), average=84 B
Читается это так: почти два гигабайта живой памяти выделены на сорок первой строке кэша цен, и с момента базового снимка выросли именно они — не «Python прожорливый», а конкретное место в коде, которое что-то держит.
Про цену снимка. take_snapshot не останавливает обработку надолго: он копирует таблицу учтённых блоков, это доли секунды даже на гигабайтах. Поэтому снимок снимают с продового экземпляра без выведения из-под нагрузки.
У сравнения три разреза, и первый выбор решает, что вы увидите. lineno — по строке выделения: разрез для утечек. traceback — по полному стеку до строки: когда одна и та же строка (dict.__setitem__ внутри библиотеки) вызывается из десяти мест, и важно, из какого. filename — по файлу: грубый первый взгляд, когда строк тысячи.
Снимок: как читать, чего в нём нет
Снимок сохраняют и разбирают на ноутбуке:
snapshot = tracemalloc.take_snapshot()
snapshot.dump("/tmp/heap-1.tracemalloc")
import tracemalloc
old = tracemalloc.Snapshot.load("heap-1.tracemalloc")
new = tracemalloc.Snapshot.load("heap-2.tracemalloc")
new = new.filter_traces((tracemalloc.Filter(False, "<frozen importlib._bootstrap>"), tracemalloc.Filter(False, tracemalloc.__file__)))
for stat in new.compare_to(old, "traceback")[:5]:
print(stat)
for line in stat.traceback.format():
print(" ", line)
Смотреть стоит в таком порядке.
Top по lineno — где выделено больше всего живой памяти. Это ответ на «что лежит».
Стек по traceback — через какие вызовы туда пришли: строка в кэше вызывается из обработчика цены, а тот — из каждого запроса каталога. Это ответ на «откуда растёт».
Сравнение двух снимков. Самый надёжный приём для медленной утечки: снять снимок, подождать час, снять второй и показать разницу через compare_to. Всё, что выросло, видно сразу, а постоянная память сервиса не мешает. Фильтры убирают шум импортов и самого tracemalloc.
И честное ограничение, которое надо знать до того, как поверить в «снимок всё покажет». tracemalloc помнит, где объект был выделен, но не кто его сейчас держит. Цепочек ссылок от корня до объекта в нём нет. На практике это редко мешает: место выделения плюс чтение кода почти всегда называет держателя. Когда этого не хватает, есть objgraph: objgraph.show_growth() печатает, каких типов стало больше с прошлого вызова (OrderRow 5000 +5000), objgraph.by_type("OrderRow") отдаёт сами объекты, а objgraph.find_backref_chain(obj, objgraph.is_proper_module) строит цепочку от объекта до модуля, который его держит, — например module → dict → list → OrderRow, то есть список в глобальной переменной модуля. Это и есть недостающий «путь до корня», но он дорог: обход всех объектов занимает секунды и делается на отдельном экземпляре.
Задачи цикла событий: самая частая утечка
В асинхронном сервисе держателем чаще всего оказывается не коллекция, а задача, которая никогда не завершится. Список живых задач с местом, где каждая стоит, снимают тем же служебным обработчиком:
@mgmt.get("/debug/tasks", response_class=PlainTextResponse)
async def tasks() -> str:
groups: dict[str, int] = {}
for task in asyncio.all_tasks():
frames = task.get_stack(limit=1)
where = f"{frames[0].f_code.co_filename}:{frames[0].f_lineno}" if frames else "done"
key = f"{task.get_coro().__qualname__} @ {where}"
groups[key] = groups.get(key, 0) + 1
return "\n".join(f"{n:6d} {key}" for key, n in sorted(groups.items(), key=lambda kv: -kv[1]))
Группировка по корутине и строке делает то же, что профиль потоков в других рантаймах: одна строка с числом 9 812 напротив fetch_price @ app/pricing/client.py:58 — и держатель найден. Полный стек каждой задачи даёт task.print_stack().
Три источника закрывают почти все случаи:
async def fetch_all(ids: list[str]) -> list[Item]:
queue: asyncio.Queue[Item] = asyncio.Queue()
for item_id in ids:
asyncio.create_task(load_into(queue, item_id))
items = []
try:
async with asyncio.timeout(2):
for _ in ids:
items.append(await queue.get())
except TimeoutError:
return items
return items
Когда время вышло на половине, функция возвращается, а оставшиеся задачи продолжают ждать ответа от соседа — без таймаута, без ссылки, без способа их остановить; каждая держит соединение и буферы. Лечится TaskGroup: выход из блока отменяет всех, кто не успел. Вторая классика — подписчик на очередь или событие, которое никто больше не поставит: обработчик websocket ушёл, а задача, читающая await event.wait(), осталась. Третья — фоновая задача «на всякий случай» на каждый запрос, которая ждёт медленного партнёра без таймаута.
Если процесс не отвечает совсем и до служебного порта не достучаться, стек снимают снаружи:
py-spy dump --pid 4242
py-spy читает память чужого процесса и печатает стеки всех потоков, включая цикл событий: что выполняется прямо сейчас, в какой строке. В контейнере для этого нужна возможность SYS_PTRACE — её дают командой kubectl debug с нужным профилем или флагом --cap-add при запуске; это единственное, что надо подготовить заранее.
Пять утечек, которые встречаются чаще остальных
Полезно знать типовые случаи: в девяти расследованиях из десяти находится один из них.
Кэш без ограничения. Обычный словарь, в который складывают результаты «чтобы быстрее». Пока ключей мало, всё хорошо; когда ключом становится идентификатор пользователя, словарь растёт вечно.
class PriceCache:
def __init__(self) -> None:
self.items: dict[str, Price] = {}
def get(self, sku: str, customer: Customer, calc: Callable[[], Price]) -> Price:
key = f"{sku}:{customer.id}"
if key not in self.items:
self.items[key] = calc()
return self.items[key]
Ревью этот код проходит легко: вычисление по требованию, ничего лишнего. Утечка спрятана в ключе: sku — конечное множество, а customer.id — нет. Та же беда у @functools.cache на методе: он держит и self, и все аргументы, которые когда-либо видел, и у lru_cache без maxsize. Лечится не «не кэшировать», а явным ограничением: lru_cache(maxsize=10_000), cachetools.TTLCache(maxsize=..., ttl=...) или внешний кэш. Правило: любая коллекция, живущая дольше запроса, обязана иметь ограничение.
Задачи. Разобраны выше; это первое, что проверяют.
Незакрытые клиенты и сессии. httpx.AsyncClient(), созданный внутри обработчика и не закрытый, оставляет пул соединений и сокеты; то же с сессией базы без async with. Растут process_open_fds и память за пределами объектов Python, пока не придёт OSError: Too many open files. Клиент к партнёру создают один раз на процесс в lifespan и закрывают там же.
Обработчики и подписчики, добавленные на каждый запрос. logger.addHandler(...) внутри функции, signal.connect при создании объекта, atexit.register, weakref.finalize с замыканием на большой объект: со временем в памяти живут десятки поколений одного и того же. У всех этих вызовов место — старт процесса, не обработчик.
Исключения, сохранённые в списке. errors.append(exc) ради отчёта в конце пакетной обработки держит у каждого исключения трассировку, у трассировки — кадры, у кадров — все локальные переменные, включая тело запроса, которое и привело к ошибке. Тысяча ошибок — тысяча тел. Сохраняют str(exc) или вызывают traceback.clear_frames(exc.__traceback__) перед тем, как положить исключение в список.
Две утечки, которых не видно в tracemalloc
tracemalloc ровный, снимки не растут, а процесс всё равно растёт и в итоге погибает по лимиту контейнера. Значит, память утекает не через аллокатор Python, и tracemalloc её не покажет по определению.
Память за пределами объектов Python. Всё, что выделила библиотека на C мимо аллокатора интерпретатора — буферы драйвера базы, массивы numpy, изображения в Pillow, криптография, — учтено не будет. Видно это по разнице process_resident_memory_bytes и tracemalloc.get_traced_memory(): первое растёт, второе нет. Для таких случаев есть memray: memray run --native -o out.bin -m uvicorn app.main:app записывает и выделения на C со стеками, memray flamegraph out.bin рисует их, а memray attach <pid> подключается к уже работающему процессу на Linux. Накладные расходы у него выше, чем у tracemalloc, поэтому это инструмент стенда и воспроизведения, а не постоянный спутник прода.
Память, которую аллокатор не возвращает. Объекты освобождены, но страницы остались у процесса: аллокатор Python держит арены по мегабайту и отдаёт арену только пустой целиком, а системный malloc под ним заводит отдельную арену на каждый поток и фрагментирует её. Это не утечка, а задержка, и отличают их по графику: утечка растёт монотонно, задержка выходит на полку. Если полка выше лимита, помогают MALLOC_ARENA_MAX=2 в окружении контейнера и jemalloc через LD_PRELOAD; а ещё — не держать в одном процессе и пул потоков на сорок потоков, и большие временные буферы.
Общее правило: если tracemalloc спокоен, а процесс растёт, сравнивают три числа — учтённую память tracemalloc, process_resident_memory_bytes и process_open_fds — и разница между ними называет категорию до того, как снят хоть один снимок.
Профилирование процессора: где тратится время
Вторая половина темы — не память, а скорость. Метрика говорит, что обработчик стал медленнее, но не говорит, на чём именно.
py-spy — выборочный профилировщик: сто раз в секунду он читает стеки потоков чужого процесса и складывает статистику. Процесс не меняется и не перезапускается, накладные расходы — единицы процентов, поэтому его запускают на живом сервисе:
py-spy record --pid 4242 --duration 30 --idle --output profile.svg
Тридцать секунд сервис работает как обычно, потом открывается диаграмма-«пламя». Широкая полоса — функция, в которой проведено много времени. Четыре вещи, которые в ней ищут: самые широкие полосы в бизнес-коде; долю сериализации и проверки моделей — json.dumps, pydantic на тысячах объектов; select и epoll в цикле событий — время в ожидании ввода-вывода, которое не ускорить кодом, и ради него нужен флаг --idle, иначе спящие потоки выпадут из картины; и то, что держит GIL, — флаг --gil оставляет только кадры, которые его занимают, и показывает, почему четыре потока пула не дали ускорения.
Для цикла событий есть свой профиль, по умолчанию выключенный, потому что стоит дороже: PYTHONASYNCIODEBUG=1 или loop.set_debug(True) с порогом loop.slow_callback_duration. Каждая синхронная операция дольше порога попадает в журнал строкой Executing <Task ... coro=<handle_order() ...>> took 0.081 seconds — это тот самый time.sleep, запрос через синхронный драйвер или json.loads на десяти мегабайтах, который останавливает все остальные запросы. Включают его на время расследования, не навсегда.
Когда нужно понять не «где», а «почему медленно именно сейчас» — пауза сборщика, задача, которая не получает процессор, — снимают py-spy dump несколько раз подряд с интервалом в секунду: по пяти стекам видно, стоит ли процесс в одном месте. На стенде удобнее pyinstrument: pyinstrument -r html -m uvicorn app.main:app пишет дерево вызовов с временем на каждом уровне и понимает await.
И непрерывное профилирование: Pyroscope со своим агентом для Python собирает профили со всех экземпляров постоянно и хранит историю. Тогда вопрос «почему вчера в три ночи было медленно» отвечается профилем за три ночи, а не повторением проблемы.
Как поймать утечку до продакшена
Всё выше — про расследование на живом сервисе. Половину таких историй можно закрыть раньше, и это стоит дешевле.
Тест на утечку задач. После теста не должно остаться задач, которых не было до него:
import asyncio
import pytest
@pytest.fixture(autouse=True)
async def no_leaked_tasks():
before = asyncio.all_tasks()
yield
await asyncio.sleep(0)
leaked = asyncio.all_tasks() - before - {asyncio.current_task()}
assert not leaked, [t.get_coro().__qualname__ for t in leaked]
Тест с примером fetch_all из раздела про задачи падает: три задачи стоят на load_into. Это ровно тот дефект, который потом стоит ночи, и ловится он за миллисекунды.
Тест на наблюдаемое свойство. Измерять память в тесте ненадёжно; проверяют размер коллекции, число открытых дескрипторов, число подписчиков после тысячи операций с разными ключами:
def test_cache_bounded_per_customer(cache):
for i in range(1000):
cache.get("SKU-1", Customer(id=f"customer-{i}"), calc)
assert len(cache) <= 100
Дескрипторы считают через psutil.Process().num_fds() до и после сотни запросов: разница больше нуля — где-то клиент без async with.
Выделения под нагрузкой. Во время нагрузочного прогона включают tracemalloc на одном экземпляре и сравнивают снимки в начале и в конце: там обычно находится копия тела запроса в журнале, deepcopy настроек на каждый вызов или список, который собирают целиком вместо генератора.
Долгий прогон. Утечка по определению видна только со временем. Перед крупным выпуском гоняют умеренную нагрузку часами и смотрят три графика: process_resident_memory_bytes, asyncio_tasks и process_open_fds. Ползут за четыре часа — будут ползти и в проде.
Что поставить заранее. Служебные обработчики tracemalloc и задач на management-порту на всех сервисах, графики памяти, задач и дескрипторов на дашборде, лимит памяти в манифесте с запасом на арены и SYS_PTRACE для py-spy в профиле отладки. Ни одна из этих настроек не требует работы после установки, а вместе они превращают «воркер умер ночью» в «есть снимок за три ночи, разберём утром».
Порядок действий, когда «сервис ест память»
Чтобы не метаться, полезно держать в голове последовательность.
Сначала форма графика отличает утечку от нормального роста, потом два инструмента показывают виновника, и только после правки повторный замер подтверждает.
Сначала три графика: asyncio_tasks, process_open_fds и process_resident_memory_bytes. Растут задачи — список задач по корутинам, он назовёт строку за минуту. Растут дескрипторы — клиенты и сессии без закрытия, список открытых файлов процесса (ls -l /proc/<pid>/fd) покажет, сокеты это или файлы. Растёт память при ровных задачах и дескрипторах — tracemalloc, лучше два снимка с интервалом в час и compare_to. tracemalloc ровный, а процесс растёт — память за пределами аллокатора Python или ещё не возвращённые арены: memray --native на стенде, MALLOC_ARENA_MAX в проде.
Когда виновник найден, проверьте его по списку типовых утечек: почти всегда это задача на await без таймаута, кэш с идентификатором в ключе, клиент без закрытия или исключение с трассировкой в списке. И добавьте проверку в тест того модуля, где нашли, — чтобы в следующий раз утечку поймала сборка.
Глубже: сборщик мусора, поколения и два разных концарасширенное
Утечка — медленный рост. Есть вторая история про память, которая выглядит как периодические замедления при здоровом графике, и её читают по следу сборщика.
Строки DEBUG_STATS. gc: collecting generation 2... — какое поколение собирается: нулевое дёшево и часто, второе обходит все отслеживаемые объекты процесса. gc: objects in each generation: 5386 3840 0 — сколько объектов ждёт в каждом. gc: done, 133 unreachable, 0 uncollectable, 0.0010s elapsed — сколько циклов нашли и сколько это заняло. Смотрят на частоту сборок второго поколения и на их длительность: при миллионе долгоживущих объектов полный обход занимает сотни миллисекунд, и на это время под GIL стоят все запросы процесса. То же число снимают метрикой: рост python_gc_collections_total{generation="2"} рядом с p99 показывает, совпадают ли пики.
Регуляторы. Пороги gc.get_threshold() — сколько новых объектов накопить до сборки нулевого поколения и сколько сборок младшего до сборки старшего; в Python 3.14 это (2000, 10, 0). gc.freeze() после прогрева переносит всё, что живо на старте — модули, настройки, справочники, — в постоянное поколение, и сборщик больше их не обходит: полный проход дешевеет в разы. В процессах, которые форкают воркеров с предзагрузкой, freeze вызывают до форка ещё и ради копирования при записи: иначе сборщик, трогая заголовки объектов, делает общие страницы приватными в каждом воркере.
Два разных конца. MemoryError в журнале означает, что аллокатору отказали в памяти, — это видно, и на Linux с избыточным выделением такое редкость. OOMKilled с кодом 137 означает, что весь процесс превысил лимит контейнера и убит ядром снаружи: в журнале ничего, снимка нет. Превысил не один объект, а процесс целиком: объекты, арены, память библиотек на C, и всё это помножено на число воркеров uvicorn --workers, у каждого из которых своя копия. Флаг --limit-max-requests, перезапускающий воркер после N запросов, скрывает утечку, а не лечит её. Подробный расчёт лимитов — в статье про рантайм Python в контейнере.
Порядок разбора периодических замедлений: p99 рядом с частотой сборок второго поколения; совпали — сборщик, дальше gc.freeze() и число долгоживущих объектов; не совпали — не память, дальше py-spy и журнал медленных обратных вызовов в момент замедления.
Коротко
- Метрики показывают, что память растёт; кто её держит — отвечают
tracemallocи список задач. Утечка видна не по высокому потреблению, а по тому, что память, задачи цикла событий или дескрипторы ползут вверх при ровной нагрузке. tracemallocвключают переменнойPYTHONTRACEMALLOCна одном экземпляре, снимки снимают служебным обработчиком без остановки;linenoдля утечек,tracebackдля пути,compare_toдля разницы двух снимков.tracemallocпомнит место выделения, а не держателя; цепочку до корня строитobjgraph.find_backref_chainна отдельном экземпляре.- Самая частая утечка асинхронного сервиса — задачи: ожидание соседа без таймаута после возврата из функции, подписчик на событие, которое никто не поставит, фоновая задача на каждый запрос; список по корутинам или
py-spy dumpснаружи. - Типовые утечки памяти: кэш с идентификатором в ключе и
cacheбезmaxsize, клиентhttpxбез закрытия, обработчик на каждый запрос, исключение с трассировкой в списке; любая коллекция дольше запроса обязана иметь ограничение. tracemallocспокоен, а процесс растёт: память библиотек на C или невозвращённые арены;memray --nativeна стенде,MALLOC_ARENA_MAXв проде.- Процессор профилируют
py-spy recordна живом процессе с--idleи--gil; блокировки цикла событий — журналом медленных обратных вызовов на время расследования; «почему именно сейчас» — сериейpy-spy dump; историю — непрерывным профилированием. - До прода ловят фикстурой на оставшиеся задачи, проверкой размера коллекции и числа дескрипторов,
tracemallocпод нагрузкой, многочасовым прогоном с тремя графиками. - Сборщик читают по частоте и длительности сборок второго поколения;
gc.freeze()после прогрева и до форка;MemoryError— отказ аллокатору,OOMKilled— весь процесс сверх лимита без следа в журнале.
Что почитать дальше
- Метрики на Python — что снимать постоянно, чтобы заметить проблему до падения.
- Конфигурация observability в Python — почему служебные обработчики живут на management-порту.
- От алерта до строки лога на Python — как связать метрику, трассировку и журнал в одном расследовании.
- Рантайм Python в контейнере — воркеры, лимиты памяти и
MALLOC_ARENA_MAX. - Async и конкурентность в FastAPI —
TaskGroup, таймауты и завершение фоновых задач.