DarkRiDDeR15 мин

Под капотом reverse proxy: два HTTP-соединения, заголовки и таймауты

HTTPNginxАрхитектура

Симптом становится дорогим, когда proxy считают невидимой деталью: приложение разрешает небезопасный редирект, потому что видит HTTP; ограничение по адресу клиента работает для всей аудитории как для одного посетителя; ответ внезапно обрывается по 504, и команда увеличивает все таймауты разом. Цена такой реакции — не только задержка. Появляется недоверенный источник данных, длиннее висят соединения, а журнал больше не показывает, на каком участке запрос перестал двигаться.

Причина в механике: reverse proxy не пересылает тот же самый TCP/HTTP-разговор без изменений. Он завершает входной запрос, принимает решение по своему server/location, а затем создаёт request к upstream. У нового request есть свой peer address, свои default headers, свой момент подключения и свои интервалы чтения. Для автора, который в 2020 году изучает delivery и backend boundary, это не повод строить новую платформу. Достаточно назвать эти состояния и проверить их в маленькой конфигурации, не выдавая учебный запуск за production-факт.

Два соединения дают две правды о запросе

На внешнем hop-е клиент отправляет method, target, Host, заголовки и тело Nginx. Nginx может выбрать location, переписать URI, добавить или удалить заголовок, буферизовать данные и закрыть соединение по своему лимиту. На внутреннем hop-е upstream получает то, что сформировал proxy. Поэтому слово «оригинальный запрос» в обсуждении часто мешает: полезнее каждый раз уточнять, о каком hop-е идёт речь. Внешний URL мог быть HTTPS, а внутренний hop — HTTP; это корректно, если контракт сообщает приложению исходную схему и эта информация пришла от доверенной стороны.

В Nginx proxy_set_header не является декоративным списком. Документация модуля фиксирует, что по умолчанию proxy задаёт собственные значения для Host и Connection; при явных директивах на уровне location важно увидеть итоговую конфигурацию, а не надеяться на наследование. Именно поэтому ревью конфигурации должно отвечать на прямой вопрос: чему равен Host у upstream, какой заголовок сигнализирует схему и какие headers сознательно не передаются. Иначе приложение может собрать URL с именем upstream либо потерять нужный признак внешнего запроса.

Данные на границе proxy: значение без источника не становится фактом
СигналНа каком hop-е появляетсяРиск ложного чтенияПроверяемое правило
Hostво внешнем запросе; proxy формирует значение для upstreamupstream получает своё имя вместо внешнего маршрутаявно задать policy Host и сверить её с маршрутизацией приложения
X-Forwarded-Proto или Forwardedproxy после TLS termination или иной согласованной границыклиент мог прислать поле сам, а приложение приняло его как достоверноепринимать схему только из пути, где известен и ограничен источник proxy
X-Forwarded-For / X-Real-IPна proxy; возможно, раньше уже был другой proxyвсе пользователи выглядят одним peer либо подставленная цепочка считается клиентомназвать доверенные hop-ы и правило разбора до включения realip или кода приложения
request_time и upstream-поляв момент записи access-log504 считают названием причины, а не результатом ожиданиясравнить время proxy с событием приложения и конкретным proxy timeout

Стандартный Forwarded из RFC 7239 описывает параметры for, by, host и proto. При этом в старых приложениях широко встречаются X-Forwarded-*, а переход между форматами не происходит сам. RFC подчёркивает более важное ограничение: эти данные можно изменить на пути, в том числе клиентом. Поэтому приложение не должно превращать header в источник прав для каждого прямого соединения. Nginx realip module также требует явно указать доверенные адреса через set_real_ip_from; это хороший сигнал, что доверие — часть конфигурации, а не строка парсинга.

Заголовки должны описывать ровно один договор

Ниже не «рецепт для любого сервера», а учебный минимум. Он показывает, где находятся точки договора. Host передаётся как $host; X-Real-IP показывает непосредственный peer, а X-Forwarded-For расширяет уже имеющуюся цепочку. Если Nginx сам расположен за другим proxy, этот peer может быть адресом предыдущего hop-а. Исправлять это нужно не редактированием X-Forwarded-For в приложении, а отдельной проверкой реальной цепочки и доверенных источников.

# Учебная конфигурация: имена, адреса и значения не относятся к рабочему контуру.
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;
    }
}

Схема X-Forwarded-Proto $scheme тоже имеет границу. Она показывает схему соединения, пришедшего именно на этот Nginx. Если TLS завершился раньше, $scheme может быть http, хотя пользователь открыл HTTPS. В таком случае нельзя просто жёстко записать https: сначала фиксируют, где происходит termination, какой proxy законно сообщает исходную схему и как этот proxy защищён от прямого обхода. Результатом является один договор, а не несколько конкурирующих headers, из которых код выбирает удобный.

Вертикальная схема границы заголовков: внешний запрос несёт недоверенные клиентские поля, Nginx формирует договор Host и X-Forwarded-*, приложение читает только согласованные поля от известного proxy, а отдельный access-log связывает оба hop-а
Header становится пригодным для решений только вместе с источником. Строка без описанной доверенной границы не доказывает ни схему, ни адрес клиента.

Три таймаута отвечают на разные паузы

Число в конфигурации не равно времени ответа API. proxy_connect_timeout ограничивает установление соединения с proxied server. proxy_send_timeout применим между последовательными операциями записи запроса upstream. proxy_read_timeout применяется между последовательными операциями чтения ответа. Последний пункт особенно важен в диагностике: длинный ответ, который регулярно отдаёт данные, и зависший upstream без следующего байта выглядят для него по-разному. Называть оба случая «медленным запросом» значит потерять полезную развилку.

На практике я сначала рисую бюджет без выдуманных чисел: сколько имеет право ждать клиент, сколько proxy готов ждать подключения, сколько — первый или следующий байт, и где у приложения собственное ограничение. Затем выбираю один класс запроса. Для обычного JSON endpoint не нужен бесконечный read timeout. Для streaming endpoint нельзя применять тот же лимит без понимания пауз в протоколе. Если timeout меняется, рядом фиксируется ожидаемое изменение в access-log и способ вернуть старое значение. Это делает конфигурацию предметом ревью, а не накоплением чисел из чужих статей.

Таймаутный разбор: что наблюдать до изменения числа
НаблюдениеНе делать выводПроверкаСледующее действие
Не удалось подключиться к upstream«приложение медленно отвечает»отдельно сверить proxy_connect_timeout и доступность именно upstream-hop-аисследовать адрес, порт, процесс или лимит подключения; не увеличивать read timeout
Подключение есть, но ответ не даёт следующего байта«нужно увеличить всё»сопоставить proxy_read_timeout, access-log и приложение на одном токененайти ожидание в приложении или осознанно изменить лимит для данного типа endpoint
Клиент разрывает запрос раньше proxy«Nginx вернул ошибку сам»смотреть внешний timeout клиента отдельно от request_time proxyсогласовать бюджет между клиентом, proxy и приложением
У streaming endpoint есть регулярные части ответа«долгий ответ всегда ошибка»проверить паузу между частями и назначение endpointвыделить отдельную policy, а не переносить общий JSON-лимит

Журнал связывает гипотезу с hop-ом

Access-log полезен, когда в одной строке есть путь, итоговый status, полное request_time и upstream-поля. Nginx log module документирует log_format и $request_time; proxy module даёт данные о выбранном upstream и времени этого взаимодействия. Такой формат не раскрывает cookie, authorization header или реальный адрес пользователя. Он нужен, чтобы сделать различимыми хотя бы две гипотезы: proxy не подключился к upstream или подключился, но не получил заголовки ответа до своего лимита.

# Учебный log_format; путь журнала и значения подставляются в стенде.
log_format proxy_boundary '$request_method $uri status=$status '
                          'request=$request_time upstream=$upstream_addr '
                          'upstream_status=$upstream_status '
                          'connect=$upstream_connect_time '
                          'header=$upstream_header_time '
                          'response=$upstream_response_time';

access_log /path/to/proxy-boundary.log proxy_boundary;

Сам лог не говорит, какой именно запрос в приложении был дорогим. Для этого в учебном упражнении добавляют безопасный токен и сверяют его с логом приложения, если такой лог уже существует. Не стоит добавлять в access-log полный query string, тело или заголовок авторизации ради удобства расследования. В 2020 году для небольшой команды достаточно минимального формата и дисциплины: одно поле добавляют только если заранее понятно, какую развилку оно поможет проверить.

Последовательность разбора механизма

  1. Назвать внешний симптом и стоимость: неверная схема, адрес, Host, 502/504 или превышение ожидания; не смешивать эти варианты в одну жалобу.
  2. Нарисовать два соединения и отметить TLS termination, Nginx location и конкретный upstream; не публиковать реальные имена, адреса и секреты.
  3. Проверить итоговую конфигурацию proxy_set_header: Host, схема, цепочка адресов, Connection и policy для недоверенных прямых запросов.
  4. Разделить connect, send и read timeout, затем сопоставить каждый лимит с типом endpoint и наблюдаемым промежутком ожидания.
  5. Добавить или проверить минимальный log_format без чувствительных данных и пройти один контролируемый запрос на изолированном стенде.
  6. Изменить одну причину, повторить тот же маршрут и записать ограничение: какие hop-ы или proxy ещё не входят в договор.

Что эта модель не обещает

Она не делает header безопасным сама по себе, не превращает status 504 в диагноз и не выбирает «правильные» таймауты без нагрузки и сценария. Она лишь отделяет факты: где запрос был принят, где был сформирован новый request, что proxy передал дальше и между какими операциями истекло время. Для начинающего движения в delivery это уже заметный шаг: ошибка перестаёт быть свойством «сервера вообще» и получает конкретную границу, конфигурацию и проверку.

Здесь не запускались Nginx, curl, browser, CI, staging или production. Адрес 127.0.0.1, путь журнала и таймауты принадлежат учебному примеру, а не реальному контуру; никаких hostnames, секретов и фактических IP не публикуется. Перед использованием нужны отдельные проверки топологии, доверенных proxy, версии Nginx, поведения фреймворка при forwarded headers и требований к хранению диагностических журналов.

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

  • 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 и границы между получателем и отправителем сообщения