Привет, Хабр! Сервис на FastAPI живёт четвёртые сутки, RSS вырос с 400 МБ до 3,2 ГБ, контейнер вот-вот получит OOMKilled. Внутри стоит tracemalloc, и он рисует ровную линию: питоновские объекты занимают те же 180 МБ, что и в первый час. gc.collect() из отладочного endpoint возвращает пару сотен собранных объектов и не меняет ничего.
Ситуация выглядит противоречиво ровно до тех пор, пока не вспомнишь, что RSS процесса и «память, занятая объектами Python» — величины из разных слоёв, и между ними стоят ещё два. Ниже разберём, как эти слои разделить, каким инструментом смотреть на каждый и что делать с результатом.
Три слоя между объектом и страницей памяти
Питоновский объект проходит длинный путь, прежде чем превратиться в страницу физической памяти.
Сверху лежат сами объекты — списки, словари, экземпляры классов, буферы. За ними следит сборщик мусора и их же видит tracemalloc.
Ниже работает pymalloc, собственный аллокатор CPython. Он берёт у системы куски по 256 КБ и нарезает их под мелкие объекты сам, потому что дёргать системный malloc на каждый десятибайтовый кортеж слишком дорого. Всё, что больше 512 байт, pymalloc пропускает мимо себя прямо в системный аллокатор.
Ещё ниже стоит аллокатор системы — обычно glibc, который держит собственные арены и решает, возвращать ли освобождённые страницы ядру.
RSS отражает только самый нижний уровень. Объекты могли давно умереть, pymalloc мог освободить блоки, а страницы всё ещё числятся за процессом, потому что арена не пуста целиком или потому что glibc придержал их у себя. Отсюда и картина, с которой мы начали.
Прежде чем что-то искать, полезно измерить оба конца и посмотреть на расхождение:
import os, tracemalloc, gc def memory_report(): tracemalloc.start(25) current, peak = tracemalloc.get_traced_memory() with open(f"/proc/{os.getpid()}/status") as f: rss = next(l for l in f if l.startswith("VmRSS")).split()[1] return { "python_objects_mb": round(current / 1024 / 1024, 1), "rss_mb": round(int(rss) / 1024, 1), "gc_objects": len(gc.get_objects()), "gc_counts": gc.get_count(), }
{'python_objects_mb': 181.4, 'rss_mb': 3204.7, 'gc_objects': 412883, 'gc_counts': (401, 8, 2)}
Разрыв в 3 ГБ между двумя числами — это и есть ответ на вопрос, где искать: не в питоновском коде, а слоем или двумя ниже.
Питоновский слой исключаем первым
Даже когда tracemalloc выглядит ровным, потратить пять минут на его нормальный прогон стоит: график в дашборде часто считает не то, что нужно, а сравнение двух снимков считает именно то.
import tracemalloc tracemalloc.start(25) snapshot_before = tracemalloc.take_snapshot() # ... прогоняем нагрузку: тысяча запросов, час работы, что угодно snapshot_after = tracemalloc.take_snapshot() top = snapshot_after.compare_to(snapshot_before, "lineno") for stat in top[:10]: print(stat)
app/cache.py:47: size=612 MiB (+612 MiB), count=1840221 (+1840221), average=349 B app/serializers.py:88: size=14.2 MiB (+1.1 MiB), count=38412 (+2914), average=388 B
Столбец с плюсом важнее абсолютного размера: сервис под нагрузкой всегда держит какие-то временные буферы, и большие числа сами по себе ни о чём не говорят. Растущее между снимками — говорит.
Аргумент 25 в start задаёт глубину сохраняемого стека. Единица дешевле, но покажет только строку аллокации без контекста, а в сервисе одна и та же строка вызывается из десятка мест.
Двадцать пять даёт полную картину ценой заметных накладных расходов, на глубине 1 они держатся в пределах нескольких процентов, на 25 доходят до трети по памяти, так что в проде так не живут, а включают на время расследования.
Когда сравнение снимков показывает рост, дальше нужен не размер, а держатель ссылки:
import objgraph objgraph.show_growth(limit=10) # ... нагрузка ... objgraph.show_growth(limit=10)
dict 412883 +180422 Response 94012 +94012 CachedItem 91883 +91883
objgraph.show_backrefs( objgraph.by_type("CachedItem")[0], max_depth=5, filename="backrefs.png", )
Картинка со ссылками почти всегда приводит к чему-то очень скучному: глобальному словарю-кешу без ограничения размера, декоратору lru_cache без maxsize, списку в атрибуте класса, обработчику событий, который никто не отписал.
Отдельно проверьте циклы, которые сборщик не может разобрать:
gc.set_debug(gc.DEBUG_SAVEALL) gc.collect() print(len(gc.garbage))
Непустой gc.garbage означает объекты, участвующие в цикле, который сборщик разорвать не берётся. И помните, что gc.collect() вообще занимается только циклами: объект, на который есть живая нецикличная ссылка, не соберётся никогда, сколько его ни зови.
Объекты не растут, а RSS растёт
Убедившись, что объекты не копятся, спускаемся ниже. Здесь tracemalloc бесполезен по конструкции — он видит только то, что аллоцировал сам CPython, и слеп к памяти, которую запросила библиотека на C.
Работающий инструмент на этом уровне — memray, потому что он умеет отслеживать и питоновские, и нативные аллокации.
pip install memray memray run --native -o profile.bin -m app.main # под нагрузкой, затем: memray flamegraph profile.bin -o flamegraph.html
Флаг --native здесь ключевой: без него вы получите примерно то же, что и от tracemalloc. С ним в графе появляются кадры из C-библиотек, и становится видно, что память забрал не ваш код, а, скажем, парсер внутри драйвера базы.
Подключиться к уже работающему процессу тоже можно, но с оглядкой — операция небезопасная и известна тем, что процесс иногда падает:
memray attach $(pgrep -f app.main) -o live.bin
Вообще, все это делают либо на канареечном экземпляре, либо на реплике под воспроизведённой нагрузкой.
Есть и более лёгкий способ подтвердить, что течёт именно нативный слой, без профилировщика. В psutil начиная с версии 7.2.0 появились функции для заглядывания в кучу системного аллокатора — они показывают, сколько памяти реально запросил слой C, и умеют просить аллокатор отдать лишнее перед замером, чтобы убрать шум от его внутреннего кеширования.
Схема проверки простая: снимаете показания кучи, прогоняете подозрительную операцию несколько сотен раз, снимаете снова. Растущий объём кучи при ровном tracemalloc означает нативные аллокации, и дальше искать надо среди библиотек на C, а не в своём коде.
Тот же вывод даёт грубое сравнение двух чисел без всяких библиотек:
import ctypes, os, tracemalloc def native_gap(): with open(f"/proc/{os.getpid()}/statm") as f: rss = int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE") traced = tracemalloc.get_traced_memory()[0] return (rss - traced) / 1024 / 1024 # МБ вне питоновского слоя
Растущий зазор при стабильном traced — это либо нативные аллокации, либо удержание страниц, и различаются они одной командой, о которой ниже.
Соберите себе текущую программу и потренируйтесь на ней
Отлаживать боевой сервис вслепую так себе затея, поэтому полезно один раз собрать заведомо текущую программу и прогнать по ней весь инструментарий — тогда в проде вы будете знать, как выглядит каждый из случаев.
import asyncio, time CACHE = {} # утечка первого типа: неограниченный кеш HANDLERS = [] # утечка второго типа: забытые подписчики class Session: def __init__(self, uid): self.uid = uid self.payload = bytes(50_000) # 50 КБ на объект self.on_close = lambda: print(self.uid) # замыкание держит self async def handle(uid: int): s = Session(uid) CACHE[uid] = s # положили и не убрали HANDLERS.append(s.on_close) # подписали и не отписали await asyncio.sleep(0) async def main(): uid = 0 while True: await asyncio.gather(*(handle(uid + i) for i in range(200))) uid += 200 print(f"{uid} сессий", flush=True) await asyncio.sleep(0.1) asyncio.run(main())
Программа растёт примерно на 10 МБ в секунду и даёт классическую картину: tracemalloc покажет рост на строке с CACHE[uid] = s, objgraph найдёт словарь как держателя ссылок, а malloc_trim ничего не вернёт, потому что память действительно занята живыми объектами.
Чтобы получить противоположный случай — ровный tracemalloc при растущем RSS — уберите строку с кешем и оставьте создание и уничтожение больших временных буферов:
async def handle(uid: int): buf = bytes(300_000) # больше 512 байт, значит мимо pymalloc await asyncio.sleep(0) del buf
Объекты умирают, питоновский слой ровный, а RSS в первые минуты заметно вырастет и остановится на плато — вот так выглядит удержание страниц аллокатором, и именно на этом удобно проверить, что даёт MALLOC_ARENA_MAX и переход на jemalloc.
Память не потеряна, а придержана
Часть историй заканчивается тем, что утечки не находится вовсе, а RSS всё равно растёт. Тогда виновата не потерянная память, а невозвращённая.
Арена pymalloc на 256 КБ возвращается операционной системе только когда полностью опустеет. Один живой объект в арене держит все 256 КБ. Сервис, который создал миллион мелких объектов, потом почти все освободил, а несколько тысяч оставил в кеше, легко удержит гигабайты — объекты занимают десятки мегабайт, арены под ними не отпускаются.
Похожим образом ведёт себя glibc. Он заводит отдельные арены под потоки, и на многопоточном сервисе их количество растёт вместе с числом потоков; освобождённые блоки внутри арены переиспользуются, но страницы ядру не отдаются.
Проверить эту гипотезу можно прямым способом — попросить аллокатор вернуть, что сможет:
import ctypes libc = ctypes.CDLL("libc.so.6") libc.malloc_trim(0)
Заметное падение RSS сразу после вызова означает, что память была не потеряна, а придержана. Если RSS не изменился — вы всё-таки имеете дело с чем-то живым.
Регулировать поведение glibc можно переменными окружения, и первое, что стоит попробовать на многопоточном сервисе, — ограничить количество арен:
MALLOC_ARENA_MAX=2 python -m app.main
Радикальнее сработает смена аллокатора целиком. jemalloc возвращает страницы по таймеру и заметно лучше держит RSS на нагрузках с большим количеством мелких недолгих объектов:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 \ MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \ python -m app.main
Иногда к этому добавляют отключение pymalloc, чтобы над системным аллокатором не стоял второй со своими аренами:
PYTHONMALLOC=malloc LD_PRELOAD=/usr/lib/.../libjemalloc.so.2 python -m app.main
Вариант рабочий, но небесплатный: pymalloc существует именно потому, что он быстрее системного на мелких объектах, и отключение стоит проверять замером задержки, а не включать по совету из статьи.
В контейнере считают не то, на что вы смотрите
Отдельная порция путаницы возникает из-за того, что в контейнере смотреть надо не на RSS процесса, а на счётчик группы.
Лимит применяется к контрольной группе целиком, и в неё входит не только память процесса, но и страничный кеш от прочитанных файлов, и память ядра под сокеты и потоки. Сервис, который активно читает файлы, набирает страничный кеш, и группа упирается в лимит при вполне скромном RSS.
kubectl exec -it api-7d9f -- sh -c 'cat /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.max'
2843312128 3221225472
Разложить это число на составляющие помогает статистика группы:
kubectl exec -it api-7d9f -- grep -E '^(anon|file|slab|sock) ' /sys/fs/cgroup/memory.stat
anon 1904214016 file 812564480 slab 98549760 sock 27983872
Строка anon — это то, что действительно занял ваш процесс, file — страничный кеш, который ядро в принципе умеет вытеснить, но не всегда успевает. Если ваши 3 ГБ на две трети состоят из file, искать утечку в коде бессмысленно: помогут либо больший лимит, либо чтение файлов без оседания в кеше.
Разница между anon из этой статистики и python_objects_mb из первого замера — тот самый зазор, ради которого мы всё это и раскладывали по слоям.
Метрики, которые заводят до аварии, а не во время
Расследование идёт быстрее, когда график роста уже есть за месяц назад, а не начинается с момента, когда вы полезли разбираться.
from prometheus_client import Gauge import gc, os, tracemalloc, threading, time RSS = Gauge("proc_rss_bytes", "RSS процесса") PY_HEAP = Gauge("python_traced_bytes", "память под объектами Python") GC_OBJ = Gauge("python_gc_objects", "число отслеживаемых объектов") GC_COLLECTED = Gauge("python_gc_collected_total", "собрано за поколение", ["gen"]) def collect_memory_metrics(interval=15): tracemalloc.start(1) # глубина 1 — накладные расходы под 5% while True: with open(f"/proc/{os.getpid()}/statm") as f: RSS.set(int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")) PY_HEAP.set(tracemalloc.get_traced_memory()[0]) GC_OBJ.set(len(gc.get_objects())) for gen, stat in enumerate(gc.get_stats()): GC_COLLECTED.labels(gen=gen).set(stat["collected"]) time.sleep(interval) threading.Thread(target=collect_memory_metrics, daemon=True).start()
Глубина трассировки 1 выбрана неслучайно: она держит накладные расходы в пределах нескольких процентов и годится для постоянной работы, тогда как 25 из раздела про расследование включают на время и на одном экземпляре.
Самая полезная панель на дашборде — не абсолютные значения, а разница между RSS и питоновским слоем. Пока она держится ровной, любой рост объясняется вашим кодом. Как только она начинает расходиться, вы уже знаете, что копать надо ниже, и не потратите день на objgraph.
Куда расследование приводит чаще всего
Список причин, до которых доходят чаще всего, довольно скучный, и это хорошая новость.
Кеш без ограничения размера лидирует с большим отрывом. @lru_cache без maxsize растёт бесконечно, обычный словарь в глобальной области — тоже, а замечают это через недели.
Следом идут объекты, которые держит что-то живущее дольше запроса: зарегистрированный и не снятый обработчик, задача в asyncio, ссылку на которую сохранили и не убрали, замыкание, захватившее большой объект.
Третьей строчкой — вещи, которые вообще не выглядят аллокациями. Один разбор реального сервиса закончился на warnings.warn(), который вызывался на каждую запись в базу: под капотом он собирает форматированную строку, создаёт объект предупреждения и обходит весь стек вызовов, и на тысячах операций в секунду это даёт заметный поток мусора.
Ещё стоит проверить логирование в горячем пути. Форматирование строки происходит до того, как логгер решит, что уровень не подходит, если писать так:
log.debug(f"обработали {len(items)} записей: {items}") # строка соберётся всегда log.debug("обработали %d записей: %s", len(items), items) # соберётся только при DEBUG
На отключённом уровне первая строка всё равно создаёт строку и держит её до конца вызова, вторая не создаёт ничего. На горячем пути разница видна в профиле.
С чего начинать в следующий раз
Сначала измерьте оба конца — tracemalloc.get_traced_memory() и RSS из /proc. Расхождение в разы означает, что искать надо ниже питоновского слоя, и весь ваш прикладной код можно временно отложить.
Если питоновский слой всё-таки растёт, сравните два снимка tracemalloc под нагрузкой и найдите строку с наибольшим приростом, а затем через objgraph посмотрите, кто держит ссылку. В подавляющем большинстве случаев расследование заканчивается здесь и упирается в кеш.
Если питоновский слой ровный, снимите профиль memray с флагом --native и посмотрите, чьи кадры видны в графе. Нативная библиотека в верхних строчках означает, что дальше идти надо в её сторону — обновлять версию, менять настройки, искать известные ошибки.
Если и профиль ничего не показывает, вызовите malloc_trim(0) и посмотрите на RSS. Падение подтверждает удержание страниц аллокатором, и лечится оно настройками — MALLOC_ARENA_MAX, переходом на jemalloc с ограниченным временем удержания грязных страниц.
Держите в голове ещё одну вещь про свободнопоточные сборки: в них другой сборщик мусора с остановкой мира вместо инкрементальной работы, и поведение gc.collect() там отличается по времени и по эффекту. Отлаживать память нужно на той же сборке, которая поедет в прод, иначе выводы получатся про другой интерпретатор.
И перед тем как чинить, зафиксируйте базовую линию: снимите RSS и tracemalloc через сутки ровной нагрузки. Половина «утечек» оказывается обычным выходом на плато — сервис набирает рабочий набор данных, кеши прогреваются, и рост останавливается сам. Отличить это от настоящей утечки можно только временем наблюдения, и делать это дешевле до того, как начнёте менять аллокаторы.

Когда память или ресурсы начинают уходить под нагрузкой, полезно уметь не только читать метрики, но и воспроизводить проблему и локализовать её на нужном уровне системы. В ближайших открытых уроках можно отдельно разобрать нагрузочное тестирование, работу с конкурентными задачами в Python и базовую диагностику Linux — три навыка, которые хорошо дополняют подход из этой статьи.
13 августа, 20:00. «Минимум для старта: как провести свое первое нагрузочное тестирование». Записаться
18 августа, 20:00. «Python asyncio: gather, wait, TaskGroup на практике». Записаться
19 августа, 20:00. «Почему сервер тормозит: первая диагностика Linux для начинающего администратора». Записаться
Полный список бесплатных уроков августа смотрите в дайджесте.