RE:NODE

Воркеры gunicorn и uvicorn на небольшом сервере

Автор Команда RE:NODE
20 мин чтения


На небольшом сервере правильное число воркеров gunicorn - обычно один или два, а не (2 x cores) + 1 из документации. Дальше важны настройки, которые мало кто задаёт: timeout, соответствующий вашему самому медленному честному запросу, max_requests с jitter, чтобы убирать протекающие воркеры, preload_app - только если ваш код переживает fork, и keepalive, подходящий к прокси перед приложением. У uvicorn настроек меньше, но форма та же: --workers для процессов, --timeout-keep-alive, --limit-max-requests. Эта статья о настройке и эксплуатации. Сколько памяти занимает каждый воркер - отдельный вопрос, он разобран в статье сколько RAM нужно Python-приложению.

Всё здесь написано для контейнера с жёстким лимитом памяти и жёсткой долей CPU - так выглядит Python-тариф за $4-$7, - но подходит и для любого небольшого VPS. Если вы ещё разбираетесь с самым началом, то есть как выложить приложение, сначала прочитайте деплой FastAPI или Flask или деплой Django в продакшен и возвращайтесь.

Что на самом деле запускают gunicorn и uvicorn#

Gunicorn - это менеджер процессов, который заодно говорит по HTTP. Есть один мастер-процесс, который никогда не трогает запросы. Он слушает сокет, порождает воркеры, следит за ними и заменяет тех, кто умер или завис. Каждый воркер - полноценный интерпретатор Python со своей копией вашего приложения, своими импортированными модулями, своими подключениями к базе и своими кэшами. Между воркерами общий только слушающий сокет, поэтому счётчик в переменной уровня модуля считает отдельно в каждом воркере, а кэш в памяти - это на самом деле N отдельных кэшей.

Uvicorn - сервер для ASGI-приложений (FastAPI, Starlette, Django с asgi.py). Один процесс uvicorn запускает один цикл событий и обслуживает много запросов одновременно, пока ваш код использует await, а не блокируется. С --workers N он превращается в небольшой супервизор вроде мастера gunicorn: запускает N процессов, у каждого свой цикл, и перезапускает любой, который завершился.

bind 0.0.0.0:8000forkforkПроксиTLS, пересылает HTTPМастер gunicornfork, надзор, без запросовВоркер 1ваше приложениеВоркер 2ваше приложениеБаза данныходин пул на воркер
Схема процессов типичной небольшой установки

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

Команда gunicorn по умолчанию слушает 127.0.0.1:8000 и запускает один sync-воркер. Внутри контейнера этот адрес недоступен снаружи, поэтому первое изменение на любом хостинге - --bind 0.0.0.0:<port>, где порт берётся из окружения. На Python-сервере RE:NODE выделенный порт показан на вкладке Startup; в соседних статьях для него используется SERVER_PORT, так что проверьте имя на своей вкладке, прежде чем копировать строку.

Как выбрать число воркеров#

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

Отталкивайтесь по очереди от двух лимитов.

  1. CPU. Для приложения, упирающегося в процессор, воркеры сверх ceil(cpu share) ничего не дают. Для приложения, которое в основном ждёт базу или внешний API, важнее не число воркеров, а число запросов в работе на воркер - для этого и нужны потоки и async.
  2. Память. Измерьте резидентный размер одного воркера после реального трафика, умножьте на число воркеров, прибавьте мастер (несколько десятков МБ) и оставьте свободной не меньше четверти лимита на всплески запросов и собственный рост интерпретатора.
bash
$ ps -o pid,rss,cmd -C gunicorn  PID   RSS CMD 4121 31204 gunicorn: master [app.main:app] 4128 96740 gunicorn: worker [app.main:app] 4129 97112 gunicorn: worker [app.main:app]

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

Отправная точка для Python-линейки в предположении около 100 МБ на воркер, что типично для приложения на FastAPI или Flask с ORM и драйвером базы (у вас может быть вдвое больше):

ТарифПамять / доля CPUНачальная конфигурация
Starter1 GB / 0.5 core1 воркер, 4 потока или async
Standard2 GB / 1 core2 воркера, по 2-4 потока
Advanced4 GB / 1.5 cores2-3 воркера
Pro6 GB / 2 cores3-4 воркера

