Назад к блогу

Active-Active кластер HAProxy с DNS-фейловером и Python-дашбордом

Проектирование и реализация Active-Active кластера HAProxy с Python-дашбордом для мониторинга и управления — zero downtime и полный контроль.

Active-Active кластер HAProxy с DNS-фейловером и Python-дашбордом

Единственный HAProxy, через который шёл весь трафик нескольких проектов, отказал после неудачного обновления конфига. Все домены за ним легли. Я правил конфигурацию, перезапускал контейнер, а в Telegram летели алерты. Минут двадцать даунтайма — и решение: нужна отказоустойчивость. Так началась работа над FxDNSC — Active-Active кластером HAProxy с панелью управления.

Боль: один reverse proxy = одна точка отказа

Классическая схема — один HAProxy перед бэкендами. Работает прекрасно, пока работает. Но стоит ему упасть — и ложится всё. Можно настроить мониторинг, получать алерты, выстроить быструю процедуру восстановления. Но пока ты проснулся, подключился по SSH, нашёл проблему и починил — прошло 10-20 минут. Для пет-проектов это допустимо. Для продакшена, где за балансировщиком живут несколько сервисов с реальными пользователями, — нет.

Мне нужно было решение, при котором отказ одного нода не приводит ни к секунде простоя для конечных пользователей.

Почему Active-Active, а не Active-Passive

Active-Passive — стандартный подход: один нод работает, второй спит и ждёт своего часа. Keepalived + VRRP, плавающий виртуальный IP. Просто, понятно, проверено. Но у меня две проблемы с этой схемой.

Первая — ресурсы. В Active-Passive половина железа простаивает. Я плачу за два VPS, а трафик обслуживает только один. Для инфраструктуры, где считают каждый рубль, это расточительство.

Вторая — топология. Мои серверы стоят в разных дата-центрах с разными публичными IP. Никакого общего L2-сегмента, никакого broadcast-домена для VRRP. Keepalived с плавающим VIP здесь физически не работает.

Решение — Active-Active на DNS Round-Robin. Оба IP прописаны в A-записях каждого домена. Оба HAProxy принимают трафик одновременно, распределяя нагрузку между собой. Если один падает — DNS-фейловер автоматически убирает его IP из записей через API хостера. Когда нод восстанавливается — IP возвращается обратно.

Архитектура: HAProxy pair + DNS failover + WebSocket sync

Каждый нод — это Docker Compose стек из четырёх сервисов: init, haproxy, certbot и dashboard.

Init-контейнер на Alpine стартует первым. Его задача — шаблонизация конфигов. В haproxy.cfg зашиты плейсхолдеры __NODE_NAME__, __NODE_IP__, __PEER_NAME__, __PEER_IP__, которые init подставляет из переменных окружения. Важный момент: при повторных запусках init не перезаписывает существующий конфиг — он сохраняет динамически добавленные через Data Plane API бэкенды.

HAProxy 2.9 работает в network_mode: host — ему нужен прямой доступ к сетевому стеку для привязки к публичному IP. Конфигурация разделена на несколько фронтендов: порт 80 для HTTP-to-HTTPS редиректа и ACME-челленджей, порт 443 в TCP-режиме для SNI-инспекции и SSL passthrough, порт 8443 на loopback для SSL-терминации, порт 8404 для страницы статистики, и Data Plane API на порту 5555 для программного управления конфигурацией.

Синхронизация между нодами работает на двух уровнях. На уровне HAProxy — peers mycluster синхронизирует stick-tables для rate limiting. На уровне приложения — WebSocket-соединение между дашбордами обменивается состоянием: домены, серверы, TCP-сервисы, SSL-сертификаты. Конфликты разрешаются по updated_at — побеждает более свежая запись. Удаления отслеживаются через tombstones (SyncDeletion) с ретеншеном 7 дней, чтобы удалённая на одном ноде сущность не воскресла при следующей синхронизации.

Python-дашборд: почему custom, а не Grafana

HAProxy имеет встроенную страницу статистики. Но она read-only. Чтобы добавить новый домен, мне нужно было SSH на сервер, отредактировать конфиг, проверить его через haproxy -c, перезагрузить процесс, а потом повторить всё на втором ноде. Два сервера — двойная работа, двойной шанс на ошибку.

Grafana и коммерческие решения решают задачу мониторинга, но не управления. Мне нужен был единый интерфейс, где можно добавить домен за пару кликов, управлять SSL-сертификатами, видеть health checks обоих нодов в реальном времени, настроить TCP-балансировку для Redis или MySQL — и чтобы все изменения мгновенно синхронизировались между серверами. Ничего готового под эту задачу я не нашёл.

Стек получился лёгким: FastAPI с SQLAlchemy 2.0 async и aiosqlite на бэкенде, React 19 с TypeScript и TailwindCSS на фронте. Бэкенд управляет HAProxy через Data Plane API v3 — все изменения конфигурации идут через транзакции:

