Files
progcode/web/scripts/upgrade-2020-04.mjs
T
huncode b1e9aae0c2
Build and deploy / deploy (push) Successful in 13s
revise April 2020 proxy articles
2026-07-31 11:22:40 +03:00

374 lines
60 KiB
JavaScript
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.
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const captionHtml = '<caption>' + caption + '</caption>';
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + captionHtml + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function proseText(html) {
return visibleText(
html
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
.replace(/<div class="table-scroll">[\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.',
'# <PROXY_URL> и <HOST> — подстановки для изолированного стенда, не реальные адреса.',
'curl -i --max-time 5 \\',
" '<PROXY_URL>/health' \\",
" -H 'Host: <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=<backend> 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 правильный, а проблема на самом деле в <code>Host</code>, который по умолчанию у 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 стоит прямо перед приложением; для другого перед ним уже есть балансировщик. Во втором случае <code>$remote_addr</code> на Nginx может обозначать предыдущий proxy, а не браузер. Подключать модуль realip имеет смысл только после того, как список доверенных источников определён отдельно. Его <code>set_real_ip_from</code> — это не косметическая настройка, а разрешение заменить адрес по заголовку.'),
heading('Минимальная конфигурация как объект ревью'),
paragraph('В учебной конфигурации ниже upstream намеренно локальный, а <code>server_name</code> не раскрывает ни одного рабочего имени. Она показывает форму договора, а не готовый фрагмент для копирования. <code>Host</code> передаётся явно, потому что у Nginx есть свои значения по умолчанию для заголовков proxied request. <code>X-Forwarded-For</code> накапливается через <code>$proxy_add_x_forwarded_for</code>, а <code>X-Forwarded-Proto</code> фиксирует схему hop-а, который пришёл на Nginx. Приложение должно принимать эти поля только из согласованной границы, а не из любого прямого HTTP-запроса.'),
codeBlock(boundaryConfig),
paragraph('Три таймаута в примере отвечают на разные вопросы. <code>proxy_connect_timeout</code> ограничивает установление соединения с upstream. <code>proxy_send_timeout</code> относится к передаче запроса upstream между последовательными операциями записи. <code>proxy_read_timeout</code> относится к промежутку между чтениями ответа, а не к полной длительности ответа. Поэтому число <code>15s</code> не является «временем работы API»: потоковый ответ может жить дольше, если upstream регулярно отдаёт байты, а тихий запрос может оборваться раньше, если приложение перестало отвечать. Значения здесь учебные, их нельзя переносить в рабочий контур без бюджета ожидания всего маршрута.'),
figure(
'/assets/editorial/2020/reverse-proxy-request-hops-2020.svg',
'Вертикальная схема трёх hop-ов: клиент передаёт запрос Nginx, Nginx фиксирует контракт заголовков и таймаутов, затем создаёт отдельный запрос к приложению; внизу показана точка совместной диагностики по access-log и логу приложения',
'Один пользовательский запрос даёт как минимум два HTTP-hop-а. Ошибку ищем на том hop-е, где меняется нужный сигнал, а не в абстрактном «сервере».',
),
heading('Один безопасный маршрут вместо широкого smoke-теста'),
paragraph('Чтобы проверить контракт, не нужен полный прогон сайта. Нужен маршрут без пользовательских данных и с понятным ответом: например, <code>/health</code> или отдельный endpoint стенда. В запрос добавляется учебный токен, который можно увидеть и в журнале proxy, и в логе приложения, если приложение уже умеет его писать. Токен не становится средством аутентификации и не заменяет request ID; он всего лишь связывает две записи в контролируемом упражнении. Если таких журналов нет, сначала добавляют безопасный формат, а потом запускают проверку.'),
codeBlock(boundaryCurl),
paragraph('После такого запроса нельзя делать вывод «приложение работает за proxy», если пришёл только <code>200</code>. Проверяются четыре вещи: status и ответные заголовки снаружи, запись proxy с тем же токеном, запись приложения с этим же токеном и точное значение схемы или host, которое приложение использовало. Если внешнее соединение TLS, а приложение видит HTTP, это может быть корректно на внутреннем hop-е. Ошибкой становится не сам HTTP, а отсутствие согласованного сигнала, по которому код различает внешнюю схему.'),
heading('Типовые симптомы не лечатся одним заголовком'),
paragraph('Симптом «все адреса одинаковые» имеет минимум две причины. Либо приложение честно пишет peer address и видит Nginx, либо оно доверяет неподтверждённому <code>X-Forwarded-For</code>. Проверка разная: сначала определяем, есть ли прямой доступ к приложению и какой 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-адреса либо секреты. Конфигурация, адрес <code>127.0.0.1</code>, путь журнала и таймауты — только учебные заполнители. Перед применением в проекте нужно отдельно подтвердить топологию, доверенные 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-разговор без изменений. Он завершает входной запрос, принимает решение по своему <code>server</code>/<code>location</code>, а затем создаёт 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 <code>proxy_set_header</code> не является декоративным списком. Документация модуля фиксирует, что по умолчанию proxy задаёт собственные значения для <code>Host</code> и <code>Connection</code>; при явных директивах на уровне 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('Стандартный <code>Forwarded</code> из RFC 7239 описывает параметры <code>for</code>, <code>by</code>, <code>host</code> и <code>proto</code>. При этом в старых приложениях широко встречаются <code>X-Forwarded-*</code>, а переход между форматами не происходит сам. RFC подчёркивает более важное ограничение: эти данные можно изменить на пути, в том числе клиентом. Поэтому приложение не должно превращать header в источник прав для каждого прямого соединения. Nginx realip module также требует явно указать доверенные адреса через <code>set_real_ip_from</code>; это хороший сигнал, что доверие — часть конфигурации, а не строка парсинга.'),
heading('Заголовки должны описывать ровно один договор'),
paragraph('Ниже не «рецепт для любого сервера», а учебный минимум. Он показывает, где находятся точки договора. <code>Host</code> передаётся как <code>$host</code>; <code>X-Real-IP</code> показывает непосредственный peer, а <code>X-Forwarded-For</code> расширяет уже имеющуюся цепочку. Если Nginx сам расположен за другим proxy, этот peer может быть адресом предыдущего hop-а. Исправлять это нужно не редактированием X-Forwarded-For в приложении, а отдельной проверкой реальной цепочки и доверенных источников.'),
codeBlock(boundaryConfig),
paragraph('Схема <code>X-Forwarded-Proto $scheme</code> тоже имеет границу. Она показывает схему соединения, пришедшего именно на этот Nginx. Если TLS завершился раньше, <code>$scheme</code> может быть <code>http</code>, хотя пользователь открыл HTTPS. В таком случае нельзя просто жёстко записать <code>https</code>: сначала фиксируют, где происходит 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. <code>proxy_connect_timeout</code> ограничивает установление соединения с proxied server. <code>proxy_send_timeout</code> применим между последовательными операциями записи запроса upstream. <code>proxy_read_timeout</code> применяется между последовательными операциями чтения ответа. Последний пункт особенно важен в диагностике: длинный ответ, который регулярно отдаёт данные, и зависший 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, полное <code>request_time</code> и upstream-поля. Nginx log module документирует <code>log_format</code> и <code>$request_time</code>; 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. Адрес <code>127.0.0.1</code>, путь журнала и таймауты принадлежат учебному примеру, а не реальному контуру; никаких 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('Выбираю маршрут, на котором нет персональных данных и который допускает повтор: <code>/health</code>, техническая страница или специальный стендовый 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, нельзя одновременно увеличивать <code>proxy_read_timeout</code>, менять пул соединений и добавлять retry. Иначе следующая запись в журнале не ответит, какая из трёх правок что изменила.'),
heading('Учебный access-log должен сохранять развилку'),
paragraph('Nginx пишет access-log в формате, который задаётся через <code>log_format</code>. Для proxy-разбора хватает небольшой строки: метод и URI, итоговый status, <code>$request_time</code>, выбранный upstream, его status и времена подключения, заголовков и ответа. Значения upstream-полей иногда остаются пустыми; это тоже сигнал о том, что нужно проверить конкретную ветку, а не маскировать пустоту нулём. Формат не должен писать authorization, cookie, тело или реальный IP пользователя: диагностика не оправдывает сбор лишних данных.'),
codeBlock(headerLogConfig),
paragraph('После настройки формата удобнее прочитать одну синтетическую строку, чем десятки настоящих. В примере ниже <code>connect</code> маленький, а <code>header</code> отсутствует до 3.001 секунды. Это поддерживает гипотезу, что Nginx быстро начал upstream-hop, но не получил заголовки ответа до лимита. Это не доказывает, что виновата база, GC, внешняя зависимость или сам framework. Следующий шаг — посмотреть конфигурацию и безопасную запись приложения для того же токена, а не объявить причину по одной цифре.'),
codeBlock(syntheticLog),
paragraph('Та же дисциплина применима к 502. Status 502 сообщает, что proxy не смог отдать клиенту корректный upstream-ответ в данной конфигурации, но не заменяет сравнение location, <code>proxy_pass</code>, URL и лога upstream. Если рядом нет <code>upstream_status</code>, это может сузить ветку, но не выбирает её автоматически. Правильная запись расследования звучит короче и честнее: «на учебном маршруте 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-е <code>$scheme</code> может быть <code>http</code>, даже если внешний пользователь пришёл по HTTPS. Для адреса важно, кто является непосредственным peer и кто имеет право заменить его данными из header. <code>set_real_ip_from</code> описывает доверенную сторону; отсутствие такого знания нельзя компенсировать тем, что приложение возьмёт первый элемент <code>X-Forwarded-For</code>.'),
paragraph('Если проект использует стандартный <code>Forwarded</code>, правила его сохранения и расширения должны быть описаны вместе с proxy. Если используется <code>X-Forwarded-*</code>, это не делает решение неверным, но добавляет обязанность назвать формат и источник. Нельзя склеивать значения из <code>Via</code>, <code>Forwarded</code> и 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('После запуска стендового варианта смотрят не только на <code>200</code>. Для схемы проверяют, что приложение использовало согласованный forwarded-signal, а не peer socket. Для адреса проверяют путь доверия от входного proxy до кода. Для timeout сравнивают нужный интервал с данными proxy и лога приложения. Если обработчик отдаёт streaming response, отдельной проверкой фиксируют интервал между частями: <code>proxy_read_timeout</code> измеряет паузу между чтениями, а не полную длительность передачи. Это ограничение меняет постановку задачи и должно остаться рядом с выбранным значением.'),
heading('Что остаётся после исправления'),
paragraph('Хороший разбор оставляет не «правильный timeout», а повторяемый маршрут: схема hop-ов, список заголовков с источником, минимальный log_format и один безопасный запрос. В следующий раз это позволяет ответить быстрее: проблема в соединении с upstream, в паузе ответа, в контракте схемы или в недоверенном адресе. Так автор развивает ширину от frontend HTTP к delivery boundary, но не притворяется владельцем большой распределённой платформы. Для этого уровня достаточно видеть свою границу и не скрывать неизвестное.'),
paragraph('Реальные Nginx, curl, browser, CI, staging и production в этой статье не запускались. Синтетическая строка лога, значения времени, адрес <code>127.0.0.1</code>, 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;
}
}