Внутренние службы редко стоит показывать наружу напрямую: у каждой свой порт и свой способ аутентификации, и чем больше точек входа, тем сложнее следить за их состоянием. Обратный прокси принимает внешние соединения и передаёт их нужной внутренней службе, оставаясь единственной открытой дверью. Разбираем, как собрать такую схему на Nginx или Angie, как передать заголовки, что делать с WebSocket и как не выдать структуру внутренней сети.

Что даёт единая точка входа

Прямой доступ к внутренней службе означает, что наружу открыт её собственный порт. Служба, рассчитанная на работу в доверенной сети, получает запросы от произвольных адресов, и её штатные средства проверки оказываются единственным барьером. Прокси добавляет слой, на котором сосредоточены завершение TLS, фильтрация по адресам и путям, ограничение частоты запросов и ведение журнала.

Вторая польза связана с изменениями: службу можно перенести на другой порт или другую машину, и снаружи это останется незаметным. Третья в том, что несколько служб получают одно имя и один сертификат, а различаются путями запроса.

Стоит понимать и границы. Прокси не заменяет сетевой экран: внутренние порты должны быть закрыты для внешнего трафика на уровне фильтра, иначе обход тривиален. И он не делает службу безопасной сам по себе, а лишь убирает её из прямого доступа.

Базовая схема маршрутизации запросов

Минимальная конфигурация описывает сервер с именем, местом расположения и адресом внутренней службы.

server {
    listen 80;
    server_name app.example.ru;

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

Такая схема работает, но передаёт внутренней службе неполные сведения: она видит адрес прокси вместо адреса посетителя, не знает исходного имени хоста и не понимает, шёл ли запрос по шифрованному соединению. Для приложений, которые формируют ссылки и ведут журнал, это критично.

    location / {
        proxy_pass http://127.0.0.1:8080;
        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 X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  $server_port;
    }

Заголовок X-Forwarded-For накапливает цепочку адресов: переменная proxy_add_x_forwarded_for добавляет текущий адрес к полученному списку. Приложение читает первый адрес цепочки и доверяет ему только при запросе от собственного прокси, иначе посетитель подставит любое значение.

Многие фреймворки имеют режим доверенного прокси, в котором адрес посетителя берётся из заголовка, а ссылки строятся с учётом внешнего протокола. Без этого режима приложение считает соединение незашифрованным и подставляет в адреса порт внутренней службы.

Как наклонная черта в конце адреса proxy_pass меняет путь запроса

Самая частая причина неработающей маршрутизации связана с наличием пути в директиве proxy_pass. Правило короткое: если путь указан, он заменяет часть адреса, совпавшую с location, а если пути нет, запрос передаётся как есть.

    # запрос /api/users уйдёт как /api/users
    location /api/ {
        proxy_pass http://127.0.0.1:9000;
    }

    # запрос /api/users уйдёт как /users
    location /api/ {
        proxy_pass http://127.0.0.1:9000/;
    }

Второй вариант нужен, когда префикс служит только для маршрутизации, а внутренняя служба ожидает пути от корня. Первый когда префикс является частью адресного пространства службы. Ошибка в один такой символ даёт ответы 404 на все запросы, кроме корневого.

Ещё одна тонкость касается путей с символами, требующими кодирования: при наличии пути в proxy_pass адрес передаётся после декодирования. Если поведение должно быть предсказуемым, путь строят через переменные.

    location ~ ^/files/(?<fname>.+)$ {
        proxy_pass http://127.0.0.1:8081/static/$fname;
    }

Завершение TLS, HTTP/2 и переадресация

Внешний слушатель принимает шифрованные соединения, внутренняя сторона работает по обычному протоколу на локальном адресе.

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name app.example.ru;

    ssl_certificate     /etc/ssl/certs/app.example.ru.pem;
    ssl_certificate_key /etc/ssl/private/app.example.ru.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 80;
    server_name app.example.ru;
    return 301 https://$host$request_uri;
}

Директива http2 on включает вторую версию протокола для слушателя, в старых выпусках она задавалась параметром listen. Кэш сессий снижает число полных согласований. Переадресация с незашифрованного слушателя выполняется кодом 301 с сохранением исходного пути и строки запроса.

Для внутреннего обмена удобно держать группу серверов с адресами служб, чтобы изменение порта не требовало правки всех блоков location.

upstream app_backend {
    server 127.0.0.1:8080;
    keepalive 32;
}

Параметр keepalive задаёт число соединений, которые остаются открытыми для повторного использования. Для его работы в блоке location нужны две настройки: передача версии протокола 1.1 и очистка заголовка Connection, иначе каждое обращение открывает новое соединение.

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

WebSocket, длинные запросы и таймауты

