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

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

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

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

Как выбрать способ и что проверить перед внедрением

Перечислим то, что стоит проверить до внедрения и учесть при выборе способа:

  1. Наличие директивы zone в каждой группе, которая должна меняться во время работы, поскольку без общей памяти динамика невозможна;
  2. Время жизни записей в DNS и значение параметра valid, потому что их сумма определяет задержку реакции на изменение состава;
  3. Доступность сервера имён, так как при его отказе группа перестаёт обновляться и продолжает работать по устаревшему списку;
  4. Права на сокет контейнерной среды, дающие фактически контроль над хостом, и осознанность этого решения на разделяемой машине;
  5. Ограничение доступа к интерфейсу управления локальным адресом или отдельной сетью без исключений.

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

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, распределение времени ответа и скорость ввода нового узла в оборот. Если узел получает полную нагрузку мгновенно при пустых кэшах, часть запросов замедлится, и лечится это плавным вводом или прогревом.

Итог перехода на динамические группы ощущается не в производительности, а в процессе. Развёртывание перестаёт требовать правки конфигурации, состав группы описывается там же, где описаны узлы, а расхождение между текстом конфигурации и реальностью исчезает.