Два сервера и половинка третьего: как мы строили HA‑инфраструктуру для Totum

Когда говорят про High Availability, довольно быстро получается классическая картина: три сервера, балансировщик, кластер PostgreSQL, распределённое хранилище, мониторинг и ещё несколько компонентов, про которые никто не вспоминал, пока всё работало на одной виртуалке.
У нас исходная ситуация была намного прозаичнее: была рабочая система на Totum, которую нужно было перенести в новую инфраструктуру и одновременно избавиться от единственной точки отказа.
При этом хотелось выполнить несколько условий:
1) падение одной основной VM не должно останавливать систему;
2) PostgreSQL должен автоматически переключаться;
3) приложение должно понимать, на какой ноде ему разрешено выполнять активные операции;
4) пользовательские файлы должны оставаться доступными после переключения;
5) переключение не должно требовать ручного изменения DNS;
6) всё должно разворачиваться и обслуживаться через Ansible;
7) покупать третью полноценную машину только ради кворума не хотелось.
Последний пункт в итоге и породил архитектуру, которую между собой я называл «HA для бедных». Спойлер: третья машина всё-таки появилась. Но это маленький witness, который практически ничего не делает с точки зрения бизнес-нагрузки.
Что такое Totum и почему с ним вообще возник этот квест
Totum — self-hosted low-code платформа для создания внутренних web-приложений: ERP, учётных систем, личных кабинетов и других кастомных бизнес-систем. Приложения строятся вокруг таблиц и прикладной логики, а сама платформа может работать со сторонними системами через API и инициировать HTTP-запросы.
В нашем случае это уже была не тестовая установка, а рабочая бизнес-система с PostgreSQL, пользовательскими файлами, интеграциями и большим количеством прикладной логики.
Официальная документация Totum отдельно рекомендует перед промышленной эксплуатацией настраивать PHP-FPM, PostgreSQL, память, диски и индексы под реальную нагрузку. Но до оптимизации ещё нужно было решить более фундаментальную задачу: что произойдёт, если сервер просто умрёт?
Как было
Упрощённо старая архитектура выглядела так:
Internet
|
v
+-------------+
| Server |
|-------------|
| nginx |
| PHP-FPM |
| Totum |
| PostgreSQL |
| files |
+-------------+
Она прекрасна ровно до первого серьёзного сбоя. Падает VM — вместе с ней исчезают приложение, база, файлы, nginx, PHP и фоновые процессы. Можно иметь backup, snapshot и прекрасную инструкцию восстановления, но это всё равно Disaster Recovery, а не High Availability.
Что хотелось получить
Первая очевидная схема:
Load Balancer
/ \
/ \
v v
+---------------+ +---------------+
| Node 1 | | Node 2 |
|---------------| |---------------|
| nginx | | nginx |
| PHP-FPM | | PHP-FPM |
| Totum | | Totum |
| PostgreSQL| | PostgreSQL |
| Patroni | | Patroni |
+---------------+ +---------------+
И вот здесь появляется первая классическая проблема распределённых систем: кто из двух главный?
А кто из двух главный?
Для PostgreSQL мы выбрали Patroni. Но Patroni нужен Distributed Configuration Store, который позволит участникам договориться, кто лидер. В нашем случае таким хранилищем стал etcd.
И для etcd две ноды — плохая идея. При двух участниках кворум равен двум. Потеряли одну машину — оставшаяся не имеет большинства.
Получается парадокс: мы построили HA из двух серверов, но потеря одного сервера мешает кластеру нормально принимать решения. Можно поставить третью полноценную VM. Но зачем нам третья машина с такими же CPU/RAM, если приложение на ней работать не будет? Так появился witness.
«Треть сервера», которая спасает две боевые машины
Финальная схема получилась такой:
Internet
|
v
+-------------------+
| L7 Load Balancer |
+-------------------+
/ \
/ \
v v
+---------------+ +---------------+
| Node A | | Node B |
|---------------| |---------------|
| nginx | | nginx |
| PHP-FPM | | PHP-FPM |
| Totum | | Totum |
| PostgreSQL | | PostgreSQL |
| Patroni | | Patroni |
| etcd | | etcd |
+---------------+ +---------------+
\ /
\ /
v v
+---------+
| Witness |
|---------|
| etcd |
+---------+
Важно: witness не является PostgreSQL-репликой. На нём нет копии production-базы и нет Totum. Его задача значительно проще — быть третьим голосом в etcd.
etcd-1 — Node A
etcd-2 — Node B
etcd-3 — Witness
Если Node A исчезает, Node B + Witness дают 2 голоса из 3. Если исчезает Node B — кворум снова сохраняется. А маленькая VM обходится заметно дешевле полноценной третьей application/database-ноды.
Вот это я и называю «HA для бедных». На самом деле правильнее назвать это HA с отдельным quorum witness.
PostgreSQL: Patroni и asynchronous replication
Patroni
|
+------+------+
| |
v v
PostgreSQL PostgreSQL
PRIMARY ---> REPLICA
WAL
База работает на двух основных серверах, репликация асинхронная. У такого решения есть очевидный компромисс: при аварийной потере primary теоретически можно потерять небольшой объём последних транзакций, которые ещё не успели попасть на replica.
Синхронная репликация уменьшила бы этот риск, но увеличила бы зависимость latency записи от второй ноды. Для нашей задачи был выбран asynchronous-вариант.
Но переключить PostgreSQL недостаточно
Допустим, Patroni прекрасно сделал failover: Node A был PRIMARY, упал, Node B стал PRIMARY. С базой всё хорошо. А что делает приложение?
Если nginx на обеих машинах принимает запросы, можно случайно получить две одновременно работающие application-ноды, каждая из которых считает себя активной. Для некоторых stateless-приложений это нормально. Для нашей конфигурации Totum — нет.
Node A = ACTIVE
Node B = PASSIVE
или наоборот.
Но никогда ACTIVE + ACTIVE.
Как мы связали роль PostgreSQL с ролью Totum
На обеих машинах работает небольшой role-check механизм. Он периодически спрашивает локальный Patroni, является ли текущая нода primary.
if patroni_is_primary; then
touch /var/lib/totum-ha/active
else
rm -f /var/lib/totum-ha/active
fi
На практике вокруг этого есть дополнительная логика, но сама идея именно такая: PostgreSQL leader становится источником истины для application-role.
PostgreSQL leader
|
v
Totum ACTIVE
Как балансировщик понимает, куда отправлять пользователей
Внешний L7-балансировщик знает обе Totum-ноды, но health-check смотрит специальный endpoint:
GET /ha-health
ACTIVE отвечает HTTP 200, PASSIVE — HTTP 503.
ALB
|
GET /ha-health
/ \
/ \
200 503
| |
v X
ACTIVE PASSIVE
Если Patroni переключает primary, role-check меняет application-role, health-check видит новую картину и начинает отправлять пользовательский traffic на новую ноду. DNS при этом менять не надо.
А что делать с файлами
С базой всё относительно стандартно: PostgreSQL replication. Но у Totum есть ещё приложение и файлы. И они сами по себе через Patroni не реплицируются. Для этого использовали lsyncd.
ACTIVE
|
| rsync/lsyncd
v
PASSIVE
Главная проблема здесь — направление синхронизации. Если сегодня ACTIVE Node A, данные идут A → B. После failover направление должно автоматически перевернуться: A ← B. Role-check управляет и этим.
Почему двусторонний rsync — плохая идея
Самая опасная версия такой архитектуры выглядела бы как A ↔ B, где обе машины что-то пишут друг другу. Это прямой путь к конфликтам.
Поэтому мы сделали fencing: принимать изменения должна только PASSIVE-нода. Если сервер становится ACTIVE, входящую синхронизацию нужно ограничить.
Иначе можно получить неприятный сценарий:
Node A был ACTIVE.
A упал.
Node B стал ACTIVE.
На B появились новые файлы.
A вернулся со старым состоянием.
Старые данные поехали поверх новых.
Что именно синхронизировать
Мы синхронизировали application files и необходимые каталоги Totum, но исключили то, что должно быть локальным для каждой машины.
Conf.php
.git/
http/fls/
logs/
tmp/
cache/
totumTmpfiles/
backups/
Конкретный список зависит от установки. Главный принцип: не надо превращать lsyncd в распределённую файловую систему. Синхронизировать стоит только то, что действительно должно переехать вместе с application-role.
Отдельная ловушка: локальный PostgreSQL
Для приложения конфигурацию сделали одинаковой на обеих машинах: Totum всегда обращается к PostgreSQL на той же VM.
'host' => '127.0.0.1',
'port' => 5432,
Не нужно писать отдельную логику выбора DB host. Patroni уже гарантирует, какая локальная PostgreSQL-инстанция сейчас primary.
Ansible: после ручного прототипа всё должно стать кодом
Практически всю инфраструктуру в итоге собрали через Ansible.
[totum_nodes]
totum-prod-1
totum-prod-2
[etcd_cluster]
totum-prod-1
totum-prod-2
etcd-witness
[monitoring]
etcd-witness
Идея была принципиальной: если после аварии для восстановления сервера нужно вспоминать историю shell-команд из Telegram, это ещё не инфраструктура.
roles/
├── etcd/
├── patroni/
├── postgresql/
├── totum/
├── ha/
├── lsyncd/
└── monitoring/
Не запускайте весь playbook просто потому, что можете
На production находилась конкретная версия Totum, а application-role в Ansible могла содержать другую Git-версию. Поэтому команда вроде ansible-playbook site.yml могла неожиданно затронуть не только инфраструктуру, но и application code.
После этого большой site.yml перестал восприниматься как кнопка «починить всё». Изменения применяли отдельными ролями или узкими playbook'ами.
Миграция production
Самая неприятная часть любой HA-архитектуры — не построить её с нуля, а перетащить туда существующий production.
Сначала подняли новую инфраструктуру параллельно старой и проверили:
etcd quorum OK
Patroni leader OK
replication OK
Totum ACTIVE OK
Totum PASSIVE OK
health checks OK
lsyncd OK
failover OK
После этого перенесли production-базу и файлы. Старый сервер некоторое время оставался rollback-вариантом. И только после проверок переключили пользовательский traffic на новый Load Balancer.
Очень полезный тест: сломать сервер самому
HA нельзя проверить командой systemctl status patroni. Нужно реально потерять ноду и убедиться, что система восстанавливается в ожидаемое состояние.
До:
Node A = ACTIVE
Node B = PASSIVE
Выключаем A.
Ожидаем:
Node A = DOWN
Node B = PostgreSQL PRIMARY
Node B = Totum ACTIVE
Load Balancer -> Node B
Возвращаем A:
Node A = PostgreSQL REPLICA
Node A = Totum PASSIVE
Node B остаётся ACTIVE
Потом тест повторяется в обратную сторону. Только после таких проверок можно говорить, что failover существует не только на архитектурной диаграмме.
Всё заработало. А потом Totum начал тормозить
После миграции возникла отдельная проблема. Снаружи всё выглядело прекрасно: CPU почти свободен, RAM хватает, диск не забит, PostgreSQL replication в норме, load average небольшой, login page открывается быстро.
Но пользователи периодически говорили: «Totum умер». Интерфейс на несколько десятков секунд практически переставал отвечать.
CPU = 5%
не означает:
application = healthy
PHP-FPM оказался первой половиной проблемы
В production pool было:
pm.max_children = 14
Во время деградации процессы PHP-FPM доходили до лимита. В slowlog одновременно появлялись длительные внешние HTTP-запросы, long polling, проверки изменений таблиц и уведомлений.
PHP worker
|
+---- curl() ------> external API
|
+---- waits 20 sec
Для FPM такой worker всё это время недоступен другим запросам. Если заняты все 14, новые запросы ждут, и UI выглядит так, будто умер целиком.
Временный slowlog PHP-FPM
slowlog = /var/log/php-fpm-totum-slow.log
request_slowlog_timeout = 2s
request_slowlog_trace_depth = 50
После этого стало видно, где именно зависают worker'ы: внешние curl-вызовы через getFromScript, long polling и проверки уведомлений. Так мы получили уже не ощущение пользователя «что-то медленно», а конкретный stack trace.
А потом мы начали измерять curl
Одного slowlog оказалось мало. Мы временно добавили instrumentation вокруг внешних HTTP-вызовов и логировали DNS, CONNECT, TLS, TTFB, TOTAL и HTTP code — без query string, cookies и токенов.
connect = ~0.010 s
TTFB = 76.4 s
total = 76.4 s
connect = ~0.009 s
TTFB = 15.6 s
total = 15.7 s
То есть соединение устанавливалось практически мгновенно. Задержка происходила после подключения — пока внешний application backend готовил ответ.
Почему увеличение PHP-FPM всё-таки помогло
Внешний API от этого быстрее не стал. Но мы изменили pool:
pm.max_children = 40
pm.start_servers = 20
pm.min_spare_servers = 20
pm.max_spare_servers = 40
После этого эффект был практически мгновенным: отдельный медленный запрос по-прежнему мог выполняться долго, но весь Totum больше не ложился вместе с ним. Это принципиальная разница между «мы ускорили запрос» и «мы локализовали влияние медленного запроса».
Не верьте ps, включите FPM status
На одном из этапов мы чуть не сделали неправильный вывод: посчитали процессы PHP-FPM и приняли количество процессов за количество занятых worker'ов. В dynamic mode это неверно: FPM специально держит idle workers.
Поэтому включили штатный status endpoint:
pm.status_path = /fpm-status
И получили реальные показатели:
idle processes: 27
active processes: 3
total processes: 30
max active processes: 10
max children reached: 0
slow requests: 45
То есть total=30 не означает загрузку 75%. Реальная текущая нагрузка в этом срезе была 3/40 = 7,5%, а максимум после restart — 10/40 = 25%. После изменения pool перестал быть bottleneck.
Раз уж метрики есть — отправляем их в Prometheus
На серверах уже был node_exporter с включённым textfile collector. Поэтому отдельный exporter для PHP-FPM оказался не нужен.
Небольшой скрипт раз в 15 секунд забирает /fpm-status и пишет метрики в файл textfile collector:
/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
Grafana
В Grafana добавили три ключевых графика.
Active workers
totum_php_fpm_active
Использование pool
100 *
totum_php_fpm_active
/
totum_php_fpm_max_children
Slow requests за пять минут
increase(totum_php_fpm_slow_requests[5m])
Теперь при следующей жалобе «Totum тормозит» вместо SSH + ps + гаданий достаточно открыть dashboard. Если FPM utilization близок к 100% и queue > 0 — смотрим pool. Если utilization низкий и queue = 0 — ищем конкретный slow request, SQL или внешний сервис.
Что получилось в итоге
Internet
|
v
+----------------+
| Load Balancer |
+----------------+
/ \
/ \
v v
+----------------+ +----------------+
| Node A | | Node B |
|----------------| |----------------|
| nginx | | nginx |
| PHP-FPM | | PHP-FPM |
| Totum | | Totum |
| PostgreSQL | | PostgreSQL |
| Patroni | | Patroni |
| etcd | | etcd |
| node_exporter | | node_exporter |
+----------------+ +----------------+
\ /
\ /
\ /
+-------------+
| Witness |
|-------------|
| etcd |
| Prometheus |
| Grafana |
+-------------+
PostgreSQL:
PRIMARY ---> REPLICA
Files:
ACTIVE ---> PASSIVE
Application:
PRIMARY DB == ACTIVE Totum
Load Balancer:
200 -> sends traffic
503 -> removes backend
Что я вынес из этого проекта
Для кворума не обязательно покупать третью такую же машину. Если две основные ноды достаточно мощные, третий etcd member может жить на маленьком witness. Главное понимать, что это не третья data replica и не запасной application server.
HA базы ещё не означает HA приложения. Можно идеально переключить PostgreSQL и при этом получить split-brain на application layer.
Файлы сложнее базы. PostgreSQL replication решает PostgreSQL. Всё остальное приходится проектировать отдельно.
Low CPU ничего не говорит о latency приложения. Worker, который десятки секунд ждёт HTTP response, почти не использует CPU, но всё равно занимает слот FPM.
Наблюдаемость надо строить до инцидента, а не во время него. Следующую подобную систему стоит сразу запускать с active/idle workers, queue, slow requests, HTTP latency, DB latency и replication lag.
Не оптимизируйте приложение одним увеличением ресурсов. 14 → 40 workers решило systemic failure, но не сделало внешний API быстрее. Это был правильный mitigation, а не магическое лечение всей производительности.
Эксплуатация должна быть простой. Если для обычного reboot каждый раз нужен автор архитектуры, работа ещё не закончена.
Вместо заключения
Мы начинали с задачи «перенести Totum на два сервера». Закончили системой, в которой есть две application/database-ноды, 3-member etcd quorum, Patroni, automatic failover, application fencing, L7 health checks, file replication, Ansible, Prometheus, Grafana и PHP-FPM monitoring.
И маленький witness, который большую часть времени почти ничего не делает. Наверное, именно поэтому это моя любимая часть архитектуры: он нужен ровно в тот момент, когда одна из двух дорогих машин перестаёт отвечать. А до этого просто тихо помогает двум взрослым серверам договориться, кто сегодня главный.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.