Знакомая картина в боевом мониторинге. График памяти процесса растёт ровной восходящей лестницей, перезапуск контейнера сбрасывает её к норме, через несколько часов всё повторяется. Сервис не падает мгновенно, но к концу суток упирается в лимит контейнера, и оркестратор героически перезапускает его, маскируя проблему. На вид код чистый, в логах ни одной ошибки, а память течёт.
В 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 показывает, сколько таких объектов накопилось, а понимание типичных сценариев из боевой практики переводит вывод инструментов в конкретную правку кода. Двадцать минут на этот разбор окупаются месяцами стабильной памяти, без ночных перезапусков и графиков в форме лестницы.