Веб-сервер воспринимают как инструмент для HTTP, а остальное отдают специализированным балансировщикам. Между тем модуль stream в Angie проксирует и распределяет любые TCP и UDP соединения, и для балансировки базы данных или DNS отдельный компонент не нужен. Разбираем, чем уровень L4 отличается от HTTP, как собрать группу узлов и какие алгоритмы там доступны.
Чем модуль stream отличается от http и почему запросов там нет
Главное отличие лежит в модели работы. Модуль http относится к уровню приложений и оперирует отдельными запросами: разбирает заголовки, применяет правила к адресу и методам, формирует ответ. Модуль stream работает на транспортном уровне и оперирует соединениями.
Содержимое трафика для stream это чёрный ящик. Отсюда практические следствия: правила по адресу страницы недоступны, кэширование невозможно, сжатие неприменимо, зато работает любой протокол поверх TCP или UDP без поддержки со стороны балансировщика.
Исключения из правила о чёрном ящике есть, и они полезны. Модули предварительного чтения умеют доставать отдельные сведения из начала соединения: имя сервера из расширения SNI в незашифрованном приветствии, версию протокола, а также параметры некоторых прикладных протоколов. Этого хватает для маршрутизации, но не для разбора содержимого.
Вторая деталь: одноимённые директивы в контекстах http и stream похожи, но не взаимозаменяемы, и копирование блока из http-конфигурации заканчивается ошибкой проверки.
Проверить, собран ли сервер с нужным модулем, можно стандартно:
angie -V 2>&1 | tr ' ' '\n' | grep stream
Проксирование TCP с ограничением доступа и ведением журнала
Минимальная рабочая конфигурация проксирования занимает четыре строки. Возьмём задачу: дать доступ к базе данных через единую точку входа, ограничить число соединений и разрешить подключение только из своей подсети.
stream {
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
listen 6306;
proxy_pass 192.168.1.2:3306;
limit_conn addr 1;
allow 192.168.1.0/24;
deny all;
}
}
Схема протокола перед адресом узла не указывается, поскольку уровень L4 не знает о HTTP и схема ему не нужна. Директива listen принимает те же формы, что и в http-контексте: адрес с портом, только порт, диапазон портов, отдельные сокеты для двух версий протокола и юникс-сокеты.
Отдельно стоит настроить журнал, потому что по умолчанию в потоковом контексте он не ведётся:
stream {
log_format basic '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time $upstream_addr';
access_log /var/log/angie/stream-access.log basic;
}
Записи появляются после завершения сеанса и содержат суммарную статистику по трафику. Коды состояния здесь искусственные и выбраны по аналогии с HTTP: 200 для успешного сеанса, 400, 403, 500, 502 и 503 для соответствующих ситуаций. Переменная session_time показывает длительность в секундах, и именно по ней удобно находить зависшие соединения.
Проксирование UDP с параметром reuseport и ожидаемым числом ответов
UDP отличается от TCP отсутствием установления соединения, и балансировщику приходится самому отслеживать, к какому внутреннему узлу относится каждый приходящий пакет. Для этого требуется параметр reuseport в директиве listen.
Типовая задача, проксирование DNS:
stream {
server {
listen 553 udp reuseport;
listen 553;
proxy_pass 192.168.1.1:53;
proxy_responses 1;
proxy_timeout 10s;
}
}
Директива proxy_responses задаёт число пакетов, ожидаемых от узла в ответ на один клиентский. Для DNS это единица, и явное значение позволяет закрыть сеанс сразу после ответа, не дожидаясь таймаута. Значение 0 подходит для односторонней передачи.
Конвертации между протоколами не происходит: какой протокол использовал клиент, такой применяется и при проксировании. Если нужны и TCP, и UDP на одном порту, обе директивы listen указываются явно.
Вторая: без reuseport UDP-сеансы отслеживаются ненадёжно, и при нескольких рабочих процессах ответы могут уходить не тому процессу, который держит соответствие. Параметр решает задачу на уровне ядра, создавая отдельный сокет на каждый рабочий процесс.
Группа узлов в блоке upstream и доступные алгоритмы распределения
Балансировка начинается с описания группы. Блок upstream задаётся внутри контекста stream и используется по имени:
stream {
upstream backend {
server 192.168.0.1:8000 weight=2 max_fails=2 fail_timeout=10s;
server 192.168.0.2:8000 max_conns=100;
server 192.168.0.3:8000 backup;
}
server {
listen 9090;
proxy_pass backend;
}
}
По умолчанию применяется распределение по кругу, и вес в такой схеме означает относительную долю соединений. Параметр backup выводит узел из обычного оборота, он принимает трафик только когда все основные недоступны. Параметр max_conns ограничивает одновременные соединения на узел, что защищает слабый сервер от переполнения.
Доступные алгоритмы в открытой версии включают распределение по кругу, выбор узла с наименьшим числом соединений, хеширование по произвольному ключу и случайный выбор с учётом числа соединений. Коммерческая версия добавляет распределение по наименьшему времени и алгоритм с обратной связью, а также активные проверки состояния узлов и режимы привязки сеансов.
Отдельно стоит отметить, чего на уровне L4 нет: привязки по адресу клиента в том виде, как она работает в HTTP, и режима липких cookies. Причина очевидна, поскольку cookies это понятие прикладного уровня, и в TCP-потоке их не существует.
Хеширование позволяет получить устойчивость соответствия между клиентом и узлом без хранения состояния:
upstream dns_pool {
hash $remote_addr consistent;
server 10.0.0.53:53;
server 10.0.0.54:53;
}
Параметр consistent уменьшает объём перераспределения при изменении состава группы. Без него добавление одного узла перенаправляет на новые места почти все ключи, а с ним переезжает только доля, пропорциональная изменению размера группы.
Пассивные проверки состояния узлов и поведение при отказе
Активные проверки, когда балансировщик сам опрашивает узлы, доступны только в коммерческой версии. В открытой работает пассивная схема: сервер считает неудачные попытки и временно исключает узел из оборота.
Параметр max_fails задаёт число неудач в пределах интервала fail_timeout, после которого узел считается недоступным на оставшуюся часть этого интервала. В потоковом контексте считается именно число неудачных попыток установить соединение, а не запросов, поскольку запросов там нет.
upstream app_pool {
least_conn;
server 192.168.0.10:8080 max_fails=3 fail_timeout=15s;
server 192.168.0.11:8080 max_fails=3 fail_timeout=15s;
}
Значения зависят от характера нагрузки. Для коротких соединений подходят интервалы в единицы секунд, чтобы отказавший узел быстро возвращался в оборот. Для длинных сеансов интервал стоит увеличить, иначе клиенты получат серию обрывов.
Поведение при отказе стоит проверить намеренно: остановить узел под нагрузкой и убедиться, что соединения перераспределяются, а время обнаружения отказа соответствует ожиданиям.
Полезный параметр при длинных сеансах, proxy_timeout. Он задаёт предельное время бездействия, после которого соединение закрывается с обеих сторон:
server {
listen 9090;
proxy_pass app_pool;
proxy_connect_timeout 5s;
proxy_timeout 1h;
}
Значение по умолчанию в десять минут подходит не всем. Для интерактивных протоколов без постоянного обмена данными его стоит увеличить, иначе соединения будут обрываться в середине работы.
Терминация TLS, маршрутизация по SNI и передача трафика без расшифровки
Модуль stream поддерживает полный набор возможностей работы с TLS. Простейший случай, терминация на балансировщике:
stream {
server {
listen 443 ssl;
ssl_certificate /etc/angie/ssl/full.crt;
ssl_certificate_key /etc/angie/ssl/priv.key;
proxy_pass 192.168.1.2:80;
}
}
Интереснее случай, когда на одном порту нужно развести трафик между получателями. Помогает предварительное чтение, достающее имя сервера из клиентского приветствия:
map $ssl_preread_server_name $route {
default "";
site.example a;
api.example b;
}
stream {
upstream pool {
server 192.168.0.10:443 sid=a;
server 192.168.0.11:443 sid=b;
sticky route $route;
}
server {
listen 443;
ssl_preread on;
proxy_pass pool;
}
}
Расшифровки при этом не происходит, трафик передаётся как есть, а маршрутизация строится по имени, которое клиент сам указывает в открытом виде. Режим привязки sticky route доступен в открытой версии и работает именно по такому ключу, в отличие от режимов с обучением привязке, которые относятся к коммерческой редакции.
Есть и противоположная задача: передать соединение в HTTP-модуль того же сервера. Для этого существует директива pass:
server {
listen 443 ssl;
server_name site.example;
ssl_certificate /etc/angie/ssl/full.crt;
ssl_certificate_key /etc/angie/ssl/priv.key;
pass 127.0.0.1:8000;
}
Схема позволяет держать один порт снаружи и разводить часть доменов на HTTP внутри того же процесса, а часть на внешние TCP-сервисы.
Когда терминация не нужна вовсе, предварительное чтение всё равно полезно для маршрутизации по версии протокола или по списку протоколов приложения. Соответствующие переменные доступны и могут использоваться в блоке map наравне с именем сервера.
Два параметра таймаутов отвечают за разные вещи, и путать их дорого стоит.
Параметр proxy_connect_timeout ограничивает время установки соединения с узлом. Значение по умолчанию в шестьдесят секунд избыточно: для внутренних сетей разумно от одной до пяти секунд, иначе клиент держит открытое соединение впустую.
Параметр proxy_timeout задаёт предельное время бездействия установленного соединения, по умолчанию десять минут. Для протоколов с длинными паузами значение стоит поднять.
Отдельных параметров для ожидания при передаче данных на узел и при чтении ответа в потоковом модуле нет, и искать их в справочнике не стоит: обе стадии обслуживает один proxy_timeout. Считать его временем бездействия точнее, чем временем передачи, поскольку таймер сбрасывается при любом движении данных в любую сторону, а длинная односторонняя передача с паузами больше заданного значения соединение потеряет.
Ограничение max_conns работает так: при достижении предела соединение передаётся следующему узлу, а когда свободных не остаётся, клиент получает отказ. Такое поведение лучше бесконечного ожидания, но в журнале отказ выглядит короткой записью с кодом 502 без пояснений.
Полезно также задать размер очереди ожидания, если узлы периодически заняты целиком:
upstream db_pool {
server 192.168.0.20:5432 max_conns=50;
server 192.168.0.21:5432 max_conns=50;
queue 32 timeout=3s;
}
Директива queue удерживает соединения в ожидании вместо немедленного отказа. Для баз данных это лучше обрыва, поскольку всплеск подключений обычно короткий.
Ограничения соединений, время ожидания и проверка конфигурации перед применением
Уровень L4 не даёт средств ограничить частоту запросов, поскольку запросов нет, но ограничения по числу соединений работают штатно. Зона объявляется в контексте stream и применяется внутри сервера:
stream {
limit_conn_zone $binary_remote_addr zone=perip:10m;
server {
listen 5432;
proxy_pass db_pool;
limit_conn perip 4;
}
}
Размер зоны задаёт число состояний. Десять мегабайт вмещают сотни тысяч записей, чего хватает для небольшой инфраструктуры.
Перед применением конфигурацию стоит проверить, поскольку ошибка в потоковом блоке приводит к отказу запуска целиком:
angie -t
angie -s reload
Перечислим то, что стоит проверить после запуска:
- Наличие модуля stream в выводе angie -V, поскольку в некоторых сборках он не входит;
- Значение proxy_responses для UDP, потому что без него сеансы живут до таймаута и расходуют ресурсы;
- Параметр reuseport при UDP и при большом числе входящих соединений, так как он распределяет приём между рабочими процессами;
- Настройку журнала, который в потоковом контексте по умолчанию отключён;
- Значения max_fails и fail_timeout на соответствие характеру нагрузки, короткие интервалы для коротких соединений и длинные для сеансов.
Смысл L4-балансировки в веб-сервере в сокращении числа компонентов. Там, где требовался отдельный балансировщик для базы данных и отдельный для HTTP, остаётся один процесс с двумя блоками конфигурации и общим механизмом перезагрузки без обрыва соединений.