Как должны выглядеть современные сервисы на питоне, многие имеют представление. Все они, так или иначе, имеют поддержку асинхронных операций. А вот, как их лучше деплоить? Здесь некоторые руководства (как FastAPI) отвели целый раздел для рекомендаций, а некоторые (как Django) ограничились несколькими абзацами с крайне размытыми формулировками. Мне не посчастливилось следовать именно последнему.

Прочитав эту статью, Вы, возможно, захотите внести изменения в докерфайлы Ваших сервисов. Благодаря протоколам WSGI и ASGI, это можно сделать без особого труда. Именно поэтому все изложенные в статье советы - вредные. Также, Вы узнаете о nginx unit - ещё об одном годном сервере приложений.

Немного предыстории. На одном проекте у нас с командой в очередной раз встала задача деплоя питоновского веб-приложения. Обычно выбор стоит между gunicorn и uwsgi. Но в этот раз я успел услышать про новый сервер nginx unit и очень хотел его попробовать. Предварительно, конечно, нужно было отговорить команду от всех других адекватных вариантов.

Например, возьмём gunicorn - традиционный вариант, асинхронные фреймворки часто рекомендуют именно его. Для него есть асинхронный воркер, наряду с синхронным. Однако, если мы немного почитаем документацию, нас будет ждать разочарование: все воркеры должны быть идентичными. То есть, выбирайте: или все синхронные, или все асинхронные. Хотите тех и других - делайте 2 сервиса и ставьте перед ними nginx. Если всё равно нужен nginx - подумал я, не лучше ли сразу взять nginx unit? Этот аргумент подействовал, и так мы стали использовать nginx unit.

Некоторые читатели, конечно, видят изъян в моих рассуждениях: не обязательно иметь и синхронные, и асинхронные сервисы. Некоторые прекрасно обходятся только последними: заворачивают синхронные операции, если они есть, в асинхронные и горя не знают. Выполняются синхронные операции при этом в отдельном потоке - я говорю сейчас об "адаптерах" вроде sync_to_async. Но лучше я сначала немного расскажу о nginx unit.

Прекрасная документация, удобный API, очень настраиваемый - с nginx unit действительно приятно работать. В интернете пишут, что он хорошо ведёт себя в бенчмарках - я этого не проверял. У nginx unit действительно есть понятие логического "приложения", которое, скорее всего, всегда соответствует отдельному запущенному процессу. Таких приложений может быть запущено множество, и запросы могут роутиться на определённые из них, исходя из каких-то критериев. Я сделал 2 приложения, не оригинально назвав их wsgi и asgi. При этом, они слушают один и тот же порт - чудеса.

Немного о самих протоколах WSGI и ASGI. Нет, особо рассказывать не буду: ну, имеет наше сообщество тягу к использованию протоколов, это же прекрасно. Последний из двух - ASGI - появился вообще случайно, и изначально преследовал другие цели, насколько мне известно. Вебсокеты мы реализовывать не будем - пусть веб-сервер этим занимается, а нам даст вместо соединения корутину, которая умеет читать и писать - примерно такова его идея. Он не является официальным стандартом: для него нет соответствующего PEP. Тем не менее, стандарты вроде WSGI и ASGI существенно облегчают создание веб-серверов вроде nginx unit.

{
    "listeners":{
        "*:8000":{
            "pass":"routes/proj"
        }
    },
    "routes": {
        proj: {...}
    },
    "applications":{
        "wsgi":{
            "type":"python 3",
            "protocol": "wsgi",
            "path":"/app",
            "module": "proj.wsgi",
            "callable": "application"
        },
        "asgi":{
            "type":"python 3",
            "protocol": "asgi",
            "path":"/app",
            "module": "proj.asgi",
            "callable": "application"
        }
    }
}

Вот так выглядел примерный конфиг того, что получилось. Как видите, в нём действительно есть wsgi и asgi приложения. Напомню, что был и альтернативый вариант - иметь только асинхронный сервис (asgi), выполняя все функции с синхронным I/O в отдельном потоке. Итак, мы подошли к довольно интересному вопросу: нужно ли нам вообще WSGI приложение? Давайте сравним эти два варианта. Схеме с wsgi приложением дадим кодовое название "первый вариант", а схеме без него - второй. Насколько я знаю, общепринятым является именно второй вариант - где есть только ASGI-приложение (если я неправ, поправьте меня).

Итак, в чём же разница?

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

  2. Давайте подумаем: насколько асинхронный воркер (второй вариант) подходит для обработки "синхронных" запросов? Что представляет из себя такой воркер? Асинхронный поток и несколько синхронных потоков. Насколько такой воркер знает о своей нагрузке (сколько запросов у него в очереди, может ли он принимать новые) ? Ничего не знает. Можно только надеяться, что нагрузка на воркеры будет более-менее однородной. В первом же случае (синхронные воркеры) всё очень просто: воркер или занят, или свободен. Конечно, новые запросы распределяются на свободные воркеры. Воркеров много, поэтому они хорошо сглаживают неоднородную нагрузку.

Резюмируя: есть веские причины обрабатывать всю синхронную нагрузку вне асинхронных потоков, а последние по максимуму использовать для асинхронной нагрузки - для чего, собственно, они и предназначены. Справедливости ради, иногда при обработке асинхронных запросов мы всё-таки используем функции с синхронным I/O - конечно, для этого приходится их запускать в отдельном потоке. Но чем такой нагрузки меньше - тем лучше.

