Сценарий из жизни. У тебя апстрим из четырёх приложений, фронт - nginx. В пятницу вечером одна нода ловит сборщик мусора и начинает отвечать по 40 секунд вместо 80 миллисекунд. Через минуту мониторинг краснеет весь: время ответа выросло у всех урлов, воркеры заняты, новые соединения встают в очередь. Виноват один бэкенд, а страдает весь сервис. Почему? Потому что nginx по умолчанию готов ждать ответа очень долго, не размыкает соединение и держит слот занятым.
Эта глава - про то, как сделать прокси устойчивым к нестабильному бэкенду. Мы разберём три кита надёжности: таймауты (когда сдаваться), повторы и failover (куда уходить при сбое) и защиту самого nginx от медленных клиентов. И отдельно - главную ловушку повторов: двойные POST. Это та тема, где невнимательная настройка создаёт инциденты вместо того, чтобы их предотвращать. Ключевые слова, которые ты будешь гуглить в 3 часа ночи - nginx 504 timeout, nginx failover, nginx retries - после этого урока перестанут быть загадкой.

Таймауты прокси: три рубежа и их смысл
У проксирования к бэкенду есть три фазы, и на каждую свой таймаут. Это не абстракция - это буквально три разных момента, когда что-то может зависнуть.
- proxy_connect_timeout - сколько ждать установки TCP-соединения с апстримом. Это TCP-handshake, ещё до отправки байта запроса. Если бэкенд жив и слушает порт - укладывается в миллисекунды. Если истёк этот таймаут, значит нода либо мертва, либо сеть отвалилась, либо очередь accept переполнена. По умолчанию 60s - это вечность. Эталон - 5s. Дольше ждать установки соединения внутри одного дата-центра смысла нет: за 5 секунд здоровый бэкенд ответит сто раз.
- proxy_send_timeout - таймаут между двумя последовательными операциями записи, когда nginx отправляет тело запроса в апстрим. Считается не на весь запрос целиком, а сбрасывается после каждой успешной записи. Важно для больших POST/PUT (загрузка файлов): если бэкенд перестал читать body, через этот таймаут nginx отвалится.
- proxy_read_timeout - таймаут между двумя последовательными операциями чтения ответа от апстрима. Тоже не на весь ответ - он сбрасывается каждый раз, когда приходит новая порция данных. Это самый частый виновник ошибки 504. В 99 процентах случаев nginx 504 timeout - это именно proxy_read_timeout: соединение установилось, запрос ушёл, а бэкенд молчит дольше положенного. Дефолт - 60s.
Эталонные значения для шлюза-фронта:
Код: Выделить всё
# globals/proxy.conf - общие таймауты на весь сервер
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
Код: Выделить всё
location /api/ {
proxy_pass http://backend_upstream;
# быстрые API живут на строгом глобальном 10s
}
location /reports/export {
proxy_pass http://backend_upstream;
proxy_read_timeout 300s; # тяжёлая выгрузка - локальное исключение
proxy_send_timeout 60s;
}
nginx proxy_next_upstream: failover и грабля двойных POST
Таймаут говорит nginx когда сдаться на конкретной ноде. А proxy_next_upstream говорит что делать дальше: попробовать следующий сервер в апстриме. Это и есть встроенный failover - бесплатный механизм отказоустойчивости nginx без всяких внешних балансировщиков.
Директива перечисляет условия, при которых запрос перебрасывается на следующий сервер:
Код: Выделить всё
# globals/proxy.conf
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_next_upstream_timeout 15s;
- error - ошибка при установке соединения или передаче (бэкенд отказал в соединении, оборвал его).
- timeout - сработал один из proxy_*_timeout.
- http_502 / http_503 / http_504 - бэкенд явно ответил кодом ошибки. Перебрасываем на соседа, который, возможно, здоров. А вот http_500 в этот список добавлять опасно: 500 часто значит баг в коде приложения, который воспроизведётся и на соседней ноде - ты просто размножишь нагрузку от заведомо падающего запроса.
- proxy_next_upstream_tries 3 - максимум 3 попытки суммарно (включая первую). Без этого лимита при упавшем целиком апстриме nginx переберёт ВСЕ серверы по кругу на каждый запрос - и это превратит локальный сбой в шторм ретраев. Эталон - 3.
- proxy_next_upstream_timeout 15s - общий бюджет времени на все попытки. Даже если попыток ещё не 3, после 15 секунд суммарно nginx прекращает перебор и отдаёт ошибку клиенту. Защита от ситуации, когда каждая попытка честно отрабатывает таймаут и в сумме клиент ждёт минуту.
Именно поэтому есть отдельное слово - non_idempotent:
Код: Выделить всё
# ОПАСНО - так делать почти никогда не надо
proxy_next_upstream error timeout http_502 non_idempotent;
Практический вывод: держи non_idempotent выключенным глобально, а если он реально нужен - включай точечно в location идемпотентного эндпоинта и больше нигде.Тонкость: защита снимается только если запрос УЖЕ отправлен в апстрим. Если соединение даже не установилось (чистый connect error, ни байта не ушло) - nginx безопасно повторит даже POST, потому что бэкенд гарантированно ничего не получил. То есть error на этапе connect ретраится всегда, а timeout после отправки POST - нет. Логично: повтор опасен ровно тогда, когда бэкенд мог что-то сделать.
Circuit breaking через max_fails и связь с failfrom
proxy_next_upstream спасает один запрос - перекидывает его на здоровую ноду. Но если больная нода так и остаётся в ротации, каждый новый запрос будет сначала идти к ней, ловить таймаут и только потом уходить дальше. Это лишние секунды задержки на ровном месте. Нужен механизм, который временно выкинет больную ноду из ротации. В open-source nginx это пассивный circuit breaker через параметры сервера в апстриме:
Код: Выделить всё
# upstream.conf
upstream backend_upstream {
server 10.0.0.1:9000 max_fails=3 fail_timeout=30s;
server 10.0.0.2:9000 max_fails=3 fail_timeout=30s;
server 10.0.0.3:9000 max_fails=3 fail_timeout=30s;
server 10.0.0.4:9000 backup; # включится, только когда все основные легли
keepalive 64;
}
Это пассивная схема: nginx узнаёт о проблеме, только когда реальный запрос на больную ноду провалится. Активных health checks (когда nginx сам периодически пингует бэкенды отдельным запросом и выводит их из ротации до того, как пострадает живой трафик) в open-source nginx нет - они есть в NGINX Plus за деньги и бесплатно в Angie (директива upstream_probe), вместе со slow_start для плавного возврата ноды после восстановления. Если активные проверки и плавный прогрев тебе нужны без подписки - это весомый аргумент в пользу Angie как форка nginx.
Грейсфул-деградация: proxy_intercept_errors и красивые ошибки
Допустим, все попытки провалились - апстрим лёг целиком. Что увидит клиент? По умолчанию nginx проксирует ответ бэкенда как есть, включая его сырую страницу ошибки 502/504 - часто это уродливый дефолтный HTML или вообще утечка стека приложения. Чтобы перехватить такие ответы и подменить их своими страницами, нужна связка proxy_intercept_errors и error_page:
Код: Выделить всё
location /api/ {
proxy_pass http://backend_upstream;
proxy_intercept_errors on; # перехватываем коды >= 300 от апстрима
error_page 502 503 504 = /errors/maintenance.html;
}
location = /errors/maintenance.html {
root /var/www/static;
internal; # доступна только внутренним редиректом
}
Тонкость: интерсепт ловит все коды от 300, так что аккуратно с редиректами и 404 от бэкенда - либо перечисляй конкретные коды в error_page, либо учитывай, что 404 приложения тоже будет перехвачен.
Анти-Slowloris: таймауты на клиента (аудит безопасности)
Всё выше - про защиту от медленного бэкенда. Но есть зеркальная угроза: медленный клиент. Атака Slowloris - это когда злоумышленник открывает сотни соединений и шлёт заголовки запроса по одному байту в секунду, не завершая запрос. Каждое такое соединение держит воркер занятым. Сотни медленных соединений - и легитимные клиенты не могут подключиться. Дёшево для атакующего, больно для тебя.
Защита - три клиентских таймаута, которые часто забывают выставить (включи их в чек-лист любого аудита nginx):
Код: Выделить всё
# http.conf или server-уровень
client_header_timeout 5s; # на получение ВСЕХ заголовков запроса
client_body_timeout 10s; # таймаут между чтениями тела запроса
send_timeout 10s; # таймаут между записями ответа КЛИЕНТУ
- client_header_timeout - если клиент не прислал заголовки целиком за это время, соединение рвётся. Прямой контрудар по Slowloris: байт-в-секунду больше не пройдёт.
- client_body_timeout - то же для тела запроса. По умолчанию оба по 60s - для атакующего этого хватит, чтобы держать соединения почти бесплатно.
- send_timeout - таймаут между записями, когда nginx отправляет ответ клиенту. Если клиент перестал читать (тоже техника медленной атаки - Slow Read), соединение освобождается.
Мини-лаба: устойчивый прокси к нестабильному бэкенду
Соберём всё руками. Нужен docker или две машины. Идея: бэкенд, который умеет тормозить и падать, и nginx, который это переживает.
Шаг 1. Подними два простых бэкенда. Быстрый способ - два контейнера с любым "эхо"-сервером на портах 9001 и 9002. Один сделай заведомо медленным (например, отдающим ответ с задержкой 30 секунд) - это будет наша "больная" нода.
Шаг 2. Конфиг nginx:
Код: Выделить всё
upstream lab_backend {
server 127.0.0.1:9001 max_fails=2 fail_timeout=20s; # медленный
server 127.0.0.1:9002 max_fails=2 fail_timeout=20s; # быстрый
keepalive 32;
}
server {
listen 8080;
client_header_timeout 5s;
client_body_timeout 10s;
location / {
proxy_pass http://lab_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 3s; # специально маленький для лабы
proxy_send_timeout 3s;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 6s;
proxy_intercept_errors on;
error_page 502 503 504 = /down.html;
add_header X-Upstream-Addr $upstream_addr always;
add_header X-Upstream-Status $upstream_status always;
}
location = /down.html { return 200 "backend is down, try later\n"; }
}
Код: Выделить всё
for i in $(seq 1 6); do curl -s -D - http://127.0.0.1:8080/ -o /dev/null | grep -i x-upstream; echo ---; done
Шаг 5. Урони обе ноды (останови оба контейнера) и повтори запрос. Теперь все попытки провалятся, сработает error_page и ты увидишь "backend is down" вместо сырой 502. Грейсфул-деградация в действии.
Шаг 6 (опасный, для понимания). Поменяй метод на POST: curl -X POST. Убедись, что при timeout запрос НЕ ретраится (X-Upstream-Addr содержит один адрес). Потом добавь non_idempotent в proxy_next_upstream, повтори - и увидишь, что теперь POST перебрасывается. Прочувствуй, почему на платёжном эндпоинте это было бы катастрофой.
Типичные грабли
- non_idempotent на весь шлюз ради "надёжности". Классика двойных списаний. POST по умолчанию не ретраится не от тупости nginx, а по стандарту. Снимай защиту только точечно и только для реально идемпотентных эндпоинтов.
- proxy_read_timeout 600s глобально. Один тяжёлый отчёт - и ты задрал таймаут всем, открыв дверь для зависших соединений на всех урлах. Поднимай локально, в конкретном location.
- Забыли proxy_next_upstream_tries. При падении всего апстрима nginx перебирает все серверы на каждый запрос - сбой превращается в шторм. Ставь лимит (эталон 3) и бюджет timeout.
- http_500 в next_upstream. Баг приложения воспроизведётся на всех нодах - ты только размножишь падающую нагрузку. Ретрай для явных 5xx-ошибок инфраструктуры (502/503/504), а не для прикладных 500.
- Дефолтные client_*_timeout по 60s. Открытая дверь для Slowloris. Это первое, что смотрит аудит. Урезай до 5-10s.
- error_page без proxy_intercept_errors on. Директива есть, а не работает - клиент видит сырую ошибку бэкенда. Без интерсепта error_page не ловит апстрим-ответы.
- Несогласованные таймауты по слоям. Внешний nginx рвёт соединение раньше, чем бэкенд закончил - результат потерян, ресурсы сожжены. Снаружи всегда не короче, чем внутри.
- Запрос POST /order ушёл в бэкенд, бэкенд завис, сработал proxy_read_timeout. В директиве указано "error timeout http_502". Повторит ли nginx запрос на соседнюю ноду и почему? А если бы таймаут случился на этапе connect (соединение не установилось)?
- В чём разница между proxy_send_timeout и send_timeout? Какой из них защищает от Slowloris?
- proxy_read_timeout 10s. Бэкенд шлёт ответ чанками по одному байту каждые 8 секунд в течение пяти минут. Оборвётся ли соединение по таймауту? Почему?
- Как связаны max_fails в upstream и список условий в proxy_next_upstream? Что произойдёт с нодой после max_fails=3 провалов в окне fail_timeout=30s?
Устойчивый прокси стоит на трёх опорах. Таймауты (эталон: connect 5s, send/read 10s, тяжёлые эндпоинты - локально) решают когда сдаваться на ноде. proxy_next_upstream с tries=3 и согласованным списком условий решает куда уходить, а max_fails временно гасит больную ноду. Связка proxy_intercept_errors + error_page прячет уродливые ошибки бэкенда за вежливой страницей. Клиентские таймауты client_header/body_timeout и send_timeout защищают сам nginx от медленных атак. И золотое правило, которое спасёт тебе нервы и деньги: не включай non_idempotent бездумно - двойной POST это двойное списание. Активные health checks и slow_start, если они нужны без подписки на Plus - смотри в сторону Angie.