Каждое изменение состава группы проксируемых серверов в обычной схеме требует правки конфигурации и её перечитывания. Перечитывание проходит мягко, без обрыва соединений, но ручное действие делает процесс медленным и ошибкоопасным. В контейнерной среде узлы появляются и исчезают десятки раз в день. В Angie есть четыре способа менять состав группы во время работы, и выбирают их по тому, где живут узлы.
Общая память группы и почему без неё динамика невозможна
Отправная точка одна: группа должна быть объявлена с зоной разделяемой памяти. Без неё состав вычисляется на этапе разбора конфигурации и хранится в памяти каждого рабочего процесса отдельно, то есть изменить его без перечитывания нельзя.
http {
upstream backend {
zone backend-zone 64k;
server 192.168.0.10:8080;
server 192.168.0.11:8080;
}
}
Директива zone задаёт имя и размер области с состоянием группы: список узлов, их параметры, счётчики неудач и данные балансировки. Размер 64 килобайта покрывает несколько десятков узлов.
Состояние живёт в общей памяти, а не в тексте конфигурации, поэтому все четыре динамических способа требуют этой директивы, а попытка применить их без неё заканчивается ошибкой при проверке.
Изменения не переживают перезапуск, если их не сохранил источник. Состав группы после запуска восстанавливается из первоисточника: записей DNS, меток контейнеров или конфигурации.
Разрешение имён во время работы и контроль времени жизни записей
Самый способ проще прочих: указать в группе имя вместо адреса и разрешить серверу обновлять его во время работы.
http {
resolver 127.0.0.53 valid=30s ipv6=off status_zone=local;
upstream backend {
zone backend-zone 64k;
server back.example.ru resolve;
}
server {
listen 80;
location / {
proxy_pass http://backend;
}
}
}
Параметр resolve у директивы server включает периодическое разрешение имени. По умолчанию сервер преобразует имя в адрес один раз и дальше работает с полученным списком, а с этим параметром список обновляется.
Директива resolver обязательна, и в ней важен параметр valid. Он задаёт время повторного запроса записи и переопределяет значение из ответа сервера имён. Разумны десятки секунд: меньше создаёт лишний трафик, больше затягивает реакцию.
Параметр ipv6=off отключает запрос записей AAAA, без него при каждом обновлении выполняется два запроса. Параметр status_zone включает учёт запросов в мониторинге.
Сервер имён становится критичным компонентом: пока он недоступен, состав группы не обновляется. Несколько адресов в директиве resolver дают опрос по кругу, но разумнее использовать локальный кэширующий преобразователь.
За одним именем может скрываться группа адресов, поскольку записей A допустимо несколько. Недостаток в том, что все узлы получают одинаковые параметры: ни вес, ни признак запасного задать нельзя.
Записи SRV как способ передать вес и признак запасного узла
Записи типа SRV решают ограничение предыдущего способа, поскольку содержат три числовых поля помимо адреса.
Фрагмент зоны в формате BIND выглядит так:
localback IN A 127.0.0.1
_http._tcp.back IN SRV 10 1 9001 localback.local.lan.
_http._tcp.back IN SRV 10 2 9002 localback.local.lan.
_http._tcp.back IN SRV 20 1 9003 localback.local.lan.
_http._tcp.back IN SRV 20 2 9004 localback.local.lan.
Первое число это приоритет записи, влияющий на статус узла: минимальный приоритет соответствует основным серверам, остальные становятся запасными. Второе число используется как вес, третье задаёт порт.
В конфигурации группы добавляется указание имени службы:
upstream backend {
zone backend-zone 64k;
server back.local.lan resolve service=http;
}
Две записи с приоритетом 10 становятся рабочими узлами с весами 1 и 2, а две с приоритетом 20 переходят в запасные. Одним изменением в зоне перераспределяют нагрузку и выводят узел из оборота, не трогая конфигурацию сервера.
Подход хорошо ложится на инфраструктуру, где состав узлов описан в DNS. Минус один: задержка распространения изменений определяется временем жизни записи и параметром valid.
Сбор группы из меток контейнеров через Docker API
Третий способ рассчитан на контейнерную среду. Angie опрашивает службу управления контейнерами и строит список узлов по меткам.
Подключение выполняется одной директивой в контексте http:
http {
docker_endpoint unix:/var/run/docker.sock;
upstream u {
zone u-zone 64k;
}
}
В блоке upstream нет ни одной директивы server, состав группы целиком определяется метками работающих контейнеров.
Пользователю, от которого работает сервер, нужен доступ к сокету. Обычно вопрос решается добавлением в группу, и последствия стоит осознавать: доступ к сокету контейнерной среды равен полному контролю над хостом.
Метки задаются в описании сервиса:
services:
app-a:
image: registry.local/app:1.4
labels:
- "angie.http.upstreams.u.port=8080"
- "angie.network=compose_app-network"
- "angie.http.upstreams.u.weight=2"
- "angie.http.upstreams.u.max_fails=3"
networks:
- app-network
restart: unless-stopped
app-b:
image: registry.local/app:1.4
labels:
- "angie.http.upstreams.u.port=8080"
- "angie.network=compose_app-network"
- "angie.http.upstreams.u.weight=1"
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge
Первая метка указывает внутренний порт контейнера. Вторая задаёт имя сети с префиксом каталога проекта, поэтому указывают полное имя, а не короткое из файла описания. Третья и четвёртая повторяют параметры директивы server.
Проверить полученное имя сети легко:
docker network ls
docker inspect app-a --format '{{json .NetworkSettings.Networks}}'
Узел появляется в группе в момент запуска контейнера и исчезает при его остановке. Развёртывание новой версии сводится к запуску контейнера с метками.
Плавный вывод узла из оборота и прогрев нового
Динамический состав группы порождает задачу, которой нет в статичной конфигурации: как убрать узел, не оборвав соединения клиентов, которые на нём уже работают. Немедленное исключение из списка приводит к тому, что установленные соединения закрываются, и для долгих операций вроде загрузки большого файла это заметно.
Правильный подход состоит из двух шагов. Сначала узел переводят в состояние, где он перестаёт принимать новые соединения, но продолжает обслуживать существующие. После того как счётчик активных соединений на нём обнуляется, узел удаляют из группы. Режим принудительного перевода доступен в коммерческой редакции, а в открытой схеме тот же результат достигается уменьшением веса до нуля или исключением имени из записей DNS с последующим ожиданием времени жизни записи.
Зеркальная задача возникает при добавлении узла. Если новый процесс получает полную долю трафика в первую же секунду, а его собственные кэши пусты и соединение с базой не прогрето, время ответа на этой части запросов вырастет. Плавный ввод решает задачу: доля узла нарастает от нуля до штатной за заданное время.
Проверить текущее распределение помогает наблюдение за счётчиками:
watch -n2 "curl -s http://127.0.0.1:8080/api/http/upstreams/backend | python3 -m json.tool | grep -A3 '"peers"'"
Пара цифр, за которыми стоит следить, это количество активных соединений и число неудачных попыток на каждом узле. Растущее число неудач на одном узле при их отсутствии на других почти всегда указывает на проблему самого узла, а не балансировщика.
Изменение состава через интерфейс управления и защита доступа
Четвёртый способ, прямое управление через HTTP-интерфейс. Он включается отдельной директивой и открывается в защищённой локации:
server {
listen 127.0.0.1:8080;
location /api {
api /api;
allow 127.0.0.1;
deny all;
}
}
Интерфейс отдаёт сведения в формате JSON и содержит раздел управления конфигурацией, где состав группы меняется запросами PUT, PATCH и DELETE. Чтение доступно всегда, изменение требует явного включения.
Получить текущий состав группы:
curl -s http://127.0.0.1:8080/api/http/upstreams/backend | python3 -m json.tool
Привязка к локальному адресу и явный запрет остального не формальность. Интерфейс позволяет перенаправлять трафик куда угодно, поэтому доступ к нему извне недопустим.
Интерфейс полезен и для наблюдения: в нём видны счётчики по узлам, состояния зон памяти, статистика запросов и сведения о преобразованиях имён. При разборе вопроса, почему трафик уходит не на тот узел, это первый инструмент.
Выбор между способами определяется не вкусом, а местом, где живёт источник правды о составе группы. Если узлы описаны в DNS, берут разрешение имён или записи SRV. Если узлы это контейнеры, берут метки. Если состав меняется внешней системой управления контейнерами, берут интерфейс управления. Смешивать способы в одной группе не стоит, поскольку при конфликте источников поведение становится трудно предсказуемым.
Сценарий, который продумывают заранее: источник сведений о составе группы стал недоступен. Последствия зависят от способа.
При сбое преобразователя сервер продолжает работать с последним полученным списком до истечения срока из параметра valid. Срок не стоит делать коротким: десятки секунд дают запас на кратковременный сбой.
При работе через метки исчезновение контейнера означает его остановку, и это ожидаемо. Но потерю связи с сокетом стоит проверить намеренно, остановив службу управления контейнерами и посмотрев, продолжит ли группа обслуживать трафик.
При управлении через интерфейс состав после перезапуска восстанавливается из конфигурации. Узлы, добавленные только запросами, пропадут, поэтому изменения должны дублироваться в конфигурации или вноситься внешней системой при каждом запуске.
Правило простое: у конфигурации должен оставаться минимум, с которым сервер работает после холодного запуска. Полностью пустая группа означает ошибку на любой запрос.
Как выбрать способ и что проверить перед внедрением
Перечислим то, что стоит проверить до внедрения и учесть при выборе способа:
- Наличие директивы zone в каждой группе, которая должна меняться во время работы, поскольку без общей памяти динамика невозможна;
- Время жизни записей в DNS и значение параметра valid, потому что их сумма определяет задержку реакции на изменение состава;
- Доступность сервера имён, так как при его отказе группа перестаёт обновляться и продолжает работать по устаревшему списку;
- Права на сокет контейнерной среды, дающие фактически контроль над хостом, и осознанность этого решения на разделяемой машине;
- Ограничение доступа к интерфейсу управления локальным адресом или отдельной сетью без исключений.
Отдельного разговора заслуживает наблюдение за составом группы. При динамическом обновлении полезно видеть, какие узлы в группе прямо сейчас, а не предполагать это по тексту конфигурации. Помимо интерфейса управления помогает журнал:
logread 2>/dev/null || journalctl -u angie -n 50 --no-pager
Для DNS стоит проверить, что сервер отдаёт обновлённый список с ожидаемым временем жизни:
dig +short A back.example.ru
dig +short SRV _http._tcp.back.local.lan
Для контейнерного способа проверяют, что метки читаются в ожидаемом виде, поскольку опечатка в имени сети не даёт ошибки, а оставляет контейнер невидимым:
docker inspect app-a --format '{{range $k,$v := .Config.Labels}}{{$k}}={{$v}}
{{end}}'
Симптом характерный: контейнер работает, служба отвечает, а группа пуста и запросы завершаются ошибкой 502. Причина почти всегда в несовпадении имени сети или в отсутствии доступа к сокету.
Поведение при изменении состава стоит проверить под нагрузкой, а не в спокойном состоянии. Простой тест запускает непрерывный поток запросов, после чего один узел останавливается и запускается новый:
ab -n 100000 -c 20 http://127.0.0.1/ > /tmp/ab.log &
docker compose stop app-b
docker compose up -d app-c
Интересуют три величины: число failed requests, распределение времени ответа и скорость ввода нового узла в оборот. Если узел получает полную нагрузку мгновенно при пустых кэшах, часть запросов замедлится, и лечится это плавным вводом или прогревом.
Итог перехода на динамические группы ощущается не в производительности, а в процессе. Развёртывание перестаёт требовать правки конфигурации, состав группы описывается там же, где описаны узлы, а расхождение между текстом конфигурации и реальностью исчезает.