RE:NODE

Приложения14 мин чтения

Деплой FastAPI и Flask: uvicorn, gunicorn и воркеры

Как запустить приложение на FastAPI или Flask в продакшене: ASGI и WSGI, привязка к 0.0.0.0, число воркеров и память, заголовки прокси, переменные окружения и health-проверки.

0 прочтений

Приложение, которое работает у вас на ноутбуке под flask run или uvicorn main:app --reload, ближе к продакшену, чем принято думать. Привяжитесь к 0.0.0.0 вместо localhost, берите порт из окружения, замените сервер разработки на gunicorn или uvicorn, выберите число воркеров, которое ваша память реально потянет, и скажите приложению, что оно стоит за прокси. Вот и вся работа из пяти пунктов. Всё, что ниже, - подробности к ним: числа, по которым считаются воркеры, настройки, меняющие поведение, и ошибки, которые вы встретите примерно в том порядке, в каком они перечислены.

Два фреймворка различаются в одном, и от этого зависит почти каждый последующий выбор. Flask синхронный и говорит на WSGI. FastAPI асинхронный и говорит на ASGI. Разберитесь с этим, и остальное встанет на место.

ASGI или WSGI: что нужно вашему фреймворку#

WSGI - старый синхронный интерфейс веб-приложений в Python: сервер передаёт приложению запрос и ждёт ответа. Один воркер обрабатывает один запрос за раз. Это просто, это проверено годами, и удержать открытым websocket он не умеет.

ASGI - асинхронная замена. Один процесс может держать тысячи соединений одновременно, если код внутри него отдаёт управление, пока ждёт ввода-вывода. Именно поэтому websocket, server-sent events и long-polling возможны в одном процессе.

ФреймворкИнтерфейсКакой сервер использоватьСтрока импорта
FlaskWSGIgunicornwsgi:app
DjangoWSGI (ASGI по желанию)gunicornmyproject.wsgi:application
FastAPIASGIuvicornapp.main:app
StarletteASGIuvicornapp.main:app

Flask поддерживает функции представлений async def начиная с версии 2.0, и в это легко вчитать лишнее. Flask выполняет асинхронное представление, запуская цикл событий на один этот запрос и дожидаясь его завершения, так что воркер всё равно занят на весь запрос. Вы получаете синтаксис, а не конкурентность. Если одному процессу нужно обслуживать много медленных запросов одновременно, вам нужен ASGI-фреймворк, а не async-представление в WSGI-фреймворке.

Обратный случай тоже стоит назвать. Если ваше приложение - несколько CRUD-эндпоинтов перед базой данных, Flask под gunicorn с несколькими потоками справится отлично, а механизмов, в которых можно ошибиться, у него меньше. Выбирайте FastAPI ради валидации типов и генерируемой схемы OpenAPI, а не потому, что async на бумаге быстрее.

Requirements, виртуальное окружение и шаг установки#

Всё, что запускает сервер, берётся из зафиксированного по версиям файла requirements, установленного в виртуальное окружение, которое принадлежит приложению. Не в системный Python и не в то, что pip подобрал в тот день.

requirements.txt
fastapi==0.115.0uvicorn[standard]==0.30.6gunicorn==22.0.0pydantic-settings==2.4.0psycopg[binary]==3.2.1
bash
$ python -m venv .venv$ .venv/bin/pip install --upgrade pip$ .venv/bin/pip install --no-cache-dir -r requirements.txt

Если вызывать .venv/bin/pip и .venv/bin/python напрямую, а не активировать окружение, исчезает целый класс проблем вида «у меня в шелле работает»: состояния шелла, в котором можно ошибиться, просто нет. --no-cache-dir важен на маленьком тарифе: кэш wheel-пакетов у pip лежит в домашнем каталоге и незаметно разрастается до сотен мегабайт на диске, который может быть всего 5 ГБ. В статье requirements и виртуальные окружения в Python разобраны фиксация версий, lock-файлы и пакеты, которые пытаются собраться сами на машине с половиной ядра.