Именно это я имел в виду, когда говорил, что прочитав статью, Вы, возможно, захотите деплоить Ваше приложение по-другому (захотите добавить WSGI-приложение). Об этом можно проголосовать в конце статьи. Сейчас же давайте остановимся на некоторых практических аспектах. Мы остановились на том, что у нас есть 2 приложения, WSGI и ASGI, которые слушают один и тот же порт. Но как понять, какой запрос обрабатывать в WSGI-приложении, а какой в ASGI? Какой эндпоинт асинхронный, а какой нет? Об этом знает наше питоновское приложение (потому что у асинхронного эндпоинта функция-обработчик асинхронная). Но nginx unit не знает. Можно ли как-то решить этот вопрос, не прибегая к использованию специальных урлов для асинхронных эндпоинтов?

Оказывается, что с nginx unit - можно, и достаточно несложно. Дело в том, что, как я писал, nginx unit очень конфигурируемый, и роутинг запросов - одна из наиболее конфигурируемых его частей. Конфигурационный файл - это json, его можно сгенерировать автоматически - целиком или какие-то его части. Так мы и сделали - сгенерировали config.json, в котором структура routes отражают структуру urls.py в нашем Django приложении.

Если кому-то интересно, как может выглядеть такой генератор для routes - то примерно вот так https://github.com/pwtail/newunit/blob/master/generate_routes.py. Это черновой вариант - более продвинутый, чем тот, что у нас на проде, но менее отлаженный.

В итоге, в нашем django-приложении мы можем сделать вьюшку либо синхронной функцией (что бывает чаще всего), либо - асинхронной, и всё магически будет обработано именно там, где нужно.

Не могу не сказать пару слов об особенностях "поддержки" асинхронности в Django. Этот фреймворк очень заботится о разработчиках, поэтому пытается застраховать их от возможных ошибок. Например, если Вы сделали функцию-обработчик асинхронной, но что-то не сложилось для асинхронной обработки Вашего запроса (например, есть неподходящее middleware), Django молчаливо адаптирует вашу асинхронную функцию в синхронную. Если у Вас асинхронный I/O, но Вы задеплоили WSGI-приложение - тоже адаптирует, если синхронный I/O в ASGI-приложении - аналогично. Это может быть удобно для dev-сервера, но для продакшна подход немного странный, на мой взгляд. Стоит ли говорить, что с нашим автоматическим роутингом запросов в нужное приложение, такая "дружественность к разработчику" ничего полезного, кроме того, что прячет ошибки, не делает. Отключить такое поведение непросто (из коробки - никак).

В остальном же - да, можно сказать, что Django поддерживает асинхронность. А nginx unit - действительно очень настраиваемый. Можно даже разные HTTP-методы для одного урла в разные приложения направлять. Но такое сам Django, увы, не поддерживает: нельзя, чтобы, скажем, метод GET обрабатывался синхронной функцией, а метод POST - асинхронной. С последним фактом я, конечно, мириться не стал, и зафайлил на это баг: https://code.djangoproject.com/ticket/33780. Его закрыли через 5 минут (да, я умею настраивать нужные параметры в баг-трекере) как дубликат: оказывается, 16 лет назад уже предлагали что-то похожее.

Напоследок - как и обещал, опрос.

Комментарии (5)


  1. edo1h
    16.06.2022 06:33

    Если всё равно нужен nginx — подумал я, не лучше ли сразу взять nginx unit

    странная логика, это два совершенно разных проекта


    1. abetkin Автор
      16.06.2022 09:40

      Я написал отчасти в шутку, но эти проекты не такие разные, как Вы думаете :)


  1. stepacool
    16.06.2022 09:02
    +2

    выполняя все функции с синхронным I/O в отдельном потоке

    Я не совсем понял - поток же быстро "застрянет"? Одна блокирующая операция на несколько секунд заставит все остальные ждать. И так быстро дойдет до сотен секунд. Почему один поток для синхронных операций? Почему много потоков для асинхронных операций? Наоборот же.

    Резюмируя: есть веские причины обрабатывать всю синхронную нагрузку вне асинхронных потоков

    Множественное число у асинхронных потоков - почему? Асинхронность - про один тред с event loop и прочим, если мы про async, а не более глобально(параллельность).

    Может произошла коллизия терминов, и вы имели ввиду под "потоками" не Thread из ОС, а в целом нечто абстрактное как "работа". Но думаю, если вы про Threads, то что-то странное написано...


    1. abetkin Автор
      16.06.2022 09:37

      Я никоим образом не хотел сказать, что "синхронный" поток только один. "исполняя в отдельном потоке" - я просто имел в виду, что не в том же самом. Коллизии терминов нет, я имею в виду использование thread pool для выполнения синхронных функций - треды обычные, системные. Что касается множественного числа асинхронных потоков - ну конечно, их может быть несколько. Каждый в своём процессе, у каждого свой event loop. Запустив несколько асинхронных воркеров, Вы сможете лучше использовать возможности Вашей многоядерной системы. Иногда имеет смысл иметь только один асинхронный поток - когда он хранит что-то в памяти, например, список соединений


      1. stepacool
        16.06.2022 15:19

        Да, тогда понятно. Ну я бы отредактировал, а то неоднозначно можно воспринять текст.

        Про множественное число асинхронных потоков я удивился в контексте одного процесса - зачем, это лишь оверхед. Если в разных воркерах/процессах - конечно, там все "отдельное".