Двусторонняя репликация Dovecot через dsync: живое зеркало почты между двумя серверами
rsync по cron тихо разрушает ящики: гонки при записи, битые индексы, потерянные флаги и разъехавшиеся UID. dsync работает не с файлами, а с IMAP-объектами — GUID, UIDVALIDITY, флагами. Разбираем, как собрать active/active зеркало между двумя узлами Dovecot с near-zero RPO, и почему без sticky-привязки пользователя вы получите split-brain.
EvilMail Team12 июля 2026 г.12 мин чтения
Почему rsync по cron ломает почту, а dsync — нет
Возьмём типичный сценарий «отказоустойчивой почты», который живёт в половине гайдов: rsync -a /var/mail/vhosts/ mail2:/var/mail/vhosts/ в cron каждые пять минут. Он работает ровно до первого совпадения по времени. В момент копирования на mail1 LDA дописывает новое письмо в dovecot.index, а IMAP-клиент в соседней сессии делает EXPUNGE и перетасовывает dovecot.index.cache. rsync копирует эти файлы по отдельности, в разных состояниях, и на mail2 приезжает индекс, который ссылается на письма, которых уже нет, и не знает про письма, которые уже есть.
Результат вы увидите не сразу. Через день клиент подключается к mail2, Dovecot обнаруживает, что UIDVALIDITY не бьётся с тем, что помнит клиент, и командует полную перекачку ящика. Флаги \Seen слетают — сотни «новых» писем, которые давно прочитаны. Появляются дубли. В худшем случае повреждённый dovecot.index.cache
роняет процесс imap при обращении к конкретному письму.
Корень проблемы в том, что почтовый ящик Dovecot — это не набор файлов, а объектная база с инвариантами. У каждого письма есть неизменный GUID. У каждого ящика — UIDVALIDITY и монотонно растущий UIDNEXT. Кэш, индексы и флаги согласованы между собой в конкретный момент. rsync копирует байты, ничего не зная про эти инварианты, и любая параллельная запись оставляет вас с рассогласованным снимком.
dsync устроен иначе. Он открывает оба ящика через движок хранения Dovecot и сливает их по GUID: сравнивает, каких писем не хватает на каждой стороне, синхронизирует флаги и keywords, согласует UIDVALIDITY. Даже если письмо одновременно легло на оба узла, слияние по GUID не теряет ни одно из них. Это не копирование, а синхронизация на уровне IMAP-семантики.
Сразу поставим рамку, чтобы не было иллюзий: репликация — это HA и near-zero RPO, но не бэкап. Удаление реплицируется так же мгновенно, как доставка. rm или EXPUNGE на mail1 — и через долю секунды письма нет и на mail2. Настоящий бэкап (снапшоты, offsite, retention) остаётся отдельной задачей. Если из всей статьи вы вынесете один тезис — пусть это будет этот.
Архитектура: replicator, aggregator, notify и dsync
Репликация в реальном времени в Dovecot собрана из четырёх компонентов, и полезно понимать, кто кого будит.
Поток такой. notify-плагин висит внутри процессов imap/lmtp и ловит любое изменение в ящике — новое письмо, смену флага, EXPUNGE. Он не запускает dsync сам, а бросает событие в fifo replication-notify. aggregator читает этот fifo и передаёт события процессу replicator. Replicator держит пользователей в очереди с приоритетами (none / low / high / sync) и по очереди запускает dsync, который коннектится к doveadm-серверу соседнего узла на порт 12345 и делает инкрементальный mirror.
Важная деталь, которую часто упускают: dsync-протокол идёт поверх doveadm-соединения, а не отдельным демоном. На принимающей стороне нет никакого «dsync-сервера» — есть doveadm-server, слушающий 12345, и dsync разговаривает с ним. Поверх событийной репликации работает страховка: replication_full_sync_interval периодически прогоняет полную сверку и догоняет события, которые могли потеряться — например, если процесс упал в промежутке между notify и запуском dsync.
Что нужно до старта: требования и грабли
Прежде чем трогать конфиги, приведите оба узла к общему знаменателю:
Идентичный `mail_location` на обоих узлах. Путь и формат хранения должны совпадать.
Совместимые версии Dovecot. Одна мажорная ветка. Не пытайтесь реплицировать между 2.3 и 2.4 в проде.
Единый UID/GID vmail. Если на mail1 vmail=5000, а на mail2 vmail=5001, права на реплицированные файлы разъедутся.
Синхронное время (NTP/chrony). dsync разрешает конфликты флагов по времени. Расхождение часов на 30 секунд — и \Seen может «откатиться». Это не рекомендация, это требование.
Общий `doveadm_password` или SSH-ключи с обеих сторон.
Отдельно про формат хранения. sdbox/mdbox реплицируются чище, чем Maildir. У dbox стабильные GUID, привязанные к письму навсегда, и single-instance attachment storage — одинаковые вложения хранятся один раз. Maildir тоже поддерживается и работает, но GUID там менее стабильны, и при некоторых операциях (переименование папок, миграции) UIDVALIDITY меняется чаще, провоцируя лишние ресинки. Строите зеркало с нуля — берите sdbox.
Firewall: порт 12345/tcp открывается только между узлами. Это канал прямого доступа к чужим ящикам, ему нечего делать в интернете.
Конфигурация обоих узлов (симметрично)
Кладём /etc/dovecot/conf.d/10-replication.conf. Ниже рабочий конфиг для mail1; на mail2 отличается ровно одна строка — mail_replica указывает на mail1.
process_min_avail = 1 держит replicator всегда запущенным, а не поднимает по требованию — иначе первые события после старта теряются. doveadm_password должен быть одинаковым на обоих узлах: им dsync аутентифицируется на соседнем doveadm-server. На mail2 всё то же самое, меняется только цель:
Про Dovecot 2.4: синтаксис конфига там переработан, ушла привычная цепочка $mail_plugins, часть настроек репликации переехала в новые блоки. Если вы на 2.4 — сверяйтесь с документацией вашей сборки: логика компонентов та же, но имена директив местами другие. Приведённый конфиг — каноничный для веток 2.3.x, на которых сегодня работает большинство продакшенов.
Транспорт: doveadm TCP+TLS против SSH
mail_replica понимает три схемы, и выбор транспорта — это баланс между простотой, скоростью и безопасностью.
`remote:[email protected]` — dsync запускается через SSH. Просто, аутентификация по ключам, ничего лишнего не открываешь. Минус — форк SSH-сессии на каждый прогон дороже по CPU; на нагруженном сервере с тысячами событий это заметно.
`tcp:mail2.evilmail.pro:12345` — прямое соединение к doveadm-server. Быстро, дёшево, но открытым текстом. Допустимо только в доверенной сети — приватный VLAN между узлами одного ДЦ или туннель.
`tcps:mail2.evilmail.pro:12345` — то же, но TLS поверх doveadm. Продакшн-выбор для узлов в разных ДЦ через публичную сеть.
doveadm_password живёт в конфиге и должен совпадать с обеих сторон — это shared secret для взаимной аутентификации. На практике лучшая схема между дата-центрами — поднять WireGuard или приватный VLAN между узлами и гнать репликацию по нему. Тогда tcp: внутри туннеля даёт скорость без плейнтекста в открытой сети, а вы не завязаны на управление TLS-сертификатами для doveadm отдельно от основного почтового TLS.
Первый запуск, ручная синхронизация и проверка
Событийная репликация подхватывает только изменения после запуска. Существующие ящики нужно залить руками один раз:
bash
# первичное заполнение конкретного ящика на соседа
doveadm sync -u [email protected] tcps:mail2.evilmail.pro:12345
# статус очереди репликатора целиком
doveadm replicator status
# по всем пользователям с деталями
doveadm replicator status '*'
Вывод replicator status '*' — ваша главная приборная панель:
Колонка failed = y означает, что последний прогон для пользователя не прошёл — сосед недоступен или ящик повреждён. fast sync — когда была последняя быстрая инкрементальная синхронизация, full sync — последняя полная сверка. Форсировать пользователя в очередь:
Если в replicator status видите «Waiting 'failed' requests» — часть пользователей осела в очереди неудачных попыток. Replicator сам будет их периодически перезапускать, но постоянно растущий счётчик failed — сигнал разбираться руками, а не ждать.
Согласованность и защита от split-brain
Здесь живёт главный подводный камень двустороннего режима, и я его чинил в три часа ночи, так что слушайте внимательно.
Ещё раз ключевой факт: письма не теряются никогда — слияние по GUID гарантирует это даже при одновременной записи с двух сторон. Но UID расходятся. Если один и тот же пользователь пишет одновременно на mail1 и mail2, каждый узел назначает новым письмам свои локальные UID. dsync потом сольёт содержимое, но UID у одного и того же письма на двух узлах будут разными. Клиент, державший сессии к обоим узлам, увидит несовпадение UID, решит, что ящик пересобрали, и перекачает всё заново. Для ящика на 40 ГБ это весёлая ночь.
Решение простое по формулировке и обязательное по исполнению: один пользователь в каждый момент обслуживается только одним backend. Active/active строится по пользователям, а не по соединениям одного пользователя. Все сессии [email protected] идут на mail1, все сессии [email protected] — на mail2; каждый ящик пишется только на своём узле, а на второй лишь реплицируется. Реализуется это через Dovecot director (в ветке 2.3) или через proxy / внешний L4-балансировщик со sticky-хешем по имени пользователя (в 2.4, где director удалён). Без этого слоя двусторонняя репликация — не HA, а генератор split-brain.
Если split-brain всё-таки случился (кривой LB, ручное вмешательство), лечится полным ресинком с выбором источника истины:
Флаги при этом мёржатся по времени изменения — и вот здесь снова кланяемся NTP. Разъехавшиеся часы превращают мёрж флагов в лотерею.
Мониторинг и типовые сбои
Что реально держать на графиках и алертах:
Длина очереди replicator. Растёт — сосед недоступен или не успевает. Снимается из doveadm replicator status.
Счётчик failed. Ненулевой и растущий — разбираться немедленно.
Лаг full-sync. Если full sync не обновлялся дольше replication_full_sync_interval — что-то встало.
TCP-проверка порта 12345 между узлами. Порт лёг — репликация встала, а событийная модель этого какое-то время «не замечает».
Расхождение числа писем. Сравнивайте doveadm mailbox status -u [email protected] messages '*' на обоих узлах. Устойчивая разница — красный флаг.
Активные прогоны смотрятся через doveadm replicator dsync-status. Самая частая ошибка в логах — dsync: Error с конкретным GUID: почти всегда это повреждённый dbox, лечится doveadm force-resync -u по этому ящику.
Поведение при падении узла штатное: пока mail2 лежит, запросы к нему копятся как failed, RPO деградирует к длине очереди. Когда mail2 возвращается, replicator автоматически догоняет очередь. Если узел лежал долго и вы не уверены в полноте догона — прогоните ручной doveadm sync по затронутым пользователям и сверьте счётчики писем.
Чеклист внедрения
Одинаковая мажорная версия и сборка Dovecot на обоих узлах.