Python в production: ASGI, gunicorn/uvicorn, конфигурация и observability

Production-дисциплина для Python-сервиса: WSGI/ASGI и серверы (gunicorn/uvicorn), воркеры и GIL, конфигурация из env, graceful shutdown, structured logging и OpenTelemetry

Прототип на FastAPI запускается одной командой — а вот довести его до эксплуатации под нагрузкой уже дисциплина: правильный сервер и число воркеров, конфигурация, корректное завершение и наблюдаемость.

Три из этих четырёх вещей обычно описывают словами: «выберите число воркеров по формуле», «включите graceful shutdown», «валидируйте конфигурацию на старте». Здесь они измерены, и каждый совет оказался точнее или неожиданнее, чем его обычная формулировка.

digital-cookbook/python/web

Ретрофутуристичная схема-блюпринт последовательности остановки: по конвейеру стоят восемь механических рук с номерами от 1 до 8, каждая держит поднятым ровно одно изделие, снизу их объединяет скоба с подписью «в руках у воркеров — эти уцелели»; позади них поперёк ленты опущен тяжёлый затвор «SIGTERM», рядом табличка «времени хватало: 0,26 с»; дальше по ленте плотная масса остальных изделий с подписью «остальные 24 из 32» осыпается с края в ящик «потеряно: 24»; слева вверху два одинаковых блока с подписью «один и тот же ASGI, разные команды запуска» — у левого «uvicorn –workers» на цепи висит гиря «+50 мс», у правого «gunicorn -k UvicornWorker» гири нет, между ними метка «на порядок»; справа вверху экран с зелёной надписью «/health: 200», подключённый к треснувшему механизму с отсоединённой подтекающей трубой, подпись «исправен по прибору, нерабочий по существу»

В статье

Сколько нужно воркеров

Правильный ответ начинается с вопроса «какая у вас нагрузка», и разница между двумя ответами радикальная.

Одно и то же приложение, 64 одновременных запроса, ручка с ожиданием 20 миллисекунд вместо похода в базу:

способ запуска 1 воркер 8 воркеров рост
gunicorn, синхронные воркеры 48,2 390,7 8,11x
gunicorn, gthread 391,1 1849,9 4,73x ⚠️
gunicorn + uvicorn-воркеры 2927,1 2934,7 1,00x

⚠️ Строку gthread по числам читать нельзя, и это честнее показать, чем спрятать. У четырёх воркеров пять кругов дали 877, 1513, 1519, 1609 и 1625 запросов в секунду, у восьми — 1177, 1574, 1850, 2462 и 2632. Разброс 1,85x и 2,24x при 1,01–1,10x у остальных плеч; собственный анализатор стенда такие клетки к ранжированию не допускает. Качественно gthread ведёт себя как промежуточная модель — масштабируется лучше синхронной и хуже асинхронной, — но конкретный множитель роста из этих фикстур брать нельзя.

Синхронная модель совпала с формулой с точностью до процента: 48,2 / 96,6 / 193,6 / 390,7 запроса в секунду при одном, двух, четырёх и восьми воркерах — против теоретического потолка 50 / 100 / 200 / 400. Разброс по кругам у неё 1,01–1,04x, то есть числу можно верить.

Формула простая и стоит того, чтобы её помнить:

пропускная способность = воркеров / время ответа

Синхронный воркер занят всё время ожидания целиком. При ответе в 20 миллисекунд один воркер даёт 50 запросов в секунду, и одновременность на это не влияет никак — лишние запросы просто стоят в очереди. Видно это по задержке: при четырёх воркерах и 64 одновременных запросах p50 равен 329 миллисекундам, хотя сам ответ занимает двадцать.

Асинхронной модели воркеры на ожидании не нужны — 1,00x. Один цикл событий уже держит 2927 запросов в секунду, добавление процессов не даёт ничего.

А вот на счётной нагрузке картина переворачивается: воркеры нужны всем моделям, потому что это отдельные процессы и они обходят GIL. Синхронные дают 6,80x при восьми воркерах — согласуется с замерами GIL, где восемь процессов дали 6,53x.

Отсюда практическое правило. Считаете число воркеров под ожидание — берите формулу выше и добавляйте запас на пики. Считаете под счёт — упирайтесь в число ядер, дальше процессы начнут мешать друг другу.

Способ запуска стоит порядка величины

