DarkRiDDeR14 мин

Reverse proxy перед приложением: собираем проверяемую HTTP-границу

HTTPNginxПрактика

Симптом обычно выглядит как ошибка приложения: внешний запрос приходит по HTTPS, а код считает его HTTP; все посетители в журнале имеют один адрес; после добавления proxy часть URL вдруг теряет префикс. Цена не в одном неверном заголовке. Неправильная схема ломает редирект или флаг cookie, общий адрес клиента делает бесполезным разбор ошибок, а случайный таймаут превращает медленный ответ в обвинение «бэкенд упал». Если сразу менять middleware или увеличивать лимит, команда лечит последний наблюдаемый слой, а не причину.

У reverse proxy простая, но важная роль: он принимает внешнее HTTP-соединение и создаёт отдельное соединение к приложению. Между ними меняются адрес peer, схема, часть заголовков, лимиты ожидания и журнал. Поэтому в апреле 2020 года я бы начал не с универсальной конфигурации, а с короткого контракта по hop-ам: что видит клиент, что обязан передать Nginx, что приложение имеет право считать доверенным и какой один маршрут докажет это на изолированном стенде. Ниже приведены учебные значения; они не были применены к browser, staging или production.

Прокси — не прозрачный провод

Пока клиент подключён к Nginx, для Nginx адрес клиента — адрес удалённой стороны этого соединения. Когда Nginx обращается к upstream, приложение видит уже Nginx как непосредственного соседа. Это не ошибка и не повод вручную подменять адрес в каждом контроллере. Это граница, на которой нужно явно договориться о передаваемых данных. Аналогично с TLS: TLS может завершаться перед приложением, а внутренний hop остаётся обычным HTTP. Приложение не может вычислить внешнюю схему из сокета, если proxy не передал ей согласованный признак.

В этой модели полезно разделить три класса данных. Первый — маршрут: метод, URI и Host, по которым приложение выбирает обработчик и строит ссылки. Второй — происхождение: схема и цепочка адресов, нужные для ограниченного набора решений. Третий — время: когда proxy смог подключиться к upstream, отправить запрос и дождаться следующего байта ответа. Смешать их легко: например, таблица в базе говорит, что URL правильный, а проблема на самом деле в Host, который по умолчанию у proxy не обязан совпадать с внешним host.

Контракт запроса по hop-ам: наблюдение должно иметь владельца
СлойЧто он видитЧто может изменитьЧто проверять
Клиентвнешний URL, статус и ответные заголовкитолько свой запросметод, путь, ожидаемый статус и безопасный диагностический токен
Nginxвходной запрос и отдельный upstream-hopHost, X-Forwarded-*, лимиты соединения и чтенияявные proxy_set_header, proxy_pass и формат access-log
Приложениесоединение от proxy и переданные заголовкисвою логику маршрута и журналкакие заголовки допустимы только от доверенного proxy
Журналитог обработки на proxyничего не исправляетстатус, request_time и upstream-поля рядом, без секретов и пользовательских данных

Эта таблица намеренно не называет конкретную топологию сети. Для одного проекта Nginx стоит прямо перед приложением; для другого перед ним уже есть балансировщик. Во втором случае $remote_addr на Nginx может обозначать предыдущий proxy, а не браузер. Подключать модуль realip имеет смысл только после того, как список доверенных источников определён отдельно. Его set_real_ip_from — это не косметическая настройка, а разрешение заменить адрес по заголовку.

Минимальная конфигурация как объект ревью

В учебной конфигурации ниже upstream намеренно локальный, а server_name не раскрывает ни одного рабочего имени. Она показывает форму договора, а не готовый фрагмент для копирования. Host передаётся явно, потому что у Nginx есть свои значения по умолчанию для заголовков proxied request. X-Forwarded-For накапливается через $proxy_add_x_forwarded_for, а X-Forwarded-Proto фиксирует схему hop-а, который пришёл на Nginx. Приложение должно принимать эти поля только из согласованной границы, а не из любого прямого HTTP-запроса.

# Учебная конфигурация: имена, адреса и значения не относятся к рабочему контуру.
upstream app_backend {
    server 127.0.0.1:3000;
}

server {
    listen 8080;
    server_name _;

    location / {
        proxy_http_version 1.1;
        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_set_header Connection "";

        proxy_connect_timeout 3s;
        proxy_send_timeout 10s;
        proxy_read_timeout 15s;
        proxy_pass http://app_backend;
    }
}

Три таймаута в примере отвечают на разные вопросы. proxy_connect_timeout ограничивает установление соединения с upstream. proxy_send_timeout относится к передаче запроса upstream между последовательными операциями записи. proxy_read_timeout относится к промежутку между чтениями ответа, а не к полной длительности ответа. Поэтому число 15s не является «временем работы API»: потоковый ответ может жить дольше, если upstream регулярно отдаёт байты, а тихий запрос может оборваться раньше, если приложение перестало отвечать. Значения здесь учебные, их нельзя переносить в рабочий контур без бюджета ожидания всего маршрута.

Вертикальная схема трёх hop-ов: клиент передаёт запрос Nginx, Nginx фиксирует контракт заголовков и таймаутов, затем создаёт отдельный запрос к приложению; внизу показана точка совместной диагностики по access-log и логу приложения
Один пользовательский запрос даёт как минимум два HTTP-hop-а. Ошибку ищем на том hop-е, где меняется нужный сигнал, а не в абстрактном «сервере».

