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