async def begin_transaction(self) -> str:
    version = await self.get_configuration_version()
    async with self._client() as c:
        r = await c.post("/services/haproxy/transactions",
                         params={"version": version})
        r.raise_for_status()
        return r.json()["id"]

async def commit_transaction(self, txn_id: str) -> dict:
    async with self._client() as c:
        r = await c.put(f"/services/haproxy/transactions/{txn_id}")
        r.raise_for_status()
        return r.json()

Транзакционность — ключевое преимущество. Раньше опечатка в haproxy.cfg означала даунтайм. Теперь Data Plane API валидирует конфиг до применения: если невалидно — HAProxy продолжает работать со старым конфигом, транзакция откатывается.

Docker deployment strategy

Деплой организован через GitHub Actions. Push в main запускает пайплайн: бэкап SQLite-базы с обоих серверов через docker cp, ротация старых бэкапов (храню последние 5), последовательный деплой — сначала первый нод, health check, потом второй, health check. Если health check не проходит — автоматический откат: git checkout на предыдущий коммит, восстановление базы из бэкапа, перезапуск контейнера.

По сути это канареечный деплой: если первый нод сломался при обновлении, второй продолжает обслуживать весь трафик. DNS-фейловер автоматически уберёт упавший нод из ротации, а rollback-шаг в CI попытается его восстановить.

Контейнеры монтируют именованные Docker volumes: haproxy-config для конфигов, dashboard-data для SQLite, letsencrypt для сертификатов, haproxy-sock для Unix-сокета Runtime API. Это позволяет пересоздавать контейнеры без потери состояния.

Мониторинг: какие метрики важны

Я выделил три категории метрик, которые реально помогают в эксплуатации.

Здоровье бэкендов — самое важное. Дашборд опрашивает HAProxy Runtime API через Unix-сокет и показывает статус health check каждого сервера за каждым доменом. Причём агрегированно: через WebSocket-канал приходит статистика с peer-ноды, и я вижу состояние бэкендов на обоих серверах в одном интерфейсе.

Статистика запросов. Отдельная stick-table bk_request_monitor трекает запросы по комбинации Host + URL, собирая http_req_cnt, http_req_rate, bytes_in_cnt, bytes_out_cnt. Фоновый коллектор (request_stats_collector.py) каждые 30 секунд считывает эти данные и агрегирует в RequestStat с часовой гранулярностью. Ретеншен — 30 дней. Этого достаточно, чтобы видеть тренды, находить аномалии и понимать, какие эндпоинты нагружены.

Failover-события. Модуль dns_failover.py каждые 15 секунд проверяет доступность peer-ноды через http://{PEER_IP}:8404/stats. Три неудачных проверки подряд (45 секунд) — запускается failover: IP упавшего пира удаляется из DNS-записей через Beget API, Telegram-бот отправляет уведомление. При восстановлении — обратный процесс. Каждое событие записывается в DnsFailoverEvent с типом, IP-адресами и статусом. Порог в 3 проверки — защита от ложных срабатываний при кратковременных сетевых проблемах между дата-центрами.

Результаты

За несколько месяцев эксплуатации FxDNSC обслуживает несколько продакшен-проектов. Цифры, которые я могу назвать:

  • Время failover: от обнаружения проблемы до переключения DNS — 45-60 секунд. Это не мгновенно, как с Keepalived VIP, но для DNS-based подхода с серверами в разных дата-центрах — приемлемо. Пользователи с кэшированными DNS-записями ощущают перерыв, но TTL выставлен на 300 секунд — большинство клиентов переключаются достаточно быстро. Не уверен, что порог в 3 проверки оптимален — возможно, стоит снизить до 2, но пока ложных срабатываний не было и трогать не хочется.
  • Время деплоя: полный пайплайн — бэкап, деплой на оба нода, health checks — укладывается в 3-4 минуты. За это время ни один запрос не теряется благодаря docker compose up -d, который пересоздаёт только изменённые контейнеры.

Главный вывод: Active-Active на DNS — не замена Keepalived/VRRP. Это другой подход для другой ситуации. Когда серверы в разных дата-центрах и нет общего L2, DNS Round-Robin с программным фейловером — единственный разумный вариант. Ретроспективно, WebSocket-синхронизацию между дашбордами я бы проектировал иначе — CRDT вместо updated_at разрешения конфликтов. Текущая схема работает, но при одновременном редактировании на двух нодах можно потерять изменения. На практике это не случалось, потому что дашборд обычно открыт на одном ноде, но архитектурно это слабое место.

Кастомный дашборд с Data Plane API превращает HAProxy из чёрного ящика с текстовыми конфигами в управляемый сервис с транзакционными изменениями, синхронизацией и полной наблюдаемостью.