Расширение [standard] у uvicorn стоит иметь. Оно подтягивает uvloop и httptools, которые заменяют написанные на чистом Python цикл событий и HTTP-парсер реализациями на C, а также библиотеку websockets. Это самое дешёвое улучшение пропускной способности для приложения на FastAPI, и стоит оно одной строки.

Где выполнять установку - это решение. Если поставить pip install -r requirements.txt перед командой запуска, каждый перезапуск будет ставить всё заново: это медленно, зато гарантирует, что работающий код и объявленные зависимости совпадают. Однократная установка вручную загружается быстрее, но расходится с файлом при первом же добавленном пакете, о котором забыли. На хосте, который забирает ваш репозиторий при старте, обычно правильнее первый вариант: pip пропускает то, что уже удовлетворено, поэтому перезапуск без изменений в зависимостях добавляет пару секунд, а не пару минут.

Команда запуска: привязка, порты и то, что ломается чаще всего#

Здесь спотыкается большинство первых деплоев, и причина всегда одна и та же. Внутри контейнера 127.0.0.1 означает сам контейнер. Снаружи до него не достучаться. Сервер обязан слушать 0.0.0.0.

bash
# FastAPI, один процесс$ uvicorn app.main:app --host 0.0.0.0 --port 8000# Flask, четыре воркера gunicorn$ gunicorn wsgi:app --bind 0.0.0.0:8000 --workers 4# FastAPI под gunicorn, который управляет воркерами uvicorn$ gunicorn app.main:app -k uvicorn.workers.UvicornWorker \    --bind 0.0.0.0:8000 --workers 4

Третья форма - традиционный способ запустить несколько воркеров uvicorn, и сейчас она в разгаре миграции. Начиная с uvicorn 0.30 встроенный модуль uvicorn.workers объявлен устаревшим в пользу отдельного пакета uvicorn-worker, класс в котором называется uvicorn_worker.UvicornWorker. Прежде чем копировать строку, проверьте, какую версию вы зафиксировали. Uvicorn умеет и сам запускать воркеры через --workers 4 без gunicorn вообще, и это на одну зависимость меньше при том же результате.

Строка импорта имеет вид module:attribute: путь к модулю записывается через точки, а атрибут - это объект приложения. app.main:app означает «переменную app внутри app/main.py». Если ваше приложение на Flask использует паттерн фабрики, gunicorn вызовет её сам: gunicorn "myapp:create_app()". Uvicorn для этого требует --factory.

Порт берите из окружения, а не прописывайте жёстко: порт, который выдаёт панель или платформа, не всегда 8000:

bash
$ uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_PORT:-8000}

Панели на базе Pterodactyl отдают выданный порт как переменную окружения на вкладке Startup; на других платформах используют PORT. Посмотрите на вкладку, возьмите то имя, которое там найдёте, и оставьте разумное значение по умолчанию для собственной машины.

Воркеры, потоки и сколько памяти стоит каждый#

Документация gunicorn советует (2 x cores) + 1 воркеров. Этот совет предполагает машину, ядра которой целиком ваши. На контейнере с жёсткой долей CPU в полъядра он даёт число, которое исчерпает вашу память задолго до того, как кому-нибудь поможет, потому что каждый воркер - это отдельный процесс операционной системы со своей копией интерпретатора и каждого импортированного модуля.

Измерьте один раз на своём приложении: ps -o rss= -p PID после того, как оно обработало какой-то трафик. Для начала: небольшое приложение на Flask занимает около 50-80 МБ на воркер; приложение на FastAPI с моделями pydantic и драйвером базы данных ближе к 80-150 МБ; всё, что импортирует numpy, pandas или библиотеку машинного обучения, - это другая весовая категория, и считать его нужно только по замерам.

Память тарифаДоля CPUFlask (синхронные воркеры)FastAPI (асинхронные воркеры)
1 GB0.5 core1-21
2 GB1 core2-31-2
4 GB1.5 cores3-42
6-8 GB2-3 cores4-62-3

