{ "index": 279, "slug": "editorial-2020-04-practice-reverse-proxy", "title": "Reverse proxy перед приложением: как проверить границу HTTP", "excerpt": "Приложение за proxy видит внутреннее соединение, а не клиента. Разбираем Host, forwarded-заголовки и таймауты по hop-ам, а затем проверяем контракт одним безопасным маршрутом.", "contentHtml": "

Запрос приходит по HTTPS, но приложение строит редирект на HTTP. В журнале все посетители имеют один IP. Иногда тот же endpoint отвечает 200, а иногда получает 504. Эти симптомы похожи на ошибку приложения, хотя причина часто находится на границе между клиентом, reverse proxy и upstream.

\n

Цена ошибки растёт быстро. Неверная схема ломает абсолютные ссылки и secure-cookie. Неверный адрес клиента портит rate limit и расследование инцидента. Непонятый таймаут превращает медленный ответ в спор о том, «упал ли backend». Исправлять эти симптомы одним новым заголовком опасно: proxy создаёт отдельное соединение и меняет контекст запроса.

\n

Тезис: проверяйте каждый hop отдельно

\n

Reverse proxy не является прозрачным проводом. Он принимает одно HTTP-соединение от клиента и открывает другое соединение к приложению. Для Nginx непосредственный peer — клиент или предыдущий proxy. Для приложения непосредственный peer — Nginx. На границе могут измениться Host, схема, цепочка адресов, момент ожидания и видимый статус.

\n

Рабочая проверка поэтому должна отвечать на четыре разных вопроса. Что отправил клиент? Что Nginx передал upstream? Что приложение прочитало? Что записали оба журнала? Пока эти ответы смешаны, статус 502 или 504 остаётся только симптомом.

\n

Механизм двух соединений

\n

Предположим, TLS завершается на Nginx. Внешний hop выглядит так: клиент подключается к Nginx по HTTPS. Внутренний hop может идти к приложению по HTTP. Само по себе это нормально. Приложение не узнает внешнюю схему из внутреннего сокета. Оно узнает её только из согласованного forwarded-заголовка или из другого доверенного контракта.

\n

То же относится к адресу. $remote_addr на Nginx обозначает адрес непосредственного источника входного соединения. Если перед Nginx уже стоит балансировщик, это может быть адрес балансировщика. Заголовок X-Forwarded-For не становится истинным только потому, что его прислал клиент. Приложение должно принимать его от заранее определённого доверенного proxy, а не от любого HTTP-подключения.

\n

Host отвечает за другой класс ошибок. Приложение может выбирать tenant, строить редирект или проверять origin по этому полю. Если proxy не передал внешний host явно, upstream получит значение, отличное от того, что ввёл пользователь. Сначала фиксируют ожидаемый контракт, потом выбирают директиву и адаптер фреймворка. Обратный порядок порождает угадывание.

\n
Диагностика границы reverse proxy
СимптомПричинаПроверкаДействие
Редирект ведёт на httpTLS завершился на proxy, а приложение не получило согласованную схемуСопоставить внешний URL, forwarded-заголовок и поле, которое читает приложениеПередать один явный признак схемы и разрешить его только от доверенного proxy
Все клиенты имеют IP proxyПриложение пишет peer address внутреннего соединенияСравнить remote address на proxy с цепочкой адресов в upstreamНастроить доверенную цепочку real IP; не брать первое значение из любого заголовка
Пропал tenant или изменился hostUpstream получил другой Host или URI после proxy_passЗаписать host и URI на proxy и в приложении для одного тестового запросаЯвно зафиксировать Host и правило преобразования URI
504 после ровного интервалаProxy не дождался следующего события upstreamСравнить connect, header и response time с таймаутами и логом приложенияОпределить, какой этап превысил бюджет; не увеличивать все таймауты сразу
Снаружи 502, в приложении нет записиСбой соединения до обработки запроса приложениемПроверить upstream address, connect time и доступность процессаИсправить маршрут или состояние upstream; не искать ошибку в контроллере
\n

Учебная конфигурация

\n

Ниже показана малая HTTP-конфигурация для изолированного стенда. Она слушает обычный HTTP на порту 8080, поэтому в этом упражнении $scheme равен http. Имя upstream, порт, путь журнала и значения таймаутов учебные. Этот фрагмент не включает TLS-сертификаты и не является готовым production-рецептом. Его задача — сделать внутренний hop видимым и дать каждой директиве проверяемый смысл.

\n
upstream app_backend {\n    server 127.0.0.1:3000;\n}\n\nserver {\n    listen 8080;\n    server_name _;\n\n    location / {\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Connection \"\";\n\n        proxy_connect_timeout 3s;\n        proxy_send_timeout 10s;\n        proxy_read_timeout 15s;\n        proxy_pass http://app_backend;\n    }\n}
\n

proxy_connect_timeout отвечает за установление соединения с upstream. proxy_send_timeout ограничивает паузы при передаче запроса. proxy_read_timeout ограничивает паузу между последовательными чтениями ответа. Последняя директива не задаёт полную длительность endpoint. Потоковый ответ может идти дольше, если upstream регулярно отправляет данные. Тихий ответ может оборваться раньше.

\n

В примере X-Forwarded-For дополняет цепочку, а не безусловно заменяет её. Но это ещё не политика доверия. Если приложение доступно в обход Nginx, клиент сможет прислать такой заголовок напрямую. Сначала закрывают обход или фильтруют источник, затем включают обработку forwarded-данных. Для real IP отдельно перечисляют доверенные сети.

\n
\"Клиент
Один внешний запрос даёт как минимум два HTTP-hop-а. Ищите изменение сигнала на конкретной границе, а не в абстрактном «сервере».
\n

Логи должны доказывать путь

\n

Статус ответа без времени и upstream-контекста мало помогает. Для учебной проверки достаточно записать метод, URI, итоговый статус, безопасный диагностический токен, адрес upstream, полное время запроса и интервалы подключения, получения заголовков и ответа. В журнал нельзя добавлять секреты, cookie и произвольное тело запроса.

\n
log_format proxy_boundary '$request_method $uri status=$status '\n                          'debug=$http_x_debug_token '\n                          'request=$request_time upstream=$upstream_addr '\n                          'upstream_status=$upstream_status '\n                          'connect=$upstream_connect_time '\n                          'header=$upstream_header_time '\n                          'response=$upstream_response_time';\n\naccess_log /path/to/proxy-boundary.log proxy_boundary;
\n

Синтетическая строка ниже показывает способ чтения полей. Она не получена от реального Nginx и не доказывает причину сама по себе.

\n
GET /health status=504 request=3.001 upstream=<backend> upstream_status=-\nconnect=0.001 header=- response=3.001
\n

Такой результат сужает поиск: proxy установил соединение, но не получил ответ в пределах лимита. Дальше проверяют лог приложения и его собственный timeout. Если upstream_status отсутствует, это не доказательство, что приложение не запустилось: нужно проверить формат журнала и точный этап отказа.

\n

Проверка одним безопасным маршрутом

\n

Для контракта не нужен полный smoke-тест. Выберите endpoint без пользовательских данных, например /health на учебном стенде. Добавьте диагностический токен, который записывают и proxy, и приложение. Токен не заменяет аутентификацию и не должен содержать секрет.

\n
# Учебный HTTP-запрос к конфигурации на 8080.\ncurl -i --max-time 5 \\\n  'http://127.0.0.1:8080/health' \\\n  -H 'Host: public.example.test' \\\n  -H 'X-Debug-Token: proxy-study-2020-04'
\n

Положительный результат состоит не только из 200. Внешний ответ должен иметь ожидаемые статус и заголовки. В записи Nginx должны быть debug=proxy-study-2020-04 и адрес upstream. В записи приложения должны быть тот же токен, ожидаемый Host и X-Forwarded-Proto=http для этого HTTP-стенда. Если хотя бы одна запись отсутствует, проверка не подтверждает весь путь. Для проверки внешнего HTTPS после отдельной настройки listen 443 ssl повторяют тот же маршрут по TLS и ожидают X-Forwarded-Proto=https; сертификаты и доверенную TLS-конфигурацию этот фрагмент не задаёт.

\n

Порядок действий

\n
  1. Запишите один симптом и его цену: неверный редирект, потерянный адрес, 502/504 или неожиданный timeout.
  2. Нарисуйте реальные hop-ы: кто принимает внешний запрос, где завершается TLS и кто является upstream.
  3. Назначьте владельца каждому сигналу: Host, схема, forwarded-цепочка, время подключения и время ответа.
  4. Определите доверенную границу. Укажите, кто имеет право выставлять forwarded-заголовки и может ли клиент обойти proxy.
  5. Соберите минимальную конфигурацию, проверьте её синтаксис и не меняйте одновременно route, код приложения и все таймауты.
  6. Выполните HTTP-запрос к учебному стенду. Сопоставьте ответ, запись proxy и запись приложения по безопасному токену.
  7. Если нужен HTTPS-контроль, повторите проверку только после отдельной настройки TLS и сравните значение forwarded-схемы с внешним URL.
  8. Если гипотеза не подтверждается, откатите одну изменённую строку и проверьте следующий hop. Не превращайте увеличение таймаута в финальное решение без объяснения.
\n

Ограничения и отрицательный путь

\n

Такая схема не решает проблемы, которые находятся за пределами HTTP-контракта. Она не доказывает корректность балансировки, TLS-сертификата, DNS, firewall, размера буфера или поведения нескольких upstream. Она также не делает forwarded-заголовки безопасными при открытом прямом доступе к приложению.

\n

Если внешний статус 200, но приложение всё равно строит неправильный URL, проверяйте не сеть, а поле, которое использует код. Если в proxy есть запрос, а в приложении нет записи, проверяйте соединение, маршрутизацию и ранний отказ. Если приложение пишет запрос, но proxy отдаёт 504, сравнивайте интервалы между байтами и общий бюджет маршрута. В каждом отрицательном пути меняйте одну гипотезу и сохраняйте наблюдаемый результат.

\n

Критерий готовности

\n

Граница готова, когда один безопасный запрос проходит через ожидаемые hop-ы, внешний ответ соответствует договору, proxy и приложение связываются по диагностическому токену, Host и схема читаются ожидаемо, а таймаут можно объяснить конкретным этапом. Для HTTP-стенда это http, для TLS-входа — https. Конфигурация имеет проверенный синтаксис, прямой обход запрещён или явно учтён, а для неуспешного результата есть обратный шаг.

\n

Все значения в примерах — учебные. Здесь не заявлены запуск Nginx, выполнение curl, проверка browser, staging или production. Перед повторением зафиксируйте версию командой nginx -v и сверяйте синтаксис и значения по умолчанию с документацией этой версии. Перед применением в проекте подтвердите топологию, доверенные сети, таймаут приложения и правила хранения журналов.

\n

Проверяемые источники

\n" }