Симптом становится дорогим, когда 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 либо потерять нужный признак внешнего запроса.
| Сигнал | На каком hop-е появляется | Риск ложного чтения | Проверяемое правило |
|---|---|---|---|
| Host | во внешнем запросе; proxy формирует значение для upstream | upstream получает своё имя вместо внешнего маршрута | явно задать policy Host и сверить её с маршрутизацией приложения |
| X-Forwarded-Proto или Forwarded | proxy после TLS termination или иной согласованной границы | клиент мог прислать поле сам, а приложение приняло его как достоверное | принимать схему только из пути, где известен и ограничен источник proxy |
| X-Forwarded-For / X-Real-IP | на proxy; возможно, раньше уже был другой proxy | все пользователи выглядят одним peer либо подставленная цепочка считается клиентом | назвать доверенные hop-ы и правило разбора до включения realip или кода приложения |
| request_time и upstream-поля | в момент записи access-log | 504 считают названием причины, а не результатом ожидания | сравнить время 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, из которых код выбирает удобный.
Три таймаута отвечают на разные паузы
Число в конфигурации не равно времени ответа 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 году для небольшой команды достаточно минимального формата и дисциплины: одно поле добавляют только если заранее понятно, какую развилку оно поможет проверить.
Последовательность разбора механизма
- Назвать внешний симптом и стоимость: неверная схема, адрес, Host, 502/504 или превышение ожидания; не смешивать эти варианты в одну жалобу.
- Нарисовать два соединения и отметить TLS termination, Nginx location и конкретный upstream; не публиковать реальные имена, адреса и секреты.
- Проверить итоговую конфигурацию proxy_set_header: Host, схема, цепочка адресов, Connection и policy для недоверенных прямых запросов.
- Разделить connect, send и read timeout, затем сопоставить каждый лимит с типом endpoint и наблюдаемым промежутком ожидания.
- Добавить или проверить минимальный log_format без чувствительных данных и пройти один контролируемый запрос на изолированном стенде.
- Изменить одну причину, повторить тот же маршрут и записать ограничение: какие 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 и границы между получателем и отправителем сообщения