Эти столбцы исходят из примерно 150 МБ на воркер и оставляют запас под тела запросов, кэши и изредка большие ответы. Асинхронный воркер выглядит скромно, потому что ему не нужны товарищи: один процесс uvicorn может обслуживать сотни одновременных запросов, пока работа сводится к ожиданию базы данных. Воркеры сверх купленной доли CPU ничего не дают.

Две настройки меняют картину:

  • --worker-class gthread --threads 4 даёт синхронному воркеру gunicorn четыре потока. Четыре запроса, которые все ждут базу данных, могут выполняться одновременно ценой памяти одного процесса. Для Flask-приложения, упирающегося в ввод-вывод, это самая выгодная настройка.
  • --max-requests 1000 --max-requests-jitter 100 отправляет воркер на покой примерно после тысячи запросов и запускает свежий. Это грубый инструмент, и это же способ пережить медленную утечку в зависимости, которую вы не контролируете. Разброс (jitter) не даёт всем воркерам перезапускаться одновременно.

Для FastAPI есть одно правило, которое важнее всех остальных: никогда не блокируйте цикл событий. Синхронный вызов базы данных, requests.get или тяжёлый по CPU цикл внутри обработчика async def останавливает все остальные запросы в этом процессе, пока он не закончится. От части проблем FastAPI защищает сам: обычные обработчики def он выполняет в пуле потоков на несколько десятков мест, поэтому синхронный эндпоинт безопасен; эндпоинт async def с блокирующим кодом - нет.

У избытка воркеров на контейнерных хостах есть особый способ отказа. На RE:NODE сервер, достигший лимита памяти, останавливается ядром и запускается заново с чистого листа, а не уходит в своп, так что лишний воркер проявляется как приложение, которое падает под нагрузкой, а не которое тормозит. Наблюдатель за падениями замечает повторные перезапуски и открывает тикет после трёх за час; это полезный сигнал, что число воркеров и ваш тариф не согласованы.

За обратным прокси: HTTPS, адреса клиентов и таймауты#

В продакшене перед вашим приложением что-то завершает TLS и пересылает ему обычный HTTP. Это что-то выставляет три заголовка - X-Forwarded-For, X-Forwarded-Proto и X-Forwarded-Host, - и ваше приложение игнорирует все три, пока вы не скажете иначе.

HTTPS 443HTTPБраузерapi.example.comСлот проксиTLS, X-Forwarded-Foruvicorn или gunicornслушает 0.0.0.0:8000Воркер 1Воркер 2База данныхпо пулу на воркер
Как запрос доходит до воркера, который отвечает

Uvicorn в актуальных версиях читает пересылаемые заголовки по умолчанию, но только с адресов, перечисленных в --forwarded-allow-ips, а по умолчанию там 127.0.0.1. Если ваш прокси на другом адресе, заголовки отбрасываются, и каждый клиент выглядит как прокси. Укажите адрес прокси или *, если до порта достучаться может только он.

Для Flask нужен middleware:

python
from flask import Flaskfrom werkzeug.middleware.proxy_fix import ProxyFixapp = Flask(__name__)app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

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

Ещё две проблемы такого рода. Циклы редиректов возникают, когда приложение считает, что запрос пришёл по HTTP, перенаправляет на HTTPS, а прокси пересылает тот же запрос снова: исправьте заголовок proto, и цикл исчезнет. А websocket требуют, чтобы заголовки upgrade были проброшены и чтобы таймаут чтения был достаточно большим для простаивающего соединения; это отдельная тема в статье websocket за обратным прокси.

В тарифы для приложений на RE:NODE входит слот прокси: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически внутри окна в 21 день, а настоящий адрес клиента придёт в X-Forwarded-For. Значит, настройка выше там не опциональна: только так в ваших логах появится что-то кроме одного повторяющегося адреса.

Переменные окружения и настройки#

