← Документация Hope·Hope Mailhopedc.ru

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. Поведение при сбое

  1. Лидер (узел A) выходит из строя → перестаёт продлевать lease.
  2. Через HOPE_CLUSTER_TTL секунд lease «протухает».
  3. Резерв (узел B) на очередном тике перехватывает lease (term+1) и — стартовав как резерв — перезапускается, загружая свежее состояние с общего хранилища, после чего становится лидером.
  4. /api/ready на B отвечает 200 → keepalived/HAProxy направляют трафик на B.
  5. Фоновые воркеры (очередь, напоминания, 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. Чек-лист внедрения

  1. На всех узлах задан одинаковый HOPE_JWT_SECRET и уникальный HOPE_NODE_ID.
  2. HOPE_CLUSTER_ENABLED=1, HOPE_CLUSTER_DIR указывает на общий/свидетельский каталог.
  3. DB_DATA_DIR и BLOB_DATA_DIR — на общем хранилище (Фаза 1).
  4. keepalived поднимает VIP, HAProxy проверяет /api/ready и маршрутизирует порты 25/587/143/110/8090/8443.
  5. systemd с Restart=always (нужно для перезапуска при повышении).
  6. Проверка: остановите лидера, убедитесь, что резерв стал ready и трафик переключился; GET /api/cluster/status показывает нового лидера.