{ "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 создаёт отдельное соединение и меняет контекст запроса.
\nReverse proxy не является прозрачным проводом. Он принимает одно HTTP-соединение от клиента и открывает другое соединение к приложению. Для Nginx непосредственный peer — клиент или предыдущий proxy. Для приложения непосредственный peer — Nginx. На границе могут измениться Host, схема, цепочка адресов, момент ожидания и видимый статус.
\nРабочая проверка поэтому должна отвечать на четыре разных вопроса. Что отправил клиент? Что Nginx передал upstream? Что приложение прочитало? Что записали оба журнала? Пока эти ответы смешаны, статус 502 или 504 остаётся только симптомом.
\nПредположим, TLS завершается на Nginx. Внешний hop выглядит так: клиент подключается к Nginx по HTTPS. Внутренний hop может идти к приложению по HTTP. Само по себе это нормально. Приложение не узнает внешнюю схему из внутреннего сокета. Оно узнает её только из согласованного forwarded-заголовка или из другого доверенного контракта.
\nТо же относится к адресу. $remote_addr на Nginx обозначает адрес непосредственного источника входного соединения. Если перед Nginx уже стоит балансировщик, это может быть адрес балансировщика. Заголовок X-Forwarded-For не становится истинным только потому, что его прислал клиент. Приложение должно принимать его от заранее определённого доверенного proxy, а не от любого HTTP-подключения.
Host отвечает за другой класс ошибок. Приложение может выбирать tenant, строить редирект или проверять origin по этому полю. Если proxy не передал внешний host явно, upstream получит значение, отличное от того, что ввёл пользователь. Сначала фиксируют ожидаемый контракт, потом выбирают директиву и адаптер фреймворка. Обратный порядок порождает угадывание.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Редирект ведёт на http | TLS завершился на proxy, а приложение не получило согласованную схему | Сопоставить внешний URL, forwarded-заголовок и поле, которое читает приложение | Передать один явный признак схемы и разрешить его только от доверенного proxy |
| Все клиенты имеют IP proxy | Приложение пишет peer address внутреннего соединения | Сравнить remote address на proxy с цепочкой адресов в upstream | Настроить доверенную цепочку real IP; не брать первое значение из любого заголовка |
| Пропал tenant или изменился host | Upstream получил другой Host или URI после proxy_pass | Записать host и URI на proxy и в приложении для одного тестового запроса | Явно зафиксировать Host и правило преобразования URI |
| 504 после ровного интервала | Proxy не дождался следующего события upstream | Сравнить connect, header и response time с таймаутами и логом приложения | Определить, какой этап превысил бюджет; не увеличивать все таймауты сразу |
| Снаружи 502, в приложении нет записи | Сбой соединения до обработки запроса приложением | Проверить upstream address, connect time и доступность процесса | Исправить маршрут или состояние upstream; не искать ошибку в контроллере |
Ниже показана малая HTTP-конфигурация для изолированного стенда. Она слушает обычный HTTP на порту 8080, поэтому в этом упражнении $scheme равен http. Имя upstream, порт, путь журнала и значения таймаутов учебные. Этот фрагмент не включает TLS-сертификаты и не является готовым production-рецептом. Его задача — сделать внутренний hop видимым и дать каждой директиве проверяемый смысл.
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}\nproxy_connect_timeout отвечает за установление соединения с upstream. proxy_send_timeout ограничивает паузы при передаче запроса. proxy_read_timeout ограничивает паузу между последовательными чтениями ответа. Последняя директива не задаёт полную длительность endpoint. Потоковый ответ может идти дольше, если upstream регулярно отправляет данные. Тихий ответ может оборваться раньше.
В примере X-Forwarded-For дополняет цепочку, а не безусловно заменяет её. Но это ещё не политика доверия. Если приложение доступно в обход Nginx, клиент сможет прислать такой заголовок напрямую. Сначала закрывают обход или фильтруют источник, затем включают обработку forwarded-данных. Для real IP отдельно перечисляют доверенные сети.
Статус ответа без времени и upstream-контекста мало помогает. Для учебной проверки достаточно записать метод, URI, итоговый статус, безопасный диагностический токен, адрес upstream, полное время запроса и интервалы подключения, получения заголовков и ответа. В журнал нельзя добавлять секреты, cookie и произвольное тело запроса.
\nlog_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 и не доказывает причину сама по себе.
\nGET /health status=504 request=3.001 upstream=<backend> upstream_status=-\nconnect=0.001 header=- response=3.001\nТакой результат сужает поиск: proxy установил соединение, но не получил ответ в пределах лимита. Дальше проверяют лог приложения и его собственный timeout. Если upstream_status отсутствует, это не доказательство, что приложение не запустилось: нужно проверить формат журнала и точный этап отказа.
\nДля контракта не нужен полный smoke-тест. Выберите endpoint без пользовательских данных, например /health на учебном стенде. Добавьте диагностический токен, который записывают и proxy, и приложение. Токен не заменяет аутентификацию и не должен содержать секрет.
# Учебный 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-конфигурацию этот фрагмент не задаёт.
Такая схема не решает проблемы, которые находятся за пределами HTTP-контракта. Она не доказывает корректность балансировки, TLS-сертификата, DNS, firewall, размера буфера или поведения нескольких upstream. Она также не делает forwarded-заголовки безопасными при открытом прямом доступе к приложению.
\nЕсли внешний статус 200, но приложение всё равно строит неправильный URL, проверяйте не сеть, а поле, которое использует код. Если в proxy есть запрос, а в приложении нет записи, проверяйте соединение, маршрутизацию и ранний отказ. Если приложение пишет запрос, но proxy отдаёт 504, сравнивайте интервалы между байтами и общий бюджет маршрута. В каждом отрицательном пути меняйте одну гипотезу и сохраняйте наблюдаемый результат.
\nГраница готова, когда один безопасный запрос проходит через ожидаемые hop-ы, внешний ответ соответствует договору, proxy и приложение связываются по диагностическому токену, Host и схема читаются ожидаемо, а таймаут можно объяснить конкретным этапом. Для HTTP-стенда это http, для TLS-входа — https. Конфигурация имеет проверенный синтаксис, прямой обход запрещён или явно учтён, а для неуспешного результата есть обратный шаг.
Все значения в примерах — учебные. Здесь не заявлены запуск Nginx, выполнение curl, проверка browser, staging или production. Перед повторением зафиксируйте версию командой nginx -v и сверяйте синтаксис и значения по умолчанию с документацией этой версии. Перед применением в проекте подтвердите топологию, доверенные сети, таймаут приложения и правила хранения журналов.
proxy_pass, proxy_set_header и таймаутов proxy.log_format, access_log и переменных времени. Страницы актуальные, поэтому для исторического стенда версию Nginx нужно закрепить отдельно.