MAATRIX / Блог / Как работает reverse proxy: путь запроса от клиента до приложения

Как работает reverse proxy: путь запроса от клиента до приложения

MAATRIX

Когда в логах приложения все запросы приходят с одного и того же IP, а nginx время от времени отдаёт 502 или 504 без видимой причины, разбираться приходится не в коде приложения, а в том, что происходит между клиентом и бэкендом. Reverse proxy — это не чёрный ящик, а понятная цепочка шагов: приём соединения, TLS, выбор бэкенда, проксирование заголовков, буферизация ответа. Разберём этот путь по порядку — так вы сможете быстро находить, на каком именно шаге что-то пошло не так.

Шаг 1: приём соединения на внешнем порту

Запрос клиента начинается с обычного TCP-соединения на порт, который слушает reverse proxy — обычно 443 для HTTPS и 80 для HTTP. Именно nginx, Caddy или Traefik, а не ваше приложение, принимает это соединение первым. Приложение может вообще не иметь внешнего IP и слушать только 127.0.0.1 или unix-сокет — снаружи виден только прокси.

В конфиге nginx это выглядит так:

server {
    listen 443 ssl;
    server_name example.com;
    ...
}

Важно понимать: прокси держит это соединение отдельно от соединения к бэкенду — это два разных TCP-канала («клиент → прокси» и «прокси → бэкенд»), и у каждого свои правила (об этом ниже, в разделе про keepalive и таймауты). Если есть сомнения, кто вообще слушает порт, проверьте прямо:

ss -tlnp | grep :443

Если порт держит что-то другое или два процесса конкурируют за него — запросы до прокси не доходят, и разбор следующих шагов бессмысленен, пока не решена эта база.

Шаг 2: терминирование TLS

Если соединение защищённое, следующий шаг — TLS-хендшейк. Прокси предъявляет клиенту сертификат, согласовывает шифры и версию протокола, и всё это происходит на прокси, а не на бэкенде. Дальше у вас два стандартных варианта:

  • TLS termination — прокси расшифровывает трафик и общается с бэкендом по обычному HTTP. Так работает подавляющее большинство связок nginx + приложение на одном сервере.
  • TLS passthrough — прокси не расшифровывает, а просто перенаправляет зашифрованный поток дальше (актуально для TCP-режима, например stream-блока nginx или L4-балансировки), и TLS терминируется уже на бэкенде.

