Веб-сервер воспринимают как инструмент для 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

Перечислим то, что стоит проверить после запуска:

  1. Наличие модуля stream в выводе angie -V, поскольку в некоторых сборках он не входит;
  2. Значение proxy_responses для UDP, потому что без него сеансы живут до таймаута и расходуют ресурсы;
  3. Параметр reuseport при UDP и при большом числе входящих соединений, так как он распределяет приём между рабочими процессами;
  4. Настройку журнала, который в потоковом контексте по умолчанию отключён;
  5. Значения max_fails и fail_timeout на соответствие характеру нагрузки, короткие интервалы для коротких соединений и длинные для сеансов.

Смысл L4-балансировки в веб-сервере в сокращении числа компонентов. Там, где требовался отдельный балансировщик для базы данных и отдельный для HTTP, остаётся один процесс с двумя блоками конфигурации и общим механизмом перезагрузки без обрыва соединений.