[\s\S]*?<\/div>/g, ''),
);
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const proseLength = proseText(bodyHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary or official sources are required');
}
return {
...meta,
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
proseLength,
};
}
const nginxProxy = {
title: 'Nginx: модуль ngx_http_proxy_module',
url: 'https://nginx.org/en/docs/http/ngx_http_proxy_module.html',
note: 'первичная документация директив proxy_pass, proxy_set_header, proxy_connect_timeout, proxy_send_timeout и proxy_read_timeout',
};
const nginxRealIp = {
title: 'Nginx: модуль ngx_http_realip_module',
url: 'https://nginx.org/en/docs/http/ngx_http_realip_module.html',
note: 'модуль меняет адрес клиента только по указанному заголовку и только для источников, явно объявленных доверенными через set_real_ip_from',
};
const nginxLog = {
title: 'Nginx: модуль ngx_http_log_module',
url: 'https://nginx.org/en/docs/http/ngx_http_log_module.html',
note: 'справочник log_format, access_log и переменной request_time; формат журнала должен быть частью диагностического контракта',
};
const rfc7239 = {
title: 'RFC 7239: Forwarded HTTP Extension',
url: 'https://www.rfc-editor.org/rfc/rfc7239',
note: 'стандартный заголовок Forwarded, его связь с X-Forwarded-* и ограничение: данные заголовка нельзя считать достоверными без доверенной цепочки proxy',
};
const rfc7230 = {
title: 'RFC 7230: HTTP/1.1 Message Syntax and Routing',
url: 'https://www.rfc-editor.org/rfc/rfc7230',
note: 'историческая для апреля 2020 года спецификация HTTP/1.1: маршрутизация, Host и границы между получателем и отправителем сообщения',
};
const boundaryConfig = [
'# Учебная конфигурация: имена, адреса и значения не относятся к рабочему контуру.',
'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;',
' }',
'}',
];
const boundaryCurl = [
'# Учебный запрос; не выполнялся в browser, staging или production.',
'# и — подстановки для изолированного стенда, не реальные адреса.',
'curl -i --max-time 5 \\',
" '/health' \\",
" -H 'Host: ' \\",
" -H 'X-Debug-Token: proxy-study-2020-04'",
'',
'# Сверяем статус, Location, Set-Cookie и заголовок, который приложение',
'# записывает как схему или request token. Сам curl не доказывает путь до upstream.',
];
const headerLogConfig = [
'# Учебный 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;',
];
const syntheticLog = [
'# Синтетический пример для чтения полей; это не access-log реального сервера.',
'GET /health status=504 request=3.001 upstream= upstream_status=-',
'connect=0.001 header=- response=3.001',
'',
'# Это формирует гипотезу «proxy не получил заголовки ответа до своего лимита»,',
'# но не называет причину: её проверяют по конфигурации и логу приложения.',
];
const practiceArticle = createRevision(
{
slug: 'editorial-2020-04-practice-reverse-proxy',
title: 'Reverse proxy перед приложением: собираем проверяемую HTTP-границу',
categories: ['HTTP', 'Nginx', 'Практика'],
cover: '/assets/editorial/2020/reverse-proxy-request-hops-2020.svg',
excerpt: 'Proxy создаёт второе HTTP-соединение и меняет контекст запроса. Собираем малый контракт для заголовков, таймаутов и диагностики вместо правок «вслепую».',
readingMinutes: 14,
},
[
paragraph('Симптом обычно выглядит как ошибка приложения: внешний запрос приходит по HTTPS, а код считает его HTTP; все посетители в журнале имеют один адрес; после добавления proxy часть URL вдруг теряет префикс. Цена не в одном неверном заголовке. Неправильная схема ломает редирект или флаг cookie, общий адрес клиента делает бесполезным разбор ошибок, а случайный таймаут превращает медленный ответ в обвинение «бэкенд упал». Если сразу менять middleware или увеличивать лимит, команда лечит последний наблюдаемый слой, а не причину.'),
paragraph('У reverse proxy простая, но важная роль: он принимает внешнее HTTP-соединение и создаёт отдельное соединение к приложению. Между ними меняются адрес peer, схема, часть заголовков, лимиты ожидания и журнал. Поэтому в апреле 2020 года я бы начал не с универсальной конфигурации, а с короткого контракта по hop-ам: что видит клиент, что обязан передать Nginx, что приложение имеет право считать доверенным и какой один маршрут докажет это на изолированном стенде. Ниже приведены учебные значения; они не были применены к browser, staging или production.'),
heading('Прокси — не прозрачный провод'),
paragraph('Пока клиент подключён к Nginx, для Nginx адрес клиента — адрес удалённой стороны этого соединения. Когда Nginx обращается к upstream, приложение видит уже Nginx как непосредственного соседа. Это не ошибка и не повод вручную подменять адрес в каждом контроллере. Это граница, на которой нужно явно договориться о передаваемых данных. Аналогично с TLS: TLS может завершаться перед приложением, а внутренний hop остаётся обычным HTTP. Приложение не может вычислить внешнюю схему из сокета, если proxy не передал ей согласованный признак.'),
paragraph('В этой модели полезно разделить три класса данных. Первый — маршрут: метод, URI и Host, по которым приложение выбирает обработчик и строит ссылки. Второй — происхождение: схема и цепочка адресов, нужные для ограниченного набора решений. Третий — время: когда proxy смог подключиться к upstream, отправить запрос и дождаться следующего байта ответа. Смешать их легко: например, таблица в базе говорит, что URL правильный, а проблема на самом деле в Host, который по умолчанию у proxy не обязан совпадать с внешним host.'),
dataTable(
'Контракт запроса по hop-ам: наблюдение должно иметь владельца',
['Слой', 'Что он видит', 'Что может изменить', 'Что проверять'],
[
['Клиент', 'внешний URL, статус и ответные заголовки', 'только свой запрос', 'метод, путь, ожидаемый статус и безопасный диагностический токен'],
['Nginx', 'входной запрос и отдельный upstream-hop', 'Host, X-Forwarded-*, лимиты соединения и чтения', 'явные proxy_set_header, proxy_pass и формат access-log'],
['Приложение', 'соединение от proxy и переданные заголовки', 'свою логику маршрута и журнал', 'какие заголовки допустимы только от доверенного proxy'],
['Журнал', 'итог обработки на proxy', 'ничего не исправляет', 'статус, request_time и upstream-поля рядом, без секретов и пользовательских данных'],
],
),
paragraph('Эта таблица намеренно не называет конкретную топологию сети. Для одного проекта Nginx стоит прямо перед приложением; для другого перед ним уже есть балансировщик. Во втором случае $remote_addr на Nginx может обозначать предыдущий proxy, а не браузер. Подключать модуль realip имеет смысл только после того, как список доверенных источников определён отдельно. Его set_real_ip_from — это не косметическая настройка, а разрешение заменить адрес по заголовку.'),
heading('Минимальная конфигурация как объект ревью'),
paragraph('В учебной конфигурации ниже upstream намеренно локальный, а server_name не раскрывает ни одного рабочего имени. Она показывает форму договора, а не готовый фрагмент для копирования. Host передаётся явно, потому что у Nginx есть свои значения по умолчанию для заголовков proxied request. X-Forwarded-For накапливается через $proxy_add_x_forwarded_for, а X-Forwarded-Proto фиксирует схему hop-а, который пришёл на Nginx. Приложение должно принимать эти поля только из согласованной границы, а не из любого прямого HTTP-запроса.'),
codeBlock(boundaryConfig),
paragraph('Три таймаута в примере отвечают на разные вопросы. proxy_connect_timeout ограничивает установление соединения с upstream. proxy_send_timeout относится к передаче запроса upstream между последовательными операциями записи. proxy_read_timeout относится к промежутку между чтениями ответа, а не к полной длительности ответа. Поэтому число 15s не является «временем работы API»: потоковый ответ может жить дольше, если upstream регулярно отдаёт байты, а тихий запрос может оборваться раньше, если приложение перестало отвечать. Значения здесь учебные, их нельзя переносить в рабочий контур без бюджета ожидания всего маршрута.'),
figure(
'/assets/editorial/2020/reverse-proxy-request-hops-2020.svg',
'Вертикальная схема трёх hop-ов: клиент передаёт запрос Nginx, Nginx фиксирует контракт заголовков и таймаутов, затем создаёт отдельный запрос к приложению; внизу показана точка совместной диагностики по access-log и логу приложения',
'Один пользовательский запрос даёт как минимум два HTTP-hop-а. Ошибку ищем на том hop-е, где меняется нужный сигнал, а не в абстрактном «сервере».',
),
heading('Один безопасный маршрут вместо широкого smoke-теста'),
paragraph('Чтобы проверить контракт, не нужен полный прогон сайта. Нужен маршрут без пользовательских данных и с понятным ответом: например, /health или отдельный endpoint стенда. В запрос добавляется учебный токен, который можно увидеть и в журнале proxy, и в логе приложения, если приложение уже умеет его писать. Токен не становится средством аутентификации и не заменяет request ID; он всего лишь связывает две записи в контролируемом упражнении. Если таких журналов нет, сначала добавляют безопасный формат, а потом запускают проверку.'),
codeBlock(boundaryCurl),
paragraph('После такого запроса нельзя делать вывод «приложение работает за proxy», если пришёл только 200. Проверяются четыре вещи: status и ответные заголовки снаружи, запись proxy с тем же токеном, запись приложения с этим же токеном и точное значение схемы или host, которое приложение использовало. Если внешнее соединение TLS, а приложение видит HTTP, это может быть корректно на внутреннем hop-е. Ошибкой становится не сам HTTP, а отсутствие согласованного сигнала, по которому код различает внешнюю схему.'),
heading('Типовые симптомы не лечатся одним заголовком'),
paragraph('Симптом «все адреса одинаковые» имеет минимум две причины. Либо приложение честно пишет peer address и видит Nginx, либо оно доверяет неподтверждённому X-Forwarded-For. Проверка разная: сначала определяем, есть ли прямой доступ к приложению и какой proxy имеет право дописывать заголовок. Действие — закрепить этот маршрут в конфигурации и в приложении, а не заменить адрес на первое значение из любой строки. RFC 7239 отдельно напоминает: forwarded-информация может быть изменена на пути и не становится достоверной без доверенной цепочки.'),
paragraph('Симптом «редирект ведёт на http» тоже не означает, что Nginx сломан. Сначала проверяют, где завершается TLS, какой заголовок proxy передаёт и какое поле реально читает фреймворк. Затем сравнивают один учебный запрос до и после конфигурации. Если приложение использует один заголовок, а proxy передаёт другой, исправляется контракт на границе или адаптер приложения — но не добавляется ещё один независимый способ угадать схему. Так сохраняется возможность объяснить поведение новому человеку по четырём строкам конфигурации.'),
heading('Маршрут внедрения и отката'),
orderedList([
'Записать один симптом в наблюдаемой форме: неверный Host, схема, адрес клиента, 502/504 или неожиданный timeout; назвать его цену для пользователя или разработки.',
'Нарисовать реальные hop-ы без адресов и секретов: кто принимает внешний запрос, где заканчивается TLS и какой процесс является upstream для Nginx.',
'Согласовать для приложения явный набор полей: Host, признак схемы, forwarded-цепочка и один безопасный диагностический токен; отдельно назвать, кто имеет право их выставлять.',
'Собрать итоговую конфигурацию Nginx и проверить её синтаксис в изолированной среде; не менять одновременно route, приложение и значения таймаутов.',
'Выполнить один учебный curl-маршрут на стенде, сопоставить записи proxy и приложения, затем изменить только ту строку конфигурации, которая объясняет расхождение.',
'Сохранить ожидаемый результат и обратный шаг: какие поля и лимиты вернуть, если проверка не подтверждает гипотезу. Без этого увеличение таймаута остаётся необъяснимым обходом.',
]),
heading('Граница должна быть маленькой и явной'),
paragraph('Reverse proxy полезен тем, что отделяет внешний HTTP от процесса приложения. Но это разделение требует контракта: что передаётся, чему доверяют, сколько ждут и где виден результат. В апреле 2020 года для небольшого сервиса достаточно Nginx-конфигурации, одного диагностического маршрута, таблицы сигналов и короткого access-log. Не требуется усложнять схему service mesh, Kubernetes Ingress или распределённой трассировкой, чтобы перестать угадывать причину 502.'),
paragraph('Эта статья не запускала Nginx, curl, browser, staging или production и не использует реальные hostnames, IP-адреса либо секреты. Конфигурация, адрес 127.0.0.1, путь журнала и таймауты — только учебные заполнители. Перед применением в проекте нужно отдельно подтвердить топологию, доверенные proxy, таймаут приложения, безопасный диагностический маршрут и правила хранения журналов.'),
],
[nginxProxy, nginxRealIp, nginxLog, rfc7239, rfc7230],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2020-04-mechanism-reverse-proxy',
title: 'Под капотом reverse proxy: два HTTP-соединения, заголовки и таймауты',
categories: ['HTTP', 'Nginx', 'Архитектура'],
cover: '/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg',
excerpt: 'Почему приложение видит не тот адрес и схему, а 504 не объясняет причину само по себе: раскладываем proxy на отдельные соединения и проверяем контракт заголовков.',
readingMinutes: 15,
},
[
paragraph('Симптом становится дорогим, когда proxy считают невидимой деталью: приложение разрешает небезопасный редирект, потому что видит HTTP; ограничение по адресу клиента работает для всей аудитории как для одного посетителя; ответ внезапно обрывается по 504, и команда увеличивает все таймауты разом. Цена такой реакции — не только задержка. Появляется недоверенный источник данных, длиннее висят соединения, а журнал больше не показывает, на каком участке запрос перестал двигаться.'),
paragraph('Причина в механике: reverse proxy не пересылает тот же самый TCP/HTTP-разговор без изменений. Он завершает входной запрос, принимает решение по своему server/location, а затем создаёт request к upstream. У нового request есть свой peer address, свои default headers, свой момент подключения и свои интервалы чтения. Для автора, который в 2020 году изучает delivery и backend boundary, это не повод строить новую платформу. Достаточно назвать эти состояния и проверить их в маленькой конфигурации, не выдавая учебный запуск за production-факт.'),
heading('Два соединения дают две правды о запросе'),
paragraph('На внешнем hop-е клиент отправляет method, target, Host, заголовки и тело Nginx. Nginx может выбрать location, переписать URI, добавить или удалить заголовок, буферизовать данные и закрыть соединение по своему лимиту. На внутреннем hop-е upstream получает то, что сформировал proxy. Поэтому слово «оригинальный запрос» в обсуждении часто мешает: полезнее каждый раз уточнять, о каком hop-е идёт речь. Внешний URL мог быть HTTPS, а внутренний hop — HTTP; это корректно, если контракт сообщает приложению исходную схему и эта информация пришла от доверенной стороны.'),
paragraph('В Nginx proxy_set_header не является декоративным списком. Документация модуля фиксирует, что по умолчанию proxy задаёт собственные значения для Host и Connection; при явных директивах на уровне location важно увидеть итоговую конфигурацию, а не надеяться на наследование. Именно поэтому ревью конфигурации должно отвечать на прямой вопрос: чему равен Host у upstream, какой заголовок сигнализирует схему и какие headers сознательно не передаются. Иначе приложение может собрать URL с именем upstream либо потерять нужный признак внешнего запроса.'),
dataTable(
'Данные на границе proxy: значение без источника не становится фактом',
['Сигнал', 'На каком 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'],
],
),
paragraph('Стандартный Forwarded из RFC 7239 описывает параметры for, by, host и proto. При этом в старых приложениях широко встречаются X-Forwarded-*, а переход между форматами не происходит сам. RFC подчёркивает более важное ограничение: эти данные можно изменить на пути, в том числе клиентом. Поэтому приложение не должно превращать header в источник прав для каждого прямого соединения. Nginx realip module также требует явно указать доверенные адреса через set_real_ip_from; это хороший сигнал, что доверие — часть конфигурации, а не строка парсинга.'),
heading('Заголовки должны описывать ровно один договор'),
paragraph('Ниже не «рецепт для любого сервера», а учебный минимум. Он показывает, где находятся точки договора. Host передаётся как $host; X-Real-IP показывает непосредственный peer, а X-Forwarded-For расширяет уже имеющуюся цепочку. Если Nginx сам расположен за другим proxy, этот peer может быть адресом предыдущего hop-а. Исправлять это нужно не редактированием X-Forwarded-For в приложении, а отдельной проверкой реальной цепочки и доверенных источников.'),
codeBlock(boundaryConfig),
paragraph('Схема X-Forwarded-Proto $scheme тоже имеет границу. Она показывает схему соединения, пришедшего именно на этот Nginx. Если TLS завершился раньше, $scheme может быть http, хотя пользователь открыл HTTPS. В таком случае нельзя просто жёстко записать https: сначала фиксируют, где происходит termination, какой proxy законно сообщает исходную схему и как этот proxy защищён от прямого обхода. Результатом является один договор, а не несколько конкурирующих headers, из которых код выбирает удобный.'),
figure(
'/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg',
'Вертикальная схема границы заголовков: внешний запрос несёт недоверенные клиентские поля, Nginx формирует договор Host и X-Forwarded-*, приложение читает только согласованные поля от известного proxy, а отдельный access-log связывает оба hop-а',
'Header становится пригодным для решений только вместе с источником. Строка без описанной доверенной границы не доказывает ни схему, ни адрес клиента.',
),
heading('Три таймаута отвечают на разные паузы'),
paragraph('Число в конфигурации не равно времени ответа API. proxy_connect_timeout ограничивает установление соединения с proxied server. proxy_send_timeout применим между последовательными операциями записи запроса upstream. proxy_read_timeout применяется между последовательными операциями чтения ответа. Последний пункт особенно важен в диагностике: длинный ответ, который регулярно отдаёт данные, и зависший upstream без следующего байта выглядят для него по-разному. Называть оба случая «медленным запросом» значит потерять полезную развилку.'),
paragraph('На практике я сначала рисую бюджет без выдуманных чисел: сколько имеет право ждать клиент, сколько proxy готов ждать подключения, сколько — первый или следующий байт, и где у приложения собственное ограничение. Затем выбираю один класс запроса. Для обычного JSON endpoint не нужен бесконечный read timeout. Для streaming endpoint нельзя применять тот же лимит без понимания пауз в протоколе. Если timeout меняется, рядом фиксируется ожидаемое изменение в access-log и способ вернуть старое значение. Это делает конфигурацию предметом ревью, а не накоплением чисел из чужих статей.'),
dataTable(
'Таймаутный разбор: что наблюдать до изменения числа',
['Наблюдение', 'Не делать вывод', 'Проверка', 'Следующее действие'],
[
['Не удалось подключиться к 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-лимит'],
],
),
heading('Журнал связывает гипотезу с hop-ом'),
paragraph('Access-log полезен, когда в одной строке есть путь, итоговый status, полное request_time и upstream-поля. Nginx log module документирует log_format и $request_time; proxy module даёт данные о выбранном upstream и времени этого взаимодействия. Такой формат не раскрывает cookie, authorization header или реальный адрес пользователя. Он нужен, чтобы сделать различимыми хотя бы две гипотезы: proxy не подключился к upstream или подключился, но не получил заголовки ответа до своего лимита.'),
codeBlock(headerLogConfig),
paragraph('Сам лог не говорит, какой именно запрос в приложении был дорогим. Для этого в учебном упражнении добавляют безопасный токен и сверяют его с логом приложения, если такой лог уже существует. Не стоит добавлять в access-log полный query string, тело или заголовок авторизации ради удобства расследования. В 2020 году для небольшой команды достаточно минимального формата и дисциплины: одно поле добавляют только если заранее понятно, какую развилку оно поможет проверить.'),
heading('Последовательность разбора механизма'),
orderedList([
'Назвать внешний симптом и стоимость: неверная схема, адрес, Host, 502/504 или превышение ожидания; не смешивать эти варианты в одну жалобу.',
'Нарисовать два соединения и отметить TLS termination, Nginx location и конкретный upstream; не публиковать реальные имена, адреса и секреты.',
'Проверить итоговую конфигурацию proxy_set_header: Host, схема, цепочка адресов, Connection и policy для недоверенных прямых запросов.',
'Разделить connect, send и read timeout, затем сопоставить каждый лимит с типом endpoint и наблюдаемым промежутком ожидания.',
'Добавить или проверить минимальный log_format без чувствительных данных и пройти один контролируемый запрос на изолированном стенде.',
'Изменить одну причину, повторить тот же маршрут и записать ограничение: какие hop-ы или proxy ещё не входят в договор.',
]),
heading('Что эта модель не обещает'),
paragraph('Она не делает header безопасным сама по себе, не превращает status 504 в диагноз и не выбирает «правильные» таймауты без нагрузки и сценария. Она лишь отделяет факты: где запрос был принят, где был сформирован новый request, что proxy передал дальше и между какими операциями истекло время. Для начинающего движения в delivery это уже заметный шаг: ошибка перестаёт быть свойством «сервера вообще» и получает конкретную границу, конфигурацию и проверку.'),
paragraph('Здесь не запускались Nginx, curl, browser, CI, staging или production. Адрес 127.0.0.1, путь журнала и таймауты принадлежат учебному примеру, а не реальному контуру; никаких hostnames, секретов и фактических IP не публикуется. Перед использованием нужны отдельные проверки топологии, доверенных proxy, версии Nginx, поведения фреймворка при forwarded headers и требований к хранению диагностических журналов.'),
],
[nginxProxy, nginxRealIp, nginxLog, rfc7239, rfc7230],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2020-04-field-reverse-proxy',
title: 'Разбор reverse proxy: как не перепутать 504, неверную схему и адрес клиента',
categories: ['HTTP', 'Nginx', 'Отладка'],
cover: '/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg',
excerpt: '502 или 504 — это начало расследования, а не имя причины. Собираем безопасную таблицу сигналов, учебный access-log и маршрут проверки одного proxy-hop-а.',
readingMinutes: 14,
},
[
paragraph('Симптомы proxy часто приходят пачкой: клиент получает 504, приложение пишет HTTP вместо HTTPS, а журнал видит один и тот же адрес для каждого пользователя. Цена поспешного исправления заметна быстро: таймаут увеличивают для всех endpoint-ов, в код добавляют исключение для «настоящей схемы», а access-log остаётся без полей, которые могли бы отделить подключение от ожидания ответа. Через неделю тот же сбой возвращается, но уже с другим status и без возможности сравнить два случая.'),
paragraph('Полезный разбор начинается с ограничения. Здесь нет production-инцидента и нет реального access-log: ниже синтетический сценарий с учебными полями. Его задача — научить отличать сигнал от вывода. Reverse proxy формирует отдельный upstream-hop, поэтому один status не говорит, где именно потеряно время или кто изменил заголовок. В апреле 2020 года достаточно собрать путь одного запроса, безопасный формат лога и короткую таблицу «симптом → причина → проверка → действие», не приписывая себе запуск на живом контуре.'),
heading('Начинаем с одного маршрута и одного вопроса'),
paragraph('Выбираю маршрут, на котором нет персональных данных и который допускает повтор: /health, техническая страница или специальный стендовый endpoint. Вопрос тоже один. Например: «получил ли Nginx заголовки ответа upstream до истечения своего read timeout?» Это лучше, чем вопрос «почему всё медленно». Для ответа нужны совпадающие признаки: путь и безопасный токен в входном запросе, итоговый status и время в access-log, плюс запись приложения, если она уже умеет логировать этот токен. Если последней записи нет, это результат наблюдения, а не повод придумать её содержание.'),
paragraph('Следующий вопрос — о границе доверия: «кто установил X-Forwarded-Proto и X-Forwarded-For?» Приложение может видеть адрес Nginx совершенно корректно. Проблемой это становится, когда приложение использует этот peer как пользовательский адрес или принимает forwarded-header от прямого внешнего соединения. Nginx realip module требует отметить trusted sources, а RFC 7239 предупреждает, что forwarded-информация может быть изменена на пути. Поэтому сначала рисуется топология, затем определяется доверенный proxy, и только после этого меняется конфигурация или middleware.'),
dataTable(
'Карта диагностики: status — вход в ветку, а не готовый диагноз',
['Симптом', 'Вероятная граница', 'Минимальная проверка', 'Ограниченное действие'],
[
['504 на одном endpoint', 'ожидание proxy между операциями чтения upstream', 'сопоставить proxy_read_timeout, request_time, upstream_header_time и лог приложения на одном токене', 'исправить задержку upstream или отдельно пересмотреть лимит именно этого endpoint'],
['502 после смены proxy_pass', 'маршрут до upstream, URI или ответ upstream', 'прочитать итоговый location/proxy_pass и status upstream без изменения таймаутов', 'вернуть один неверный маршрут либо исправить конфигурацию; не лечить 502 read timeout'],
['Приложение строит http-ссылку', 'TLS termination и договор о схеме', 'сверить внешний маршрут, $scheme на Nginx и поле, которое читает фреймворк', 'зафиксировать единственный доверенный forwarded-сигнал'],
['Все пользователи имеют адрес proxy', 'peer address и realip boundary', 'выяснить, существует ли прямой доступ к приложению и какие hop-ы доверены', 'не парсить первый X-Forwarded-For; задать список доверенных источников отдельно'],
],
),
paragraph('В таблице нет строки «перезапустить всё». Перезапуск может совпасть с исчезновением эффекта, но не доказать причину. Если конфигурация изменилась одновременно с приложением, сначала возвращают понятный контроль: один location, один upstream, один запрос. Если симптом связан с timeout, нельзя одновременно увеличивать proxy_read_timeout, менять пул соединений и добавлять retry. Иначе следующая запись в журнале не ответит, какая из трёх правок что изменила.'),
heading('Учебный access-log должен сохранять развилку'),
paragraph('Nginx пишет access-log в формате, который задаётся через log_format. Для proxy-разбора хватает небольшой строки: метод и URI, итоговый status, $request_time, выбранный upstream, его status и времена подключения, заголовков и ответа. Значения upstream-полей иногда остаются пустыми; это тоже сигнал о том, что нужно проверить конкретную ветку, а не маскировать пустоту нулём. Формат не должен писать authorization, cookie, тело или реальный IP пользователя: диагностика не оправдывает сбор лишних данных.'),
codeBlock(headerLogConfig),
paragraph('После настройки формата удобнее прочитать одну синтетическую строку, чем десятки настоящих. В примере ниже connect маленький, а header отсутствует до 3.001 секунды. Это поддерживает гипотезу, что Nginx быстро начал upstream-hop, но не получил заголовки ответа до лимита. Это не доказывает, что виновата база, GC, внешняя зависимость или сам framework. Следующий шаг — посмотреть конфигурацию и безопасную запись приложения для того же токена, а не объявить причину по одной цифре.'),
codeBlock(syntheticLog),
paragraph('Та же дисциплина применима к 502. Status 502 сообщает, что proxy не смог отдать клиенту корректный upstream-ответ в данной конфигурации, но не заменяет сравнение location, proxy_pass, URL и лога upstream. Если рядом нет upstream_status, это может сузить ветку, но не выбирает её автоматически. Правильная запись расследования звучит короче и честнее: «на учебном маршруте proxy не получил заголовки upstream до указанного лимита; следующий эксперимент — сверить endpoint и лог процесса». В ней нет ни выдуманного запуска, ни обещания, что увеличение таймаута решит всё.'),
figure(
'/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg',
'Вертикальная схема расследования reverse proxy: зафиксировать один симптом, выбрать один безопасный маршрут, проверить контракт headers и timeout, сопоставить proxy access-log с логом приложения, затем изменить только подтверждённую границу и повторить маршрут',
'Диагностика не начинает с значения timeout. Сначала определяется hop и наблюдаемый сигнал, затем меняется одна причина.',
),
heading('Неверная схема и адрес — отдельные ветки'),
paragraph('Схема и адрес часто попадают в один «proxy bug», хотя проверяются по-разному. Для схемы важно место TLS termination и заголовок, который приложение реально читает. На внутреннем HTTP-hop-е $scheme может быть http, даже если внешний пользователь пришёл по HTTPS. Для адреса важно, кто является непосредственным peer и кто имеет право заменить его данными из header. set_real_ip_from описывает доверенную сторону; отсутствие такого знания нельзя компенсировать тем, что приложение возьмёт первый элемент X-Forwarded-For.'),
paragraph('Если проект использует стандартный Forwarded, правила его сохранения и расширения должны быть описаны вместе с proxy. Если используется X-Forwarded-*, это не делает решение неверным, но добавляет обязанность назвать формат и источник. Нельзя склеивать значения из Via, Forwarded и X-заголовков так, будто они всегда описывают одну упорядоченную цепочку. RFC 7239 прямо отделяет эти форматы и не обещает, что каждый proxy обновляет их одинаково. Для небольшого сервиса практичнее выбрать один договор и проверить его на одном маршруте.'),
heading('Порядок проверки без широкого перезапуска'),
orderedList([
'Зафиксировать один симптом, один URL-шаблон без чувствительных данных и один ожидаемый результат; назвать стоимость, если он повторится.',
'Отметить на схеме внешний клиентский hop, Nginx и upstream; отдельно указать, где завершается TLS и есть ли proxy перед Nginx.',
'Проверить итоговые директивы proxy_pass, proxy_set_header и timeout для конкретного location, а не фрагмент из другого include-файла.',
'Собрать безопасный access-log с request_time и upstream-полями, затем выполнить один учебный запрос на изолированном стенде с диагностическим токеном.',
'Сопоставить status и время proxy с записью приложения только для этого токена; если записи приложения нет, зафиксировать неизвестное вместо домысла.',
'Изменить одну подтверждённую границу, повторить тот же запрос и записать критерий отката. Если результат не изменился, вернуться к следующей строке таблицы, а не расширять действие.',
]),
heading('Пример проверки заголовков без реального endpoint'),
paragraph('Ниже показана команда с подстановками. Её назначение — сделать будущий учебный запрос повторяемым: оператор заменяет только URL и Host стенда, а не копирует скрытые cookie или рабочую авторизацию. До запуска нужно договориться, какой ответ безопасно проверять и где появится диагностический токен. Команда не была выполнена и не подтверждает доступность какого-либо адреса.'),
codeBlock(boundaryCurl),
paragraph('После запуска стендового варианта смотрят не только на 200. Для схемы проверяют, что приложение использовало согласованный forwarded-signal, а не peer socket. Для адреса проверяют путь доверия от входного proxy до кода. Для timeout сравнивают нужный интервал с данными proxy и лога приложения. Если обработчик отдаёт streaming response, отдельной проверкой фиксируют интервал между частями: proxy_read_timeout измеряет паузу между чтениями, а не полную длительность передачи. Это ограничение меняет постановку задачи и должно остаться рядом с выбранным значением.'),
heading('Что остаётся после исправления'),
paragraph('Хороший разбор оставляет не «правильный timeout», а повторяемый маршрут: схема hop-ов, список заголовков с источником, минимальный log_format и один безопасный запрос. В следующий раз это позволяет ответить быстрее: проблема в соединении с upstream, в паузе ответа, в контракте схемы или в недоверенном адресе. Так автор развивает ширину от frontend HTTP к delivery boundary, но не притворяется владельцем большой распределённой платформы. Для этого уровня достаточно видеть свою границу и не скрывать неизвестное.'),
paragraph('Реальные Nginx, curl, browser, CI, staging и production в этой статье не запускались. Синтетическая строка лога, значения времени, адрес 127.0.0.1, URL-подстановки и путь файла приведены только для обучения; hostnames, секреты, реальные IP и пользовательские данные не публикуются. Перед применением нужен отдельный прогон в согласованном стенде, проверка версии Nginx, владельца конфигурации, доверенных proxy и политики журналирования.'),
],
[nginxProxy, nginxRealIp, nginxLog, rfc7239, rfc7230],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
const isDirectRun = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2020-04.mjs --print-revisions\n');
process.exitCode = 1;
}
}