Расскажем, как мы сделали отказоустойчивый WireGuard-сервер в Yandex Cloud, раскинув его на три зоны доступности. Получилось просто, надёжно и без сложных кластеров.
Мы не рассматриваем настройку самого WireGuard, конфигурацию групп безопасности, настройку VPC, NLB и прочее. Вся логика сосредоточена на том, чтобы обеспечить автоматическое переключение между зонами при сбоях. Сеть VPN-клиентов — 172.28.90.0/24 — должны быть доступна с любой из трёх зон.
Задача
Инфраструктура развёрнута в 3 зонах Yandex Cloud: A, B, D. Все три зоны содержат идентичные виртуальные машины с настроенным WireGuard. У ВМ одна виртуальная подсеть — 172.28.90.0/24
Важно не только, чтобы VPN-клиенты могли достучаться до инфраструктуры, но и чтобы сама инфраструктура могла достучаться до клиентов. То есть, трафик должен быть двусторонним: от клиента — к инфраструктуре, и обратно. Поэтому при активации другой зоны нужно изменить маршрут 172.28.90.0/24 — чтобы он указывал на активный WireGuard-сервер.
Цель
При падении одной из зон (B, A, D) подключение должно автоматически переключиться на другую зону.
NLB Яндекса не поддерживает указание весов или приоритетов целевых хостов — он работает в режиме Round Robin. Более того, NLB не умеет динамически менять таблицу маршрутизации в зависимости от того, какие хосты сейчас живы — это можно реализовать только вручную или с помощью дополнительной логики.
Но нам нужна строгая логика приоритетов:
-
если жива зона B — всё идёт туда
-
если нет — переключаемся на A, затем на D
Это побудило нас создать отдельную логику в виде bash-скрипта.
Реализация
Архитектура
-
В каждой зоне развёрнут идентичный WireGuard-сервер.
-
NLB пробрасывает 51820/UDP на все три хоста.
-
Дополнительно открыт 8080/TCP — для healthcheck. В целевой группе NLB Яндекса настроена проверка доступности именно по этому порту.
-
Маршрут 172.28.90.0/24 должен указывать на текущую зону.
Описание лабораторной среды:
Виртуальные машины:
test-wg-net-b — зона ru-central1-b — внутренний IP: 192.168.176.4 — внешний IP: 158.160.zzz.111
test-wg-net-a — зона ru-central1-a — внутренний IP: 192.168.175.4 — внешний IP: 89.169.zzz.222
test-wg-net-d — зона ru-central1-d — внутренний IP: 192.168.177.4 — внешний IP: 158.160.zzz.333
Таблицы маршрутов:
-
playground-b: ID enp51tlttf5kgmbhv0mm — связана с зоной B
-
playground-a: ID enp0bba8apijqq5vg0bf — связана с зоной A
-
playground-d: ID enp0aunn7ov103747ilt — связана с зоной D
Шлюз:
-
gateway-id: enpkq1j24hveb8efk009
От слова к действию
Что нужно для запуска
-
Установить YC CLI на все ВМ.
-
Привязать сервисный аккаунт с правами vpc.admin и vpc.gateways.user
-
Аутентифицируйтесь от имени сервисного аккаунта изнутри виртуальной машины
Логика работы скрипта
-
Каждая ВМ проверяет, живы ли зоны с более высоким приоритетом (через TCP-порт 8080).
-
Если такая зона найдена — listener отключается.
-
Если приоритетные зоны недоступны:
-
текущая ВМ запускает listener
-
обновляет маршруты в таблицах
-
остаётся активной до тех пор, пока не появится более приоритетная зона
-
Схема выглядит следующим образом:
Бесконечный цикл проверок: │ ├──► Проверка живых зон выше приоритетом │ ├── Есть живые?───► Отключаем listener, если активен │ └── Нет живых? ───► Запускаем listener │ ├── Запуск успешен? ───► Обновляем маршруты │ │ ├─ Если обновление успешно → listener активен │ │ └─ Если обновление отменено → отключаем listener │ └── Запуск провален? ──► Ждём следующей проверки │ └──► Ждём 5 секунд (`CHECK_INTERVAL`) и повторяем цикл
И сам скрипт с системным инитом:
Скрытый текст
#!/bin/bash PORT=8080 CHECK_INTERVAL=5 STABILITY_CHECKS=3 ZONE="ru-central1-b" # Укажи зону в которой находится vm: ru-central1-b, ru-central1-a, ru-central1-d declare -A ZONE_IPS=( ["ru-central1-b"]="158.160.zzz.111" ["ru-central1-a"]="89.169.zzz.222" ["ru-central1-d"]="158.160.zzz.333" ) declare -A ZONE_WEIGHTS=( ["ru-central1-b"]=10 ["ru-central1-a"]=20 ["ru-central1-d"]=30 ) declare -A ZONE_INTERNAL_IPS=( ["ru-central1-b"]="192.168.176.4" ["ru-central1-a"]="192.168.175.4" ["ru-central1-d"]="192.168.177.4" ) declare -A ROUTE_TABLE_IDS=( ["ru-central1-b"]="enp51tlttf5kgmbhv0mm" ["ru-central1-a"]="enp0bba8apijqq5vg0bf" ["ru-central1-d"]="enp0aunn7ov103747ilt" ) CURRENT_LISTENER_STATE="down" SELF_ZONE="$ZONE" SELF_IP="${ZONE_IPS[$SELF_ZONE]}" SELF_INTERNAL_IP="${ZONE_INTERNAL_IPS[$SELF_ZONE]}" SELF_WEIGHT="${ZONE_WEIGHTS[$SELF_ZONE]}" GATEWAY_ID="enpkq1j24hveb8efk009" if [[ -z "$SELF_IP" || -z "$SELF_WEIGHT" ]]; then echo "Unknown or undefined zone: $SELF_ZONE" exit 1 fi echo "$(date): Starting NLB port manager for $SELF_ZONE (IP: $SELF_IP, weight: $SELF_WEIGHT)" function higher_priority_alive { local success_count=0 for ((i=0; i<STABILITY_CHECKS; i++)); do for ZONE in "${!ZONE_IPS[@]}"; do OTHER_IP="${ZONE_IPS[$ZONE]}" OTHER_WEIGHT="${ZONE_WEIGHTS[$ZONE]}" if [[ "$ZONE" == "$SELF_ZONE" ]]; then continue; fi if (( OTHER_WEIGHT < SELF_WEIGHT )); then nc -z -w 2 "$OTHER_IP" "$PORT" > /dev/null 2>&1 if [[ $? -eq 0 ]]; then ((success_count++)) break 2 fi fi done sleep 1 done if (( success_count == 0 )); then return 1 else return 0 fi } function update_route_tables { # Самая важная дополнительная проверка: if higher_priority_alive; then echo "$(date): Higher-priority zone alive before updating routes. Aborting route update." return 1 fi echo "$(date): Updating route tables for $SELF_ZONE" for ZONE in "${!ZONE_IPS[@]}"; do if [[ "$ZONE" != "$SELF_ZONE" ]]; then /root/yandex-cloud/bin/yc vpc route-table update "${ROUTE_TABLE_IDS[$ZONE]}" --route destination=0.0.0.0/0,gateway-id=$GATEWAY_ID echo "$(date): Cleared route table for $ZONE" fi done /root/yandex-cloud/bin/yc vpc route-table update "${ROUTE_TABLE_IDS[$SELF_ZONE]}" \ --route destination=172.28.90.0/24,next-hop=$SELF_INTERNAL_IP \ --route destination=0.0.0.0/0,gateway-id=$GATEWAY_ID echo "$(date): Updated route table for $SELF_ZONE with internal IP $SELF_INTERNAL_IP" } function ensure_listener_running { if ! pgrep -f "nc -lk -p $PORT" > /dev/null; then echo "$(date): Starting port listener on $PORT" nohup nc -lk -p "$PORT" > /dev/null 2>&1 & sleep 2 if nc -z localhost "$PORT"; then echo "$(date): Listener started successfully" return 0 else echo "$(date): Failed to start listener" return 1 fi fi } function ensure_listener_stopped { if pgrep -f "nc -lk -p $PORT" > /dev/null; then echo "$(date): Stopping port listener on $PORT" pkill -f "nc -lk -p $PORT" fi } while true; do if higher_priority_alive; then if [ "$CURRENT_LISTENER_STATE" == "up" ]; then echo "$(date): Higher-priority zone detected — disabling listener" ensure_listener_stopped CURRENT_LISTENER_STATE="down" fi else if [ "$CURRENT_LISTENER_STATE" == "down" ]; then echo "$(date): No higher-priority zones alive — attempting to enable listener" if ensure_listener_running; then if update_route_tables; then CURRENT_LISTENER_STATE="up" else echo "$(date): Route update aborted, stopping listener" ensure_listener_stopped CURRENT_LISTENER_STATE="down" fi else echo "$(date): Listener start failed." fi fi fi sleep "$CHECK_INTERVAL" done
Сохраним его и сделаем исполняемым по пути /usr/local/bin/nlb-priority.sh
Создаем системный инит по пути /etc/systemd/system/nlb-priority.service
[Unit] Description=NLB Port Priority Manager After=network.target [Service] Type=simple ExecStart=/usr/local/bin/nlb-priority.sh Restart=always RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable nlb-priority.service sudo systemctl restart nlb-priority.service sudo systemctl status nlb-priority.service
Поведение при переключении
-
Нормальная работа:
-
Зона B является приоритетной, и при её активном состоянии listener запущен.
-
NLB, обнаружив активный healthcheck на порту 8080, направляет трафик к зоне B.
-
-
При отказе зоны B:
-
Listener в зоне B останавливается, и NLB удаляет её из целевой группы.
-
Одна из оставшихся зон (A или D) захватывает инициативу: поднимает listener и обновляет маршруты так, чтобы трафик VPN клиентов направлялся к ней.
-
Остальные зоны видят, что активна другая зона, и остаются в режиме ожидания.
-
Как это выглядит:
Скрытый текст
Apr 04 09:50:48 test-wg-net-b systemd[1]: Started NLB Port Priority Manager. Apr 04 09:50:48 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:48 UTC 2025: Starting NLB port manager for ru-central1-b (IP: 158.160.zzz.111, weight: 10) Apr 04 09:50:51 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:51 UTC 2025: No higher-priority zones alive — attempting to enable listener Apr 04 09:50:51 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:51 UTC 2025: Starting port listener on 8080 Apr 04 09:50:53 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:53 UTC 2025: Listener started successfully Apr 04 09:50:56 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:56 UTC 2025: Updating route tables for ru-central1-b Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: id: enp0bba8apijqq5vg0bf Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: folder_id: b1gbit9aq58oi0rg8l83 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: created_at: "2025-03-31T14:22:25Z" Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: name: playground-a Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: network_id: enp0r2dk0s00i4ph2l50 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: static_routes: Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: - destination_prefix: 0.0.0.0/0 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34483]: gateway_id: enpkq1j24hveb8efk009 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:57 UTC 2025: Cleared route table for ru-central1-a Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: id: enp0aunn7ov103747ilt Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: folder_id: b1gbit9aq58oi0rg8l83 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: created_at: "2025-03-31T14:22:42Z" Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: name: playground-d Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: network_id: enp0r2dk0s00i4ph2l50 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: static_routes: Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: - destination_prefix: 0.0.0.0/0 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34491]: gateway_id: enpkq1j24hveb8efk009 Apr 04 09:50:57 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:57 UTC 2025: Cleared route table for ru-central1-d Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: id: enp51tlttf5kgmbhv0mm Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: folder_id: b1gbit9aq58oi0rg8l83 Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: created_at: "2025-03-31T14:22:33Z" Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: name: playground-b Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: network_id: enp0r2dk0s00i4ph2l50 Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: static_routes: Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: - destination_prefix: 172.28.90.0/24 Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: next_hop_address: 192.168.176.4 Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: - destination_prefix: 0.0.0.0/0 Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34499]: gateway_id: enpkq1j24hveb8efk009 Apr 04 09:50:58 test-wg-net-b nlb-priority.sh[34463]: Fri Apr 4 09:50:58 UTC 2025: Updated route table for ru-central1-b with internal IP 192.168.176.4