Конфигурация принадлежит окружению, а не репозиторию. Паттерн, который выдерживает работу в команде, - типизированный объект настроек, громко падающий при старте, если чего-то не хватает:

python
from pydantic_settings import BaseSettings, SettingsConfigDictclass Settings(BaseSettings):    model_config = SettingsConfigDict(env_file=".env")    database_url: str    secret_key: str    debug: bool = Falsesettings = Settings()

Отсутствующая DATABASE_URL теперь останавливает процесс при импорте с понятным сообщением, а не даёт ошибку соединения на первом запросе через час. У Flask есть уменьшенная версия той же идеи: app.config.from_prefixed_env() загружает в объект конфигурации все переменные, начинающиеся с FLASK_.

.env
DATABASE_URL=postgresql://app:secret@db.example.net:5432/appSECRET_KEY=change-meDEBUG=falsePYTHONUNBUFFERED=1

Держите .env вне git и задавайте настоящие значения на сервере. Всё, что введено на вкладке Startup панели, видно каждому, кто может открыть этот сервер, поэтому выдавайте соавторам роль без этого доступа, если им нужна только консоль. В статье переменные окружения и секреты разобрано остальное, включая то, что делать после того, как ключ вставили туда, куда не следовало.

Статические файлы, загрузки и база данных#

Flask отдаёт собственную папку static/ сам. FastAPI делает это через монтирование:

python
from fastapi.staticfiles import StaticFilesapp.mount("/static", StaticFiles(directory="static"), name="static")

Отдавать статику из Python нормально в малом масштабе. Перестаёт быть нормальным, когда воркер заблокирован чтением файла на 40 МБ для человека с медленным соединением, а это реальная цена на тарифе с одним-двумя воркерами. Если перед вами есть прокси, пусть он кэширует всё, что может, а для всего, у чего в имени файла есть хэш, отправляйте длинные значения Cache-Control.

Загрузки пользователей нельзя складывать в /tmp, и нельзя туда, что заменяется при деплое. Выберите каталог на постоянном диске, пишите туда и помните, что он входит в диск тарифа - 5 ГБ на самом маленьком тарифе приложений, 50 ГБ на самом большом. Загрузки - ещё и то, что люди забывают бэкапить, потому что их нет ни в репозитории, ни в базе данных.

Что касается базы данных, следить нужно за числом соединений, а не запросов. Каждый воркер gunicorn создаёт собственный пул соединений, так что итог - это воркеры, умноженные на размер пула:

python
engine = create_engine(    settings.database_url,    pool_size=5,    max_overflow=5,    pool_pre_ping=True,)

Четыре воркера с такой конфигурацией под нагрузкой могут открыть сорок соединений, чего одного достаточно, чтобы исчерпать небольшой сервер PostgreSQL. Уменьшайте пул по мере того, как увеличиваете воркеры, и прочитайте пулы соединений и лимиты, прежде чем считать значение по умолчанию безопасным. Слоты баз данных в панели поставляются со сгенерированными хостом, пользователем и паролем; отдельный экземпляр PostgreSQL или MongoDB - это тариф хостинга баз данных.

Миграции выполняйте до запуска сервера, а не внутри него:

bash
$ .venv/bin/alembic upgrade head && .venv/bin/uvicorn app.main:app \    --host 0.0.0.0 --port ${SERVER_PORT:-8000}

Логи, health-проверки и чистое завершение#

Python буферизует stdout, когда тот не подключён к терминалу. В консоли панели это выглядит как приложение, которое десять минут ничего не печатает, а потом выдаёт всё разом, и это самая частая ложная тревога в хостинге приложений. Задайте PYTHONUNBUFFERED=1, и она исчезнет.

Gunicorn по умолчанию пишет логи ошибок в stderr, но access-лога не пишет вообще, пока вы не попросите: --access-logfile - отправляет его в stdout. Uvicorn пишет строки доступа по умолчанию, а --no-access-log их отключает; это стоит сделать, если health-проверка опрашивает вас каждые пять секунд и хоронит под собой всё остальное.