Все ступени перечислены на странице тарифов Python, от $4 в месяц. Обратите внимание, что на старших строках первым лимитом редко бывает память - им становится доля CPU. После двух-трёх воркеров на однозначной доле ядер вы в основном покупаете переключения контекста.

Есть ещё одна причина предпочесть два воркера одному, если это по карману: единственный воркер - единственная точка зависания. Если он упёрся в медленный запрос, паузу сборщика мусора или перезапуск, сервис недоступен всё это время. Два воркера означают, что второй продолжает отвечать, пока первый восстанавливается. На тарифе в 1 GB такой компромисс часто недоступен, и именно тогда потоки или async оправдывают себя.

Классы воркеров: sync, gthread и uvicorn#

worker_class в gunicorn определяет, как воркер обрабатывает запросы, и это главный рычаг после их числа.

Класс воркераКонкурентность на воркерДля чего
sync (по умолчанию)1 запросFlask или Django, упор в CPU, много воркеров
gthreadthreads запросовFlask или Django в ожидании базы
uvicorn_worker.UvicornWorkerЦикл событийFastAPI, Starlette, Django ASGI

`sync` прост и предсказуем, и именно ему сильнее всего нужен буферизующий прокси впереди: sync-воркер занят всё время, пока клиент досылает свой запрос, так что медленное соединение занимает воркер целиком. За прокси, который читает запрос полностью, прежде чем переслать его, это не проблема.

`gthread` даёт каждому воркеру пул потоков. Четыре потока в одном процессе обслуживают четыре запроса, которые все ждут базу, за память примерно одного процесса - это лучший по соотношению цены и пользы вариант для WSGI-приложения с упором в ввод-вывод на небольшом тарифе:

bash
$ gunicorn wsgi:app --bind 0.0.0.0:8000 \    --workers 2 --worker-class gthread --threads 4

Следите за арифметикой ниже по течению: два воркера по четыре потока могут одновременно держать восемь подключений к базе. Размер пула и тариф базы подбирайте под это число, а не под число воркеров. Если указать --threads больше 1 при классе sync по умолчанию, в современных версиях класс сам переключится на gthread, но явная запись избавляет от догадок.

Воркеры uvicorn под gunicorn дают надзор gunicorn вместе с циклом событий uvicorn:

bash
$ gunicorn app.main:app -k uvicorn_worker.UvicornWorker \    --bind 0.0.0.0:8000 --workers 2

Для этого нужен пакет uvicorn-worker. Старый uvicorn.workers.UvicornWorker, который поставлялся внутри uvicorn, объявлен устаревшим и в закреплённой у вас версии может уже отсутствовать, поэтому перед копированием строки из старого руководства проверьте pip show uvicorn. Можно и вовсе обойтись без gunicorn:

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

Что выбрать? Собственные --workers у uvicorn - на одну зависимость меньше, и для небольшого приложения этого вполне достаточно. Gunicorn добавляет зрелый набор эксплуатационных возможностей: max_requests с jitter, таймауты на воркер, которые убивают зависший процесс, хуки post_fork, давно устоявшийся протокол сигналов и более тонкую настройку логов. Uvicorn в последних релизах сокращает отставание (см. раздел про перезагрузку), но если нужна уверенность, что зависший воркер будет убит, gunicorn пока надёжнее как супервизор.

Правило для async-воркеров: никогда не блокируйте цикл событий. Синхронный вызов базы или requests.get внутри обработчика async def останавливает все остальные запросы в этом процессе, пока он не вернётся. Заблокированный цикл не вызывает ошибки. Он проявляется как задержки, а под gunicorn в итоге как WORKER TIMEOUT, потому что цикл же ещё и отправляет heartbeat воркера мастеру.

Таймауты, которые убивают запросы#

timeout в gunicorn (по умолчанию 30 секунд) понимают неверно чаще всего. Это не таймаут запроса. Это максимальное время, в течение которого воркер может не сообщать мастеру, что он жив. У sync-воркеров heartbeat происходит между запросами, поэтому на практике он ведёт себя как лимит на запрос: запрос на 31 секунду стоит воркеру жизни. Строка в логе ни с чем не спутается:

code
[2026-10-17 09:14:02 +0000] [4121] [CRITICAL] WORKER TIMEOUT (pid:4128)[2026-10-17 09:14:03 +0000] [4128] [INFO] Worker exiting (pid: 4128)[2026-10-17 09:14:03 +0000] [4140] [INFO] Booting worker with pid: 4140

