Reverse proxy и HTTP/2–HTTP/3
Оглавление · DNS и TLS · Инфраструктура
Задача: понять, как один внешний вход выбирает приложение и почему версия HTTP на входе может отличаться от версии соединения с backend.
Один вход, несколько приложений
Forward proxy действует со стороны клиента, который обращается через него к другим серверам. Reverse proxy принимает запросы к обслуживаемым сайтам и направляет их внутренним приложениям. Здесь Caddy завершает TLS, выбирает маршрут и получает ответ upstream. Это отдельный процесс, а не часть React.
Исходник схемы
flowchart LR Client["Браузер: публичный HTTPS"] --> Caddy["Caddy: TLS и маршрутизация"] Caddy --> Web["web:3000 — Next.js"] Caddy --> CMS["strapi:1337 — CMS"] Caddy --> Media["s3:3902 — Garage website"] Caddy --> Docs["docs:8080 — статический учебник"]
Схема показывает целевые upstream текущего production Caddyfile. Наличие блока в конфигурации не доказывает, что соответствующий контейнер уже опубликован. Storybook имеет аналогичный отдельный upstream storybook:8080.
Публичное имя, TCP/UDP-порт, маршрут HTTP и внутренний адрес — разные части конфигурации. Несколько доменов могут вести на один IP и порт 443: Caddy различает сайты по имени. DNS сам не выбирает между web и Strapi по пути /admin.
Заголовки и граница доверия
Caddy обычно сохраняет Host при HTTP-проксировании и формирует X-Forwarded-For, X-Forwarded-Proto и X-Forwarded-Host. Входные значения этих forwarded-заголовков по умолчанию не принимаются как доверенные. Если перед Caddy стоит другой proxy, цепочку доверия настраивают явно, а не принимают любой заголовок пользователя. Caddy reverse_proxy.
Приложению может понадобиться исходная схема HTTPS для абсолютных ссылок или cookies, хотя внутреннее соединение использует HTTP. Адрес пользователя из заголовка тоже зависит от доверенной цепочки. Это не универсальное средство идентификации пользователя; авторизацию не строят на произвольном X-Forwarded-For.
TLS termination не делает любой backend публичным. Выбор доступных доменов, путей, методов и права приложения остаются отдельными решениями. CORS не заменяет авторизацию; сертификат для CMS не разрешает чтение её закрытого API.
Rewrite отличается от redirect
Redirect возвращает клиенту ответ с Location: клиент делает новый запрос, а адрес может измениться. Rewrite меняет путь на стороне сервера до обработки или проксирования: браузер продолжает видеть исходный URL. Caddy rewrite.
В Caddyfile.production media-блок устроен так:
@clean not path /strapi /strapi/*
rewrite @clean /strapi{uri}
reverse_proxy s3:3902 {
header_up Host media.web.garage.localhost
}
| Внешний путь | Upstream-путь | Назначение |
|---|---|---|
| /photo.webp | /strapi/photo.webp | Чистая публичная ссылка |
| /strapi/photo.webp | /strapi/photo.webp | Сохранение старой ссылки |
| /strapi | /strapi | Prefix не добавляется повторно |
Для этих примеров запрос без query. {uri} сохраняет также query при переписывании; rewrite не скачивает и не перемещает объект. Prefix соответствует ключам uploads, а не каталогу на VPS. Host media.web.garage.localhost соответствует bucket media и root_domain .web.garage.localhost из Garage. DNS для этого Host не используется как адрес подключения: адрес уже s3:3902. Это объясняет, почему произвольный Host может дать другой ответ при том же IP.
Сверяйте весь путь с моделью S3 и CMS-адаптером; новая rewrite-конфигурация здесь не вводится. S3 API 3900 и website 3902 имеют разную роль: Caddy отдаёт публичные изображения через website, Strapi загружает их через подписанные S3-запросы.
Транспорт сохраняет семантику HTTP
| Версия | Представление обмена | Транспорт | Что учитывать |
|---|---|---|---|
| HTTP/1.1 | Стартовая строка, заголовки, тело | Обычно TCP, для HTTPS — TLS | Keep-alive и несколько соединений клиента |
| HTTP/2 | Бинарные frames и параллельные streams | TCP; в браузерном HTTPS — TLS | Потеря TCP-данных может задержать все streams соединения |
| HTTP/3 | HTTP streams поверх QUIC | QUIC поверх UDP с TLS 1.3 | UDP-доступность, поддержка клиента и сети |
Методы, статусы, идемпотентность и кеширование остаются HTTP-семантикой. HTTP/2 мультиплексирует streams внутри соединения, но TCP доставляет общий упорядоченный поток байтов: потеря пакета может задержать независимые запросы. HTTP/2, RFC 9113. HTTP/3 использует QUIC: потеря данных одного stream не требует остановки доставки других streams как в TCP; общая перегрузка сети и некоторые зависимости протокола всё равно могут влиять на них. Это не обещание «всегда быстрее». HTTP/3, RFC 9114.
Исходник схемы
flowchart LR Browser["Браузер"] -->|"HTTPS: h2/TCP или h3/QUIC"| Caddy["Caddy"] Caddy -->|"Отдельное HTTP-соединение в Compose"| Backend["Next / Strapi / Garage"]
Caddy завершает входное соединение и создаёт другое к upstream. Поэтому h3 между браузером и Caddy не требует h3 в Next.js. В compose опубликованы TCP 443 и UDP 443 (локально через HTTPS_PORT, по умолчанию 8443). Открытый TCP 443 не подтверждает, что UDP доступен через firewall или сеть провайдера.
Поддержка h3 в конфигурации не означает, что конкретный клиент его выбрал. Клиент может узнать о нём, например, через Alt-Svc; наличие заголовка не доказывает успешный QUIC-обмен. При проверке фиксируйте фактически выбранную версию HTTP. Для curl сначала смотрите curl --version: --http3 требует соответствующей сборки, а --http3-only не допускает fallback. HTTP/3 в curl.
Локальный опыт: адрес подключения и имя сайта
Этот пример запускает собственный HTTP-сервер на случайном loopback-порту, проверяет его через curl и закрывает в finally. Нужны Node 24 и curl. Он демонстрирует Host и --resolve; TLS, Caddy и публичный DNS здесь не проверяются.
node --input-type=module <<'JS'
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const server = createServer((request, response) => {
const site = request.headers.host?.split(":")[0];
response.writeHead(site === "lesson.example.invalid" ? 200 : 404);
response.end(site === "lesson.example.invalid" ? "lesson" : "unknown site");
});
try {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address();
const common = ["--noproxy", "*", "--silent", "--show-error", "--max-time", "5"];
const { stdout } = await run("curl", [
...common, "--fail", "--resolve", `lesson.example.invalid:${port}:127.0.0.1`,
`http://lesson.example.invalid:${port}/`,
]);
assert.equal(stdout, "lesson");
const wrong = await run("curl", [
...common, "--output", "/dev/null", "--write-out", "%{http_code}",
`http://127.0.0.1:${port}/`,
]);
assert.equal(wrong.stdout, "404");
console.log("Один адрес: известный Host — 200; Host с IP — 404");
} finally {
await new Promise((resolve) => server.close(resolve));
}
JS
--resolve подставляет адрес для пары имя:порт, сохраняя имя в URL. Для HTTPS это также сохраняет имя, используемое для SNI и проверки сертификата: полезно при диагностике своего нового сервера до смены DNS. Подстановка не исправляет неверный сертификат. --noproxy в этом опыте исключает влияние proxy окружения на loopback. Опции curl resolve.
Упражнение
Изобразите путь запроса фотографии: публичное имя, TLS-участок, rewrite, upstream Host и key. Затем укажите, какие наблюдения отличат отсутствующий объект, неверный Host Garage и недоступный s3:3902. Во втором опыте h2 работает, h3 нет: проверьте поддержку клиента и UDP-путь, прежде чем менять приложение.
Команды действующего релиза находятся в главе о деплое; эта глава не подтверждает свежую публикацию или доступность протоколов на VPS.