Health-эндпоинт должен быть дешёвым и не трогать базу данных:

python
@app.get("/healthz")async def healthz():    return {"status": "ok"}

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

Оба сервера правильно обрабатывают SIGTERM: перестают принимать новые соединения, дают завершиться выполняющимся запросам и выходят. Gunicorn даёт воркерам --graceful-timeout секунд (по умолчанию 30), прежде чем убить их. Именно в этот момент выполняется завершение lifespan у FastAPI, и здесь вы закрываете пулы и сбрасываете всё буферизованное. В статье плавное завершение и health-проверки есть полный паттерн, в том числе объяснение, почему запрос, который длится дольше graceful-таймаута, - это проблема проектирования, а не конфигурации.

Что идёт не так#

В логе написано «Uvicorn running», но никто не подключается. Хост - 127.0.0.1. Замените на 0.0.0.0 и перезапустите.

«Address already in use» при старте. Предыдущий процесс всё ещё держит порт. Используйте Kill в панели или найдите его через ss -ltnp на машине, где у вас есть шелл.

`ModuleNotFoundError` после деплоя, который локально работал. Пакет, установленный вручную несколько месяцев назад, так и не попал в requirements.txt. Пересоздайте виртуальное окружение из файла локально, и вы увидите то же падение.

Всё хорошо, пока не зайдут четыре человека одновременно. Один синхронный воркер без потоков. Добавьте --threads 4, а затем добавьте воркеров, если позволяет память.

Запросы умирают ровно на тридцатой секунде. Это --timeout у gunicorn, который убивает воркер, не закончивший работу. Увеличьте его, если эндпоинт законно медленный, но тридцатисекундный веб-запрос обычно должен быть фоновой задачей.

Слишком много перенаправлений. Приложение считает, что запрос пришёл по HTTP. Исправьте обработку X-Forwarded-Proto, прежде чем трогать код редиректа.

Адрес клиента одинаков для всех запросов. Пересылаемым заголовкам не доверяют. Задайте --forwarded-allow-ips или добавьте ProxyFix.

Память растёт весь день, а сервер перезапускается ночью. Что-то течёт. --max-requests выигрывает время, пока вы ищете причину; график памяти в консоли покажет, склон это или ступенька.

FAQ#

Нужен ли gunicorn, если я уже использую uvicorn?

Нет. Uvicorn умеет управлять собственными воркерами через --workers, и для большинства деплоев этого достаточно. Gunicorn стоит добавить, когда вам нужны его надзор за процессами, его формат access-лога или настройки вроде --max-requests, которых у uvicorn нет.

Сколько воркеров помещается в 1 ГБ?

Один или два. Приложение на FastAPI обычно занимает 80-150 МБ на воркер, ещё ничего не сделав, а контейнеру нужно вместить и тела запросов, и любой кэш. На самых маленьких тарифах один воркер с потоками или асинхронной конкурентностью лучше двух воркеров, борющихся за одну и ту же память.

Поддержка async во Flask - то же самое, что во FastAPI?

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

Можно ли запустить фоновый воркер на том же сервере?

Да, если он небольшой: запустите его той же командой через &, чтобы оба процесса жили в одном контейнере. Он делит CPU и память тарифа с веб-процессом, так что всё тяжёлое заслуживает собственного сервера. Для периодической работы, а не очереди, обычно дешевле планировщик внутри приложения.

Нужен ли nginx перед uvicorn?

Не нужен, если кто-то другой уже завершает TLS и пересылает запросы вам. Uvicorn - вполне способный HTTP-сервер. Прокси добавляет сертификаты, кэширование статики, ограничения размера запроса и место для ограничения частоты; если один уже стоит перед вами, второй добавляет лишь ещё один переход.

Почему консоль панели ничего не показывает, пока приложение не упадёт?

Python буферизует stdout, потому что это не терминал. Задайте PYTHONUNBUFFERED=1 в окружении или передайте интерпретатору -u, и вывод будет появляться по мере записи.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000