У gthread и async-воркеров heartbeat идёт из другого места, поэтому медленный запрос сам по себе таймаут не вызывает; его вызывает заблокированный главный поток или цикл событий. Эта разница важна при отладке: WORKER TIMEOUT на async-воркере означает, что цикл что-то заморозило, а не что один запрос шёл долго.

Какие таймауты участвуют и кому они принадлежат:

НастройкаПо умолчаниюЧто ограничивает
gunicorn timeout30Молчание воркера до его убийства
gunicorn graceful_timeout30Сколько останавливающийся воркер может доделывать запросы
gunicorn keepalive2Простой keep-alive-соединения в секундах
uvicorn --timeout-keep-alive5Простой keep-alive-соединения в секундах
uvicorn --timeout-graceful-shutdownнетОжидание соединений при остановке

Поднять timeout до 120 из-за одного медленного отчёта - распространённый неверный ответ. Это же позволяет любому по-настоящему зависшему воркеру простаивать две минуты на тарифе, где их всего два. Лучшие варианты по порядку:

  • Сделайте медленное быстрым: индекс, запрос поменьше, кэш.
  • Вынесите это из запроса: верните 202 Accepted, выполните работу в фоновой задаче, затем опрашивайте результат.
  • Если оно обязано быть синхронным, поднимите timeout и напишите рядом комментарий, почему, и оставьте потоки или лишний воркер, чтобы один долгий запрос не забирал с собой всё приложение.

У uvicorn вообще нет таймаута запроса. Если внешний вызов завис, завис и обработчик, поэтому задавайте таймауты на каждом исходящем клиенте (httpx.AsyncClient(timeout=10), statement_timeout базы), а не ждите, что вас спасёт сервер. У прокси перед приложением тоже есть свой таймаут чтения, обычно больше, чем по умолчанию у gunicorn; если прокси сдастся первым, клиент увидит 504, а воркер будет занят ещё несколько секунд.

Наконец, worker_tmp_dir. Gunicorn пишет файлы heartbeat во временный каталог, а в контейнерах, где он лежит на медленной или оверлейной файловой системе, эти записи могут блокироваться и вызывать ложные таймауты. Документированное решение - каталог в памяти:

bash
$ gunicorn app.main:app --worker-tmp-dir /dev/shm

Preload, хуки и ловушка fork#

По умолчанию каждый воркер сам импортирует ваше приложение уже после fork. С --preload (или preload_app = True) мастер импортирует его один раз до порождения воркеров. Отсюда следуют две вещи.

Что вы получаете. Ошибки запуска всплывают сразу в мастере, а не в виде цикла неудачных стартов каждого воркера. Воркеры стартуют быстрее, а страницы с импортированным кодом могут разделяться между процессами по принципу copy-on-write, так что суммарная память меньше, чем у N независимых импортов.

На что не стоит рассчитывать. Подсчёт ссылок в Python пишет в объекты при каждом использовании, а это пачкает страницы и со временем прекращает их совместное использование. Экономия настоящая, но частичная: ожидайте, что она уменьшается по мере старения воркеров. Прежде чем решать, стоит ли это усложнения, измерьте резидентную память с --preload и без него.

Чем вы платите. Всё, что создано при импорте, создаётся один раз и наследуется каждым воркером. Для конфигурации это безвредно, а для подключений - катастрофа:

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

Решение - создавать подключения после fork, в хуке. Конфигурационный файл gunicorn - обычный Python, поэтому хуки - это функции:

gunicorn.conf.py
def post_fork(server, worker):    # runs in each worker after the fork, before it serves anything    from app.db import engine    engine.dispose(close=False)

engine.dispose(close=False) - способ SQLAlchemy 2.x выбросить пул, унаследованный от мастера, не закрывая подключения, которыми мастер ещё владеет. Для любой другой клиентской библиотеки схема та же: выбросьте то, что создал мастер, и дайте каждому воркеру открыть своё. Если ваше приложение открывает подключения лениво при первом использовании, как большинство ORM, preload обычно безопасен как есть.

У preload есть ещё одно взаимодействие, о которое спотыкаются при деплоях, оно разобрано в следующих разделах: при --preload сигнал HUP перечитывает конфиг, но не импортирует код заново, потому что код загружен в мастере, а мастер не перезапускался.

Keepalive и прокси перед приложением#

HTTP keep-alive позволяет клиенту использовать одно TCP-соединение для многих запросов. Для небольшого приложения важно соединение между прокси и вашим сервером. На тарифах для приложений и веб-хостинга RE:NODE входит слот обратного прокси, который терминирует TLS и пересылает обычный HTTP на ваш порт, передавая реальный адрес клиента в X-Forwarded-For. Общую картину см. в статье что делает обратный прокси.

Две настройки:

  • `keepalive` в gunicorn, по умолчанию 2 секунды, применяется только к классам воркеров gthread и async. Воркер sync закрывает соединение после каждого ответа, потому что не может позволить себе ждать простаивающее соединение, когда он единственный, кто обслуживает трафик.
  • `--timeout-keep-alive` в uvicorn, по умолчанию 5 секунд.

Классическая ошибка - гонка. Прокси держит простаивающее соединение к вашему приложению и отправляет по нему запрос в тот же миг, когда ваш сервер решает, что оно простояло достаточно, и закрывает его. Прокси видит сброс и возвращает 502 на запрос, который до вашего кода так и не дошёл. Это происходит эпизодически, чаще при малом трафике, чем при большом, и в логе приложения ничего нет. Правило такое: keep-alive-таймаут приложения должен быть больше, чем таймаут простоя прокси в сторону приложения. Узнайте, как долго ваш прокси держит соединения вверх по потоку в простое; значение для слота прокси эта статья назвать не может, поэтому измеряйте. Если вы видите редкие 502 при чистом логе приложения, поднимите keepalive примерно до 10 как эксперимент и посмотрите, прекратятся ли они.

У слишком большого значения есть цена: каждое простаивающее соединение занимает место. В воркере gthread удерживаемое соединение занимает поток. При четырёх потоках и щедром keep-alive четыре простаивающих соединения могут оставить воркер без свободных рук для пятого клиента. Держите значение скромным, от 5 до 10 секунд, а число потоков - выше числа соединений прокси, которое вы ожидаете.

Вторая половина - доверие к пересылаемым заголовкам. Оба сервера верят X-Forwarded-For и X-Forwarded-Proto только с доверенных адресов: gunicorn через forwarded_allow_ips (переменная окружения FORWARDED_ALLOW_IPS), uvicorn через --forwarded-allow-ips. По умолчанию у обоих 127.0.0.1, что не совпадает с прокси, подключающимся с другого адреса. В итоге каждый запрос выглядит пришедшим от прокси и по обычному HTTP, поэтому URL редиректов строятся с http://, а в логах виден один посетитель. Значение * это исправляет, но допустимо только если порт приложения недоступен иначе как через прокси. Если вы не уверены, что это так, впишите в список адрес прокси и посмотрите, какой адрес пира пишет ваш сервер.

max-requests и медленные утечки#

max_requests велит gunicorn отправлять воркер на покой после обработки заданного числа запросов и заменять его свежим. Это грубый, но действенный инструмент против медленной утечки в вашем коде или в зависимости, которую вы не контролируете, и против постепенной фрагментации, из-за которой долгоживущие процессы Python со временем растут. Всегда сочетайте его с jitter:

gunicorn.conf.py
max_requests = 1000max_requests_jitter = 100

Jitter добавляет к лимиту каждого воркера случайное число от 0 до 100, чтобы они не упирались в него на одном и том же запросе. Без него на тарифе с двумя воркерами оба могут перезапуститься почти одновременно, и возникает окно, в котором обслуживать некому. Заменяемый воркер сначала дорабатывает текущий запрос и только потом выходит, поэтому пользователи не видят ошибки; в худшем случае они видят чуть более медленный ответ, пока загружается новый воркер.

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

В uvicorn то же самое называется --limit-max-requests N: процесс завершается после N запросов. Под надзором gunicorn он порождается заново. С собственными --workers uvicorn новые релизы тоже перезапускают его, а старые нет, и после каждого перезапуска в сервисе оставалось бы меньше процессов. Проверьте закреплённый у вас релиз, прежде чем на это полагаться, и предпочтите gunicorn, если не можете это подтвердить.

max_requests прячет утечку, а не чинит её. Если вы к нему прибегли, запишите зачем и найдите причину, когда появится время. Тему памяти продолжает статья лимиты памяти Node о том, как это выглядит в Node, а утечки памяти игровых серверов описывает приёмы отличия утечки от нормального роста.

Перезагрузка без потери запросов#

Мастер gunicorn реагирует на сигналы, и это весь его эксплуатационный интерфейс:

