RE:NODE

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

Django в продакшене: настройки, статика, gunicorn

Выводим проект Django в продакшен: DEBUG и ALLOWED_HOSTS, воркеры gunicorn, collectstatic и WhiteNoise, миграции, заголовки прокси и защищённые cookie.

0 прочтений

Проект Django уходит в продакшен после изменения пяти вещей, всё остальное остаётся как есть: DEBUG выключен, задан ALLOWED_HOSTS, секретный ключ приходит из окружения, статика собрана туда, откуда её может прочитать веб-сервер, а вместо runserver работает gunicorn. Про большую часть остального Django расскажет сам, если спросить его командой python manage.py check --deploy.

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

Сначала спросите Django, что не так#

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

bash
$ DJANGO_SETTINGS_MODULE=myproject.settings.production \    python manage.py check --deploy --fail-level WARNING

Она пожалуется на DEBUG, на отсутствие настроек HSTS, на cookie, не помеченные как secure, и на SECRET_KEY, похожий на тот, что создал startproject. Каждое предупреждение - реальная проблема с документированным решением, а --fail-level WARNING заставляет команду завершаться с ненулевым кодом, то есть её можно поставить в скрипт деплоя, и он вас остановит.

Ловит она не всё. Ей ничего не известно о том, что ваша статика недоступна, о числе соединений с базой и о том, выставляет ли прокси перед вами заголовки, на которые рассчитаны ваши настройки. Это разделы ниже.

Настройки: DEBUG, ALLOWED_HOSTS и секретный ключ#

DEBUG = False - самая важная строка. При включённом DEBUG исключение отображает страницу с вашими настройками, вашим окружением и трассировкой стека с локальными переменными - тому, кто его вызвал. Кроме того, Django хранит в памяти каждый выполненный SQL-запрос всё время жизни процесса, и это выглядит в точности как утечка памяти.

Выключение меняет ещё два поведения, которые многих удивляют. Django совсем перестаёт отдавать статические файлы и начинает применять ALLOWED_HOSTS. Запрос, заголовок Host которого нет в этом списке, получает голый 400 и запись DisallowedHost в логе.

settings.py
import osDEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]ALLOWED_HOSTS = ["app.example.com", "www.example.com"]CSRF_TRUSTED_ORIGINS = ["https://app.example.com", "https://www.example.com"]

Чтение секретного ключа через os.environ[...], а не .get(...), сделано намеренно: отсутствующий ключ останавливает процесс при импорте с понятной ошибкой, а не заставляет молча работать с None и обесценивать все сессии. CSRF_TRUSTED_ORIGINS со времён Django 4.0 требует схему, и именно она виновата в сообщениях «CSRF verification failed - Origin checking failed», которые появляются в день, когда сайт переезжает за HTTPS.

Держите боевые настройки в отдельном модуле, а не ветвитесь по переменной окружения внутри одного файла. Обычная раскладка - myproject/settings/base.py, а development.py и production.py импортируют из него; тогда на вопрос «что на самом деле задано в продакшене» можно ответить, прочитав один короткий файл. Сами значения приходят из окружения: где их хранить и что делать, если одно утекло, описано в статье переменные окружения и секреты.

Запуск: gunicorn, воркеры и память#

Django говорит на WSGI, так что gunicorn - ответ по умолчанию, а myproject/wsgi.py уже есть в вашем проекте:

bash
$ gunicorn myproject.wsgi:application \    --bind 0.0.0.0:${SERVER_PORT:-8000} \    --workers 3 --timeout 30 --access-logfile -

Привязка к 0.0.0.0, а не к 127.0.0.1, делает приложение доступным извне контейнера; это самая частая причина ситуации «работает, но никто не подключается». Порт берётся из окружения потому, что панель или платформа выделяет его за вас, а имя переменной указано на вкладке Startup.

Каждый воркер - отдельный процесс со своей копией Django, ваших моделей, шаблонов и каждого установленного стороннего приложения. Скромный проект занимает 100-200 МБ на воркер; тот, где есть Django REST Framework, библиотека для PDF и обработчик изображений, тяжелее. Измеряйте через ps -o rss= -p PID, а не гадайте.