Для обычного веб-приложения почти всегда используется первый вариант — он проще в обслуживании: сертификат обновляется в одном месте (например, через Let's Encrypt на прокси), а бэкенд вообще не занимается криптографией. Минимальный конфиг:

server {
    listen 443 ssl;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Обратите внимание: proxy_pass здесь идёт по http://, а не https:// — TLS уже закончился на прокси, дальше трафик до бэкенда идёт открытым. Это нормально, если бэкенд на том же сервере или в изолированной внутренней сети. Если бэкенд на другом сервере в публичном интернете — этот участок тоже стоит шифровать.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Шаг 3: выбор бэкенда — path-based и host-based роутинг

Если прокси обслуживает несколько сайтов или несколько сервисов одного приложения, следующий шаг — решить, куда именно отправить запрос. Есть два основных способа маршрутизации.

Host-based роутинг — прокси смотрит на заголовок Host (или на SNI ещё на этапе TLS-хендшейка) и по имени домена выбирает нужный server-блок:

server {
    server_name app.example.com;
    location / { proxy_pass http://127.0.0.1:3000; }
}

server {
    server_name api.example.com;
    location / { proxy_pass http://127.0.0.1:4000; }
}

Path-based роутинг — один домен, но разные префиксы пути ведут на разные бэкенды. Это типичная схема для микросервисов за одним прокси:

server {
    server_name example.com;

    location /api/ {
        proxy_pass http://127.0.0.1:4000/;
    }

    location /admin/ {
        proxy_pass http://127.0.0.1:5000/;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Нюанс, на котором спотыкаются регулярно: если у proxy_pass в конце указан путь (как http://127.0.0.1:4000/), nginx отрезает совпавший префикс location и подставляет остаток пути после адреса. Если путь у proxy_pass не указан — весь исходный путь передаётся бэкенду как есть. Мелочь, но именно из-за неё бэкенд то получает /api/users, то просто /users, и приложение отвечает 404 там, где всё вроде настроено правильно. При отладке роутинга сверяйте именно это поведение в первую очередь.

В Traefik и Caddy та же логика решается декларативно — через labels у контейнера или блоки route в Caddyfile, но суть та же: прокси должен на основе Host/SNI/пути однозначно выбрать один upstream. Для reverse proxy перед Docker-контейнерами подход описан в статье про Traefik как reverse proxy для Docker.

Шаг 4: проксирование заголовков — что видит бэкенд

Здесь начинается участок, где чаще всего теряют данные о клиенте. Когда прокси открывает второе, отдельное TCP-соединение к бэкенду, с точки зрения бэкенда клиентом является... сам прокси. Без явной настройки приложение видит IP прокси (обычно 127.0.0.1) во всех логах, вместо реального IP посетителя.

Решение — проксировать нужные заголовки вручную:

location / {
    proxy_pass http://127.0.0.1:3000;
    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;
}
  • X-Real-IP — просто IP клиента, каким его видит прокси.
  • X-Forwarded-For — список IP через запятую: реальный клиент плюс все прокси, через которые прошёл запрос (важно, если у вас цепочка из нескольких прокси или CDN перед вашим сервером).
  • X-Forwarded-Proto — исходный протокол (http или https), который бэкенд иначе не узнает, так как между ним и прокси всегда обычный HTTP.

Дальше приложению нужно явно сказать, что оно стоит за прокси и должно доверять этим заголовкам, а не remote_addr соединения. Например, в Express это app.set('trust proxy', 1), в Django — USE_X_FORWARDED_HOST и middleware, в большинстве фреймворков есть свой аналог. Пропустите этот шаг — и вся логика, завязанная на IP (антифрод, geo-ограничения, rate limiting), будет работать неправильно: приложение видит один и тот же адрес для всех посетителей.

Отдельно стоит подстраховаться от подмены: если запрос иногда может прийти к бэкенду не через ваш прокси, а напрямую, доверять X-Forwarded-For без проверки нельзя — клиент способен подставить туда что угодно. Бэкенд должен принимать эти заголовки только от известного адреса самого прокси.

Шаг 5: буферизация ответа и keepalive к бэкенду

Когда бэкенд начинает отвечать, у прокси есть выбор: либо сразу передавать байты клиенту по мере получения (потоково), либо сначала полностью получить ответ от бэкенда в буфер и только потом отдавать клиенту. По умолчанию nginx буферизует ответ:

proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;

Буферизация полезна: она освобождает бэкенд от ожидания медленного клиента (мобильный интернет, слабое соединение) — бэкенд быстро отдал ответ в буфер прокси и пошёл обрабатывать следующий запрос. Обратная сторона — большие ответы (стриминг, отдача больших файлов, Server-Sent Events) с буферизацией ведут себя не так, как ожидается: клиент не получает данные, пока не заполнится буфер или не придёт весь ответ. Для таких маршрутов буферизацию отключают точечно:

location /stream/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_buffering off;
}

Второй момент этого шага — как прокси общается с бэкендом на транспортном уровне. По умолчанию nginx открывает новое TCP-соединение к бэкенду на каждый запрос, что при высокой нагрузке означает лишние накладные расходы на установку соединения. Держать пул уже открытых соединений позволяет keepalive:

upstream backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Здесь keepalive 32 — это количество простаивающих соединений к upstream, которые nginx держит открытыми для повторного использования, а не общий лимит одновременных соединений. Строка proxy_set_header Connection "" обязательна: без неё nginx по умолчанию проксирует заголовок Connection: close от клиента, и keepalive-соединение к бэкенду не заработает, сколько его ни настраивай.

Шаг 6: таймауты — где путаница возникает чаще всего

Таймауты — это, пожалуй, главный источник поверхностной путаницы вокруг reverse proxy, потому что их как минимум два независимых уровня, и они не связаны напрямую.

Таймауты прокси — это про то, сколько сам nginx готов ждать ответа от бэкенда, прежде чем считать соединение мёртвым:

proxy_connect_timeout 5s;
proxy_send_timeout    60s;
proxy_read_timeout    60s;

Таймауты приложения — это отдельные настройки внутри самого бэкенда: таймаут воркера в Gunicorn, таймаут запроса в Node.js, лимит выполнения в PHP-FPM. Они никак не следуют из настроек nginx автоматически.

Классическая ситуация: приложение готово выполнять тяжёлый запрос (экспорт отчёта, генерация файла) две минуты, а proxy_read_timeout в nginx стоит на значении по умолчанию около минуты — и клиент получает 504 посередине честной, ещё не завершившейся работы бэкенда. Встречается и обратное: таймаут воркера у приложения ниже таймаута прокси — тогда бэкенд обрывает соединение первым, и nginx на этом основании отдаёт 502.

Правило простое: таймаут прокси должен быть чуть больше, чем максимально ожидаемое время ответа бэкенда для конкретного маршрута, а не единым числом на весь сайт. Для обычных динамических страниц хватает значений в районе 30-60 секунд; для тяжёлых операций лимиты стоит поднимать точечно, через отдельный location, а не глобально — иначе зависшие процессы на лёгких маршрутах будут висеть непропорционально долго. Если 504 появляется регулярно, посмотрите в лог ошибок nginx — там указано, на каком upstream и на какой стадии случился обрыв; подробный разбор есть в статье про 504 Gateway Timeout. Похожая диагностика нужна и для 502 Bad Gateway — там чаще всего дело в том, что бэкенд уже упал или не успел подняться.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему бэкенд видит один и тот же IP для всех клиентов?

Скорее всего, не настроены заголовки X-Real-IP / X-Forwarded-For в конфиге прокси, либо они настроены, но приложение не сконфигурировано доверять им (нет trust proxy или аналога). Проверьте оба места: и конфиг nginx, и настройки фреймворка.

В чём разница между nginx, Caddy и Traefik как reverse proxy?

Логика (приём соединения, TLS, роутинг, проксирование заголовков) везде одинаковая по сути, различается способ конфигурации: nginx — императивный конфиг, Caddy — компактный Caddyfile с автоматическим TLS из коробки, Traefik — конфигурация через labels/аннотации, что удобно для Docker и Kubernetes. Сравнение подробнее — в статье Caddy или nginx: что выбрать.

Нужно ли шифровать трафик между прокси и бэкендом, если оба на одном сервере?

Строго говоря, не обязательно, если бэкенд слушает только 127.0.0.1 или unix-сокет и недоступен снаружи напрямую. Если бэкенд на другом сервере или в общей сети с другими арендаторами — этот участок стоит тоже закрыть TLS или хотя бы изолировать сетью (VPN, приватная подсеть).

Почему при включённом keepalive к бэкенду соединения всё равно не переиспользуются?

Самая частая причина — забытая строка proxy_set_header Connection "" и proxy_http_version 1.1 в конфиге. Без них nginx продолжает проксировать заголовок Connection: close, и keepalive-пул из блока upstream фактически не используется.

Что делать, если прокси отдаёт 504, а приложение уверяет, что ответило быстро?

Проверьте, не в очереди ли сам процесс приложения (например, все воркеры заняты, и запрос ждёт свободного). Прокси в этом случае честно ждёт ответа именно от TCP-соединения, а не от логического «завершения обработки» — если воркер занят, счётчик времени у прокси идёт с момента отправки запроса, а не с момента, когда бэкенд реально начал его обрабатывать.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →