Знакомая картина в боевом мониторинге. График памяти процесса растёт ровной восходящей лестницей, перезапуск контейнера сбрасывает её к норме, через несколько часов всё повторяется. Сервис не падает мгновенно, но к концу суток упирается в лимит контейнера, и оркестратор героически перезапускает его, маскируя проблему. На вид код чистый, в логах ни одной ошибки, а память течёт.

В FastAPI-приложениях, которые ходят в базу через SQLAlchemy, источник такой утечки в подавляющем большинстве случаев один и тот же. Это управление жизненным циклом сессии и её карты идентичности, в которой база загруженных объектов копится тихо и незаметно. Симптомы общие, причины конкретные, и есть короткий рабочий маршрут отладки, который укладывается в двадцать минут от первого подозрения до точки утечки в коде. Разберём этот маршрут со всеми инструментами и типичными граблями, на которых застревают.

Почему именно сессия SQLAlchemy так часто оказывается виновной

Сессия SQLAlchemy это не просто соединение с базой, это единица работы, которая держит все загруженные через неё ORM-объекты и отслеживает изменения. Сессия захватывает соединение из пула при первом запросе и держит его до коммита или отката транзакции. Внутри сессии живёт карта идентичности, кеш загруженных объектов, гарантирующий, что одна и та же строка из таблицы будет представлена одним и тем же Python-объектом в рамках одной сессии.

Этот кеш и есть главный кандидат на источник утечки. Каждый загруженный объект остаётся в карте идентичности сессии. Если сессия живёт долго и через неё прокачиваются тысячи строк, то всё это копится в памяти, пока сессия не закроется. В коротких HTTP-запросах с правильной зависимостью FastAPI проблемы обычно нет, сессия закрывается в finally и карта вычищается. Проблемы начинаются ровно в тех местах, где код отклоняется от учебного примера.

Типичных сценариев несколько. Сессия передаётся в фоновую задачу и переживает запрос: HTTP-запрос завершён, контекстный менеджер закрыл сессию, но фоновая задача продолжает её использовать и явно не закрывает. Это утечка не из бага во фреймворке, а из неправильного управления жизненным циклом. Глобальная или модульная переменная держит сессию между запросами, превращая её в долгожителя с растущей картой идентичности. Стриминговый эндпоинт читает большой результат в одну сессию и не очищает её. Любая из этих ситуаций даёт ровную восходящую лестницу на графике памяти.

Отдельный класс утечек возникает на уровне движка. Если в обработчике запроса каждый раз создаётся новый engine через прямой вызов с URL базы, у каждого такого вызова свой пул соединений, который не освобождается. Поток создания движков на каждый запрос это резервуар, из которого память не возвращается совсем. Engine должен создаваться один раз на приложение, и это правило не имеет исключений в рамках одного процесса.

Базовый инструмент диагностики, встроенный в Python

Двадцатиминутный маршрут начинается с tracemalloc, потому что он уже есть в стандартной библиотеке, не требует установки и даёт нужный уровень детализации. Идея инструмента простая: он отслеживает каждое выделение памяти Python с трассировкой стека, позволяет делать снимки состояния и сравнивать их между собой. Разница двух снимков сразу показывает, что именно выросло между ними и в каких строках кода это аллоцировалось.

Для боевого FastAPI-приложения удобнее всего повесить tracemalloc на отдельный диагностический роутер, чтобы включать его на лету без перезапуска и снимать срезы по запросу. Главное не оставлять такой роутер открытым в публичной сети без аутентификации, потому что он раскрывает внутренности процесса.

import tracemalloc
from fastapi import APIRouter, HTTPException, status

debug_router = APIRouter(prefix="/debug", tags=["debug"])

_snapshot_before: tracemalloc.Snapshot | None = None


@debug_router.post("/tracemalloc/start")
def start_tracing():
    tracemalloc.start(25)  # хранить 25 фреймов в трассировке
    return {"status": "started"}