Память тарифаДоля CPUВоркерыПримечания
1 ГБ0,5 ядра1Вместо второго воркера добавьте --threads 4
2 ГБ1 ядро2Комфортно для небольшого сайта
4 ГБ1,5 ядра3Обычный рабочий размер
6-8 ГБ2-3 ядра4-6Дальше пределом становится база данных, а не Django

Два флага заслуживают места. --threads 4 вместе с --worker-class gthread позволяет одному воркеру обслуживать четыре запроса, ждущих базу, расходуя намного меньше памяти, чем четыре процесса. --max-requests 1000 --max-requests-jitter 100 периодически пересоздаёт воркеры, что маскирует медленную утечку, пока вы её ищете.

Если вы всерьёз используете Channels, websocket или асинхронные представления, запускайте myproject.asgi:application под uvicorn. Всё остальное в этой статье остаётся в силе, меняется только сервер. Сравнение двух моделей - в статье деплой FastAPI или Flask.

Статика: collectstatic, WhiteNoise и media#

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

settings.py
STATIC_URL = "/static/"STATIC_ROOT = BASE_DIR / "staticfiles"STATICFILES_DIRS = [BASE_DIR / "assets"]MEDIA_URL = "/media/"MEDIA_ROOT = BASE_DIR / "media"
bash
$ python manage.py collectstatic --noinput

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

