Как мы реализовали отказоустойчивый WireGuard в трёх зонах Yandex Cloud

от автора

Расскажем, как мы сделали отказоустойчивый 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

От слова к действию

Что нужно для запуска

  1. Установить YC CLI на все ВМ.

  2. Привязать сервисный аккаунт с правами vpc.admin и vpc.gateways.user

  3. Аутентифицируйтесь от имени сервисного аккаунта изнутри виртуальной машины

Логика работы скрипта

  1. Каждая ВМ проверяет, живы ли зоны с более высоким приоритетом (через TCP-порт 8080).

  2. Если такая зона найдена — listener отключается.

  3. Если приоритетные зоны недоступны:

    • текущая ВМ запускает 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *