Зачем для HA на двух нодах нужен третий голос
Кворум в etcd — это большинство от общего числа участников, а не «сколько-нибудь живых голосов». Формула проста: нужно больше половины. Три участника — кворум два. Пять — три. Два участника — и здесь начинается неприятное: большинством становится само число «два», то есть должны быть живы оба.
Посчитаем на пальцах. Схема с двумя etcd-нодами на двух серверах:
- оба живы — кворум есть, кластер принимает решения;
- падает одна машина — остаётся один голос из двух, а нужно два. Кворума нет.
Оставшаяся нода не «деградирует в readonly» и не «работает наполовину» — etcd перестаёт выдавать решения, потому что не может подтвердить своё большинство. Для кластера, который сам управляет failover PostgreSQL через Patroni, это означает остановку автоматики ровно в тот момент, когда она нужнее всего: одна боевая машина уже лежит, а вторая не может ничего предпринять, пока не вернётся первая.
Отсюда парадокс: HA из двух серверов ломается при потере одного из них. Классическое решение — купить третью полноценную VM с теми же CPU и RAM, но приложение на ней работать не будет. Вы платите за железо, которое простаивает.
Witness-нода закрывает именно эту дыру. Это третий член etcd-кластера на маленькой VM. Он не хранит копию production-базы, не запускает Totum и не обслуживает нагрузку — его единственная работа быть третьим голосом в etcd. Расклад становится таким:
- исчезает Node A — Node B вместе с witness дают два голоса из трёх, кворум держится;
- исчезает Node B — witness снова даёт большинство.
Стоимость маленькой VM заметно ниже полноценной application/database-ноды, а отказ любой одной машины больше не обездвиживает кластер. Такую схему автор проекта называет «HA с отдельным quorum witness» — и она даёт ровно то, чего не хватало двум серверам: возможность договориться, кто сегодня главный, даже когда один из «взрослых» серверов молчит.
Patroni и PostgreSQL: асинхронная репликация и её компромисс
За автоматическим переключением базы стоит простой компромисс. Patroni сам по себе не решает, как именно реплика получает данные — за это отвечает выбранный режим репликации. В разобранной конфигурации выбрана асинхронная: primary записывает транзакцию и подтверждает её клиенту, не дожидаясь, что replica эту запись уже приняла.
Из этого прямо следует риск. Если primary внезапно исчезает, часть последних подтверждённых транзакций могла не успеть дойти до replica. Patroni поднимет replica до роли primary — и эти транзакции просто не существуют в новой главной базе. Для системы учёта это означает расхождение между тем, что пользователь уже увидел как «сохранено», и тем, что реально лежит в базе.
Почему тогда не включили синхронную репликацию, которая такой разрыв закрывает? Потому что она меняет место ожидания: запись начинает подтверждаться только после того, как данные приняты второй нодой, и latency каждой операции записи становится зависимой от состояния и скорости этой второй ноды. Для описанной нагрузки выбрали асинхронный вариант — сознательно приняв риск потери небольшого объёма последних транзакций ради стабильной скорости записи.
Ключевое здесь — что failover и сохранность данных это разные гарантии. Patroni уверенно переключит роль, но не вернёт транзакции, которых на replica не было. Если для вашего приложения потеря даже нескольких последних записей недопустима, асинхронную репликацию придётся либо менять на синхронную, либо принимать потерю как известный риск — но не делать вид, что автоматический failover его закрывает.
Role-check и fencing: как Totum узнаёт, что он ACTIVE
Patroni переключил базу — этого мало. Пока приложение не знает, кто сейчас ACTIVE, балансировщик может отправить пользователя на любую ноду. Если обе ноды считают себя активными, получите две пишущие Totum-инстанса одновременно. Для stateful-приложения это гарантированный конфликт данных.
Чтобы этого не случилось, на каждой машине работает небольший role-check механизм. Он периодически обращается к локальному Patroni и спрашивает: эта нода сейчас primary? Patroni отвечает с точки зрения PostgreSQL, а приложение использует этот ответ как источник истины для своей роли. Логика проверки выглядит так:
if patroni_is_primary; then
touch /var/lib/totum-ha/active
else
rm -f /var/lib/totum-ha/active
fiЕсть файл-маркер — нода ACTIVE. Нет маркера — PASSIVE. Дополнительная логика вокруг этого есть, но принцип именно такой: за роль приложения отвечает состояние локальной базы, а не отдельный механизм выбора лидера. Один источник правды — меньше шансов на расхождение.
Файл-маркер работает не только на чтение, но и на запись. Балансировщик на уровне L7 опрашивает специальный endpoint /ha-health на обеих нодах. Если на ноде лежит маркер, endpoint отвечает 200 — трафик идёт сюда. Если маркера нет, ответ 503 — балансировщик снимает ноду с обслуживания. Именно так балансировщик понимает, куда отправлять пользователей, и никакого ручного вмешательства при переключении не требуется.
Load Balancer:
200 → sends traffic
503 → removes backendСвязка получается короткой: PostgreSQL leader → маркер active → 200 от /ha-health → трафик на эту ноду. Четыре шага, каждый проверяем независимо. Если упадёт нода A, Patroni поднимет primary на B, role-check создаст маркер там, health-check вернёт 200, и балансировщик сам переключит поток запросов.
Стоит помнить: без fencing на уровне приложения один Patroni не спасает. База может быть в порядке, а два Totum-инстанса — писать одновременно. Файл-маркер и endpoint закрывают именно этот разрыв.
Синхронизация файлов через lsyncd и двусторонний rsync
База переключилась. Patroni выбрал нового primary, role-check переставил маркер. Но пользовательские файлы Totum живут отдельно от PostgreSQL и сами за лидером не последуют. Для их доставки использовали lsyncd.
Пока ACTIVE — Node A, поток данных идёт A → B. После failover картина обязана зеркально перевернуться: A ← B. Кто именно сейчас источник, снова решает role-check. Это тот же маркер роли, который определяет судьбу приложения, — просто применённый к файловому потоку.
Соблазн сделать схему A ↔ B, где обе машины пишут друг другу, стоит гасить сразу. Это прямой путь к конфликтам, и вот наглядный сценарий:
- Node A был ACTIVE.
- A упал.
- Node B стал ACTIVE и создал новые файлы.
- A вернулся со старым состоянием — и старые данные поехали поверх новых.
Чтобы этого не случилось, применяется fencing: изменения принимает только PASSIVE-нода. Как только сервер становится ACTIVE, входящую синхронизацию для него ограничивают. Так направление потока всегда однозначно привязано к роли.
Отдельный вопрос — что вообще синхронизировать. Переносили application files и нужные каталоги Totum, но исключали то, что обязано оставаться локальным на каждой машине: Conf.php, .git, http, fls, logs, tmp, cache, totumTmpfiles, backups. Точный список зависит от установки, но принцип простой: не превращайте lsyncd в распределённую файловую систему. Синхронизируйте только то, что действительно должно переехать вместе с ролью приложения.
Наконец, не забудьте про health-check, которым балансировщик отличает живую ноду от роли. Если для БД всё относительно стандартно, то для файлов и приложения точка принятия решения — GET /ha-health; при этом 200 означает «направляй трафик сюда», а 503 — «убери backend из ротации».
Ansible-инфраструктура и миграция с параллельным поднятием нод
В инвентаре Ansible у нас три группы. Первая — totum_nodes: две боевые машины totum-prod-1 и totum-prod-2. Вторая — etcd_cluster: те же два сервера плюс etcd-witness. Третья — monitoring, и в ней состоит только witness. Такое разделение сразу отвечает на вопрос «что куда деплоить»: роль etcd раскатывается на все три узла, patroni и postgresql — только на боевые, а monitoring не трогает production вообще.
Каталог ролей повторяет эту логику: etcd, patroni, postgresql, totum, ha, lsyncd, monitoring. Отдельная роль ha — та самая прослойка с role-check и fencing, которая связывает лидера PostgreSQL с активной нодой приложения.
Дальше начинается неприятное. На production стояла конкретная версия Totum, а роль totum тянула другую Git-версию application code. Значит, команда ansible-playbook site.yml — безобидная на вид — могла заодно обновить приложение. Не «починить инфраструктуру», а заменить код в момент, когда вы этого не планировали.
Вывод простой: перестаньте видеть в site.yml кнопку «привести всё в порядок». Запускайте отдельные роли или узкие плейбуки — --tags, --limit по конкретной группе. Тогда деплой etcd не потянет за собой сюрпризы в totum.
Миграция и rollback-сервер
Переезд шёл поэтапно. Сначала подняли новую инфраструктуру, затем перенесли production-базу и файлы. Старый сервер при этом не выключали: он оставался рабочим rollback-вариантом на случай, если что-то пойдёт не так. Пользовательский трафик переключили на новый Load Balancer только после проверок — и держали старую машину как страховку, пока новая конфигурация не доказала свою состоятельность.
Стратегия без спешки: инфраструктура → данные → проверки → трафик. Каждый шаг обратим, пока предыдущий не подтверждён.
PHP-FPM на пределе: slowlog, внешние HTTP и расширение пула
Пользователи говорят «Totum умер», а на сервере в это же время спокойно: CPU около 5%, память свободна, диск не забит, репликация PostgreSQL в норме, load average низкий. Логин-страница открывается быстро. Противоречие объясняется просто: узкое место — не железо, а занятые PHP-воркеры, каждый из которых застрял в ожидании внешнего сервиса.
Посмотрите на путь запроса. Totum умеет инициировать HTTP-вызовы к сторонним системам через getFromScript. Пока такой вызов ждёт ответа внешнего API, FPM-воркер не обслуживает ничего другого — он занят целиком, хотя процессор в это время простаивает. При pm.max_children = 14 достаточно, чтобы все 14 воркеров разом оказались на подобных вызовах, — и новые запросы выстраиваются в очередь. Снаружи это выглядит как полная остановка интерфейса на несколько десятков секунд.
Временный slowlog
Чтобы увидеть, где именно воркеры проводят время, включили slowlog PHP-FPM:
slowlog = /var/log/php-fpm-totum-slow.log
request_slowlog_timeout = 2s
request_slowlog_trace_depth = 50В логе оказались конкретные stack trace: внешние curl-вызовы, long polling, проверки изменений таблиц и уведомлений. Вместо расплывчатого «что-то медленно» появилась точка, куда смотреть.
Измерение внешних вызовов
Slowlog показал факт зависания, но не его причину. Временно обернули внешние HTTP-вызовы instrumentation — без query string, cookies и токенов — и стали логировать фазы: DNS, CONNECT, TLS, TTFB, TOTAL и HTTP code. Соединение устанавливалось за доли секунды (connect = ~0.010 s), а вот TTFB доходил до 76 секунд (TTFB = 76.4 s). Задержка возникала уже после подключения: медленно отвечал внешний application backend.
Что дало увеличение pm.max_children
Простое решение — поднять pm.max_children с 14 до 40. Отдельный медленный запрос по-прежнему выполняется долго, но теперь он удерживает один воркер из сорока, а не выбивает из строя весь пул. Эффект был почти мгновенным. Это принципиально разные вещи: ускорить запрос и локализовать его влияние — увеличение пула решает второе, а не первое. Внешний API быстрее не стал, но systemic failure, когда весь интерфейс падает из-за нескольких зависших вызовов, ушёл.
Метрики PHP-FPM в Prometheus и Grafana
Отдельный exporter для PHP-FPM держать не обязательно. На серверах уже работает node_exporter с включённым textfile collector — значит, достаточно класть готовые метрики в файл, который он подхватывает сам.
Скрипт раз в 15 секунд забирает /fpm-status и пишет результат в /var/lib/prometheus/node-exporter/totum_fpm.prom:
totum_php_fpm_active 3
totum_php_fpm_idle 27
totum_php_fpm_total 30
totum_php_fpm_max_children 40
totum_php_fpm_max_active 10
totum_php_fpm_listen_queue 0
totum_php_fpm_slow_requests 45Обратите внимание на totum_php_fpm_max_children 40 — это тот самый потолок пула после расширения. Рядом totum_php_fpm_max_active 10: максимум одновременно занятых воркеров с момента рестарта. Если этот максимум упирается в max_children, пул снова стал узким местом.
Три графика в Grafana
Первый — активные воркеры:
totum_php_fpm_activeВторой — использование пула в процентах:
100 * totum_php_fpm_active / totum_php_fpm_max_childrenТретий — медленные запросы за пять минут:
increase(totum_php_fpm_slow_requests[5m])Этого набора хватает, чтобы не гадать. Приходит жалоба «Totum тормозит» — открываете dashboard вместо SSH с ps.
Логика чтения простая. Utilization близок к 100% и listen_queue больше нуля — смотрите пул: возможно, снова нужно поднимать max_children или разбираться, почему запросы копятся. Utilization низкий и очередь пуста — проблема не в количестве воркеров, а в конкретном медленном запросе: SQL, внешний сервис, long polling. Тогда следующий шаг — slowlog и разбор отдельных трейсов.
Важная деталь про idle. FPM в dynamic mode намеренно держит свободные процессы, поэтому total — плохой индикатор загрузки. В одном из срезов total был равен 30, но реально работали всего 3 воркера из 40 — около 7,5%. Считать занятость по числу процессов в ps — прямой путь к неверному выводу.
Метрики active, idle, listen_queue и slow_requests из status endpoint дают то, чего не видно в системном мониторинге: состояние самого пула. CPU при этом может оставаться почти на нуле — точно так же, как в случае, когда воркеры просто ждут внешний API.
Один нюанс: скрипт должен быть устойчивым к недоступности /fpm-status. Если FPM перезапускается, статус на секунду пропадёт — не перезаписывайте файл пустым набором, иначе Prometheus решит, что метрики исчезли, и графики оборвутся. Проще всего писать новый файл атомарно, а при ошибке просто не трогать старый до следующего успешного опроса.
Что проверять в отказоустойчивой схеме: выводы и практический совет
Три тезиса, которые стоит унести из этой истории. Первый: кворум etcd не требует третьей полноценной машины — маленького witness достаточно, потому что он не хранит ни копию базы, ни приложение, а лишь участвует в голосовании. Второй: успешный failover PostgreSQL ещё не означает, что приложение переживёт сбой. Patroni может переключить primary за секунды, но пока вы не привязали роль Totum к статусу локального лидера и не ограничили входящую синхронизацию файлов, split-brain остаётся реальным сценарием. Третий: файлы переезжают тяжелее базы. Репликация PostgreSQL отработана годами, а вот направление rsync/lsyncd приходится разворачивать вручную при каждой смене роли — и именно здесь скрывается самая неприятная ошибка, когда вернувшаяся нода затирает свежие данные старыми.
Практический совет по проверке failover: не ограничивайтесь просмотром статусов сервисов — физически выключите ноду и пройдите весь сценарий до конца, включая возврат. Зафиксируйте ожидаемое состояние на каждом шаге, как это делали авторы: до теста Node A — ACTIVE и PRIMARY, Node B — PASSIVE; после выключения A ожидаем, что B станет PRIMARY и ACTIVE, а балансировщик уберёт упавший бэкенд по health-check; после возврата A должна подняться уже как REPLICA и PASSIVE, а B остаться главной. Затем повторите всё зеркально. Только когда оба направления отработаны и направление синхронизации файлов перевернулось автоматически, можно утверждать, что отказоустойчивость существует не только на диаграмме.