settings.py
MIDDLEWARE = [    "django.middleware.security.SecurityMiddleware",    "whitenoise.middleware.WhiteNoiseMiddleware",    "django.contrib.sessions.middleware.SessionMiddleware",]STORAGES = {    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},    "staticfiles": {        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",    },}

Словарь STORAGES - форма для Django 4.2 и новее; в старых проектах используется STATICFILES_STORAGE, который удалили в Django 5.1. Бэкенд compressed manifest хэширует каждое имя файла и пишет манифест, так что ресурсы можно кэшировать навсегда, а деплой сбрасывает кэш, меняя имена. Он же роняет сборку, если CSS-файл ссылается на несуществующую картинку: в первый раз это кажется враждебным, а во второй спасает вас.

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

База данных, миграции и соединения#

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

settings.py
DATABASES = {    "default": {        "ENGINE": "django.db.backends.postgresql",        "NAME": os.environ["DB_NAME"],        "USER": os.environ["DB_USER"],        "PASSWORD": os.environ["DB_PASSWORD"],        "HOST": os.environ["DB_HOST"],        "PORT": os.environ.get("DB_PORT", "5432"),        "CONN_MAX_AGE": 600,        "CONN_HEALTH_CHECKS": True,    }}

CONN_MAX_AGE держит соединение открытым десять минут для повторного использования. CONN_HEALTH_CHECKS, появившийся в Django 4.1, заставляет Django проверять переиспользуемое соединение перед тем, как отдать его запросу; именно это останавливает ошибки «server closed the connection unexpectedly», которые постоянные соединения иначе порождают после простоя.

Арифметика, за которой нужно следить: постоянные соединения принадлежат процессу воркера, так что три воркера gunicorn держат три соединения, и держат их независимо от того, идёт ли трафик. Добавьте плановую задачу и сессию в shell, и небольшой экземпляр PostgreSQL окажется ближе к своему пределу, чем вы ожидали. Расчёты - в статье пулы соединений и лимиты; тариф PostgreSQL на странице хостинга баз данных - ответ на случай, когда слота базы данных в панели уже не хватает.

Миграции выполняются до того, как новый код начнёт обслуживать трафик, а не изнутри него:

bash
$ python manage.py migrate --noinput

На одном сервере это одна строка в команде запуска, и она завершается до того, как gunicorn займёт порт. Интересно становится, когда миграция берёт блокировку на загруженной таблице - добавление столбца со значением по умолчанию или индекса без CONCURRENTLY - и все запросы встают в очередь за ней. Длинная версия - в статье миграции без простоя; короткая такова: любую миграцию на большой таблице стоит прочитать один раз перед запуском.

Создание первого администратора без интерактивной оболочки:

bash
$ DJANGO_SUPERUSER_USERNAME=admin \  DJANGO_SUPERUSER_EMAIL=admin@example.com \  DJANGO_SUPERUSER_PASSWORD=... \  python manage.py createsuperuser --noinput

Ваше приложение почти наверняка получает обычный HTTP от прокси, который завершил TLS. Django надо об этом сказать, иначе он решит, что каждый запрос небезопасен, начнёт строить URL с http:// и поссорится с прокси из-за редиректов:

settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")USE_X_FORWARDED_HOST = TrueSESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = TrueSECURE_SSL_REDIRECT = TrueSECURE_HSTS_SECONDS = 31536000SECURE_HSTS_INCLUDE_SUBDOMAINS = TrueSECURE_HSTS_PRELOAD = True

Задавайте SECURE_PROXY_SSL_HEADER только тогда, когда подконтрольный вам прокси выставляет этот заголовок в каждом запросе. Если приложение доступно ещё и напрямую, клиент может прислать заголовок сам, и Django ему поверит. А SECURE_SSL_REDIRECT включайте лишь после того, как заголовок прокси заработал, иначе получите бесконечный цикл редиректов: Django перенаправляет на HTTPS, прокси пересылает тот же обычный запрос, Django перенаправляет снова.

С HSTS стоит подумать, а не копировать. Годовой SECURE_HSTS_SECONDS говорит каждому браузеру, который у вас бывал, целый год отказываться от обычного HTTP, в том числе на поддоменах, если вы их включили. Для сайта, который навсегда останется на HTTPS, это правильная настройка, а если вы ещё переставляете имена хостов, она обернётся болью. Начните с нескольких часов, убедитесь, что ничего не сломалось, потом увеличивайте.

Push в GitHubветка mainПанель тянет репокороткий токенpip installrequirements.txtmanage.py migrateбаза данныхcollectstaticSTATIC_ROOTgunicornwsgi:application
От push до работающего Django

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

Деплой: что выполняется при каждом старте#

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

bash
$ pip install -r requirements.txt --no-cache-dir \  && python manage.py migrate --noinput \  && python manage.py collectstatic --noinput \  && gunicorn myproject.wsgi:application --bind 0.0.0.0:${SERVER_PORT:-8000} \     --workers 3 --access-logfile -

Безопасно при каждом старте: установка зафиксированных зависимостей (pip пропускает уже удовлетворённые), migrate (ничего не делает, когда применять нечего) и collectstatic --noinput (несколько секунд, идемпотентно). Небезопасно при каждом старте: всё, что загружает fixtures, всё, что отправляет почту, и любая команда исправления данных, которую кто-то однажды написал под инцидент.

Честность этой схеме придаёт фиксация версий. Если в requirements.txt диапазоны, а не точные версии, перезапуск в неудачный момент установит не ту версию чего-нибудь, что вы тестировали - о lock-файлах, которые действительно фиксируют, см. requirements и virtualenv для Python. Добавьте в окружение и PYTHONUNBUFFERED=1, иначе консоль ничего не покажет, пока не заполнится буфер вывода.

Логи должны идти в стандартный вывод, чтобы их видела консоль. Конфигурация Django по умолчанию только рассылает ошибки по почте на ADMINS:

settings.py
LOGGING = {    "version": 1,    "disable_existing_loggers": False,    "handlers": {"console": {"class": "logging.StreamHandler"}},    "root": {"handlers": ["console"], "level": "INFO"},}

Отдельного предупреждения заслуживает почта: Django отправляет сброс паролей и отчёты об ошибках через SMTP, а тариф хостинга - не почтовый сервер. Используйте внешнего провайдера, задайте EMAIL_HOST и остальное из окружения и проверьте сброс пароля до запуска, а не после первого заблокированного пользователя.

Фоновая работа: расписания, команды и Celery#

Встроенной очереди задач в Django нет. Варианты в порядке возрастания механики:

  • Management-команда по таймеру. python manage.py clearsessions или ваша собственная, запускаемая cron на машине, где он есть, или небольшим планировщиком рядом с веб-процессом. Это покрывает ночную уборку, построение отчётов и рассылки-дайджесты и не требует дополнительной службы.
  • django-q2, huey или похожая лёгкая очередь с брокером в базе данных. Один дополнительный процесс, никаких дополнительных служб.
  • Celery с выделенным брокером. Мощно, но это ещё одна служба, которую нужно запускать и оплачивать. Оправдано, когда задачи частые, требуют повторов и должны переживать перезапуск.

Будьте честны в том, что из этого нужно вашему приложению. Большинству небольших сайтов на Django нужен первый вариант, им продают третий, и в итоге у них появляется брокер, за которым никто не следит. Об этом же, подробнее, говорят статьи фоновые задачи на небольшом сервере и Redis, когда он нужен.

Что бы вы ни выбрали, процесс-исполнитель - это второй процесс, который делит с gunicorn ту же долю CPU и память. На тарифе в 1 ГБ один воркер gunicorn плюс один исполнитель задач - это уже весь бюджет.

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

Голый 400 и `DisallowedHost` в логе. Заголовка Host нет в ALLOWED_HOSTS. Добавьте точное имя хоста, которым пользуются люди, включая www, если DNS указывает на него.

Сайт работает, но без стилей. collectstatic не запускался, STATIC_ROOT неверен или эту директорию никто не отдаёт. Проверьте, что файл физически лежит в STATIC_ROOT, затем что middleware WhiteNoise есть и стоит на нужном месте.

`collectstatic` падает с «could not be found». Хранилище manifest разрешает ссылку внутри CSS-файла, которая указывает на отсутствующий ресурс. Исправьте ссылку; альтернатива - отключить manifest и потерять сброс кэша.

CSRF verification failed на форме, которая вчера работала. Обычно в CSRF_TRUSTED_ORIGINS нет схемы или cookie с пометкой secure приходит по соединению, которое Django считает обычным HTTP. Сначала проверьте заголовок прокси.

Слишком много редиректов. SECURE_SSL_REDIRECT без работающего SECURE_PROXY_SSL_HEADER.

`OperationalError: too many connections`. Воркеры, умноженные на постоянные соединения, превысили лимит базы данных. Уменьшите CONN_MAX_AGE, уменьшите число воркеров или перейдите на тариф с более высоким лимитом.

Память растёт до тех пор, пока сервер не перезапустится. Проверьте, что DEBUG действительно False в том модуле настроек, который реально загружен: обычный виновник - журналирование запросов. Если не уверены, выведите settings.DEBUG из manage.py shell.

FAQ#

Нужен ли nginx помимо gunicorn?

На одном сервере с WhiteNoise - нет, если перед вами уже что-то завершает TLS. Gunicorn обслуживает приложение, а WhiteNoise отдаёт статику с правильным кэшированием. Ставьте свой прокси, когда нужны лимиты размера запроса, ограничение частоты или кэширование, которыми не должен заниматься Django.

Сколько RAM нужно сайту на Django?

Один воркер gunicorn обычно занимает 100-200 МБ, так что 1 ГБ хватит небольшому сайту с одним воркером и запасом, а 2-4 ГБ - комфортный диапазон для настоящего. Число растёт вместе с зависимостями, а не с трафиком, поэтому измеряйте собственный проект, а не верьте таблице.

Нужно ли запускать миграции автоматически при деплое?

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

Куда попадают загрузки пользователей?

В MEDIA_ROOT на диске сервера, который отделён от статики и не раздаётся через WhiteNoise. Они учитываются в диске вашего тарифа, не лежат в репозитории и должны входить в расписание backup, иначе их не резервируют вообще.

Можно ли запускать Celery на том же сервере, что и веб-приложение?

Можно, и он будет делить с gunicorn долю CPU и память тарифа. Сложнее с брокером: Celery он нужен, а это ещё одна служба. Если ваши задачи периодические, а не событийные, management-команда по расписанию делает ту же работу вообще без брокера.


Комментарии

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

0/2000