Один безопасный маршрут вместо широкого smoke-теста

Чтобы проверить контракт, не нужен полный прогон сайта. Нужен маршрут без пользовательских данных и с понятным ответом: например, /health или отдельный endpoint стенда. В запрос добавляется учебный токен, который можно увидеть и в журнале proxy, и в логе приложения, если приложение уже умеет его писать. Токен не становится средством аутентификации и не заменяет request ID; он всего лишь связывает две записи в контролируемом упражнении. Если таких журналов нет, сначала добавляют безопасный формат, а потом запускают проверку.

# Учебный запрос; не выполнялся в browser, staging или production.
# <PROXY_URL> и <HOST> — подстановки для изолированного стенда, не реальные адреса.
curl -i --max-time 5 \
  '<PROXY_URL>/health' \
  -H 'Host: <HOST>' \
  -H 'X-Debug-Token: proxy-study-2020-04'

# Сверяем статус, Location, Set-Cookie и заголовок, который приложение
# записывает как схему или request token. Сам curl не доказывает путь до upstream.

После такого запроса нельзя делать вывод «приложение работает за proxy», если пришёл только 200. Проверяются четыре вещи: status и ответные заголовки снаружи, запись proxy с тем же токеном, запись приложения с этим же токеном и точное значение схемы или host, которое приложение использовало. Если внешнее соединение TLS, а приложение видит HTTP, это может быть корректно на внутреннем hop-е. Ошибкой становится не сам HTTP, а отсутствие согласованного сигнала, по которому код различает внешнюю схему.

Типовые симптомы не лечатся одним заголовком

Симптом «все адреса одинаковые» имеет минимум две причины. Либо приложение честно пишет peer address и видит Nginx, либо оно доверяет неподтверждённому X-Forwarded-For. Проверка разная: сначала определяем, есть ли прямой доступ к приложению и какой proxy имеет право дописывать заголовок. Действие — закрепить этот маршрут в конфигурации и в приложении, а не заменить адрес на первое значение из любой строки. RFC 7239 отдельно напоминает: forwarded-информация может быть изменена на пути и не становится достоверной без доверенной цепочки.

Симптом «редирект ведёт на http» тоже не означает, что Nginx сломан. Сначала проверяют, где завершается TLS, какой заголовок proxy передаёт и какое поле реально читает фреймворк. Затем сравнивают один учебный запрос до и после конфигурации. Если приложение использует один заголовок, а proxy передаёт другой, исправляется контракт на границе или адаптер приложения — но не добавляется ещё один независимый способ угадать схему. Так сохраняется возможность объяснить поведение новому человеку по четырём строкам конфигурации.

Маршрут внедрения и отката

  1. Записать один симптом в наблюдаемой форме: неверный Host, схема, адрес клиента, 502/504 или неожиданный timeout; назвать его цену для пользователя или разработки.
  2. Нарисовать реальные hop-ы без адресов и секретов: кто принимает внешний запрос, где заканчивается TLS и какой процесс является upstream для Nginx.
  3. Согласовать для приложения явный набор полей: Host, признак схемы, forwarded-цепочка и один безопасный диагностический токен; отдельно назвать, кто имеет право их выставлять.
  4. Собрать итоговую конфигурацию Nginx и проверить её синтаксис в изолированной среде; не менять одновременно route, приложение и значения таймаутов.
  5. Выполнить один учебный curl-маршрут на стенде, сопоставить записи proxy и приложения, затем изменить только ту строку конфигурации, которая объясняет расхождение.
  6. Сохранить ожидаемый результат и обратный шаг: какие поля и лимиты вернуть, если проверка не подтверждает гипотезу. Без этого увеличение таймаута остаётся необъяснимым обходом.

Граница должна быть маленькой и явной

Reverse proxy полезен тем, что отделяет внешний HTTP от процесса приложения. Но это разделение требует контракта: что передаётся, чему доверяют, сколько ждут и где виден результат. В апреле 2020 года для небольшого сервиса достаточно Nginx-конфигурации, одного диагностического маршрута, таблицы сигналов и короткого access-log. Не требуется усложнять схему service mesh, Kubernetes Ingress или распределённой трассировкой, чтобы перестать угадывать причину 502.

Эта статья не запускала Nginx, curl, browser, staging или production и не использует реальные hostnames, IP-адреса либо секреты. Конфигурация, адрес 127.0.0.1, путь журнала и таймауты — только учебные заполнители. Перед применением в проекте нужно отдельно подтвердить топологию, доверенные proxy, таймаут приложения, безопасный диагностический маршрут и правила хранения журналов.

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

  • Nginx: модуль ngx_http_proxy_module — первичная документация директив proxy_pass, proxy_set_header, proxy_connect_timeout, proxy_send_timeout и proxy_read_timeout
  • Nginx: модуль ngx_http_realip_module — модуль меняет адрес клиента только по указанному заголовку и только для источников, явно объявленных доверенными через set_real_ip_from
  • Nginx: модуль ngx_http_log_module — справочник log_format, access_log и переменной request_time; формат журнала должен быть частью диагностического контракта
  • RFC 7239: Forwarded HTTP Extension — стандартный заголовок Forwarded, его связь с X-Forwarded-* и ограничение: данные заголовка нельзя считать достоверными без доверенной цепочки proxy
  • RFC 7230: HTTP/1.1 Message Syntax and Routing — историческая для апреля 2020 года спецификация HTTP/1.1: маршрутизация, Host и границы между получателем и отправителем сообщения