Специфика FastAPI в MLOps и жизненный цикл приложения lifespan
Специфика FastAPI в MLOps и жизненный цикл приложения lifespan
Представьте, что вы разворачиваете нейросеть весом 5 ГБ. Если ваш API будет загружать её с диска в оперативную память при каждом входящем HTTP-запросе, время ответа (latency) составит десятки секунд, а на третьем параллельном запросе сервер упадет с ошибкой Out Of Memory. Пропускная способность системы описывается простой зависимостью: . Если огромно из-за постоянной загрузки весов, ваш стремится к нулю.
Главный парадокс интеграции машинного обучения в веб заключается в конфликте парадигм. Традиционный веб тяготеет к архитектуре Stateless (без сохранения состояния): запрос пришел, сервер сходил в базу данных, отдал ответ и «забыл» о клиенте. Инференс ML-моделей — это жесткий Stateful: модель должна быть загружена в память (RAM или VRAM видеокарты) до того, как придут пользователи, и постоянно находиться там.
FastAPI стал стандартом де-факто в MLOps именно потому, что он элегантно решает эту проблему, предоставляя разработчику полный контроль над состоянием приложения с помощью механизма lifespan.
Эволюция управления состоянием: почему lifespan?
В ранних версиях FastAPI (и Starlette, на котором он основан) для загрузки моделей использовались декораторы событий: @app.on_event("startup") и @app.on_event("shutdown"). Выглядело это так: мы объявляли глобальную переменную, в startup загружали в нее модель, а в shutdown очищали.
На технических собеседованиях часто спрашивают, почему этот подход признан устаревшим (deprecated). У него три фатальных недостатка:
- Глобальные переменные: Они усложняют тестирование. Если вы хотите запустить тесты параллельно с разными мок-моделями, глобальное состояние приведет к гонке данных.
- Отсутствие гарантий: Если приложение падает во время работы, событие
shutdownможет не вызваться, оставляя «висящие» процессы и занятую память на GPU. - Разрыв контекста: Логика инициализации и очистки разнесена по разным функциям, хотя семантически это один процесс.
Современный стандарт — использование асинхронного контекстного менеджера lifespan.
Анатомия lifespan в FastAPI
Контекстный менеджер lifespan оборачивает всю жизнь вашего приложения. Всё, что написано до ключевого слова yield, выполняется при старте сервера. Всё, что после — при его остановке.
Давайте посмотрим на эталонный пример подготовки ML-сервиса:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import torch
# Имитация тяжелой ML-модели
class MLModel:
def load(self):
print("Загрузка весов (5 ГБ) в VRAM...")
self.device = "cuda" if torch.cuda.is_available() else "cpu"
def predict(self, data: str) -> str:
return f"Prediction for {data} on {self.device}"
def release(self):
print("Очистка VRAM...")
torch.cuda.empty_cache()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 1. ФАЗА STARTUP: Инициализация ресурсов
model = MLModel()
model.load()
# Сохраняем модель в глобальное состояние приложения
yield {"ml_model": model}
# 2. ФАЗА SHUTDOWN: Очистка ресурсов
model.release()
print("Сервер успешно остановлен, ресурсы освобождены.")
# Передаем lifespan при создании приложения
app = FastAPI(lifespan=lifespan)
Как передать модель в endpoint?
Обратите внимание на строку yield {"ml_model": model}. Словарь, который мы возвращаем (yield), автоматически помещается в request.state. Это изолированное хранилище состояния для конкретного экземпляра приложения.
Теперь в любом эндпоинте мы можем получить доступ к нашей тяжелой модели без использования глобальных переменных:
@app.post("/predict")
async def predict_endpoint(request: Request, payload: str):
# Извлекаем модель из состояния приложения
model = request.state.ml_model
# Выполняем инференс
result = model.predict(payload)
return {"result": result}
Примечание: в реальных production-системах прямое обращение к request.state часто оборачивают в систему Dependency Injection (Depends), чтобы улучшить типизацию и автодополнение кода. Эту технику мы подробно разберем в одной из следующих глав.
Graceful Shutdown: почему это критично для MLOps
В мире микросервисов контейнеры постоянно перезапускаются (например, Kubernetes масштабирует поды или выкатывает новую версию). Когда оркестратор решает убить контейнер, он посылает сигнал SIGTERM.
Если приложение не умеет корректно обрабатывать этот сигнал (делать graceful shutdown), оно завершается жестко. Для обычного веб-сервера это означает пару оборванных HTTP-соединений. Для ML-сервиса последствия хуже:
- Утечки VRAM: Видеокарты (особенно при работе с CUDA) очень чувствительны к незавершенным процессам. Жесткое убийство процесса может оставить куски памяти в GPU помеченными как «занятые». В итоге новый под просто не сможет стартовать из-за ошибки
CUDA out of memory. - Потеря батчей: Если модель обрабатывала пакет данных (batch) в момент остановки, данные будут потеряны.
Блок кода после yield в lifespan гарантирует, что при получении SIGTERM FastAPI перестанет принимать новые запросы, дождется завершения текущих (в рамках таймаута), выполнит вашу логику очистки (model.release()) и только потом умрет.
Сквозная логика: от старта до инференса
Мы решили первую фундаментальную проблему — загрузили тяжелую модель один раз при старте и обеспечили ей безопасное удаление. Однако, если мы запустим наш predict_endpoint под нагрузкой прямо сейчас, мы столкнемся с новой проблемой.
Инференс нейросетей — это синхронная, блокирующая процессор (CPU-bound) задача. Пока наша модель высчитывает тензоры для одного пользователя, весь асинхронный event loop FastAPI будет заблокирован, и другие пользователи не смогут даже получить ответ «сервер жив». Как подружить синхронную математику с асинхронным веб-сервером — это следующая ступень проектирования, к которой мы перейдем далее.