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