СигналДействие
HUPПеречитать конфиг, запустить новых воркеров, мягко убрать старых
TERMМягкая остановка, ждёт до graceful_timeout
INT, QUITНемедленная остановка
TTIN, TTOUДобавить или убрать одного воркера
USR1Переоткрыть лог-файлы (после ротации)
USR2Запустить новый мастер рядом со старым

HUP - сигнал для деплоев. Новые воркеры стартуют с новым кодом и конфигом до того, как старым велят остановиться, а старые доделывают текущее, так что запросы в работе завершаются. На собственной машине:

bash
$ gunicorn app.main:app -c gunicorn.conf.py --pid /run/myapp.pid$ kill -HUP $(cat /run/myapp.pid)

Под systemd, а это разумный способ запускать всё на VDS, оформите это как действие reload:

/etc/systemd/system/myapp.service
[Service]User=appWorkingDirectory=/srv/myappExecStart=/srv/myapp/.venv/bin/gunicorn app.main:app -c gunicorn.conf.pyExecReload=/bin/kill -s HUP $MAINPIDKillMode=mixedTimeoutStopSec=40

Тогда systemctl reload myapp отправит HUP. TimeoutStopSec должен превышать graceful_timeout, иначе systemd убьёт процесс, пока воркеры ещё дорабатывают. Прежде чем на это полагаться, нужно знать несколько оговорок:

  • При `preload_app` сигнал `HUP` не подхватывает новый код. Код живёт в мастере. Чтобы выкатить новый код при preload, перезапустите сервис или используйте передачу через USR2 (запустить новый мастер, затем отправить WINCH и QUIT старому), что достаточно запутанно, поэтому большинство небольших установок выбирают перезапуск.
  • Сломанный новый релиз может положить сервис. Если новый код не импортируется, новые воркеры не загрузятся (Worker failed to boot), а старые будут остановлены. Выполните python -c "import app.main" или gunicorn --check-config до отправки сигнала.
  • Новые зависимости надо установить заранее. HUP не запускает pip. См. requirements и virtualenv в Python.
  • Изменения схемы базы - ваша забота. Старые и новые воркеры несколько секунд работают рядом, так что миграция, ломающая старый код, сломает его.

В многопроцессном режиме uvicorn появилось похожее поведение: недавние релизы перезапускают воркеры по одному по SIGHUP и добавляют или убирают воркеры по SIGTTIN и SIGTTOU. Как и с другими деталями uvicorn, подтвердите это в заметках к релизу той версии, которую вы закрепили.

На хостинге с панелью картина другая. Кнопка Restart в панели - это полная остановка и запуск процесса сервера; это чистый, простой и безопасный способ деплоя, и он оборвёт соединения на те несколько секунд, которые приложению нужны для загрузки. Консоль панели вводит текст в процесс, а не отправляет ему сигналы, поэтому HUP оттуда ждать не стоит; возможность отправки сигнала мы не подтверждали. Для деплоя без паузы на небольшом тарифе прочитайте деплой без простоя на небольшом сервере, а о том, что процесс, который останавливается, должен своим клиентам, - мягкая остановка и health-проверки. Если включён деплой из Git, пуш перезапускает сервер, так что действует та же пауза на загрузку.

Полный конфиг для небольшого тарифа#

Положите настройки в gunicorn.conf.py в рабочем каталоге приложения, где gunicorn ищет его по умолчанию: команда запуска останется короткой, а настройки будут проходить ревью в git, как и всё остальное. Этот подходит для приложения на FastAPI на тарифе Standard (2 GB, 1 core):

gunicorn.conf.py
import osbind = f"0.0.0.0:{os.environ.get('SERVER_PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))worker_class = "uvicorn_worker.UvicornWorker"timeout = 30graceful_timeout = 30keepalive = 10max_requests = 1000max_requests_jitter = 100worker_tmp_dir = "/dev/shm"forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")accesslog = "-"errorlog = "-"loglevel = "info"capture_output = True

Команда запуска тогда просто gunicorn app.main:app -c gunicorn.conf.py. Логи идут в стандартный вывод и вывод ошибок, откуда их читает вкладка Console; ротировать нечего. Для приложения на Flask или Django на том же тарифе замените на worker_class = "gthread", добавьте threads = 4 и уберите строку с uvicorn.

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

