Расскажем, как мы сделали отказоустойчивый WireGuard-сервер в Yandex Cloud, раскинув его на три зоны доступности. Получилось просто, надёжно и без сложных кластеров.
Мы не рассматриваем настройку самого WireGuard, конфигурацию групп безопасности, настройку VPC, NLB и прочее. Вся логика сосредоточена на том, чтобы обеспечить автоматическое переключение между зонами при сбоях. Сеть VPN-клиентов — 172.28.90.0/24 — должны быть доступна с любой из трёх зон.
Update 07/04/2025
Обновил function update_route_tables, маршрут к нашей подсети должен быть одинаков во всех трёх зонах, либо использовать одну общую таблицу маршрутизации, тут в зависимости от ваших задач.
Задача
Инфраструктура развёрнута в 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 all zones" # Обновляем маршруты для каждой зоны for ZONE in "${!ROUTE_TABLE_IDS[@]}"; do /root/yandex-cloud/bin/yc vpc route-table update "${ROUTE_TABLE_IDS[$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 $ZONE with internal IP $SELF_INTERNAL_IP" done } 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 07 07:01:50 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:50 UTC 2025: Starting NLB port manager for ru-central1-b (IP: 158.160.zzz.111, weight: 10) Apr 07 07:01:53 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:53 UTC 2025: No higher-priority zones alive — attempting to enable listener Apr 07 07:01:53 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:53 UTC 2025: Starting port listener on 8080 Apr 07 07:01:55 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:55 UTC 2025: Listener started successfully Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:58 UTC 2025: Updating route tables for all zones Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: id: enp0bba8apijqq5vg0bf Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: folder_id: b1gbit9aq58oi0rg8l83 Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: created_at: "2025-03-31T14:22:25Z" Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: name: playground-a Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: network_id: enp0r2dk0s00i4ph2l50 Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: static_routes: Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: - destination_prefix: 172.28.90.0/24 Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: next_hop_address: 192.168.176.4 Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: - destination_prefix: 0.0.0.0/0 Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7603]: gateway_id: enpkq1j24hveb8efk009 Apr 07 07:01:58 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:58 UTC 2025: Updated route table for ru-central1-a with internal IP 192.168.176.4 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: id: enp51tlttf5kgmbhv0mm Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: folder_id: b1gbit9aq58oi0rg8l83 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: created_at: "2025-03-31T14:22:33Z" Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: name: playground-b Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: network_id: enp0r2dk0s00i4ph2l50 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: static_routes: Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: - destination_prefix: 172.28.90.0/24 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: next_hop_address: 192.168.176.4 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: - destination_prefix: 0.0.0.0/0 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7610]: gateway_id: enpkq1j24hveb8efk009 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:59 UTC 2025: Updated route table for ru-central1-b with internal IP 192.168.176.4 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: id: enp0aunn7ov103747ilt Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: folder_id: b1gbit9aq58oi0rg8l83 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: created_at: "2025-03-31T14:22:42Z" Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: name: playground-d Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: network_id: enp0r2dk0s00i4ph2l50 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: static_routes: Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: - destination_prefix: 172.28.90.0/24 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: next_hop_address: 192.168.176.4 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: - destination_prefix: 0.0.0.0/0 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7617]: gateway_id: enpkq1j24hveb8efk009 Apr 07 07:01:59 test-wg-net-b nlb-priority.sh[7586]: Mon Apr 7 07:01:59 UTC 2025: Updated route table for ru-central1-d 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. Подобный подход можно адаптировать для других сервисов, где требуется строгий контроль доступности в разных зонах, обеспечивая отказоустойчивость без необходимости использования сложных кластерных технологий.
