Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров7.5K

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

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

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

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

Теги:
Хабы:
+8
Комментарии13

Публикации

Работа

Ближайшие события