Три копии одного приложения на трёх серверах, и ночью одна из копий молча падает. Если клиенты идут к приложению напрямую, треть запросов получает ошибку, и утро начинается с разбора жалоб. Если впереди работает Nginx в роли входного шлюза, отказ остаётся внутренней кухней: входной узел замечает сбой, убирает проблемный сервер из ротации и раздаёт запросы оставшимся. Ниже - разбор конфигурации от первого proxy_pass до тонкой настройки повторных попыток.

Роль одного входного узла перед несколькими приложениями

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

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

Все описанные директивы работают в свободной сборке Nginx из пакетов дистрибутива. На момент подготовки материала стабильная ветка - 1.30.x со свежим выпуском 1.30.5, линия разработки - 1.31.6. Точные цифры быстро стареют, механизмы - стабильны годами.

Базовая конфигурация передачи запросов на группу бэкендов

Отправная точка умещается в два блока: в http описывается группа серверов, в server - правило отправки запросов.

upstream app_cluster {
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
    server 10.0.0.13:8080;
}

server {
    listen 80;
    server_name shop.example.com;

    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Имя группы выбирается произвольно, proxy_pass находит её по этому имени. Адрес бэкенда задаётся IP, доменом или Unix-сокетом с префиксом unix:, порт по умолчанию 80. Полезная деталь: домен, резолвящийся сразу в несколько адресов, превращается в несколько серверов.

Host $host передаёт исходное имя сайта, иначе приложение начнёт строить ссылки на внутренние адреса. X-Real-IP несёт адрес клиента одним значением, X-Forwarded-For через $proxy_add_x_forwarded_for копит цепочку промежуточных узлов, X-Forwarded-Proto сообщает протокол обращения.

Проверка и применение - две команды:

nginx -t && nginx -s reload

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

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

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

upstream app_cluster {
    server 10.0.0.11:8080 weight=3;
    server 10.0.0.12:8080 weight=2;
    server 10.0.0.13:8080 weight=1;
}

Шесть запросов разделятся в отношении 3-2-1; схема честна для одинаковых серверов и однотипных запросов.

Когда стоимость обращений различается, скажем отчёт на тридцать секунд рядом со быстрым поиском, круг превращается в лотерею. Тогда выручает least_conn:

upstream app_cluster {
    least_conn;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

Запрос уходит серверу с наименьшим числом активных соединений, веса учитываются, а при равенстве работает взвешенный круговой обход. Директива появилась в версиях 1.3.1 и 1.2.2 и есть в любой современной сборке.

Для сессий без доработок приложения существует ip_hash. Ключом служат первые три октета адреса IPv4 или адрес IPv6 целиком, поэтому один и тот же клиент попадает на один и тот же сервер. Метод дёшев, но с известными минусами: тысячи посетителей за корпоративным шлюзом сядут на один бэкенд, а изменение состава группы перекроет распределение. Временно снимаемый сервер помечают параметром down, тогда хэширование остальных адресов не пострадает.

Следующий кандидат - hash с параметром consistent:

upstream cache_cluster {
    hash $request_uri consistent;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
    server 10.0.0.13:8080;
}

Хэширование по URI без consistent при добавлении сервера перемешивает почти все ключи. С consistent работает схема ketama: перераспределению подвергается лишь малая доля ключей, и для кэширующих слоёв разница между полным прогревом кэша и парой процентов. Замыкает список random из версии 1.15.1: выбор сервера случаен и учитывает веса, а вариант random two берёт двух случайных кандидатов и отдаёт запрос наименее загруженному. Смысл проявляется на больших пулах, где перебор всех серверов ради поиска свободного стоит дороже лотереи.

Пассивные проверки живости и параметры max_fails fail_timeout

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

Чувствительность задают параметры max_fails и fail_timeout. За окно fail_timeout сервер должен накопить max_fails неудачных попыток, после чего выбывает из ротации на то же время fail_timeout. По умолчанию окно 10 секунд, порог - одна неудача. Неудачей считается событие из списка proxy_next_upstream: ошибка соединения или таймаут по умолчанию. Ответ с кодом 500 сам по себе сервер не выключает - приложение ответило, соединение состоялось. Реакция на коды настраивается отдельно.

Практичная настройка пула выглядит так:

upstream app_cluster {
    server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:8080 backup;
}

Сервер с пометкой backup получает трафик, когда основные недоступны, - аварийный круг пула. Пометка не сочетается с методами hash, ip_hash и random. Значение max_fails=0 отключает учёт неудач для сервера, который медленно просыпается после простоя.

Ограничить напор на один бэкенд позволяет max_conns с версии 1.11.5: не больше указанного числа одновременных активных соединений; по умолчанию ограничения нет. Тонкость: без общей зоны разделяемой памяти учёт ведёт каждый рабочий процесс отдельно, и реальный потолок умножается на их число; общая зона включается директивой zone с версии 1.9.0.

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

Правила повторных попыток и таймауты при сбоях бэкендов

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

location / {
    proxy_pass http://app_cluster;
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 3;
    proxy_next_upstream_timeout 15s;
    proxy_connect_timeout 3s;
    proxy_send_timeout 10s;
    proxy_read_timeout 30s;
}

По умолчанию повтор включён только на ошибку соединения и таймаут. Коды 502, 503 и 504 добавляются явно: ответ перегруженного соседа - ещё не повод выдавать ошибку клиенту. Значение off выключает повтор целиком.

Запросы методов POST, LOCK и PATCH по умолчанию не повторяются, если уже успели уйти на бэкенд: второй платеж или повторная регистрация - сомнительный подарок пользователю. С версии 1.9.13 поведением управляет параметр non_idempotent, включать его стоит только для заведомо безопасных операций.

Число попыток и общий бюджет времени ограничивают proxy_next_upstream_tries и proxy_next_upstream_timeout. Обе появились в версии 1.7.5; нулевое значение снимает ограничение. Три попытки за 15 секунд - разумный потолок; исчерпавшему попытки клиенту отдаётся 502, мгновенный отказ честнее минутного зависания.

Таймауты - больное место многих конфигураций. Стандартные 60 секунд на соединение с бэкендом в локальной сети означают целую минуту ожидания упавшего сервера, и соединение обычно не длится дольше 75 секунд. Для внутреннего пула нормой будет 3 секунды. proxy_read_timeout по умолчанию тоже 60 секунд, но считается не на весь ответ, а между двумя последовательными чтениями: медленная генерация отчёта с регулярной отдачей кусков уложится и в 30. proxy_send_timeout аналогично ограничивает паузы при передаче запроса бэкенду.

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

Заголовки клиента, постоянные соединения и привязка сессий

Через шлюз приложению виден не клиент, а сам шлюз; картину восстанавливают заголовки, но у них два следствия. Первое: в журналах приложения появится адрес входного узла, и для честной аналитики нужны значения цепочки X-Forwarded-For. Второе: авторизация по адресу клиента обязана опираться на заголовок от шлюза и не верить заголовкам с чужих адресов, иначе журнал засорится подделками, а контроль доступа начнёт врать.

Постоянные соединения к бэкендам экономят время TCP-рукопожатия на каждом запросе. Директива keepalive существует с версии 1.1.4, но долгие годы требовала ручной настройки. С версии 1.29.7 кэш постоянных соединений включён по умолчанию и рассчитан на 32 соединения, соединения не переиспользуются между разными location, а HTTP/1.1 стал версией по умолчанию для передачи запросов бэкендам. В старых ветках, включая 1.28.x, без этих строк каждый запрос открывал новое соединение к бэкенду:

upstream app_cluster {
    least_conn;
    keepalive 32;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

location / {
    proxy_pass http://app_cluster;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}

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

Типичные ошибки конфигурации и проверка пула под нагрузкой

Список промахов, которые портят жизнь чаще всего, выглядит так:

  1. адрес бэкенда собран в переменную внутри proxy_pass, а resolver не описан - проверка конфигурации завершится ошибкой host not found in upstream;
  2. заголовок Host не передан - приложение строит абсолютные ссылки на внутренние адреса и ломает редиректы;
  3. таймауты оставлены по умолчанию - клиент минуту ждёт ответа от зависшего бэкенда вместо мгновенного переключения;
  4. non_idempotent включён бездумно - повторный POST создаёт вторую подписку или второй платеж;
  5. ip_hash закреплён без плана роста - каждое изменение состава пула перетряхивает распределение и выкидывает пользователей из сессий;
  6. веса подобраны на глаз - тройка на слабой машине превращает её в узкое место всего пула.

Для наблюдения за пулом пригодится журнал с переменными группы:

log_format upstream_state '$remote_addr [$time_local] "$request" '
                          'upstream=$upstream_addr status=$upstream_status '
                          'upstream_time=$upstream_response_time total=$request_time';

$upstream_addr показывает, кто именно отвечал, $upstream_status - коды ответов бэкендов, $upstream_response_time - время их работы. По такому журналу сбой виден сразу: статус 502 у одного адреса и переключение трафика на соседей.

Быстрая проверка ротации - цикл коротких запросов:

for i in $(seq 1 6); do
    curl -s -o /dev/null -w "%{http_code} " http://shop.example.com/api/ping
done
echo

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

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