@debug_router.post("/tracemalloc/snapshot")
def take_snapshot():
    global _snapshot_before
    if not tracemalloc.is_tracing():
        raise HTTPException(status.HTTP_400_BAD_REQUEST, "tracemalloc not started")
    snap = tracemalloc.take_snapshot()
    if _snapshot_before is None:
        _snapshot_before = snap
        return {"status": "baseline saved"}
    # Сравниваем с базовым срезом, показываем топ-20 точек роста
    stats = snap.compare_to(_snapshot_before, "lineno")
    return {
        "top": [
            {"file": str(s.traceback), "size_diff_kb": s.size_diff // 1024, "count_diff": s.count_diff}
            for s in stats[:20]
        ]
    }

Сценарий работы укладывается в пять шагов. Включить трассировку, сразу снять baseline-снимок, дать приложению поработать под боевой или синтетической нагрузкой минут десять-пятнадцать, снять второй снимок и посмотреть разницу. Топ списка покажет, в каких именно строках выделение памяти выросло сильнее всего. Запускать tracemalloc на каждом старте процесса через переменную окружения тоже можно, и для контейнеров это даже удобнее.

# Включить tracemalloc на запуске процесса
PYTHONTRACEMALLOC=25 uvicorn app.main:app

Тонкость интерпретации, без которой первые срезы вводят в заблуждение. Верхние строки в выводе часто указывают не на ваш код, а на внутренности фреймворка или цикл событий, потому что туда сходится поток аллокаций со всех обработчиков. Свой код приходится искать ниже в списке. Полезно сразу фильтровать вывод по своему пакету или маске пути, чтобы не утонуть в чужих стеках.

# Отфильтровать снимок только по своему коду
my_filter = tracemalloc.Filter(inclusive=True, filename_pattern="*/app/*")
filtered = snap.filter_traces([my_filter])

Pympler как взгляд на объекты, а не на байты

tracemalloc отвечает на вопрос где аллоцировалось. Pympler отвечает на другой вопрос: какие именно Python-объекты сейчас живут в памяти и сколько их. Для утечек в карте идентичности SQLAlchemy второй вопрос важнее, потому что симптом утечки это не байты, а растущее число живых ORM-объектов от запроса к запросу. Авторы самой SQLAlchemy в разборах подобных проблем советуют смотреть именно на объекты, а не на чистый расход памяти, и предлагают для этого Pympler.

Установка pympler делается одной командой, а в код встраивается мини-сводка по типам объектов на отдельном эндпоинте.

pip install pympler
from pympler import muppy, summary
from fastapi import APIRouter

debug_router = APIRouter(prefix="/debug", tags=["debug"])


@debug_router.get("/memory/summary")
def memory_summary():
    all_objects = muppy.get_objects()
    sum_table = summary.summarize(all_objects)
    # Возвращаем топ-30 типов по объёму
    return {"top": summary.format_(sum_table, limit=30)}

Вызов этого эндпоинта несколько раз с интервалом и под нагрузкой даёт характерную картину. Если на каждом срезе количество объектов вашей ORM-модели растёт примерно линейно числу запросов, источник утечки практически наверняка в сессии, которая не закрывается, или в кеше идентичности, который не вычищается. Точечно посмотреть рост одного класса между срезами помогает tracker.

from pympler import tracker

_tr = tracker.SummaryTracker()


@debug_router.get("/memory/diff")
def memory_diff():
    # diff показывает, что прибавилось со времени прошлого вызова
    return {"diff": _tr.format_diff()}

Полезен ещё один режим, который называется отслеживанием класса. Он позволяет привязать счётчик к конкретному типу и видеть его жизненный цикл во времени.

from pympler.classtracker import ClassTracker
from app.models import User

ct = ClassTracker()
ct.track_class(User)
ct.create_snapshot()
# ... поработать под нагрузкой ...
ct.create_snapshot()
ct.stats.print_summary()

Если число живых экземпляров вашей модели растёт между снимками, не возвращаясь к базовой линии после завершения запросов, диагноз можно ставить почти уверенно: сессии не закрываются вовремя, а карта идентичности тянет объекты в памяти.

Самые частые точки утечки в коде и как их подтвердить

Самый распространённый из паттернов утечки сессии связан с фоновыми задачами FastAPI. Сценарий выглядит безобидно: основной обработчик берёт сессию через зависимость, передаёт её в фоновую задачу через BackgroundTasks и возвращает ответ. HTTP-запрос завершается, контекстный менеджер зависимости закрывает сессию, но фоновая задача продолжает её использовать. По умолчанию в SQLAlchemy включена настройка, при которой close сбрасывает состояние, не разрывая ссылку, и сессию можно переиспользовать. В результате фоновая задача работает, никаких ошибок не выдаёт, но память не возвращается.

Подтверждение этого диагноза в pympler выглядит как стабильный рост объектов SQLAlchemy между срезами при отсутствии прироста активных HTTP-запросов. В tracemalloc верхние строки укажут на код фоновой задачи, а не на основной обработчик. Лечение это явное создание собственной сессии внутри фоновой задачи и обязательное закрытие через контекстный менеджер.

from sqlalchemy.orm import Session
from fastapi import BackgroundTasks, Depends

# ПЛОХО: переиспользование сессии запроса в фоне
@app.post("/process")
def process(bg: BackgroundTasks, db: Session = Depends(get_db)):
    bg.add_task(heavy_task, db)  # сессия переживёт запрос
    return {"ok": True}


# ХОРОШО: фон создаёт и закрывает свою сессию
def heavy_task_with_own_session():
    with SessionLocal() as own_db:
        # вся работа здесь, по выходе из with сессия закрывается
        own_db.execute(...)
        own_db.commit()


@app.post("/process")
def process(bg: BackgroundTasks):
    bg.add_task(heavy_task_with_own_session)
    return {"ok": True}

Второй типичный сценарий это длинный обработчик, который грузит много объектов через одну сессию и не сбрасывает карту идентичности. Например, выгрузка десятков тысяч строк в стрим. Каждая загруженная сущность остаётся в карте до конца запроса. Лечение это либо разбиение работы на пакеты с явным вызовом expunge_all между ними, либо использование read-only стрим-режима без отслеживания объектов.

# Сбросить карту идентичности после обработки пакета
for batch in chunked_query(db, batch_size=1000):
    process_batch(batch)
    db.expunge_all()  # выкидывает объекты из identity map

# Альтернатива: чтение без отслеживания, объекты не оседают в сессии
result = db.execute(stmt).yield_per(1000)

Третий неочевидный случай это создание движка в обработчике запроса. Он встречается в коде, переписанном из синхронного в асинхронный без понимания жизненного цикла engine. На каждый вызов рождается новый AsyncEngine со своим пулом соединений, метаданными и кешем компиляции, и ничего из этого не освобождается.

# КАТАСТРОФА: новый engine на каждый запрос
@app.get("/items")
async def get_items():
    engine = create_async_engine(DATABASE_URL)  # утечка пула
    # ...

# ПРАВИЛЬНО: engine один на приложение
engine = create_async_engine(DATABASE_URL)  # на уровне модуля
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)


