Hope Mail Server — Кластеризация и отказоустойчивость
Документ описывает модель высокой доступности (HA) HopeDB и её настройку. Модель повторяет проверенный подход MS Exchange (DAG): active-passive с репликацией, без общего хранилища как обязательного условия, клиентский доступ — через балансировщик к активному узлу.
Реализация ведётся фазами (см. дорожной карте): - Фаза 1 (готово в коде): выбор лидера (lease-лок), health/ready-проверки, гейтинг фоновых воркеров, конфигурация балансировщика. Даёт рабочий active-passive поверх общего хранилища. - Фаза 2 (готово в коде): репликация WAL и blob'ов лидер→резерв (аналог log shipping в Exchange) — для shared-nothing (без общего хранилища). См. раздел «Репликация» ниже. - Фаза 3 (готово в коде): надёжный транспорт — dedup-леджер (без двойной отправки) и Safety Net (хранение доставленного + повторная отправка). См. раздел «Надёжный транспорт» ниже.
1. Почему именно так
Движок HopeDB держит состояние в памяти (WAL) и мутирует его там же; фоновые воркеры (очередь исходящей, напоминания, AD-синхронизация) пишут автономно. Поэтому два одновременно пишущих узла на одних данных недопустимы — будет расхождение памяти и гонки записи.
Решение — как в Exchange: в каждый момент пишет один узел (лидер), резерв готов перехватить. Клиентов на лидера направляет балансировщик по проверке готовности.
| Exchange (DAG) | HopeDB |
|---|---|
| Транзакционные логи + log shipping | append-only WAL по доменам + репликация (Фаза 2) |
| Active Manager, выбор активной копии | lease-лок + выбор лидера (cluster.rs) |
| Кворум + сервер-свидетель | lease на общем/свидетельском каталоге (кворум — Фаза 2+) |
| Namespace + балансировщик (F5/KEMP) | HAProxy + keepalived (VIP) |
| Shadow redundancy / Safety Net | Фаза 3 |
2. Как работает выбор лидера (Фаза 1)
- Каждый узел периодически (
HOPE_CLUSTER_RENEW, по умолчанию 5 с) пытается захватить/продлить lease — файлcluster_lease.jsonв общем каталоге (HOPE_CLUSTER_DIR, по умолчаниюDB_DATA_DIR). - Lease содержит
holder(id узла),term(монотонный счётчик) иts(метку времени). Если lease свободен или «протух» (нет обновления дольшеHOPE_CLUSTER_TTL, по умолчанию 15 с), узел перехватывает его, увеличиваяterm. - Защита от split-brain: атомарная запись (tmp + rename) и перечитывание после записи (read-after-write) — узел считает себя лидером только если lease действительно держит он.
- Свежесть состояния при перехвате: узел, стартовавший как резерв, при повышении до лидера завершает процесс (по умолчанию), чтобы systemd перезапустил его и он загрузил актуальное состояние с диска. Отключается
HOPE_CLUSTER_EXIT_ON_PROMOTE=0. is_leader()проверяется всеми фоновыми воркерами;/api/readyотвечает 200 только на лидере.
При выключенной кластеризации (HOPE_CLUSTER_ENABLED не задан) узел всегда лидер — обычный одиночный режим.
3. Переменные окружения
| Переменная | По умолчанию | Назначение |
|---|---|---|
HOPE_CLUSTER_ENABLED |
(выкл) | 1/true — включить кластеризацию |
HOPE_NODE_ID |
node-<pid> |
уникальный идентификатор узла |
HOPE_CLUSTER_DIR |
DB_DATA_DIR |
каталог lease-файла (общий/свидетельский) |
HOPE_CLUSTER_TTL |
15 |
время жизни lease, сек |
HOPE_CLUSTER_RENEW |
5 |
период продления/проверки, сек |
HOPE_CLUSTER_EXIT_ON_PROMOTE |
вкл | перезапуск при повышении до лидера |
HOPE_CLUSTER_PEERS |
— | адреса других узлов через запятую (host:port) для кворума против split-brain |
HOPE_CLUSTER_QUORUM |
вкл, если заданы пиры | 1/true/0/false — жёстко вкл/выкл требование кворума |
HOPE_JWT_SECRET |
— | обязан быть одинаковым на всех узлах (иначе токены одного узла отклонит другой) |
HOPE_REPL_TOKEN |
— | общий токен репликации; без него эндпоинты /api/repl/* закрыты (403) |
HOPE_REPL_SOURCE |
— | список пиров через запятую (http://a:8443,http://b:8443); резерв тянет реплику с лидера |
HOPE_REPL_INTERVAL |
10 |
период синхронизации реплики, сек (≈ RPO) |
HOPE_REPL_TLS |
— | 1/true — тянуть реплику по TLS (либо задать https:// в адресе пира); шифрование канала лидер→резерв |
HOPE_SAFETY_NET_HOURS |
48 |
срок хранения доставленных писем в Safety Net, ч |
Задайте
HOPE_JWT_SECRETявным одинаковым значением на всех узлах — это критично для бесшовного переключения сессий.
4. Проверки состояния (для балансировщика и мониторинга)
| Эндпоинт | Ответ | Смысл |
|---|---|---|
GET /api/health |
200 ok всегда, пока процесс жив |
liveness |
GET /api/ready |
200 ready на лидере, 503 standby на резерве |
readiness — маршрутизация трафика |
GET /api/cluster/status |
JSON {enabled, node_id, leader, term} |
мониторинг |
Балансировщик держит клиентские соединения только с узлом, где /api/ready == 200.
5. Топология развёртывания (Фаза 1, active-passive)
┌───────────────┐
клиенты ──► │ VIP (keepalived) │ плавающий адрес
└──────┬────────┘
│
┌──────▼──────┐
│ HAProxy │ проверяет /api/ready, шлёт только на лидера
└──┬───────┬───┘
│ │
┌─────────▼─┐ ┌──▼─────────┐
│ Узел A │ │ Узел B │ hopedb-server
│ (лидер) │ │ (резерв) │
└─────┬──────┘ └─────┬──────┘
│ │
┌──▼───────────────▼──┐
│ Общее хранилище │ DB_DATA_DIR + BLOB_DATA_DIR
│ (NFS / DRBD / SAN) │ (в Фазе 2 заменяется репликацией)
└───────────────────────┘
Пример keepalived (VIP 10.0.0.10), узел A — MASTER, узел B — BACKUP:
vrrp_script chk_ready {
script "curl -sf http://127.0.0.1:8443/api/ready"
interval 3
fall 2
rise 2
}
vrrp_instance HOPE {
state MASTER # на узле B: BACKUP
interface eth0
virtual_router_id 51
priority 150 # на узле B: 100
virtual_ipaddress { 10.0.0.10 }
track_script { chk_ready }
}
Пример HAProxy — балансировка почтовых портов и API только на готовый узел:
defaults
mode tcp
timeout connect 5s
timeout client 1m
timeout server 1m
option httpchk GET /api/ready
http-check expect status 200
frontend fe_smtp_sub
bind *:587
default_backend be_nodes_587
backend be_nodes_587
server a 10.0.0.11:587 check port 8443
server b 10.0.0.12:587 check port 8443 backup
# Аналогичные frontend/backend для 143 (IMAP), 110 (POP3), 8090 (DAV), 8443 (API), 25 (SMTP-in).
Проверка check port 8443 использует /api/ready, поэтому HAProxy автоматически считает готовым только текущего лидера, а резерв (backup) включает при переключении.
6. Поведение при сбое
- Лидер (узел A) выходит из строя → перестаёт продлевать lease.
- Через
HOPE_CLUSTER_TTLсекунд lease «протухает». - Резерв (узел B) на очередном тике перехватывает lease (term+1) и — стартовав как резерв — перезапускается, загружая свежее состояние с общего хранилища, после чего становится лидером.
/api/readyна B отвечает 200 → keepalived/HAProxy направляют трафик на B.- Фоновые воркеры (очередь, напоминания, AD) активируются только на новом лидере.
Оценочное время переключения ≈ TTL + перезапуск процесса (единицы–десятки секунд). Настраивается через HOPE_CLUSTER_TTL/HOPE_CLUSTER_RENEW.
6a. Репликация (Фаза 2) — shared-nothing, как log shipping в DAG
Позволяет обойтись без общего хранилища: каждый узел с локальными дисками, лидер отдаёт файлы состояния, резерв их «догоняет».
Механизм.
- Лидер публикует защищённые токеном эндпоинты:
- GET /api/repl/manifest — список файлов DB_DATA_DIR + BLOB_DATA_DIR с размерами;
- GET /api/repl/file?root=data|blob&path=<rel>&offset=<n> — содержимое файла с указанного смещения.
Оба требуют заголовок X-Repl-Token: <HOPE_REPL_TOKEN>; без токена на сервере — 403.
- Резерв (узел, где is_leader()==false) раз в HOPE_REPL_INTERVAL секунд:
1. выбирает среди HOPE_REPL_SOURCE текущего лидера (по /api/cluster/status);
2. тянет манифест и синхронизирует локальные файлы:
- WAL (*.wal, append-only: system.wal, audit.wal, domains/*.wal) — догоняет «хвост» по смещению (log shipping);
- JSON-состояние (calendar_events.json, contacts.json, folders.json, inbox_rules.json, aliases.json, tasks.json, notes.json, smtp_*, imap_state.json и т.д.) — забирает целиком, пишет атомарно (tmp+rename);
- blob'ы (неизменяемые тела писем) — тянет только отсутствующие/несовпадающие по размеру.
Свежесть и переключение. Репликация держит диск резерва актуальным. In-memory-копию резерв обновляет при повышении до лидера (перезапуск процесса — Фаза 1). Таким образом RPO ≈ HOPE_REPL_INTERVAL, RTO ≈ HOPE_CLUSTER_TTL + перезапуск.
Усиление Фазы 2 (целостность, кворум, TLS).
- Хэш-целостность хвоста WAL. Перед дозаписью «хвоста» резерв сверяет SHA-256 хвостового окна (до 64 КиБ перед своим локальным смещением) со значением лидера: манифест несёт хэш tail каждого *.wal, а точечная сверка идёт через GET /api/repl/hash?root=…&path=…&offset=…&len=…. При совпадении — обычная дозапись с нужного смещения; при расхождении (усечение/повреждение/дивергентная запись) резерв перечитывает файл целиком и атомарно переписывает его, а не «клеит» испорченный хвост.
- Строгий кворум против split-brain. Лидер удерживает лидерство только пока «видит» большинство узлов кластера: список пиров — HOPE_CLUSTER_PEERS (host:port через запятую), живость — быстрой TCP-проверкой (таймаут 2 с) на каждом тике. Большинство = N/2 + 1 от всех узлов (пиры + сам). Потеряв большинство, узел немедленно слагает лидерство (is_leader()==false) — при разрыве сети обе половины не могут остаться лидерами одновременно. Включается автоматически при заданных пирах, принудительно — HOPE_CLUSTER_QUORUM=1|0. Текущее состояние кворума (есть/потерян и число видимых узлов) видно в панели «Мониторинг».
- TLS канала репликации. Резерв может тянуть реплику по TLS (HOPE_REPL_TLS=1 либо https:// в адресе пира) — трафик лидер→резерв шифруется. Проверка сертификата пира на этом канале отключена (шифрование транзита без PKI); при потребности в строгой проверке — вынести на внешний TLS-терминатор/взаимный TLS (План).
Важно про lease при shared-nothing. Данные больше не на общем диске, но lease-файл лидера должен быть в месте, видимом всем узлам. Варианты:
- вынести HOPE_CLUSTER_DIR на небольшой общий/свидетельский ресурс (маленький NFS/шара только под lease);
- либо в дальнейшем — кворум/внешний координатор (План, Фаза 2+).
Топология shared-nothing:
VIP (keepalived) → HAProxy (проверка /api/ready)
│
┌────▼─────┐ репликация ┌──────────┐
│ Узел A │ ──WAL/blob────► │ Узел B │
│ (лидер) │ ◄───(наоборот │ (резерв) │
│ лок.диск │ после failover)│ лок.диск │
└────┬──────┘ └────┬──────┘
└── lease на свидетеле (общий небольшой ресурс) ──┘
Каждый узел настроен на пира: HOPE_REPL_SOURCE=http://<пир>:8443. Резерв тянет с лидера; после failover роли меняются автоматически.
Ограничение Фазы 2: догоняние WAL основано на размере (append-only), проверка целостности хвоста хэшем и строгий кворум против split-brain — в плане. TLS для канала репликации не требуется в доверенной сети; при передаче через недоверенную сеть проксируйте
/api/repl/*через TLS (nginx) или туннель.
6b. Надёжный транспорт (Фаза 3) — shadow redundancy / safety net
Гарантирует, что при сбое лидера письма «в пути» (очередь исходящей) не теряются и не отправляются повторно после failover.
Dedup-леджер (delivered_ledger.json).
- Перед отправкой элемента очереди проверяется, не доставляли ли его уже (по id). После успешной доставки id записывается в леджер. Леджер реплицируется (Фаза 2), поэтому новый лидер знает, что уже отправлено, и не шлёт дубль.
- Элементы в статусе sending, «зависшие» дольше 120 с (краш во время отправки / после failover), автоматически переармируются в retry — иначе они терялись бы. В связке с леджером повторная обработка безопасна (эффективно «ровно один раз», с редким дублем только в узком окне «отправлено, но не записано» — как и в Exchange).
Safety Net (safety_net.json + blob-копии).
- Каждое успешно доставленное наружу письмо копируется в Safety Net: сырое тело — в blob-хранилище (реплицируется как неизменяемый blob), метаданные (id, message-id, from, rcpt, subject, connector, ts) — в индекс. Хранится HOPE_SAFETY_NET_HOURS часов (по умолчанию 48), затем прунится.
- Если после failover обнаружена потеря хвоста (в пределах окна RPO), оператор может повторно отправить письма из Safety Net.
Операторские эндпоинты (роль глобального администратора):
| Метод | Путь | Назначение |
|---|---|---|
| GET | /api/safetynet | статус: число писем, срок хранения, границы по времени |
| POST | /api/safetynet/resubmit | повторная отправка; тело { "since": <unix-сек> } (0 — все в пределах хранения) |
resubmitсоздаёт новые элементы очереди (может привести к повторной доставке — это осознанное действие после сбоя, как в Exchange). Выполняйте на лидере (через VIP) — на резерве вернётся ошибка.
Что не покрыто (в план). Синхронный shadow для входящего SMTP (задержать 250 OK до репликации на пира) не реализован — для принятой, но ещё не реплицированной входящей почты действует окно RPO (HOPE_REPL_INTERVAL). Основной риск потери — очередь исходящей — закрыт леджером и Safety Net.
7. Ограничения Фазы 1 и что дальше
- Общее хранилище — единая точка отказа для Фазы 1 (снимается Фазой 2: репликация WAL/blob лидер→резерв).
- Кворум упрощён (lease + read-after-write). Для строгой защиты от split-brain при сетевых разделениях в Фазе 2+ добавляется свидетель/внешний координатор.
- Клиентский трафик обязан идти через балансировщик. Прямое подключение к резерву в обход HAProxy не должно допускаться сетевой политикой (резерв не гейтит запись на уровне каждого протокола в Фазе 1 — только фоновые воркеры).
- Репликация исходящей очереди (чтобы не потерять письма «в пути» при сбое) — Фаза 3.
8. Чек-лист внедрения
- На всех узлах задан одинаковый
HOPE_JWT_SECRETи уникальныйHOPE_NODE_ID. HOPE_CLUSTER_ENABLED=1,HOPE_CLUSTER_DIRуказывает на общий/свидетельский каталог.DB_DATA_DIRиBLOB_DATA_DIR— на общем хранилище (Фаза 1).- keepalived поднимает VIP, HAProxy проверяет
/api/readyи маршрутизирует порты 25/587/143/110/8090/8443. - systemd с
Restart=always(нужно для перезапуска при повышении). - Проверка: остановите лидера, убедитесь, что резерв стал
readyи трафик переключился;GET /api/cluster/statusпоказывает нового лидера.