Трендовые github проекты в нашем телеграм канале. Подпишись → Как обойтись без третьего сервера при построении отказоустойчивого кластера
Классическая схема High Availability требует три полноценных сервера: балансировщик, кластер PostgreSQL, распределённое хранилище и мониторинг. Но полноценная третья машина ради одного лишь кворума — это переплата за железо, которое почти не участвует в работе системы. Ниже — рабочая архитектура для self-hosted приложения на двух основных нодах и одном лёгком witness-сервере.
Отправная точка: одна точка отказа
Исходная система работала на одной виртуальной машине: приложение, PostgreSQL, файлы, nginx и PHP — всё вместе. Такая схема прекрасно работает до первого серьёзного сбоя: падает VM — исчезает всё сразу. Backup и snapshot решают Disaster Recovery, но не High Availability. Требовалось, чтобы падение одной основной VM не останавливало систему, PostgreSQL переключался автоматически, приложение понимало, на какой ноде оно активно, файлы оставались доступными после переключения, DNS не приходилось трогать руками, а вся инфраструктура разворачивалась через Ansible.
Кто из двух главный
Для PostgreSQL логичный выбор — Patroni, но ему нужен Distributed Configuration Store, где участники кластера договариваются, кто лидер. Для этого подходит etcd. Проблема в том, что для etcd две ноды — плохой вариант: кворум при двух участниках равен двум, и потеря одной машины лишает оставшуюся большинства. Получается парадокс: HA-кластер из двух серверов не переживает потерю одного из них на уровне consensus.
Решение — не полноценная третья VM, а witness: маленький сервер без копии production-базы и без запущенного приложения, который просто участвует в etcd как третий голос. Раскладка: etcd-1 на Node A, etcd-2 на Node B, etcd-3 на Witness. Если пропадает Node A, у Node B и Witness остаётся 2 голоса из 3 — кворум сохраняется, та же логика работает при потере Node B. Witness стоит заметно дешевле полноценной третьей ноды, но закрывает главную проблему distributed-кворума.
PostgreSQL и asynchronous replication
База работает на двух основных серверах под управлением Patroni с асинхронной репликацией. Компромисс асинхронности — при внезапной потере primary можно потерять небольшой объём последних транзакций, которые не успели дойти до replica. Синхронная репликация снизила бы этот риск, но привязала бы latency записи к состоянию второй ноды — для этой задачи выбор сделан в пользу async.
HA базы данных — не то же самое, что HA приложения
Допустим, Patroni отработал failover идеально: Node A упала, Node B стала primary. С базой всё в порядке. Но если nginx на обеих машинах одновременно принимает пользовательский трафик, можно получить split-brain на уровне приложения — обе ноды считают себя активными. Для систем со стейтом на файловой системе это недопустимо. Должно выполняться правило: одна нода ACTIVE, вторая PASSIVE, и никогда обе ACTIVE одновременно.
Решение — небольшой role-check механизм на каждой ноде, который периодически спрашивает локальный Patroni о статусе:
if patroni_is_primary; then
touch /var/lib/app-ha/active
else
rm -f /var/lib/app-ha/active
fi
PostgreSQL leader становится источником истины для application-role: раз локальная база — primary, значит приложение на этой ноде активно.
Балансировщик и health-check без ручного DNS
Внешний L7-балансировщик знает обе ноды и опрашивает специальный endpoint /ha-health. ACTIVE-нода отвечает HTTP 200, PASSIVE — HTTP 503. Как только Patroni переключает primary, role-check меняет application-role, health-check видит новое состояние и балансировщик начинает отправлять трафик на новую активную ноду. DNS при этом не трогается вообще — переключение прозрачно для клиентов.
Файлы: самая сложная часть архитектуры
PostgreSQL replication решает вопрос с базой, но пользовательские файлы и артефакты приложения через Patroni не реплицируются — для этого используется lsyncd с направленной синхронизацией: ACTIVE-нода отправляет изменения на PASSIVE. Направление синхронизации должно переворачиваться вместе с ролью ноды: если сегодня ACTIVE — Node A, поток идёт A → B, после failover — B → A. Управляет этим тот же role-check механизм.
Двусторонний rsync (A ↔ B) — прямой путь к конфликтам записи. Правильный подход — fencing: принимать входящие изменения должна только PASSIVE-нода. Без этого ограничения возможен опасный сценарий: Node A была ACTIVE и упала, Node B стала ACTIVE и накопила новые файлы, затем Node A вернулась со старым состоянием и перезаписала свежие данные устаревшими.
Синхронизировать стоит только то, что действительно должно переезжать вместе с application-role — конфиги, пользовательские файлы, рабочие каталоги приложения. Логи, кэш и временные файлы остаются локальными для каждой ноды; не стоит превращать lsyncd в распределённую файловую систему. Ещё одна деталь: приложение на обеих машинах всегда обращается к PostgreSQL по 127.0.0.1 — Patroni уже гарантирует, что локальный инстанс сейчас primary там, где это нужно, поэтому отдельная логика выбора DB host не требуется.
Ansible, миграция и проверка отключением ноды
Вся инфраструктура — etcd, Patroni, PostgreSQL, приложение, HA-логика, lsyncd, мониторинг — описана Ansible-ролями: если для восстановления сервера после аварии нужно вспоминать историю shell-команд, это ещё не инфраструктура, а набор ручных действий. Полный запуск site.yml при этом рискован: на production может быть одна версия приложения, а application-role в Ansible — содержать другую Git-версию, и общий плейбук заденет не только инфраструктуру, но и код. Изменения стоит применять узкими плейбуками или отдельными ролями.
Перенос production проходил так: новую инфраструктуру подняли параллельно старой, проверили etcd quorum, Patroni leader, replication, роли ACTIVE/PASSIVE, health-check, lsyncd и failover, перенесли базу и файлы, оставив старый сервер как rollback-вариант, и только после этого переключили трафик на новый балансировщик. Финальная и самая полезная проверка HA — не systemctl status patroni, а реальное отключение ноды: выключить Node A, убедиться, что Node B становится PostgreSQL primary и application ACTIVE, затем вернуть Node A и проверить, что она поднимается как replica и PASSIVE, не мешая Node B. Тест стоит повторить в обе стороны — только тогда failover подтверждён на практике, а не только на диаграмме.
Когда HA работает, а приложение всё равно тормозит
После миграции возникла отдельная проблема: CPU был почти свободен, память в порядке, диск не забит, replication в норме — но интерфейс периодически подвисал на десятки секунд. Низкая загрузка CPU не означает здоровое приложение.
Первым подозреваемым оказался PHP-FPM: пул был настроен на pm.max_children = 14. Воркеры зависали на внешних HTTP-запросах — long polling, проверки уведомлений, вызовы стороннего API — и во время деградации процессы упирались в лимит. Если заняты все 14 воркеров, новые запросы встают в очередь, и весь интерфейс выглядит как упавший.
Временный slowlog (request_slowlog_timeout = 2s) и инструментация вокруг curl-вызовов (замер DNS, CONNECT, TLS, TTFB и HTTP code без query string, cookies и токенов) показали: соединение устанавливалось мгновенно, а задержка возникала уже после подключения, пока внешний backend готовил ответ. Проблема была не в сети, а в медленном стороннем API.
Сам внешний API от увеличения пула быстрее не стал, но эффект от изменения конфигурации оказался почти мгновенным:
pm.max_children = 40
pm.start_servers = 20
pm.min_spare_servers = 20
pm.max_spare_servers = 40
Отдельный медленный запрос по-прежнему выполнялся долго, но перестал укладывать всё приложение целиком. Это принципиальная разница между «ускорили запрос» и «локализовали влияние медленного запроса» — второе решает systemic failure, но не лечит первопричину.
FPM status и метрики вместо гаданий
Подсчёт процессов PHP-FPM через ps вводит в заблуждение: в dynamic mode FPM специально держит idle-воркеров, и общее число процессов не равно реальной загрузке. Штатный status endpoint (pm.status_path = /fpm-status) даёт точную картину: active processes: 3, total processes: 30, max_children: 40. Total = 30 при лимите 40 выглядит как загрузка 75%, но реальная нагрузка в этом срезе — 3/40, то есть 7,5%.
Если на серверах уже установлен node_exporter с textfile collector, отдельный экспортер для PHP-FPM не нужен: небольшой скрипт раз в 15 секунд опрашивает /fpm-status и пишет active/idle/total процессов, max_children, listen queue и slow requests в файл textfile collector. В Grafana достаточно трёх графиков: количество активных воркеров, процент использования пула (active / max_children * 100) и прирост slow requests за пять минут. При следующей жалобе на тормоза не нужен SSH и ps — достаточно посмотреть на dashboard: высокая утилизация пула с непустой очередью указывает на нехватку воркеров, низкая утилизация при пустой очереди — на конкретный медленный запрос, SQL или внешний сервис.
Выводы
Для etcd-кворума не обязательно покупать третью полноценную машину — если основные ноды достаточно мощные, третий member может жить на лёгком witness, который не хранит данные и не запускает приложение. HA базы данных не гарантирует HA приложения: split-brain может возникнуть на уровне application layer даже при идеальном failover PostgreSQL, а файлы сложнее базы и требуют отдельного fencing направления синхронизации.
Низкая загрузка CPU ничего не говорит о latency приложения: воркер, ожидающий ответ внешнего API десятки секунд, почти не расходует ресурсы процессора, но занимает слот в пуле. Наблюдаемость стоит строить до инцидента — active/idle workers, очередь, slow requests, latency внешних вызовов и replication lag должны быть на дашборде заранее. А увеличение количества воркеров — это mitigation, а не лечение первопричины: оно локализует влияние медленного запроса, но не ускоряет сам внешний сервис.