Files
progcode/editorial/agent-rewrites/278.json
T

8 lines
19 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 278,
"slug": "editorial-2020-04-mechanism-reverse-proxy",
"title": "Reverse proxy под капотом: два соединения, заголовки и таймауты",
"excerpt": "Приложение видит не внешний запрос, а новый upstream-hop. Разбираем, как proxy меняет Host, forwarded-заголовки и время ожидания, и как проверить границу без догадок.",
"contentHtml": "<p>Проблема часто выглядит как ошибка приложения. Пользователь открыл HTTPS, а код строит ссылку с http. В журнале все посетители имеют один адрес. После добавления proxy часть запросов заканчивается 502 или 504. Цена ошибки — не только один неудачный ответ. Неверная схема ломает редиректы и cookie. Ошибка в адресе клиента ломает лимиты, аудит и расследование. Слишком большой таймаут дольше держит соединения и маскирует зависший upstream.</p>\n<p>Тезис статьи простой: reverse proxy не является прозрачным проводом. Он принимает внешний HTTP-запрос, завершает один hop и создаёт новый запрос к upstream. На новой границе меняются peer address, часть заголовков, момент подключения и правила ожидания. Поэтому приложение нужно настраивать не на «оригинальный запрос вообще», а на явный договор: какие поля proxy передаёт, откуда они пришли, чему приложение доверяет и какой сигнал подтверждает каждый вывод.</p>\n<h2>Механизм: у одного запроса есть два hop-а</h2>\n<p>Клиент устанавливает соединение с proxy. Proxy выбирает <code>server</code> и <code>location</code>, проверяет маршрут, может завершить TLS, изменить URI, добавить заголовки и буферизовать тело. Затем proxy устанавливает отдельное соединение с upstream. Для приложения непосредственным соседом становится proxy, а не браузер. Это нормально. Ошибка возникает, когда код принимает внутренний peer или клиентский header за внешний факт без проверки границы.</p>\n<p>Схема работает так же. Если TLS завершается на Nginx, внешний hop может быть HTTPS, а соединение Nginx с приложением — HTTP. Значение <code>$scheme</code> на этом Nginx описывает именно вход в него. Если TLS завершился раньше, оно может не совпасть со схемой, которую видел клиент. В таком случае нужен один доверенный источник исходной схемы. Нельзя позволять приложению выбирать между несколькими заголовками по ситуации.</p>\n<p>То же относится к адресу. <code>$remote_addr</code> на Nginx обозначает непосредственную удалённую сторону входного соединения. Если перед Nginx стоит балансировщик, это может быть адрес балансировщика. <code>X-Forwarded-For</code> может содержать цепочку, которую сформировал предыдущий proxy или прислал клиент. Само имя заголовка не делает значение достоверным.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина или гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Приложение строит ссылку с <code>http</code></td><td>TLS завершился на proxy, а приложение не получило согласованный признак схемы</td><td>Сверить место TLS termination, <code>$scheme</code> и поле, которое читает приложение</td><td>Оставить один доверенный forwarded-сигнал и закрыть прямой обход приложения</td></tr><tr><td>Все пользователи имеют один адрес</td><td>Приложение видит peer proxy или неверно разбирает forwarded-цепочку</td><td>Нарисовать hop-ы и проверить доверенные источники до включения realip</td><td>Задать список доверенных proxy; не брать первый элемент заголовка вслепую</td></tr><tr><td>502 появился после изменения маршрута</td><td>Неверный <code>proxy_pass</code>, URI, порт или недоступный upstream</td><td>Проверить итоговый <code>location</code>, адрес upstream и его status</td><td>Исправить маршрут; не увеличивать <code>proxy_read_timeout</code></td></tr><tr><td>504 появляется только на одном endpoint</td><td>Proxy не получил следующий байт ответа в пределах read timeout</td><td>Сопоставить <code>request_time</code>, upstream-времена и безопасную запись приложения</td><td>Найти паузу в upstream или изменить лимит только для этого типа endpoint</td></tr><tr><td>Потоковый ответ обрывается при долгой паузе</td><td><code>proxy_read_timeout</code> ограничивает паузу между чтениями, а не всю передачу</td><td>Измерить интервалы между частями ответа</td><td>Выделить отдельную policy для streaming; не переносить общий лимит на другие маршруты</td></tr></tbody></table>\n<p>Таблица отделяет наблюдение от диагноза. Status 504 говорит о результате ожидания proxy, но не называет базу, GC, внешний API или код обработчика. Status 502 говорит о проблеме на пути к корректному upstream-ответу, но не выбирает между маршрутом, портом и самим процессом. Один сигнал даёт ветку проверки, а не готовое объяснение.</p>\n<h2>Учебная конфигурация границы</h2>\n<p>Ниже приведён минимальный пример для изолированного стенда. Имена, адрес <code>127.0.0.1</code> и значения таймаутов учебные. Пример показывает места договора, а не готовую production-конфигурацию.</p>\n<pre><code>upstream app_backend {\n server 127.0.0.1:3000;\n}\n\nserver {\n listen 8080;\n server_name _;\n\n location / {\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 &quot;&quot;;\n\n proxy_connect_timeout 3s;\n proxy_send_timeout 10s;\n proxy_read_timeout 15s;\n proxy_pass http://app_backend;\n }\n}</code></pre>\n<p><code>proxy_set_header Host $host</code> задаёт значение явно. В этом примере <code>X-Real-IP $remote_addr</code> передаёт приложению адрес непосредственного peer текущего Nginx; он не становится адресом клиента сам по себе. <code>X-Forwarded-For</code> расширяет цепочку, а не доказывает, что каждый её элемент заслуживает доверия. <code>X-Forwarded-Proto $scheme</code> описывает схему входного hop-а именно этого Nginx. Если перед ним есть другой TLS-терминатор, контракт должен учитывать его. Нельзя копировать строку <code>https</code> только потому, что внешняя страница открывается по HTTPS.</p>\n<p>Таймауты отвечают на разные паузы. <code>proxy_connect_timeout</code> относится к установлению соединения с upstream. <code>proxy_send_timeout</code> относится к последовательным операциям записи запроса. <code>proxy_read_timeout</code> относится к паузе между последовательными операциями чтения ответа. Поэтому длительность ответа и read timeout — разные величины. Streaming может длиться дольше лимита, если upstream регулярно отправляет данные. Ответ без следующего байта может оборваться раньше, чем команда ожидает.</p>\n<figure><img src=\"/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg\" alt=\"Схема границы reverse proxy: внешний запрос приходит в Nginx, proxy формирует Host и forwarded-заголовки для отдельного upstream-hop, приложение читает только согласованные поля, а access-log связывает наблюдения\" loading=\"lazy\" /><figcaption>Header становится полезным сигналом только вместе с источником и правилом доверия. Строка без границы не доказывает ни схему, ни адрес клиента.</figcaption></figure>\n<h2>Как связать два hop-а в журнале</h2>\n<p>Для первого разбора достаточно access-log с URI, итоговым status, <code>$request_time</code>, адресом upstream, его status и временами подключения, получения заголовков и ответа. Такой формат отвечает на узкий вопрос: proxy не подключился к upstream или подключился, но не дождался заголовков. Не нужно записывать cookie, authorization, тело запроса или полный query string. Лишние данные не делают гипотезу точнее.</p>\n<pre><code>log_format proxy_boundary &quot;$request_method $uri status=$status request=$request_time upstream=$upstream_addr upstream_status=$upstream_status\n connect=$upstream_connect_time header=$upstream_header_time response=$upstream_response_time&quot;;\n\naccess_log /path/to/proxy-boundary.log proxy_boundary;</code></pre>\n<p>Синтетическая строка ниже показывает, как читать поля. Она учебная и не получена от реального Nginx.</p>\n<pre><code>GET /health status=504 request=15.001 upstream=&lt;backend&gt; upstream_status=-\nconnect=0.001 header=- response=15.001</code></pre>\n<p>Строка поддерживает гипотезу: соединение установилось быстро, но этот Nginx не получил заголовки ответа до заданного <code>proxy_read_timeout 15s</code>. Она не доказывает причину внутри приложения. Следующий шаг — проверить конкретный endpoint и безопасный токен в логе приложения. Если записи нет, это неизвестное, а не повод сочинять её содержание.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Записать один симптом, один безопасный URL и цену ошибки. Не объединять неверную схему, адрес и 504 в одну причину.</li><li>Нарисовать реальные hop-ы: клиент, TLS termination, Nginx, возможный предыдущий proxy и upstream. Отдельно указать, кто имеет право выставлять forwarded-поля.</li><li>Проверить итоговый <code>location</code>, <code>proxy_pass</code> и все <code>proxy_set_header</code>. Смотреть нужно собранную конфигурацию, а не только фрагмент include-файла.</li><li>Разделить connect, send и read timeout. Для выбранного endpoint указать, какую паузу ограничивает каждое значение.</li><li>Выполнить один учебный запрос к маршруту без пользовательских данных. Сверить status и ответные заголовки с access-log и записью приложения по безопасному токену.</li><li>Изменить одну подтверждённую границу и повторить тот же запрос. Если результат не изменился, сохранить отрицательный вывод и перейти к следующей строке таблицы.</li><li>Зафиксировать откат и новый сигнал наблюдения. Увеличение таймаута без ожидаемого изменения в журнале не считается объяснённым исправлением.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Эта модель не делает forwarded-заголовок безопасным. Доверие задаёт топология и конфигурация. В Nginx realip-модуле адрес клиента меняется только для источников, которые явно разрешены через <code>set_real_ip_from</code>. Если прямой запрос может обойти доверенный proxy, приложение должно считать такие поля недоверенными или закрыть этот путь на сети.</p>\n<p>Модель также не выбирает таймауты за команду. Значение зависит от типа endpoint, бюджета клиента, лимита приложения, поведения upstream и числа одновременных соединений. Длинный timeout может уменьшить число быстрых ошибок, но увеличить очередь и расход ресурсов. Retry добавляет ещё одну попытку и может повторить неидемпотентное действие. Его нельзя добавлять как универсальное средство против 504.</p>\n<p>Отрицательный путь обязателен. Если после явного <code>Host</code> приложение всё ещё строит неверную ссылку, проверяют поле, которое читает код, и предыдущий TLS-терминатор. Если после изменения read timeout status не изменился, проверяют маршрут, доступность upstream и клиентский timeout. Если адрес остаётся адресом proxy, проверяют доверенную цепочку и прямой доступ. «Перезапустили — стало нормально» не подтверждает ни одну из этих гипотез.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Граница готова, если команда может повторить один безопасный запрос и получить доказательства по каждому hop-у: внешний status и схема, итоговый набор переданных headers, запись proxy с request/upstream-временами и согласованная запись приложения. Для адреса известен список доверенных proxy. Для каждого timeout названа конкретная пауза. Отрицательный тест с недоверенным прямым header не меняет схему, права или адрес клиента. Если хотя бы одно условие не проверено, конфигурация ещё не готова к переносу в рабочий контур.</p>\n<p>Все конфигурации, команды и значения времени в статье учебные. Nginx, curl, staging и production здесь не запускались. Перед применением нужно проверить версию Nginx, реальную топологию, владельца конфигурации, политику журналирования и возможность безопасного отката.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://nginx.org/en/docs/http/ngx_http_proxy_module.html\" target=\"_blank\" rel=\"noopener noreferrer\">Nginx: ngx_http_proxy_module</a> — официальная документация по <code>proxy_pass</code>, <code>proxy_set_header</code> и таймаутам proxy.</li><li><a href=\"https://nginx.org/en/docs/http/ngx_http_realip_module.html\" target=\"_blank\" rel=\"noopener noreferrer\">Nginx: ngx_http_realip_module</a> — официальная документация по доверенным источникам и замене адреса клиента.</li><li><a href=\"https://nginx.org/en/docs/http/ngx_http_log_module.html\" target=\"_blank\" rel=\"noopener noreferrer\">Nginx: ngx_http_log_module</a> — официальная документация по <code>log_format</code>, <code>access_log</code> и журналированию запросов.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc7239\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 7239: Forwarded HTTP Extension</a> — спецификация формата forwarded-информации и ограничений доверия к ней.</li></ul>"
}