bash
$ gunicorn app.main:app -c gunicorn.conf.py --check-config$ gunicorn app.main:app -c gunicorn.conf.py --print-config | grep -E "^(workers|timeout)"

Первая команда ловит опечатки в именах настроек и ошибки импорта; вторая печатает то, что gunicorn реально получил после применения переменных окружения и командной строки. Флаги командной строки перекрывают файл, и именно так вы потом отлаживаете настройку, которая «явно есть в конфиге» и не действует.

Диагностика проблем#

`WORKER TIMEOUT` повторяется. Воркер молчал дольше timeout. У sync-воркеров найдите медленный запрос (пишите длительность запроса через %(D)s в access_log_format). У async-воркеров ищите блокирующие вызовы внутри async def. Если это случается только при загрузке, приложение импортируется дольше, чем timeout; помогает preload, а также увеличение timeout только на время старта.

`Worker (pid:N) was sent SIGKILL! Perhaps out of memory?` Что-то убило воркера без участия gunicorn, а на тарифе с лимитом памяти первый подозреваемый - лимит памяти. При достижении лимита ядро останавливает процессы, и контейнер чисто перезапускается, а не уходит в swap. Решение - меньше воркеров, меньше потоков или приложение поменьше, именно в таком порядке. Повторные перезапуски замечаются: три за час - это предупреждение на странице сервера и тикет, шесть - приостановка сервера.

`Worker failed to boot`, и мастер завершается. Импорт не удался. Выполните тот же импорт вручную интерпретатором из venv и прочитайте трассировку. Без --preload это появляется по разу на каждого воркера, что шумно, но безвредно.

Редкие 502 от прокси при чистом логе приложения. Гонка keep-alive, описанная выше. Поднимите keepalive или --timeout-keep-alive и посмотрите.

Всё медленно при 100% CPU и куче свободной памяти. Вас ограничивает CPU. Больше воркеров только ухудшит дело. Профилируйте приложение, кэшируйте или переходите на тариф с большей долей CPU. Сервер на 100% CPU медленный, но его никогда не приостанавливают за это.

Ошибки базы только под нагрузкой. Либо подключения были открыты до fork (--preload), либо произведение воркеров, потоков и размера пула превышает то, что допускает база. Посчитайте: воркеры умножить на потоки умножить на размер пула.

Редиректы ведут на `http://`. Сервер не доверяет X-Forwarded-Proto. Задайте forwarded_allow_ips, как описано выше, а в Django - SECURE_PROXY_SSL_HEADER.

FAQ#

Сколько воркеров gunicorn запускать на тарифе в 1 GB?

Один, с несколькими потоками или async-классом воркера, если только измерение не показало, что ваше приложение занимает меньше примерно 200 МБ на воркер в сумме. Второй воркер даёт устойчивость к одиночному зависанию, но при 1 GB и 0.5 core он нередко стоит больше памяти и борьбы за CPU, чем экономит.

Достаточно ли uvicorn с --workers или нужен gunicorn?

Для небольшого приложения достаточно одного uvicorn, и зависимость у него на одну меньше. Выбирайте gunicorn, когда нужен более сильный надзор: таймаут, убивающий зависшего воркера, max_requests с jitter, хуки и давно устоявшийся интерфейс сигналов.

Нужно ли включать preload?

Только если запуск тяжёлый или вы хотите видеть ошибки импорта в мастере, и только убедившись, что ничто не открывает подключение и не запускает поток при импорте. Измерьте память с ним и без него; выигрыш от copy-on-write в Python меньше, чем обычно ждут.

Какой max_requests разумен?

Тот, что отправляет воркер на покой до того, как его память дойдёт до вашего запаса. Тысяча с jitter в сто - обычная отправная точка. Если график памяти ровный, он вам вообще не нужен.

Почему gunicorn убивает запрос, который идёт 31 секунду?

Потому что timeout по умолчанию равен 30, и sync-воркер, не ответивший к этому моменту, считается зависшим. Ускорьте запрос или вынесите его в фоновую задачу, прежде чем поднимать лимит, и поднимайте его лишь на то время, которое нужно.

Можно ли перезагрузить gunicorn без простоя на хостинге с панелью?

Не сигналом, который мы подтвердили. Restart - это чистая остановка и запуск с паузой в несколько секунд. Если пауза недопустима, запускайте два экземпляра за чем-то, что умеет выводить один из ротации, или переходите на VDS, где systemd в ваших руках и можно отправить HUP.

Это было полезно?