Протокол WebSocket начинается как обычный запрос с заголовком Upgrade, после чего соединение переводится в двусторонний обмен. Прокси должен передать эти заголовки и не закрывать соединение по собственному таймауту.

    location /ws {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

Переменную connection_upgrade описывают в блоке map, который формирует значение upgrade при наличии заголовка и пустое значение в остальных случаях.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

Таймаут чтения по умолчанию равен минуте, и для долгоживущих соединений этого мало: прокси закроет канал в момент простоя. Значение подбирают по характеру обмена, а на стороне приложения добавляют периодические служебные сообщения, чтобы промежуточное оборудование также не считало соединение неактивным.

Отдельно настраивают передачи файлов. При проксировании крупные ответы идут через буферы прокси, и при больших объёмах стоит увеличить их размер либо отключить буферизацию для потоковых данных.

    proxy_buffering    off;
    proxy_request_buffering off;

Распределение между несколькими службами и резервирование

Группа серверов позволяет раздать нагрузку между несколькими экземплярами и исключить узел, который не отвечает.

upstream backend {
    zone backend 64k;
    server 127.0.0.1:8081 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:8082 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:8083 backup;
    keepalive 32;
}

Параметр max_fails задаёт число неудач, после которых узел считается недоступным на время fail_timeout. Узел с признаком backup принимает запросы, только когда основные недоступны. Общая память зоны нужна, чтобы счётчики работали корректно при нескольких рабочих процессах.

Поведение при ошибках описывают директивой proxy_next_upstream: она указывает, в каких случаях пробовать следующий узел.

    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;
    proxy_connect_timeout 2s;

Перечень стоит составлять осторожно: повтор запроса при коде 502 может привести к двойному выполнению операции, если запрос меняет данные. Для таких мест безопаснее ограничиться ошибкой соединения и таймаутом.

Таймаут подключения в две секунды позволяет быстро отказаться от узла, который не отвечает, вместо того чтобы держать посетителя в ожидании.

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

http {
    limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=10r/s;
    limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
}

server {
    location /login {
        limit_req zone=req_per_ip burst=5 nodelay;
        proxy_pass http://127.0.0.1:8080;
    }

    location /upload {
        client_max_body_size 50m;
        limit_conn conn_per_ip 2;
        proxy_pass http://127.0.0.1:8080;
    }
}

Параметр rate задаёт среднюю частоту, burst позволяет короткое превышение, nodelay обслуживает такие запросы сразу. Для страницы входа выбирают более строгое значение, чем для чтения страниц: перебор пароля идёт быстрыми короткими сессиями.

Директива client_max_body_size ограничивает размер загружаемых данных. Значение по умолчанию в один мегабайт приводит к отказу при отправке форм с файлами, а отсутствие предела позволяет заполнить диск одним запросом.

Фильтр по адресам удобно применять к служебным путям, оставляя публичную часть сайта открытой.

    location /metrics {
        allow 192.168.0.0/16;
        allow 127.0.0.1;
        deny all;
        proxy_pass http://127.0.0.1:9100;
    }

Порядок строк имеет значение: проверка идёт сверху вниз до первого совпадения, и строка deny all в конце закрывает всё, что не попало в разрешённые диапазоны.

Отдельный механизм позволяет передать проверку прав внешней службе. Прокси отправляет подзапрос на адрес проверки и принимает решение по полученному коду ответа.

    location / {
        auth_request /auth-check;
        proxy_pass http://127.0.0.1:8080;
    }

    location = /auth-check {
        internal;
        proxy_pass http://127.0.0.1:8081/check;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
    }

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

Ограничения стоит сопровождать отдельным журналом отказов, иначе причину блокировки придётся восстанавливать по косвенным признакам.

    limit_req_status 429;
    error_log /var/log/nginx/limits.log warn;

Код 429 сообщает посетителю, что запрос отклонён из-за частоты, и приложение может показать понятное сообщение вместо общей страницы ошибки.

Скрытие внутренней структуры и проверка конфигурации

Прокси по умолчанию добавляет к ответу заголовок Server с названием и версией. Его приводят к нейтральному значению, а сведения о внутренней службе наружу не передают.

    server_tokens off;
    proxy_hide_header X-Powered-By;
    proxy_hide_header X-AspNet-Version;
    add_header Server "web" always;

Отдельно закрывают пути, которые не должны быть доступны снаружи: интерфейсы управления, служебные страницы, каталоги с резервными копиями.

    location /admin {
        allow 192.168.0.0/16;
        deny all;
        proxy_pass http://127.0.0.1:8080;
    }

Внутренние порты остаются закрытыми для внешнего трафика на уровне фильтра, и доступ к ним есть только у процесса прокси.

Проверка конфигурации выполняется командой с ключом -t, которая разбирает файлы и сообщает о синтаксических ошибках и недоступных файлах сертификатов.

sudo nginx -t
sudo nginx -s reload
curl -skI https://app.example.ru/api/status | head -n 10

При ошибке старые рабочие процессы продолжают обслуживать трафик, но новые настройки не применяются, и расхождение между файлом и фактическим состоянием остаётся незамеченным. Правило простое: сначала проверка, потом перечитывание.

Журнал на стороне прокси ведут отдельно от журналов внутренних служб. В формат записи включают адрес посетителя, путь, код ответа, время обработки и адрес внутреннего узла.

    log_format proxy '$remote_addr -> $upstream_addr [$time_local] '
                     '"$request" $status $request_time $upstream_response_time';
    access_log /var/log/nginx/proxy.log proxy;

Сопоставление времени обработки запроса и времени ответа узла показывает, где именно теряются секунды: в очереди на прокси или в самой службе.