Это тот случай, когда замер дал результат, которого я не ожидал, и он важнее формулы выше.

Тот же FastAPI, те же четыре воркера, разница только в команде запуска. Класс воркера берётся из отдельного пакета uvicorn-worker — встроенный uvicorn.workers.UvicornWorker объявлен устаревшим самим uvicorn:

команда запросов в секунду p50
uvicorn app:app --workers 4 1283 50,00 мс
gunicorn -k uvicorn_worker.UvicornWorker -w 4 16905 3,83 мс

Разрыв в тринадцать раз при одинаковом приложении и одинаковом числе ASGI-воркеров. Обе клетки устойчивы: разброс по кругам 1,01x и 1,11x.

Выдали механизм круглые числа. Задержка при многопроцессном режиме uvicorn оказалась ровно 50,00 миллисекунды на пустой ручке и ровно 70,00 на ручке с ожиданием 20 миллисекунд. Число воркеров на неё не влияет: 50,00 при двух, четырёх и восьми.

Но «пятьдесят миллисекунд каждому запросу» было бы точнее измеренного. Ступень отлично видна на медиане и держится на p95 (50,3–50,5 мс), а вот p99 уходит к 60 мс — то есть распределение получило ступень около пятидесяти миллисекунд, но хвост сидит ещё десятью выше. На ручке с ожиданием то же самое со сдвигом на двадцать: 70,0 в медиане, 70,2 на p95, 80,0 на p99. Что это за задержка — стенд не выяснил. Он показывает постоянную добавку у конкретного способа запуска на конкретной версии и конкретной машине; ни слой, ни причина не локализованы. Утверждать можно ровно то, что видно: ступень не зависит ни от числа воркеров, ни от того, что делает ручка, и к модели обработки она отношения не имеет — та же модель под gunicorn её не даёт.

Порог — ровно два воркера. При одном uvicorn --workers 1 неотличим от gunicorn: 4219 против 4274 запросов в секунду, разница задержек 0,25 миллисекунды. Добавка появляется при переходе к двум и дальше не растёт.

Полезнее самого числа здесь методологический вывод. По первым данным выходило, что uvicorn теряет пропускную способность втрое при росте с одного воркера до двух: 2922 против 914 на ручке с ожиданием. Из этого напрашивался вывод «воркеры не помогают асинхронной модели» — правдоподобный, укладывающийся в общую логику статьи и полностью ложный. Спасло то, что ступень ровно в 50 миллисекунд не похожа на свойство модели, а второй способ развёртывания вопрос закрыл.

Величина разрыва зависит от того, что делает ручка. На пустой это тринадцать раз, на ручке с ожиданием — три с четвертью: 915 против 2949. Фиксированные 50 миллисекунд весят тем больше, чем короче сам ответ.

Проверяйте свою команду запуска замером, а не доверием. Числа в статьях, включая эту, получены на конкретной версии и конкретном окружении, и разница между двумя правильными на вид командами может стоить порядка.

Что на самом деле защищает graceful shutdown

Обещание «мы корректно завершаемся» проверяется одним вопросом: что произойдёт с запросами, которые уже в работе, если оркестратор пришлёт SIGTERM.

Замер устроен так: под постоянной нагрузкой в 32 одновременных запроса серверу шлётся сигнал, и считается судьба тех запросов, которые были в полёте в этот момент. Запросы, начавшиеся после сигнала, потерями не считаются — сервер вправе их не принимать.

Контроль обязателен и идёт первым. Каждый сервер гасится дважды: штатно и обрывом через SIGKILL. При обрыве теряются все 32 из 32 — значит потери в принципе видны, и ноль означает настоящий ноль, а не слепоту замера.

сервер воркеров в полёте уцелело при SIGTERM
gunicorn 1 32 1
gunicorn 2 32 2
gunicorn 4 32 4
gunicorn 8 32 8
uvicorn — 32 32

Совпадение точное на всех четырёх точках, и его надо объяснить.

Что здесь измерено буквально. Замер видит происходящее глазами клиента: он знает, что отправил 32 запроса и получил ответ ровно на столько, сколько у сервера воркеров. Состояние очереди внутри ядра стенд не наблюдает — для этого нужна инструментация на стороне сервера или сети, и её здесь нет.