Результаты и выводы
-
Отказоустойчивость:
-
Всегда активна только одна зона, что гарантирует корректность маршрутов к VPN-клиентам.
-
Использование healthcheck NLB позволяет оперативно реагировать на сбои.
-
-
Надёжность и автоматизация:
-
Решение обеспечивает автоматическое восстановление и переключение без внешнего управляющего компонента.
-
Повторные проверки предотвращают одновременную активацию нескольких зон, что исключает возможность возникновения SPOF (single point of failure).
-
Можно ли назвать это полноценным NLB?
Данное решение использует NLB в качестве транспортного механизма для проброса портов и healthcheck, однако основная логика маршрутизации и приоритетного переключения реализована в пользовательском скрипте. Таким образом, это не полноценный NLB в традиционном понимании, а гибридное решение, которое комбинирует возможности NLB с дополнительной логикой для строгого контроля доступности по зонам.
Заключение
Простой bash-скрипт с системным сервисом решает задачу отказоустойчивого WireGuard-сервера даже с учётом ограничений NLB по приоритетам. Это лёгкое и надёжное решение позволяет автоматически переключать маршруты и балансировать трафик между зонами в Yandex Cloud. Подобный подход можно адаптировать для других сервисов, где требуется строгий контроль доступности в разных зонах, обеспечивая отказоустойчивость без необходимости использования сложных кластерных технологий.
ссылка на оригинал статьи https://habr.com/ru/articles/897526/
Добавить комментарий