Внутренние службы редко стоит показывать наружу напрямую: у каждой свой порт и свой способ аутентификации, и чем больше точек входа, тем сложнее следить за их состоянием. Обратный прокси принимает внешние соединения и передаёт их нужной внутренней службе, оставаясь единственной открытой дверью. Разбираем, как собрать такую схему на 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;
Сопоставление времени обработки запроса и времени ответа узла показывает, где именно теряются секунды: в очереди на прокси или в самой службе.