Механизм, который это объясняет — и который остаётся гипотезой, пусть и хорошо согласованной с данными. Синхронный воркер держит ровно один запрос. Остальные соединения установлены, но их никто не обрабатывает, и корректному завершению нечего с ними делать: уцелевают те, что были у воркеров в руках. Совпадение числа выживших с числом воркеров на всех четырёх точках сильно поддерживает такое объяснение, но строго доказать его клиентскими наблюдениями нельзя.

Отсюда вывод — с границей применимости, потому что без неё он превращается в правило, которого замер не даёт. На этой конфигурации «мы включили graceful shutdown» у синхронной модели означало: уцелело столько запросов, сколько воркеров, а остальные из тридцати двух потерялись. Совпадение на четырёх точках подряд позволяет ожидать того же от gunicorn с синхронными воркерами при похожей нагрузке — но именно ожидать, а не гарантировать: другой сервер, другой профиль запросов или другая длина очереди могут дать иную картину, и проверяется это только замером на вашей стороне.

И наблюдение, которое стоит запомнить отдельно: увеличение таймаута дренажа здесь не помогло. Время завершения составило 0,26 секунды при одном, двух и четырёх воркерах и 0,36 при восьми — то есть завершение не упиралось в отведённое время и добавка к нему ничего бы не изменила. Почему дренировать оказалось нечего — см. гипотезу выше; из данных следует только то, что времени хватало с запасом.

У асинхронной модели картина вышла другой: uvicorn сохранил все 32 запроса из 32. Объясняется это тем, что цикл событий держит все принятые запросы одновременно, и дренировать есть что — но и здесь это объяснение, а не измеренный механизм.

Важнее другая оговорка. Замер снят на uvicorn без ограничений: не заданы ни предел одновременных задач, ни предел соединений, ни свой таймаут корректного завершения. Всё это у uvicorn настраивается, и настройки меняют картину: с ограничением одновременности лишние соединения окажутся ровно в том же положении, что у синхронной модели, а короткий таймаут дренажа оборвёт то, что не успело. Так что «асинхронная модель дренирует всё» — вывод не про модель, а про конкретную конфигурацию без лимитов. Свою проверяйте своим замером.

Что с этим делать практически. Если вы на синхронной модели и потери при выкатке недопустимы — рычаг находится не в приложении, а перед ним: трафик должен перестать приходить до того, как процессу придёт SIGTERM.

В Kubernetes для этого используют preStop-хук, но подавать его как гарантию неверно, и вот почему. Удаление пода запускает две вещи параллельно: выполнение хука и обновление EndpointSlice. Хук не ждёт, пока балансировщики узнают об исключении, а лишь даёт время, в течение которого это может произойти. Сколько именно нужно — зависит от того, что стоит перед подом: kube-proxy на узлах обновляется быстро, а внешний балансировщик или ingress-контроллер со своим пулом может отставать на секунды.

Плюс два ограничения, о которых легко забыть. Время хука тратится из общего terminationGracePeriodSeconds — если задержка в хуке съест весь бюджет, на сам дренаж не останется ничего.

Второе — про готовность, и здесь важно различать два пути трафика. Для обычного Service снимать readiness вручную не нужно: под, у которого пошло завершение, помечается в EndpointSlice как не готовый сам, и kube-proxy перестаёт его выбирать. А вот всё, что ходит в под мимо Service — ingress-контроллер со своим пулом соединений, внешний балансировщик облака с собственными проверками, service mesh со своей регистрацией, — узнаёт об исключении по своим правилам и в свои сроки. Вот там сигнал «я больше не готов» и нужен явно, вместе с пониманием, как устроено снятие с регистрации именно у вашего балансировщика.

Так что preStop — это способ купить время, а не переключатель. Сколько времени покупать, определяется вашей топологией, и проверяется это тем же способом, что и всё в этой статье: выкаткой под нагрузкой со счётом потерь.

Конфигурация: отказ против ложной готовности

Совет «валидируйте конфигурацию на старте» верен, но его обычно обосновывают неправильно — экономией времени. Замер показывает, что дело в другом.

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

режим конфигурация поднялся /health рабочая ручка код выхода
наивный корректная да 200 200 —
наивный сломанная да 200 500 —
строгий корректная да 200 200 —
строгий сломанная нет — — 78

Смотрите не на последнюю строку, а на вторую. Наивный режим при сломанной конфигурации поднимается, занимает порт и отвечает успехом на проверку здоровья, хотя ни одного рабочего запроса выполнить не может.

Проверка здоровья написана самым обычным образом — она не заглядывает в конфигурацию. Именно поэтому она и врёт. Для оркестратора такой экземпляр исправен: он пройдёт readiness, встанет в балансировку и начнёт получать трафик, который будет отдавать пятисотками.

Строгий режим не поднимается вовсе и выходит с кодом 78 — это EX_CONFIG из sysexits.h, и он отличает ошибку конфигурации от прочих отказов.

Разница по времени при этом мизерная: 0,20 секунды против 0,37. Так что аргумент «падать раньше — быстрее» здесь не работает, и опираться на него не стоит. Работает другой: разница в том, что́ система сообщает наружу — отказ или ложную готовность.

from pydantic import BaseModel, Field, ValidationError
import os, sys

class Settings(BaseModel):
    database_url: str = Field(pattern=r"^postgresql://")
    pool_size: int = Field(ge=1, le=100)

try:
    SETTINGS = Settings(
        database_url=os.environ.get("DATABASE_URL", ""),
        pool_size=int(os.environ.get("POOL_SIZE", "0") or 0),
    )
except (ValidationError, ValueError) as exc:
    print(f"конфигурация отвергнута: {exc}", file=sys.stderr)
    raise SystemExit(78)     # EX_CONFIG

Ключевое здесь — что этот код выполняется при импорте, до того как сервер начнёт слушать порт. И что проверка здоровья, если она у вас есть, должна отражать способность обслуживать запросы, а не факт, что процесс жив.

Наблюдаемость

Здесь я обязан обозначить границу: этот раздел не измерен. Стенд серии меряет модели развёртывания, завершение и конфигурацию, но накладных расходов трассировки и структурного логирования не снимает. Дальше — устройство и практика, без чисел.

Структурные логи — structlog или logging с JSON-форматтером. Смысл не в формате как таковом, а в том, что в каждую запись попадает идентификатор трассы: иначе логи и трассы живут отдельно, и связать их в момент разбора инцидента не получится.

OpenTelemetry для Python даёт автоматическую инструментацию популярных библиотек — HTTP-сервер, клиенты, драйверы баз. Начинать стоит с неё: она покрывает большую часть картины без правок кода. Ручные спаны добавляются там, где автоматических не хватает — вокруг доменных операций, которые вы хотите видеть отдельно.

Про накладные расходы. Трассировка не бесплатна, и её цену надо измерить на своей нагрузке, а не принять на веру. Тот же принцип, что с профайлерами в статье про производительность: инструмент, включённый постоянно, обязан иметь измеренную цену. Сэмплирование — основной рычаг, но выбирать долю сэмплов без замера значит гадать.

Health и readiness — разные вещи. Первое отвечает «процесс жив», второе — «я готов принимать трафик». Раздел выше показывает, во что обходится их смешение.

Чек-лист

  1. Число воркеров считайте по типу нагрузки. Ожидание — «воркеров ÷ время ответа» плюс запас. Счёт — упирайтесь в ядра.
  2. Замерьте свою команду запуска. Две правильные на вид команды разошлись в тринадцать раз на одном приложении.
  3. Проверьте, что даёт ваш graceful shutdown, отправив сигнал под нагрузкой — и именно проверьте, а не примите на веру. На стенде у синхронной модели уцелело ровно столько запросов, сколько воркеров, и увеличение таймаута этого не меняло; ваша конфигурация может вести себя иначе, и узнать это можно только замером.
  4. Уводите трафик до сигнала, если потери недопустимы: preStop и задержка перед SIGTERM.
  5. Валидируйте конфигурацию при импорте и выходите с кодом 78. Не ради скорости отказа, а чтобы не было ложной готовности.
  6. Health не должен отвечать успехом, когда приложение не работает. Проверьте это, сломав конфигурацию намеренно.
  7. Измерьте цену трассировки на своей нагрузке, прежде чем включать её постоянно.

Соседние сюжеты

Выбор фреймворка и то, почему разница между ними тонет в первом же походе в базу, — в статье про web-фреймворки. Природа ограничения, из-за которого синхронный воркер занят всё время ожидания, — в статье про asyncio и GIL.

По аналогии с production-паттернами Rust и JVM, но со спецификой Python. Про сбор телеметрии — OpenTelemetry Collector.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии