Files
progcode/editorial/agent-rewrites/279.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 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": 279,
"slug": "editorial-2020-04-practice-reverse-proxy",
"title": "Reverse proxy перед приложением: как проверить границу HTTP",
"excerpt": "Приложение за proxy видит не тот сокет, что клиент. Разбираем Host, forwarded-заголовки и таймауты по hop-ам, а затем проверяем контракт одним безопасным маршрутом.",
"contentHtml": "<p>Запрос приходит по HTTPS, но приложение строит редирект на HTTP. В журнале все посетители имеют один IP. Иногда тот же endpoint отвечает 200, а иногда получает 504. Эти симптомы похожи на ошибку приложения, хотя причина часто находится на границе между клиентом, reverse proxy и upstream.</p>\n<p>Цена ошибки растёт быстро. Неверная схема ломает абсолютные ссылки и secure-cookie. Неверный адрес клиента портит rate limit и расследование инцидента. Непонятый таймаут превращает медленный ответ в спор о том, «упал ли backend». Исправлять эти симптомы одним новым заголовком опасно: proxy создаёт отдельное соединение и меняет контекст запроса.</p>\n<h2>Тезис: проверяйте каждый hop отдельно</h2>\n<p>Reverse proxy не является прозрачным проводом. Он принимает одно HTTP-соединение от клиента и открывает другое соединение к приложению. Для Nginx непосредственный peer — клиент или предыдущий proxy. Для приложения непосредственный peer — Nginx. На границе могут измениться Host, схема, цепочка адресов, момент ожидания и видимый статус.</p>\n<p>Рабочая проверка поэтому должна отвечать на четыре разных вопроса. Что отправил клиент? Что Nginx передал upstream? Что приложение прочитало? Что записали оба журнала? Пока эти ответы смешаны, статус 502 или 504 остаётся только симптомом.</p>\n<h2>Механизм двух соединений</h2>\n<p>Предположим, TLS завершается на Nginx. Внешний hop выглядит так: клиент подключается к Nginx по HTTPS. Внутренний hop может идти к приложению по HTTP. Само по себе это нормально. Приложение не узнает внешнюю схему из внутреннего сокета. Оно узнает её только из согласованного forwarded-заголовка или из другого доверенного контракта.</p>\n<p>То же относится к адресу. <code>$remote_addr</code> на Nginx обозначает адрес непосредственного источника входного соединения. Если перед Nginx уже стоит балансировщик, это может быть адрес балансировщика. Заголовок <code>X-Forwarded-For</code> не становится истинным только потому, что его прислал клиент. Приложение должно принимать его от заранее определённого доверенного proxy, а не от любого HTTP-подключения.</p>\n<p>Host отвечает за другой класс ошибок. Приложение может выбирать tenant, строить редирект или проверять origin по этому полю. Если proxy не передал внешний host явно, upstream получит значение, отличное от того, что ввёл пользователь. Сначала фиксируют ожидаемый контракт, потом выбирают директиву и адаптер фреймворка. Обратный порядок порождает угадывание.</p>\n<div class=\"table-scroll\"><table><caption>Диагностика границы reverse proxy</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Редирект ведёт на http</td><td>TLS завершился на proxy, а приложение не получило согласованную схему</td><td>Сопоставить внешний URL, forwarded-заголовок и поле, которое читает приложение</td><td>Передать один явный признак схемы и разрешить его только от доверенного proxy</td></tr><tr><td>Все клиенты имеют IP proxy</td><td>Приложение пишет peer address внутреннего соединения</td><td>Сравнить remote address на proxy с цепочкой адресов в upstream</td><td>Настроить доверенную цепочку real IP; не брать первое значение из любого заголовка</td></tr><tr><td>Пропал tenant или изменился host</td><td>Upstream получил другой Host или URI после proxy_pass</td><td>Записать host и URI на proxy и в приложении для одного тестового запроса</td><td>Явно зафиксировать Host и правило преобразования URI</td></tr><tr><td>504 после ровного интервала</td><td>Proxy не дождался следующего события upstream</td><td>Сравнить connect, header и response time с таймаутами и логом приложения</td><td>Определить, какой этап превысил бюджет; не увеличивать все таймауты сразу</td></tr><tr><td>Снаружи 502, в приложении нет записи</td><td>Сбой соединения до обработки запроса приложением</td><td>Проверить upstream address, connect time и доступность процесса</td><td>Исправить маршрут или состояние upstream; не искать ошибку в контроллере</td></tr></tbody></table></div>\n<h2>Учебная конфигурация</h2>\n<p>Ниже показана малая конфигурация для изолированного стенда. Имя upstream, порт, путь журнала и значения таймаутов учебные. Этот фрагмент не является готовым 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_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}</code></pre>\n<p><code>proxy_connect_timeout</code> отвечает за установление соединения с upstream. <code>proxy_send_timeout</code> ограничивает паузы при передаче запроса. <code>proxy_read_timeout</code> ограничивает паузу между последовательными чтениями ответа. Последняя директива не задаёт полную длительность endpoint. Потоковый ответ может идти дольше, если upstream регулярно отправляет данные. Тихий ответ может оборваться раньше.</p>\n<p>В примере <code>X-Forwarded-For</code> дополняет цепочку, а не безусловно заменяет её. Но это ещё не политика доверия. Если приложение доступно в обход Nginx, клиент сможет прислать такой заголовок напрямую. Сначала закрывают обход или фильтруют источник, затем включают обработку forwarded-данных. Для real IP отдельно перечисляют доверенные сети.</p>\n<figure><img src=\"/assets/editorial/2020/reverse-proxy-request-hops-2020.svg\" alt=\"Клиент отправляет запрос Nginx, Nginx передаёт согласованные заголовки приложению, а два журнала связываются общим диагностическим токеном\" loading=\"lazy\" /><figcaption>Один внешний запрос даёт как минимум два HTTP-hop-а. Ищите изменение сигнала на конкретной границе, а не в абстрактном «сервере».</figcaption></figure>\n<h2>Логи должны доказывать путь</h2>\n<p>Статус ответа без времени и upstream-контекста мало помогает. Для учебной проверки достаточно записать метод, URI, итоговый статус, адрес upstream, полное время запроса и интервалы подключения, получения заголовков и ответа. В журнал нельзя добавлять секреты, cookie и произвольное тело запроса.</p>\n<pre><code>log_format proxy_boundary '$request_method $uri status=$status '\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;</code></pre>\n<p>Синтетическая строка ниже показывает способ чтения полей. Она не получена от реального Nginx и не доказывает причину сама по себе.</p>\n<pre><code>GET /health status=504 request=3.001 upstream=&lt;backend&gt; upstream_status=-\nconnect=0.001 header=- response=3.001</code></pre>\n<p>Такой результат сужает поиск: proxy установил соединение, но не получил ответ в пределах лимита. Дальше проверяют лог приложения и его собственный timeout. Если upstream_status отсутствует, это не доказательство, что приложение не запустилось: нужно проверить формат журнала и точный этап отказа.</p>\n<h2>Проверка одним безопасным маршрутом</h2>\n<p>Для контракта не нужен полный smoke-тест. Выберите endpoint без пользовательских данных, например <code>/health</code> на учебном стенде. Добавьте диагностический токен, который можно найти в журнале proxy и в журнале приложения. Токен не заменяет аутентификацию и не должен содержать секрет.</p>\n<pre><code># Учебный запрос. URL и Host нужно заменить значениями из изолированного стенда.\ncurl -i --max-time 5 \\\n 'https://&lt;proxy-host&gt;/health' \\\n -H 'Host: &lt;public-host&gt;' \\\n -H 'X-Debug-Token: proxy-study-2020-04'</code></pre>\n<p>Положительный результат состоит не только из <code>200</code>. Внешний ответ должен иметь ожидаемые статус и заголовки. В записи Nginx должен быть тот же безопасный токен. В записи приложения должен быть тот же токен, ожидаемый Host и ожидаемая схема. Если хотя бы одна запись отсутствует, проверка не подтверждает весь путь.</p>\n<h2>Порядок действий</h2>\n<ol><li>Запишите один симптом и его цену: неверный редирект, потерянный адрес, 502/504 или неожиданный timeout.</li><li>Нарисуйте реальные hop-ы: кто принимает внешний запрос, где завершается TLS и кто является upstream.</li><li>Назначьте владельца каждому сигналу: Host, схема, forwarded-цепочка, время подключения и время ответа.</li><li>Определите доверенную границу. Укажите, кто имеет право выставлять forwarded-заголовки и может ли клиент обойти proxy.</li><li>Соберите минимальную конфигурацию, проверьте её синтаксис и не меняйте одновременно route, код приложения и все таймауты.</li><li>Выполните один учебный запрос. Сопоставьте внешний ответ, запись proxy и запись приложения по безопасному токену.</li><li>Если гипотеза не подтверждается, откатите одну изменённую строку и проверьте следующий hop. Не превращайте увеличение таймаута в финальное решение без объяснения.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Такая схема не решает проблемы, которые находятся за пределами HTTP-контракта. Она не доказывает корректность балансировки, TLS-сертификата, DNS, firewall, размера буфера или поведения нескольких upstream. Она также не делает forwarded-заголовки безопасными при открытом прямом доступе к приложению.</p>\n<p>Если внешний статус 200, но приложение всё равно строит неправильный URL, проверяйте не сеть, а поле, которое использует код. Если в proxy есть запрос, а в приложении нет записи, проверяйте соединение, маршрутизацию и ранний отказ. Если приложение пишет запрос, но proxy отдаёт 504, сравнивайте интервалы между байтами и общий бюджет маршрута. В каждом отрицательном пути меняйте одну гипотезу и сохраняйте наблюдаемый результат.</p>\n<h2>Критерий готовности</h2>\n<p>Граница готова, когда один безопасный запрос проходит через ожидаемые hop-ы, внешний ответ соответствует договору, proxy и приложение связываются по диагностическому токену, Host и схема читаются ожидаемо, а таймаут можно объяснить конкретным этапом. Конфигурация имеет проверенный синтаксис, прямой обход запрещён или явно учтён, а для неуспешного результата есть обратный шаг.</p>\n<p>Все значения в примерах — учебные. Здесь не заявлены запуск Nginx, выполнение <code>curl</code>, проверка browser, staging или production. Перед применением в проекте подтвердите топологию, доверенные сети, таймаут приложения и правила хранения журналов.</p>\n<h2>Проверяемые источники</h2>\n<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></ul>"
}