Анализ производительности промежуточного ПО FastAPI
FastAPI - это известный и любимый (почти 70 тысяч звезд на GitHub) современный, быстрый, асинхронный веб-фреймворк для создания API на Python. Он создан для простоты использования и высокой производительности, но при этом надежен и готов к производству.
Моей команде очень понравился процесс разработки приложений с помощью FastAPI, и она нашла в нем полезный опыт обучения. Однако один из моментов оказался немного сложным: когда дело дошло до тестирования производительности, мы столкнулись с неожиданно низкими значениями запросов в секунду (RPS), что побудило нас к более глубокому исследованию, которое, в свою очередь, привело нас к более подробному изучению FastAPI Middlewares.
Цель этой заметки - изучить влияние BaseHTTPMiddleware и ASGIMiddleware на производительность FastAPI-приложений и поделиться нашими выводами.
Спойлер: вот как может измениться производительность реального производственного приложения в зависимости от типа промежуточного ПО, которое мы используем для реализации некоторой общей логики:
Справочная информация
В FastAPI промежуточное ПО играет решающую роль в жизненном цикле "запрос - ответ", выступая в качестве слоя, который обрабатывает запросы и ответы до того, как они попадают в основную логику приложения, или после того, как они покидают ее. Эта функциональность имеет ключевое значение для реализации различных функций, таких как аутентификация, обработка данных, протоколирование запросов и обработка ошибок.
Если вы хотите применить общую логику для обработки запросов в нескольких конечных точках, вы можете использовать промежуточные модули. FastAPI (точнее, Starlette, на котором базируется FastAPI) предлагает два типа промежуточного ПО:
- BaseHTTPMiddleware: Этот тип промежуточного ПО создан на основе простого и понятного интерфейса. При использовании BaseHTTPMiddleware вы определяете асинхронную функцию, которая принимает запрос, вызывает следующий элемент в цепочке промежуточного ПО, а затем вы можете изменить ответ.
- ASGIMiddleware: ASGI (Асинхронный серверный шлюз cпецификациb интерфейса) гласит: Можно иметь "промежуточное ПО" ASGI - код, который играет роль и сервера, и приложения, принимая область видимости и ожидаемые вызовы отправки/получения, потенциально изменяя их, а затем вызывая внутреннее приложение. ASGIMiddleware функционирует в рамках ASGI-приложения, которое является основным компонентом таких фреймворков, как FastAPI и Starlette. Более того, такое промежуточное ПО разделяет с ASGI-приложениями одну и ту же сигнатуру
coroutine application(scope, receive, send)
. В целом, реализация и поддержка ASGIMiddleware сложнее, чем BaseHTTPMiddleware, из-за более низкого уровня абстракции, предлагающего более прямое взаимодействие с протоколом ASGI. Важно отметить, что ASGIMiddleware не подвержено некоторым важным ограничениям BaseHTTPMiddleware.
Поскольку промежуточные модули выступают в качестве прослойки в жизненном цикле "запрос-ответ", ожидается, что они будут влиять на производительность приложения. Вопрос в том, насколько сильно? И в чем разница между двумя типами промежуточных модулей, поддерживаемых в FastAPI?
Процесс выявления
Во время нагрузочного тестирования производительности с помощью ApacheBench (ab) мы обнаружили, что, хотя приложение может обрабатывать требуемый RPS, он всё еще кажется довольно низким для асинхронного приложения, которое не содержит никаких значительных вызовов, привязанных к процессору. Еще одним поводом для беспокойства стало то, что RPS не масштабируется линейно с количеством экземпляров приложения. Мы решили изучить этот вопрос подробнее.
После интенсивного исследования мы пришли к пересмотру наших промежуточных программ и обнаружили недостатки в производительности, которые я проиллюстрирую экспериментами ниже.
Эксперименты и анализ
Здесь представлены пять экспериментов, которые хорошо отражают процесс исследования, через который прошла наша команда. Я буду выполнять запросы ApacheBench (ab) в каждом экспериментальном случае с параллелизмом 100, чтобы измерить RPS нашего приложения с различными промежуточными продуктами. Я буду запускать каждый случай по пять раз и усреднять результаты.
>> ab -c 100 -n 1000 http://127.0.0.1:8000/
Настройка эксперимента
Пример 0: базовый уровень (без промежуточных устройств)
Начнем с простого приложения FastAPI, содержащего одну конечную точку и не имеющего промежуточного программного обеспечения.
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
Пример 1: одно простое среднее программное обеспечение
Здесь представлены реализации обоих типов промежуточных программ. Оба они фактически ничего не делают.
# BaseHTTPMiddleware
from urllib.request import Request
from starlette.middleware.base import BaseHTTPMiddleware
class HTTPMiddleware:
async def __call__(self, request: Request, call_next):
return await call_next(request)
app.add_middleware(BaseHTTPMiddleware, dispatch=HTTPMiddleware())
# ASGIMiddleware
from starlette.types import ASGIApp, Send, Scope, Receive
class ASGIMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
await self.app(scope, receive, send)
app.add_middleware(ASGIMiddleware)
Пример 2: пять простых, объединенных в стек программных обеспечений
Программные обеспечения по-прежнему ни на что не влияют, но можно попробовать сложить их в один стек, чтобы узнать, что будет!
# BaseHTTPMiddleware
for _ in range(5):
app.add_middleware(BaseHTTPMiddleware, dispatch=HTTPMiddleware())
# ASGIMiddleware
for _ in range(5):
app.add_middleware(ASGIMiddleware)
Пример 3: Пять стекированных блокирующих ПО
До сих пор наши тесты были очень синтетическими. Давайте попробуем сделать их более реалистичными, добавив немного логики в наши промежуточные модули. В асинхронном приложении очень важно избегать синхронных вызовов, которые блокируют весь цикл событий и могут сильно повлиять на производительность, но некоторые блокирующие вызовы неизбежны - например, логирование (не по теме: выгрузка логирования в отдельный поток может помочь минимизировать влияние).
Давайте добавим несколько массивных блокирующих вызовов в наши промежуточные модули.
# BaseHTTPMiddleware
class HTTPMiddleware:
async def __call__(self, request: Request, call_next):
with open('/tmp/middleware.log', 'a') as f:
f.write('HTTPMiddleware\n' * 1000)
return await call_next(request)
# ASGIMiddleware
class ASGIMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
with open('/tmp/middleware.log', 'a') as f:
f.write('ASGIMiddleware\n' * 1000)
await self.app(scope, receive, send)
Пример 4: Пять стекированных неблокирующих ПО
Давайте посмотрим, влияет ли добавление асинхронных вызовов в наши промежуточные модули. Здесь я включаю вызов asyncio.sleep for 10ms
в нашу логику.
# BaseHTTPMiddleware
class HTTPMiddleware:
async def __call__(self, request: Request, call_next):
await asyncio.sleep(0.01)
return await call_next(request)
# ASGIMiddleware
class ASGIMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
await asyncio.sleep(0.01)
await self.app(scope, receive, send)
Пример 5: Пять стекированных неблокирующих ПО с 100 ms sleep
То же самое, что и в предыдущем случае, но с увеличенным до 100 мс временем сна - возьмм его для представления нескольких асинхронных вызовов базы данных или обнаружения сервисов.
Результаты и наблюдения
Вот краткое изложение результатов экспериментов, описанных выше.
Пример 1: одно простое программное обеспечение
- Эффект от добавления одного промежуточного ПО довольно заметен, что говорит нам о заметных накладных расходах на работу промежуточного ПО.
- BaseHTTPMiddleware работает на ~15% медленнее.
Пример 2: пять простых, объединенных в стек программных обеспечений
- Влияние выполнения пяти BaseHTTPMiddlewares значительно. RPS падает на порядок по сравнению с Baseline.
- ASGIMiddleware практически не страдает от суммирования.
Пример 3: Пять стекированных блокирующих ПО
- Тяжелый блокирующий вызов не оказывает большого влияния на производительность BaseHTTPMiddleware, что говорит нам о том, что время ответа, вероятно, уже насыщено накладными расходами промежуточного ПО.
- Влияние на ASGIMiddleware значительно, что вполне ожидаемо.
Пример 4: Пять стекированных неблокирующих ПО
- BaseHTTPMiddleware с
sleep
работает чуть медленнее, чем в случае 2, где ПО промежуточного слоя ничего не делает, что в некоторой степени подтверждает идею о том, что мы максимально снизили влияние накладных расходов ПО промежуточного слоя. - ASGIMiddleware по-прежнему быстрее, но в меньшей степени.
Пример 5: Пять стекированных неблокирующих ПО с 100 ms sleep
- Наконец-то промежуточные модули разных типов стали работать наравне друг с другом.
Выводы и рекомендации
По результатам экспериментов BaseHTTPMiddleware имеет ощутимый прирост производительности по сравнению с ASGIMiddleware. Чем меньше время обработки логики промежуточного ПО, тем более существенной будет разница в производительности между двумя типами промежуточного ПО.
По всей видимости, это происходит из-за рутин управления задачами anyio, используемых Starlette для работы с промежуточными программами.
Класс BaseHTTPMiddleware предоставляет пользователю хорошую и простую абстракцию. Предоставляя непосредственный доступ к запросу, он делает разработку и поддержку промежуточного ПО легкой и экономически эффективной. В то же время за абстракцию приходится платить накладными расходами (выполнение задач, управление исключениями и потоками через библиотеку anyio).
ASGIMiddleware, в свою очередь, гораздо сложнее в реализации и поддержке. Фактически оно заставляет вас опустить абстракцию до уровня протокола ASGI. Это усугубляет кривую обучения и повышает требования к тестированию и обеспечению качества, но может принести разумный выигрыш в производительности.
Вот как выглядит эффект от перехода с BaseHTTPMiddleware на ASGIMiddleware в реальной жизни (оптимизированные промежуточные модули отмечены стрелками) - 20-30 % улучшение времени обработки запроса в одной конечной точке.
Даже выигрыш в производительности выглядит впечатляюще, как это часто бывает - это компромисс со сложностью. Если только вы не полагаетесь на множество стековых промежуточных модулей для своей бизнес-логики, я бы не советовал использовать ASGIMiddleware раньше, чем вас заставят это сделать требования к RPS и/или времени обработки запросов. Но важно держать эту опцию на вооружении и использовать ее, когда другие, более очевидные возможности оптимизации будут исчерпаны.
Заключение
Промежуточные модули FastAPI - это мощный инструмент для реализации различных функций, таких как аутентификация, обработка CORS, ведение логов, добавление пользовательских метрик и многое другое. Однако накладные расходы на выполнение промежуточного ПО могут быть значительными и должны учитываться при определении размера приложения. Я настоятельно рекомендую избегать преждевременной оптимизации и сохранять дизайн как можно более простым, что поможет увеличить время выхода на рынок и снизить затраты на обслуживание. Если же требования вынуждают вас к оптимизации, то переход на ASGIMiddleware может стать хорошим вариантом для рассмотрения.