async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

Кеш компиляции SQLAlchemy и подозрение, когда код кажется чистым

Если все три типичных сценария проверены и утечка всё равно идёт, остаётся один менее известный источник. Это кеш скомпилированных SQL-выражений внутри движка SQLAlchemy. При определённых паттернах работы с подзапросами, общими табличными выражениями или динамически собираемыми запросами кеш может вести себя как утечка. В обсуждении подобных проблем разработчики SQLAlchemy предлагают как первый диагностический шаг отключить этот кеш и посмотреть, исчезнет ли рост памяти.

# Диагностический шаг: отключить кеш компиляции
engine = create_engine(DATABASE_URL, query_cache_size=0)

Если после отключения кеша память перестаёт расти, проблема локализована и можно искать конкретный паттерн запросов, который её провоцирует. Оставлять кеш отключённым в продакшене не стоит, потому что он действительно ускоряет работу, но как точка диагностики этот рычаг работает безотказно.

Что в итоге складывается в двадцать минут отладки

Если свести маршрут в линейный план, он короткий. Первые пять минут: подключить диагностический роутер с tracemalloc и pympler к приложению, прогнать тесты или живой трафик, снять базовые срезы. Следующие десять минут: пустить нагрузку, снять второй срез, сравнить и определить, что именно растёт, байты в конкретных строках кода через tracemalloc или количество живых объектов конкретных классов через pympler. Последние пять минут: соотнести точку роста с одним из типичных сценариев, передача сессии в фоновую задачу, длинный обработчик с растущей картой идентичности, создание движка в коде запроса, и применить соответствующее лечение.

Главный вывод не про конкретные инструменты, а про модель мышления. Утечки в FastAPI на SQLAlchemy почти никогда не означают баг во фреймворке. Они означают, что где-то жизненный цикл сессии или движка не совпадает с жизненным циклом запроса, и объект, который должен был умереть, продолжает жить. tracemalloc показывает, где он родился, pympler показывает, сколько таких объектов накопилось, а понимание типичных сценариев из боевой практики переводит вывод инструментов в конкретную правку кода. Двадцать минут на этот разбор окупаются месяцами стабильной памяти, без ночных перезапусков и графиков в форме лестницы.