Висока доступність пошти без спільного сховища: два MX і Dovecot replication
Спільний Maildir по NFS чи DRBD — це не відмовостійкість, а генератор пошкоджених індексів і split-brain. Розбираємо, чому HA-пошта на двох MX будується на незалежних вузлах із локальним сховищем і Dovecot dsync replication, а не на одному диску під двома серверами.
EvilMail Team18 липня 2026 р.13 хв читання
Класична помилка при побудові «відмовостійкої» пошти виглядає так: беруть два сервери Postfix+Dovecot, монтують їм один NFS-експорт із Maildir і вважають, що готово — впаде один вузол, другий підхопить. Через тиждень під навантаженням у логах з'являється Corrupted index cache file, користувачі бачать зниклі й задубльовані листи, а doveadm ремонтує індекси на кожному відкритті теки. Спільне сховище під одним Maildir — це не висока доступність. Це спосіб отримати два джерела пошкодження замість одного.
Правильна HA-пошта на двох MX будується інакше: два незалежні вузли, кожен зі своїм локальним диском, а синхронізацію робить не блокове сховище, а сам Dovecot — на рівні застосунку, через dsync replication. Розберемо, чому спільний Maildir ламається і як зібрати архітектуру, де кожен вузол — повноцінна жива копія скриньок.
Чому «спільний Maildir по NFS» — це пастка, а не HA
Maildir і формат індексів Dovecot спроєктовані під припущення, що файлову систему бачить один процес на одному хості. NFS руйнує це припущення одразу в кількох місцях.
Перше — attribute caching. NFS-клієнт кешує метадані файлів (mtime, розмір, наявність) на час actimeo
HA-пошта: два MX і Dovecot replication замість спільного Maildir | evilmail.pro — EvilMail Blog
— типово десятки секунд. Коли mx1 кладе новий лист у
new/
, mx2 ще певний час його «не бачить», бо працює з кешованим списком каталогу. Dovecot трактує це як зникнення файлу й починає плутати UID.
Друге — боротьба за блокування `dovecot.index.cache`. Індекс — це файл, який Dovecot мапить у пам'ять і оновлює під локом. Два вузли, що одночасно пишуть у той самий dovecot.index через NFS, змагаються за flock/fcntl, а той поверх NFS працює ненадійно. Результат — гонки й зіпсований кеш.
Щоб NFS хоча б не розсипався одразу, Dovecot вимагає набору компромісів:
Це помітно б'є по продуктивності — кожна операція тягне примусовий fsync і скидання кешу — і навіть так документація Dovecot прямо застерігає: ризик пошкодження індексу лишається, якщо один користувач одночасно обслуговується з двох вузлів. DRBD у режимі dual-primary лише погіршує картину: тепер до всього додається ще й блоковий split-brain.
Головне ж концептуальне питання: що дає другий сервер, якщо сховище одне? Ляже NFS-стораж — обидва вузли осліпнуть одночасно. Спільне сховище і є вашою єдиною точкою відмови, просто відсунутою на шар нижче.
Архітектура: два незалежні вузли, а не одне сховище
Топологія, яку ми ставимо у продакшн: mx1.domain.pro і mx2.domain.pro, кожен зі своїм локальним NVMe, файлова система ext4 або XFS, формат поштового сховища — mdbox (mail_location = mdbox:~/mdbox). Postfix стоїть на обох, приймає SMTP і здає пошту через LMTP у локальний Dovecot, а Dovecot-replicator дублює кожну зміну на партнера.
Ключова теза: реплікація живе на рівні застосунку, а не блока. Dovecot знає семантику пошти — GUID листа, UIDVALIDITY теки, прапорці — тому зливає зміни осмислено, а не побайтово синхронізує сирі файли, як DRBD. Це master-master: писати можна на будь-який вузол, і зміни поїдуть в обидва боки.
Чому mdbox, а не Maildir? Для реплікації критично, щоб кожен лист мав стабільний глобальний ідентифікатор (GUID), а теки — узгоджений UIDVALIDITY. dbox/mdbox зберігають GUID нативно, тому dsync надійно матчить листи між вузлами. Maildir теж реплікується, але схильний до конфліктів UID: ідентичність листа там прив'язана до імені файлу, і при одночасних записах легко отримати розбіжність.
Обов'язкова передумова, про яку часто забувають, — спільна база користувачів. Обидва вузли мають читати одну userdb/passdb: SQL або LDAP. У нашій інфраструктурі це спільний mailserver (таблиці virtual_domains/virtual_users), до якого ходять обидва Dovecot. Якщо бази різні, реплікатор просто не знайде, куди класти скриньку партнера.
DNS: MX-пріоритети, PTR і чому це лише про прийом
MX-записи керують прийомом пошти й нічим більше. Базова конфігурація з пріоритетами:
dns
domain.pro. IN MX 10 mx1.domain.pro.
domain.pro. IN MX 20 mx2.domain.pro.
mx1.domain.pro. IN A 203.0.113.10
mx2.domain.pro. IN A 212.22.69.220
Відправники йдуть на MX 10; якщо mx1 недоступний — на MX 20. Хочете розкидати вхідний трафік рівномірно — задайте однакові пріоритети 10 mx1 / 10 mx2, і відправники обиратимуть випадково. Обов'язкові супутні записи:
PTR (зворотні) для обох IP. Без коректного 210.69.22.212.in-addr.arpa → mx1.domain.pro пошта з цього вузла піде в спам. PTR налаштовується у провайдера чи власника блоку IP, а не у вашій зоні.
SPF з обома вузлами:v=spf1 a mx ip4:203.0.113.10 ip4:212.22.69.220 -all.
DKIM — той самий приватний ключ і той самий selector на обох серверах. Лист, підписаний на mx1, має валідуватися незалежно від того, який вузол його відправив.
Ось де ламаються інтуїції: MX 20 приймає пошту в чергу, але не віддає IMAP/POP3. Другий MX без синхронізації скриньок — це не відмовостійкість доступу, а буфер, який згодом мусить кудись здати накопичене. Для клієнтського доступу потрібен окремий шар — єдина точка входу до IMAP, яка переживає падіння вузла. Про неї нижче.
Dovecot replication: replicator, dsync і mail_replica
Серце схеми — /etc/dovecot/conf.d/10-replication.conf. Плагіни notify і replication ловлять кожну зміну скриньки й передають реплікатору, який запускає dsync до партнера:
mail_plugins = $mail_plugins notify replication
service replicator {
process_min_avail = 1
}
service aggregator {
fifo_listener replication-notify-fifo { user = vmail }
unix_listener replication-notify { user = vmail }
}
service doveadm {
inet_listener {
port = 12345
}
}
doveadm_port = 12345
doveadm_password = <shared_secret>
plugin {
mail_replica = tcp:mx2.domain.pro:12345
replication_full_sync_interval = 12 hours
}
На mx2 усе дзеркально: mail_replica = tcp:mx1.domain.pro:12345. Потік такий: користувач отримав лист або перемістив його в теку → плагін notify штовхає подію в aggregator → replicator ставить користувача в чергу й запускає інкрементальний dsync до doveadm-server партнера на порту 12345. Завдяки notify інкрементальна синхронізація практично миттєва. replication_full_sync_interval = 12 hours — це періодична повна звірка для самозцілення: вона ловить розбіжності, які просочилися повз notify (наприклад, після рестарту вузла).
Команди експлуатації, які треба знати напам'ять:
bash
# Загальний стан черги реплікації
doveadm replicator status
# Стан по кожному користувачу: коли остання синхронізація, чи є fail
doveadm replicator status '*'
# Форсувати ре-реплікацію всіх
doveadm replicator replicate '*'
# Ручний dsync конкретного користувача (діагностика)
doveadm sync -u [email protected] tcp:mx2.domain.pro:12345
Коли піднімаєте другий вузол на вже наповнену систему, спершу зробіть початковий backfill вручну, в обхід replicator, інакше черга захлинеться:
Тут backup -R заливає скриньку на партнера в один бік, а -o plugin/mail_replica= тимчасово прибирає mail_replica, щоб не спровокувати рекурсивний тригер реплікації.
Доступ клієнтів під час падіння вузла: floating IP і director
Реплікація тримає дані в двох копіях, але клієнтам потрібна одна стабільна адреса IMAP. Найпростіше рішення — floating IP на keepalived (VRRP). Виділяєте окремий IP (наприклад, 212.22.69.211), вішаєте на нього imap.domain.pro, і keepalived тримає його на активному вузлі, за секунди перекидаючи на резервний при падінні:
Коли MASTER-вузол падає, VRRP-адверти зникають, mx2 (priority 100) забирає VIP і починає обслуговувати IMAP. Клієнти навіть не змінюють налаштувань — адреса imap.domain.pro та сама.
Тонкий момент: навіть із реплікацією небажано, щоб один користувач одночасно писав на обидва вузли — це підвищує шанс гонок на індексах між сесіями. Тому за високого навантаження додають Dovecot director — проксі, що робить сесії sticky: конкретний користувач завжди приземляється на один і той самий бекенд, поки той активний.
service director {
inet_listener { port = 9090 }
}
Director не замінює реплікацію — він її доповнює, гарантуючи, що активна робота з поштою одного юзера йде на один вузол, а реплікація тримає другий актуальним як гарячий резерв.
Розв'язання конфліктів, split-brain і моніторинг
Головна перевага реплікації на рівні застосунку над блоковою — осмислене злиття. Якщо той самий лист з'явився чи змінився з двох боків, dsync мерджить за GUID: обидва листи зберігаються, нічого не втрачається. Split-brain на рівні даних тут не веде до пошкодження — у найгіршому разі отримаєте дубль, який видно й легко прибрати, а не зіпсований dovecot.index.cache.
Що моніторити:
`doveadm replicator status` — дивіться на кількість у стані Waiting і на Sync failed. Зростання черги = вузли розходяться.
Статус `Full sync failed` — сигнал, що періодична звірка не проходить (мережа, автентифікація doveadm, розбіжність UIDVALIDITY). Це алерт негайно.
Розбіжність кількості листів між вузлами по ключових скриньках — простий сторонній скрипт, що порівнює doveadm mailbox status -u user messages INBOX на обох.
Лаг реплікації — час від зміни до появи на партнері.
І окремо, великими літерами: реплікація — це не бекап. doveadm expunge і видалення листа теж реплікуються, миттєво і на обидва вузли. Помилкове видалення чи шифрувальник знищить пошту на обох копіях одночасно. Тому off-site бекап (rsync/borg-знімок mdbox або doveadm backup на третій, не-реплікований хост) — обов'язковий окремий шар.
Чеклист розгортання та експлуатації
Firewall: порт doveadm 12345 відкритий тільки між вузлами — iptables -A INPUT -p tcp --dport 12345 -s <IP_партнера> -j ACCEPT, решту DROP.
Реплікаційний канал — по TLS (ssl = required на doveadm-inet-listener); doveadm_password — довгий випадковий секрет, однаковий на обох.
Спільна userdb/passdb (SQL або LDAP), яку читають обидва вузли; жодних локальних розбіжностей у користувачах, паролях чи sieve.
Формат сховища — mdbox (mail_location = mdbox:~/mdbox), не Maildir, заради стабільних GUID/UIDVALIDITY.
DKIM: один приватний ключ і selector на обох; ротацію ключа робіть синхронно на двох вузлах.
PTR для обох IP, SPF з обома адресами, DMARC — перевірте dig перед запуском у продакшн.
Тест failover: systemctl stop dovecot postfix на mx1 → надішліть тестовий лист → doveadm replicator status на mx2 → переконайтеся, що лист з'явився і UIDVALIDITY скриньки не змінився.
Початковий backfill через doveadm backup -R, а не через чергу replicator.
Окремий off-site бекап mdbox — реплікація не рятує від видалення.
Моніторинг replicator status з алертами на Sync failed і на зростання черги.
Спільне сховище під двома серверами виглядає простіше на схемі, але воно об'єднує саме те, що ви хотіли розділити, — стан. Два незалежні вузли з локальними дисками й реплікацією Dovecot дають те, заради чого все й затівалося: будь-який вузол може згоріти повністю, а пошта — і нова, і десятирічної давнини — лишиться доступною на другому.