Lifespan: запуск и остановка приложения

lifespan — это async-контекстный менеджер, который выполняет код один раз при старте приложения (до yield) и один раз при остановке (после yield); он пришёл на смену устаревшим @app.on_event.

Некоторые вещи делаются не на каждый запрос, а один раз на жизнь процесса: открыть пул соединений к БД, прогреть кэш, загрузить ML-модель. Это и есть зона ответственности lifespan.

До запроса нужно подготовить общие для всего приложения ресурсы. Создавать пул соединений к базе на каждый запрос — расточительно; его открывают один раз при старте и закрывают при остановке. Раньше для этого были декораторы @app.on_event("startup") и @app.on_event("shutdown"), но они объявлены устаревшими. Современный и рекомендуемый способ — единый lifespan-контекстменеджер.

from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # --- startup ---
    app.state.db_pool = await create_pool()   # один раз при старте
    print("приложение запущено, пул создан")
    yield
    # --- shutdown ---
    await app.state.db_pool.close()           # один раз при остановке
    print("приложение остановлено, пул закрыт")

app = FastAPI(lifespan=lifespan)

Логика та же, что у зависимости с yield, только в масштабе всего приложения: код до yield — старт, код после — остановка. Общие ресурсы удобно класть в app.state, откуда их потом достают зависимости. Преимущество единого менеджера в том, что старт и связанная с ним остановка живут рядом, в одном месте, а не разнесены по двум декораторам.

Как работает под капотом

ASGI-сервер при запуске отправляет приложению событие lifespan.startup, прокручивая менеджер до yield, а при остановке — lifespan.shutdown, дотягивая его. Между ними обрабатываются запросы. Смоделируем жизненный цикл на stdlib:

def lifespan():
    print("STARTUP: открываем пул соединений к БД")
    state = {"pool": "db-pool", "ready": True}
    yield state
    print("SHUTDOWN: закрываем пул соединений")
    state["ready"] = False

gen = lifespan()
state = next(gen)              # сервер запускается -> startup
print("сервер работает, ready =", state["ready"])
print("обрабатываем запрос 1, используем", state["pool"])
print("обрабатываем запрос 2, используем", state["pool"])
try:
    next(gen)                 # сервер останавливается -> shutdown
except StopIteration:
    pass
print("после остановки ready =", state["ready"])

Попробуй сам ▶ Между STARTUP и SHUTDOWN пул переиспользуется всеми запросами — в этом и смысл: дорогой ресурс создаётся один раз.

Жизненный цикл приложения целиком

Полезно держать в голове всю временную ось процесса: lifespan очерчивает её границы, а запросы живут внутри.

старт процесса
     |
     v
[ lifespan: код до yield ]  <- один раз: пул БД, кэш, модели
     |
     v
+----------------------------------------+
|  приём запросов (минуты/часы/дни)      |
|    запрос 1 -> обработчик -> ответ     |
|    запрос 2 -> обработчик -> ответ     |
|    ...  (общие ресурсы переиспользуются)|
+----------------------------------------+
     |
     v
[ lifespan: код после yield ] <- один раз: закрыть пул, кэш
     |
     v
остановка процесса

Из этой картины видно ключевое различие двух масштабов жизни: ресурсы из lifespan живут всю вертикальную ось процесса, а сессии и зависимости с yield — лишь внутри одного горизонтального «запрос → ответ». Спутать эти масштабы — значит либо пересоздавать дорогой пул на каждый запрос, либо делить одну сессию между параллельными запросами.

Частые ошибки

Первая — продолжать использовать устаревшие @app.on_event в новом коде. Вторая — открывать дорогие ресурсы (пул БД, HTTP-клиент) внутри обработчиков или зависимостей на каждый запрос вместо lifespan. Третья — забывать закрывать ресурсы после yield, оставляя «висящие» соединения при перезапуске. Четвёртая — класть в lifespan логику, специфичную для запроса (ей место в зависимостях).

Best practices

  • Используйте lifespan вместо устаревших on_event-декораторов.
  • Создавайте долгоживущие ресурсы (пулы, клиенты, модели) при старте и закрывайте при остановке.
  • Храните общие ресурсы в app.state и доставайте их через зависимости.
  • Держите startup быстрым; тяжёлую инициализацию делайте осознанно и с логированием.

Состояние приложения и доступ из зависимостей

Ресурсы, созданные в lifespan, кладут в app.state — простое пространство имён на уровне приложения. Но как обработчику до них дотянуться? Через объект запроса: request.app.state.db_pool, или, элегантнее, через зависимость, которая достаёт ресурс из состояния и отдаёт его. Так замыкается красивая картина: lifespan создаёт пул один раз, кладёт в state, а зависимость на каждый запрос достаёт из пула отдельную сессию. Каждый слой отвечает за свой масштаб жизни. Важная тонкость: при нескольких воркерах lifespan выполняется в каждом процессе-воркере отдельно, поэтому пул создаётся не один на сервис, а один на воркер — это нормально и нужно учитывать при расчёте лимитов соединений к базе. Понимание этого избавляет от загадок вроде «почему соединений к базе в N раз больше, чем я ожидал».

Итог: lifespan — единый async-контекстменеджер для старта и остановки приложения, заменивший on_event. Он создаёт и закрывает общие ресурсы один раз за жизнь процесса; запросы пользуются ими между yield.

Проверьте себя
1. Чем lifespan лучше устаревших декораторов @app.on_event('startup')/('shutdown')?
AОн работает быстрее запросов
BОн объединяет логику старта и остановки в одном async-контекстменеджере (до и после yield), а on_event устарел
CОн не требует async
DОн запускается на каждый запрос
2. Где правильно создавать пул соединений к базе данных?
AВ каждом обработчике на каждый запрос
BВ lifespan при старте приложения — один раз на процесс
CВ Pydantic-модели
DВ декораторе маршрута