Привет, Хабр! Сервис на 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 для начинающего администратора». Записаться

Полный список бесплатных уроков августа смотрите в дайджесте.

Комментарии (0)