Алексей Меленчук
вся записка
узел 03 · Обмен
дата
объём 7 мин чтения

Зачем нужен nginx: прокси и балансировка

Зачем ставить nginx перед приложением: обратный прокси, терминация TLS, буферизация, таймауты, upstream и балансировка нагрузки. И нужен ли ещё apache.

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

Приложение слушает порт 8000 и прекрасно отвечает. Перед ним ставят nginx, и на вопрос «зачем» отвечают «для балансировки». Хотя бэкенд один, машина одна, и распределять там нечего.

Ответ неверный, а привычка верная.

Что делает обратный прокси

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

Отсюда вырастает всё остальное: появляется место, где запрос можно остановить, переписать или отдать целиком, не доводя до кода.

Балансировка — тоже отсюда. Но это не причина ставить прокси, а возможность, которая появляется после.

Медленный клиент

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

Теперь клиент на мобильной сети в метро. Ответ на сто килобайт он принимает несколько секунд, и всё это время приложение пишет в сокет по чуть-чуть, а воркер занят. Не вычислениями — ожиданием.

Полсотни таких клиентов — и все воркеры разобраны, а процессор простаивает. Снаружи это выглядит как «сервер не справляется». Внутри не справляется никто, просто все стоят.

nginx снимает это буферизацией. При proxy_buffering on — а это значение по умолчанию — он вычитывает ответ бэкенда в буферы, и если ответ не влез, дописывает его во временный файл, по умолчанию до 1024 мегабайт (документация модуля proxy). Воркер приложения свободен сразу, как отдал ответ прокси. Дальше клиент тянет эти килобайты уже из nginx, где соединение стоит не процесс, а запись в цикле событий.

То же с обратной стороны: proxy_request_buffering on собирает тело запроса целиком, прежде чем передать дальше. Загрузка файла на плохом канале не держит воркера.

Здесь стоит возразить самому себе. Если сервер приложений асинхронный — uvicorn, Node, Go, — медленный клиент не занимает воркера, потому что воркера на запрос там и нет. Значит ли это, что перед асинхронным приложением прокси не нужен? Нет, но довод меняется целиком: остаются TLS, статика, лимиты и таймауты, а главный аргумент исчезает. Моя позиция верна ровно в той модели, где на запрос приходится процесс или поток. За её пределами её приходится защищать заново, и получается слабее.

Таймауты по умолчанию

proxy_connect_timeout, proxy_read_timeout, proxy_send_timeout — у всех трёх значение по умолчанию шестьдесят секунд. Записано в той же документации, и это много.

Тонкость важнее самого числа. proxy_read_timeout — не предел времени ответа, а предел паузы между двумя последовательными чтениями. Бэкенд, который раз в тридцать секунд шлёт байт, живёт вечно и ничего не нарушает. А приложение, зависшее на запросе в базу, держит соединение минуту — и минуту же держит воркер, которого буферизация как раз должна была беречь.

Дальше пара, о которой узнают в аварию. В директиве server у модуля upstream по умолчанию max_fails=1 и fail_timeout=10s: один неудачный запрос — и узел выпадает из ротации на десять секунд. Оговорка: в группе из одного сервера эти параметры игнорируются, такой узел недоступным не признаётся никогда, так что всё дальше — про группу от двух. А proxy_next_upstream по умолчанию равен error timeout, то есть не дождавшийся ответа запрос уходит на следующий сервер.

Сложите. Бэкенд начал отвечать дольше шестидесяти секунд — nginx считает это отказом, перебрасывает запрос на соседа, сосед получает двойную нагрузку и тоже перестаёт успевать. Через два круга неудачным успевает оказаться каждый узел. Из ротации они при этом не исчезают: не получив успешного ответа ни от кого, nginx возвращает клиенту результат работы с последним сервером — то есть отказа как отдельного события не видно, видно медленные ответы и растущую очередь. Это тот же механизм, что превращает повторы в самодельный DDoS, только запускает его не код, а конфигурация, которую никто не трогал.

Ни один алгоритм распределения от этого не спасает. Поэтому значения по умолчанию у таймаутов — первое, что правят в конфиге, а не последнее.

upstream и алгоритмы

Методов несколько: round-robin с весами (по умолчанию), least_conn, ip_hash, hash с опцией consistent, random. Переключаются они одной строкой.

При однородных бэкендах и коротких запросах round-robin и least_conn дают почти неразличимую картину: очереди везде короткие, выравнивать нечего. Разница появляется, когда время ответа разбросано — часть запросов тяжёлые, — и тогда least_conn действительно лучше, потому что смотрит на текущую занятость, а не на счётчик отправленных.

ip_hash стоит особняком, и он вообще не про распределение. Это способ привязать клиента к одному бэкенду, то есть признание, что состояние сессии лежит в памяти процесса. Настройка прокси тут прикрывает решение уровнем выше: пока состояние в памяти, вы не можете спокойно выключить узел на обновление.

Что ещё снимает слой перед приложением

TLS. Сертификат обновляется в одном месте, приложение говорит обычным HTTP. Ловушка известная: после этого приложение видит у всех клиентов адрес прокси — 127.0.0.1, если nginx на той же машине, или его адрес в сети, если на отдельной, — и лимиты по IP, гео и журналы доступа начинают врать. Лечится на прокси заголовком proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for и тем, что приложение этому заголовку доверяет. Модуль realip тут про другое: он чинит $remote_addr внутри самого nginx, когда перед ним стоит ещё один прокси или CDN.

Статика. Файл отдаётся с диска, не доходя до кода. Выигрыш тут меньше, чем принято думать: статику всё чаще раздают через CDN с отдельного домена, и nginx до неё не касается.

Ограничение частоты. limit_req реализует «дырявое ведро»: средняя скорость плюс допустимый всплеск, лишнее либо задерживается, либо получает 503. Ценность не в самом ограничении, а в том, где оно стоит. Отбитый на прокси запрос не потратил ни воркера, ни соединения к базе. Тот же лимит внутри приложения срабатывает, когда половина цены уже уплачена.

nginx или apache

Спор старше, чем кажется, и поставлен не про то. Apache исторически был сервером приложений: mod_php выполнял код внутри процесса, а процесс держался на соединение. nginx с самого начала был слоем перед приложением — фиксированное число рабочих процессов, событийный цикл внутри каждого.

Поэтому сегодня выбирают не между ними. Apache остаётся там, где нужен .htaccess: на общем хостинге это способ отдать настройку пользователю, которому нельзя трогать основной конфиг. В остальных случаях вопрос звучит иначе — nginx, HAProxy, Caddy или то, что уже стоит в облаке.

Условие, при котором я неправ

Если перед машинами уже стоит балансировщик облака или ingress кластера, буферизацию и таймауты делает он. Второй nginx на той же машине — лишний слой со своими шестьюдесятью секундами, и он умеет поломать то, что выше уже настроено правильно.

Проверяется одним вопросом: что именно принимает соединение от клиента? Если не ваш nginx, всё сказанное выше относится к чужому конфигу, а ваш имеет смысл ровно до тех пор, пока в нём есть строки, которых нет наверху.