RSS раздулся до 3 ГБ, а tracemalloc упирается в 180 МБ: где спрятались остальные 2,8
Картина знакомая: сервис на FastAPI работает уже четвёртые сутки, начинал с ~400 МБ RSS, а теперь процесс разросся до 3,2 ГБ и контейнер вот-вот словит OOMKilled. При этом `tracemalloc` рисует почти идеальную горизонталь - около тех же 180 МБ, что и в первый час жизни. Даже если дернуть `gc.collect()` из отладочного endpoint, вы получите пару сотен собранных объектов, но на RSS это почти не влияет. Противоречие исчезает, если помнить: RSS процесса и "память питоновских объектов" - показатели из разных слоёв, и между ними есть ещё минимум два уровня, которые `tracemalloc` по определению не видит.
Три слоя между питоновским объектом и физической страницей
Условно путь памяти выглядит так:
1) Объекты Python: списки, словари, экземпляры классов, строки, буферы. Здесь живут ссылки, граф объектов и сборщик мусора. Это как раз то, что отслеживает `tracemalloc`.
2) Аллокатор CPython (pymalloc): чтобы не ходить в системный `malloc` по поводу каждого кортежа на 10 байт, CPython берёт у ОС крупные блоки (типичный порядок - сотни килобайт, например 256 КБ) и дальше сам нарезает их под "мелочь". Всё, что крупнее ~512 байт, обычно идёт мимо `pymalloc` напрямую в системный аллокатор.
3) Системный аллокатор (часто glibc malloc): у него свои арены, свои стратегии фрагментации и своя логика возврата страниц ядру. То, что вы "освободили" на уровне Python или даже на уровне `pymalloc`, вовсе не обязано немедленно вернуться ОС.
RSS отражает в основном самый нижний уровень. Объекты могли давно умереть, `pymalloc` мог освободить внутренние блоки, но страницы всё ещё числятся за процессом: арена не опустела целиком, glibc решил придержать память "на будущее", либо образовалась фрагментация, из-за которой вернуть страницы невозможно без перемещения данных.
Именно поэтому "tracemalloc стабилен, а RSS растёт" обычно означает: искать нужно не в графе Python-объектов, а слоем (или двумя) ниже.
---
Шаг 1. Сначала зафиксируйте расхождение правильно
Даже если дашборд показывает ровную линию, потратьте несколько минут на нормальный прогон `tracemalloc` со снимками. В мониторинге нередко считают не то, что вам нужно, а сравнение snapshot'ов показывает динамику точнее.
Ключевой момент: смотрите не абсолютные числа, а прирост между снимками. Под нагрузкой сервис всегда держит временные буферы, кэши и пулы - большие значения сами по себе не доказывают утечку. А вот стабильный плюс между snapshot'ами уже похож на проблему.
Полезная настройка - глубина стека:
- `tracemalloc.start(1)` дешёвый вариант: покажет строку аллокации без контекста.
- `tracemalloc.start(25)` даёт гораздо больше смысла в сервисе, где одна и та же строка вызывается из десятка мест. Но цена заметная: накладные расходы растут, иногда до трети по памяти при глубоком стеке. Поэтому так обычно делают не "навсегда в проде", а на время расследования или на отдельном экземпляре.
Если снимки показывают рост - следующая цель не "сколько съело", а кто держит ссылку.
---
Шаг 2. Найдите держателей ссылок: утечки почти всегда банальные
Когда сравнение snapshot'ов выявило растущие строки, дальше обычно всплывает что-то скучное, но опасное:
- глобальный словарь-кэш без ограничений;
- декоратор `lru_cache` без `maxsize`;
- список в атрибуте класса, который "копит историю";
- подписка на события/сигналы, от которой никто не отписался;
- пул задач, куда складывают результаты "на потом", а "потом" не наступает.
Отдельно проверьте циклические ссылки, которые сборщик не может корректно разобрать. Признак - непустой `gc.garbage`: там оказываются объекты в цикле, который GC не решается разорвать (часто из-за `__del__` и похожих финализаторов). И важно помнить: `gc.collect()` вообще работает в основном с циклами. Если на объект есть живая обычная ссылка без цикла - он не соберётся никогда, хоть зазовитесь.
Если же вы убедились, что Python-объекты не растут, а RSS продолжает ползти вверх - пора спускаться ниже.
---
Шаг 3. Утечки "ниже Python": почему tracemalloc слеп
`tracemalloc` учитывает то, что аллоцировал интерпретатор, но он не обязан видеть память, которую запросила нативная библиотека на C/C++: драйвер БД, парсер, компрессор, криптобиблиотека, обработчик изображений, сериализатор, библиотека ML и так далее. В таких историях Python-куча ровная, а RSS увеличивается за счёт нативных аллокаций.
Инструмент, который действительно помогает на этом уровне, - memray, потому что он умеет смотреть и питоновские, и нативные аллокации. Критически важен режим `--native`: без него результат будет напоминать `tracemalloc`, а с ним в стеке появятся кадры из C-библиотек, и станет видно, что память утекает не в вашем обработчике FastAPI, а, например, внутри драйвера базы или парсера.
Подцепиться профайлером к уже работающему процессу иногда можно, но это нужно делать осторожно: операция не считается "безопасной", и известны случаи, когда процесс падает. На практике такие эксперименты проводят на канареечном экземпляре или на реплике, где воспроизведена нагрузка.
---
Где чаще всего прячутся "лишние гигабайты": типовые причины
Ниже - места, которые регулярно дают разницу в гигабайты между `tracemalloc` и RSS, даже когда "в Python всё чисто".
1) Фрагментация и удержание арен glibc
Освобождение памяти не гарантирует её возврат ОС. Процесс может "держать" страницы, потому что внутри арен ещё есть занятые куски. Это особенно заметно при разноразмерных аллокациях и волнообразной нагрузке (пики запросов, большие ответы, временные буферы).
2) Большие буферы > 512 байт идут мимо pymalloc
Как только данные становятся крупнее порога, играют правила системного аллокатора. Поэтому скачок RSS часто коррелирует с обработкой крупных JSON, архивов, изображений, логов, тел запросов/ответов, больших результатов из БД.
3) Нативные библиотеки и их внутренние кэши
Некоторые библиотеки держат собственные пулы и кэши "для скорости". На Python-уровне вы видите лишь маленький объект-обёртку, а реальный объём живёт в C-структурах.
4) Memory mapping (mmap) и файлы
Часть памяти может приходить через отображение файлов в память. В RSS это отражается, а в `tracemalloc` - нет. Аналогично - большие индексы, модели, сегменты, которые библиотека маппит.
5) Стэки потоков и фоновые воркеры
Если приложение создаёт много потоков, каждый поток имеет стек. Это не всегда гигабайты, но при неправильной архитектуре может стать заметной частью RSS и никак не связано с объектами Python.
---
Что сделать, чтобы в следующий раз не ловить проблему "во время пожара"
Соберите метрики заранее. В момент, когда RSS уже 3+ ГБ и контейнер почти умирает, расследование превращается в гонку со временем. Гораздо полезнее держать:
- RSS и PSS процесса (если доступно),
- количество потоков,
- частоту и длительность GC,
- размер очередей/пулов,
- средний и 95/99 процентиль размера ответов и тел запросов,
- объёмы сериализации (JSON/MsgPack), компрессии и т. п.
Сверяйте то, что показывает контейнер, с тем, что вы измеряете внутри. В контейнеризованной среде легко перепутать: "процесс держит память" и "cgroup считает память иначе, чем вы ожидаете". Важно понимать, какие именно поля вы сравниваете: RSS процесса, memory usage cgroup, page cache, shared memory.
Держите план воспроизведения. Самый быстрый путь - поднять копию сервиса, прогнать реплеем похожую нагрузку и включить тяжёлый профайлинг там. В бою вы не захотите ставить агрессивные профайлеры и глубокие стеки на единственный живой инстанс.
---
Мини-чеклист: куда смотреть, когда RSS растёт, а Python-куча нет
1) `tracemalloc` со snapshot'ами: есть ли прирост между снимками?
2) Если есть прирост - найдите держателя ссылки (кэш, глобальная структура, подписки, циклы, `gc.garbage`).
3) Если прироста нет - почти наверняка нативные аллокации/фрагментация: включайте профилирование, которое видит C-уровень (memray с `--native`).
4) Проверяйте крупные буферы, сериализацию, драйверы БД/HTTP, парсеры, компрессию, mmap.
5) Делайте эксперименты на канарейке или реплике под нагрузкой: подключение к живому процессу может быть рискованным.
В такой ситуации "недостающие 2,8 ГБ" обычно не пропадают и не мистически "утекают": память чаще всего либо удерживается аллокатором/аренами, либо находится в нативных структурах библиотек. И как только вы разведёте уровни (Python → pymalloc → glibc/ядро) и начнёте измерять каждый своим инструментом, расследование из магии превращается в последовательную диагностику.


