Статья рассчитана на тех, кто знает как настраиваются и работают Exim и Dovecot, и в ней я не будут останавливаться на базовых настройках этих сервисов.
Надеюсь что кто-то, прочитав заметку, получит необходимые знания или идеи для воплощения своего решения.
Задача построить отказоустойчивый сервис, с хранением почты на серверах, с доступом по IMAP.
Кластер будет обслуживать компанию с примерно с 60 филиалами, каждый из которых имеет свой домен 3-го уровня.
Главная задача сервиса, беспрерывный доступ к почте. Поэтому для хранилища будем использовать два географически разнесенных сервера, с синхронизацией почтовых каталогов.
Оба сервера будут активными, это значит что мы будем распределять нагрузку между нодами. Часть доменов будет обслуживать одна нода, часть доменов другая. В случае выхода из строя одной из нод, клиенты переключаются на другую.
В качестве фронтенда для
Схема:
STORAGE: Хранилище почтовых ящиков. Состоит из двух нод.
Каждая нода выделеный сервер с 2х4Тб HDD, расположенные на разных хостингах
DNS: storage-01.domain.ru и storage-02.domain.ru
ОС: FreeBSD,
ПО: Dovecot, Exim, Postgresql и Nginx
SMTP: Сервера обрабатывающие SMTP трафик, две ноды.
Виртуальные серверы расположенные на разных хостингах,
DNS: smtp-01.domain.ru и smtp-02.domain.ru
ОС: FreeBSD,
ПО: Exim, Postgresql
PROXY: Прокси сервер для пользовательского доступа к сервису IMAP, POP3, SMTP.
Виртуальный сервер. Единственное не продублированное звено в кластере, но в виду своей простоты поднимается в течении нескольких минут из снапшота.
DNS: mail.domain.ru
ОС: FreeBSD,
ПО: Nginx
STORAGE.
В качестве MDA был выбран Dovecot, поскольку он из коробки умеет кластер. Для хранения почты, был выбран формат Maildir, т.к. сразу захотелось дедупликацию, но об этом ниже.
Датасторы принимают почту только от своих smtp серверов и PROXY. Почту в мир отправляют сами, минуя smtp серверы. Можно их совсем спрятать, и исходящую почту отправлять через smtp ноды.
Путь в файловой системе к ящикам /usr/mail/домен 2-го уровня/домен 3-го уровня/ящик/
В авторизации в качестве логина используется полный ящик mail@ldomain.mdomain.ru
БД:
Описание таблиц:
mail таблица для хранения почтовых ящиков
- id
- mailbox — название почтового ящика
- password — пароль в MD5
- ldomain_id — id домена 3-го уровня из таблицы ldomain
- mdomain_id — id домена 2-го уровня из таблицы mdomain
- active — статус почтового ящика. вкл/выкл
ldomain таблица для описания доменов 3-го уровня
- id
- domain — название домена
- active — статус домена. вкл/выкл
mdomain таблица для описания доменов 2-го уровня
- id
- domain — название домена
- active — статус домена. вкл/выкл
maps таблица маршрутизации
- id
- ldomain_id — id домена 3-го уровня из таблицы ldomain
- mdomain_id — id домена 2-го уровня из таблицы mdomain
- storage1 — основоной сторадж
- storage2 — резервный сторадж (пока не используется)
Как уже писал выше, нагрузку (почтовые домены) я распределяю по двум storage, в таблице maps определяет, на каком из storage находится домен 3-го уровня.
mail=# select * from maps limit 3;
id | ldomain_id | mdomain_id | storage1 | storage2
----+------------+------------+--------------------+---------------
56 | 56 | 2 | storage-01.domain.ru | storage-02.domain.ru
57 | 57 | 2 | storage-02.domain.ru | storage-01.domain.ru
58 | 58 | 2 | storage-01.domain.ru | storage-02.domain.ru
(3 строк)
Опираясь на эту таблицу Exm-ы стораджей и smtp нод будут определять куда слать письма. А Nginx, к какому стораджу подключать пользователей.
Cоздание БД и таблиц:
psql -Upgsql template1
ctreate database mail;
\q
CREATE TABLE mail (
"id" BIGSERIAL PRIMARY KEY,
"mailbox" CHARACTER VARYING(32) not null,
"password" CHARACTER VARYING(128),
"ldomain_id" int NOT NULL,
"mdomain_id" int NOT NULL,
active BOOLEAN DEFAULT TRUE NOT NULL,
CONSTRAINT "mail_ldomain_id_check" CHECK (("ldomain_id" > 0))
);
CREATE TABLE "ldomain" (
"id" BIGSERIAL PRIMARY KEY,
"domain" CHARACTER VARYING(32) NOT NULL,
"active" BOOLEAN DEFAULT TRUE NOT NULL,
CONSTRAINT ldomain_k UNIQUE (domain)
);
CREATE TABLE "mdomain" (
"id" BIGSERIAL PRIMARY KEY,
"domain" CHARACTER VARYING(32) NOT NULL,
"active" BOOLEAN DEFAULT TRUE NOT NULL,
CONSTRAINT mdomain_k UNIQUE (domain)
);
CREATE TABLE "maps" (
"id" SERIAL PRIMARY KEY,
"ldomain_id" int NOT NULL,
"mdomain_id" int NOT NULL,
"storage1" CHARACTER VARYING(32) NOT NULL,
"storage2" CHARACTER VARYING(32) NOT NULL,
CONSTRAINT maps_ldomain_k UNIQUE (ldomain_id)
);
Dovecot
Dovecot выполняет функцию MDA. Я оставлю за рамками этой статьи базовую настройку Dovecot, остановлюсь только на тех моментах, которые важны для связки его с DB и MTA
/usr/local/etc/dovecot/dovecot.conf
protocols = imap pop3 lmtp # для связки с Exim буду использовать LMTP
/usr/local/etc/dovecot/dovecot-sql.conf.ext
driver = pgsql
connect = host=localhost dbname=mail user=mail password=password
default_pass_scheme = MD5
iterate_query = SELECT mail.mailbox || '@' || ldomain.domain || '.' || mdomain.domain AS user FROM mail INNER JOIN mdomain ON ( mail.mdomain_id = mdomain.id ) INNER JOIN ldomain ON ( mail.ldomain_id = ldomain.id )
password_query = SELECT mail.mailbox || '@' || ldomain.domain || '.' || mdomain.domain AS mail, mail.password FROM mail INNER JOIN mdomain ON ( mail.mdomain_id = mdomain.id ) INNER JOIN ldomain ON ( mail.ldomain_id = ldomain.id ) WHERE mailbox = '%n' AND ldomain.domain || '.' || mdomain.domain = '%d' AND mail.active = true AND ldomain.active = 'true'
user_query = SELECT '/usr/mail/' || ldomain.domain || '.' || mdomain.domain || '/' || mail.mailbox AS home FROM mail INNER JOIN ldomain ON ( mail.ldomain_id = ldomain.id ) INNER JOIN mdomain ON ( mail.mdomain_id = mdomain.id ) WHERE mail.mailbox = '%n' AND ldomain.domain || '.' || mdomain.domain = '%d'
/usr/local/etc/dovecot/conf.d/10-auth.conf
auth_username_format = %Lu # формат для авторизации mail@ldomain.mdomain.ru
!include auth-sql.conf.ext
/usr/local/etc/dovecot/conf.d/10-mail.conf
mail_location = maildir:/usr/mail/%d/%n/Maildir #Путь в файловой системе к ящикам /usr/mail/домен 2-го уровня/домен 3-го уровня/ящик/
Синхронизация хранилищ
Изначально, я настроил синхронизацию средствами самого Dovecot (dsync) но, в процессе эксплуатации вылезла очень неприятная проблема. Как оказалось, проблема была связана с типом хранилища Maildir. Dsync стал сбоить, плодить копии писем отжирая свободное место на дисках. К тому моменту я уже не мог перевести все почтовые ящики на dbox (фирменный формат Dovecot) поэтому пришлось отказаться от синхронизации посредством dsync. В целом же, к этому механизму других претензий не было.
Пришлось обратиться к rsync, нехитрым скриптом он берет из базы те домены которые обслуживаются сервером на котором он запускается и синхронизирует их каталоги на второй сервер. Соответсвенно, на втором сервере такой же скрипт гонит на первый свои каталоги. Конечно этот механизм менее надежен так как rsync запускается по расписанию, есть окно между запусками в котором если сервер выйдет из строя мы потеряем письма.
скрипт запускается с двумя параметрами — имя_локального_сервера имя_удаленного_сервера
#mailrsync.pl storage-01.domain.ru storage-02.domain.ru
скрипт синхронизации:
#!/usr/local/bin/perl
use DBI;
use threads;
use Net::Nslookup;
use Sys::Hostname;
@host = split('\.',hostname);
$dbn="mail";
$dbuser="mail";
$dbpass = "password"
$curdata=`date +%Y-%m`; chop $curdata;
$conn=DBI->connect("DBI:Pg:dbname=$dbn;host=localhost","$dbuser","$dbpass") or die "Cannot connect";
($localhostname,$remotehost)=@ARGV;
$mail_dir = "/usr/mail/";
sub domains {
$q = "SELECT ldomain.domain,mdomain.domain,maps.storage1
FROM mail
INNER JOIN ldomain on (mail.ldomain_id = ldomain.id)
INNER JOIN mdomain on (mail.mdomain_id = mdomain.id)
INNER JOIN maps on (maps.ldomain_id=ldomain.id)
WHERE maps.storage1='".$localhostname."'
AND mail.mailbox ='dir'";
$domain = $conn->prepare($q)
or die "Can't prepare statement: $DBI::errstr";
$domain->execute();
while ( my @domain = $domain->fetchrow_array ) {
@domains=(@domains,$domain[0].".".$domain[1]);
}
print "count of domains: ".($#domains + 1)."\n";
$dt = 2; # количество доменов в одном треде
$count = ($#domains / $dt );
print "count: ".$count."\n";
$i1 = 0;
for ($i2 = 0; $i2< $count; $i2++){
if ($dt > $#domains ){$dt = $#domains ;}
print $dt."\n";
print "loop: ".$i2."\n";
foreach $item (@domains[$i1..$m]){
print "in \@domains: ".$mail_dir.$item."\n";
@stack = (@stack,$mail_dir.$item."/");
}
push @threads,threads->create(\&sync,\@stack);
$i1 = $dt+1;
$dt = $dt + 2;
@stack=();
}
}
sub sync {
print "sync\n";
foreach $target (@stack){
system(`/usr/local/bin/rsync -H --delete-during -azz -e "/usr/bin/ssh -i /root/.ssh/dovecot_dsa" $target vmail\@$remotehost:$target`);
print $target."\n";
}
}
domains();
foreach $thread (@threads) {
$thread->join();
}
На этом с Dovecot, все.
Exim
определяем локальные домены, исходя из записей в таблице maps, для того чтобы Exim «знал» свои домены.
domainlist LOCAL_DOMAINS = ${lookup pgsql{ SELECT ldomain.domain || '.' || mdomain.domain AS domainname FROM ldomain, mdomain,maps WHERE ldomain.domain || '.' || mdomain.domain = LOWER('${quote_pgsql:$domain}') AND ldomain.active = 'true' AND maps.storage1 = 'storage-01.domain.ru' AND maps.ldomain_id = ldomain.id}}
в hostlist relay_from_hosts указываю адреса smtp нод и прокси, от них я принимаю почту без авторизации (клиенты авторизуются на прокси).
relay_from_hosts = localhost : smtp01.domain.ru : smtp02.domain.ru : mail.domain.ru
Входящую почту отдаю через LMPT Dovecot-у. В остальном все стандартно. Запросы к БД для поиска ящиков и паролей такие же как в листинге для Dovecot-a
SMTP ноды
БД, такая же как на стораджах, за исключением того, что в таблице mail отсутствует поле password, т.к. пользователи не подключаются к этим серверам. smtp ноды обрабатывают исключительно входящий из мира трафик. По базе проверяют существует ли ящик, пропуская дальше письма только для существующих ящиков.
Exim
Стандартный конфиг, за исключением запроса для определения маршрута
ROUTE_LIST = "${lookup pgsql{ SELECT COALESCE(storage1,'') || ' : ' || COALESCE(storage2,'') FROM ( SELECT storage1,storage2 FROM maps INNER JOIN ldomain ON ( maps.ldomain_id = ldomain.id ) INNER JOIN mdomain ON ( maps.mdomain_id = mdomain.id ) WHERE ldomain.domain || '.' || mdomain.domain = '${quote_pgsql:$domain}' UNION ALL SELECT storage1,storage2 FROM co_maps INNER JOIN co_domain ON ( co_maps.domain_id = co_domain.id ) WHERE co_domain.domain = '${quote_pgsql:$domain}') AS foo}}"
SQL запрос вытягивает имя стораджа для получателя, затем адрес стораджа указывается в роутере, в директиве route_list. Таким образом письмо отправляется на тот сторадж где находится активный домен для этого ящика.
begin routers
DATASTORE:
driver = manualroute
domains = DOMAINS
transport = remote_smtp
condition = MAILS
route_list = * ROUTE_LIST
no_more
Запросы к БД для поиска ящиков и паролей такие же как в листинге для Dovecot.
PROXY
В качестве прокси может выступать тот же Dovecot, но я выбрал Nginx, он показался проще и понятней в этом плане. Стояла одна задача, каким то образом указывать nginx-у куда отправлять пользователя.
nginx.conf на PROXY
cat /usr/local/etc/nginx/nginx.conf
worker_processes 1;
worker_rlimit_nofile 8192;
pid /var/run/nginx.pid;
error_log /var/log/nginx-error.log debug;
error_log /var/log/nginx-error.log notice;
error_log /var/log/nginx-error.log info;
events {
worker_connections 8192;
multi_accept on;
use kqueue;
}
mail {
ssl_certificate /usr/local/etc/ssl/proxy.crt;
ssl_certificate_key /usr/local/etc/ssl/proxy.key;
ssl_session_timeout 5m;
xclient off;
auth_http storage-01.domain.ru:8185/auth;
pop3_capabilities "LAST" "TOP" "USER" "PIPELINING" "UIDL" "RESP-CODES" "EXPIRE" "IMPLEMENTATION";
imap_capabilities "IMAP4" "IMAP4rev1" "UIDPLUS" "IDLE" "LITERAL+" "QUOTA" "LIST-EXTENDED";
smtp_capabilities "SIZE 52428800" "8BITMIME" "PIPELINING" "STARTTLS" "HELP";
server {
smtp_auth login plain;
listen 25;
protocol smtp;
proxy on;
starttls on;
}
server {
smtp_auth login plain;
listen 587;
protocol smtp;
proxy on;
starttls on;
}
server {
listen 110;
protocol pop3;
proxy on;
starttls on;
}
server {
listen 995;
protocol pop3;
proxy on;
starttls on;
}
server {
listen 143;
protocol imap;
proxy on;
starttls on;
}
server {
listen 993;
protocol imap;
proxy on;
starttls on;
}
}
Обратите внимание на директиву
auth_http storage-01.domain.ru:8185/auth;
На сторадже(на обоих!) тоже работает Nginx, но в режими web сервера, с одной лишь целью — обрабатывать запрос storage-01.domain.ru:8185/auth
Этот запрос в случае удачной авторизации клиента возвращает статус авторизации, имя сторажда и порт сервиса
"Auth-Status", "OK";
"Auth-Server", "storage-01.domain.ru";
"Auth-Port", "143";
После чего, nginx на PROXY отправляет клиента на сторадж который вернулся в ответе.
Можно было конечно исключить nginx на сторадже, но для этого пришлось бы и на PROXY держать базу с пользователями. В общем могли быть варианты.
Ниже конфиг Nginx на сторадже, с модулем на perl для реализации вышеописанного.
worker_processes 4;
worker_rlimit_nofile 8192;
error_log /var/log/nginx-error.log info;
events {
worker_connections 8192;
multi_accept on;
}
http {
perl_modules perl/lib;
perl_require mailauth.pm;
perl_require Digest.pm;
access_log off;
server {
listen 8185;
ssl_certificate /usr/local/etc/ssl/storage-01.crt;
ssl_certificate_key /usr/local/etc/ssl/storage-01.key;
ssl_session_timeout 5m;
location /auth {
perl mailauth::handler;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
модуль mailauth.pm
package mailauth;
use nginx;
use DBI;
use Net::Nslookup;
use Digest::MD5 qw(md5_hex);
$pg_user = "mail";
$pg_pass = "password";
$passhost = "localhost";
$mapshost = "localhost";
our $auth_ok;
$protocol_ports->{'pop3'}=110;
$protocol_ports->{'imap'}=143;
$protocol_ports->{'smtp'}=25;
$protocol_ports->{'smtpssl'}=465;
sub handler {
$r = shift;
$Passdbh=DBI->connect("DBI:Pg:dbname=mail;host=$passhost","$pg_user","$pg_pass");
if (!$Passdbh) {
$r->header_out("Auth-Status", "OK") ;
$r->header_out("Auth-Server", '0.0.0.0');
$r->header_out("Auth-Port", $protocol_ports->{$r->header_in("Auth-Protocol")});
$r->send_http_header("text/html");
return OK;
exit;
};
$Mapsdbh=DBI->connect("DBI:Pg:dbname=mail;host=$mapshost","$pg_user","$pg_pass");
$auth_ok=0;
$mailbox = $r->header_in("Auth-User");
our $get_pass_from_db=$Passdbh->prepare("SELECT password FROM mail
INNER JOIN ldomain ON ( mail.ldomain_id = ldomain.id )
INNER JOIN mdomain ON ( mail.mdomain_id = mdomain.id )
WHERE mail.mailbox || '\@' || ldomain.domain || '.' || mdomain.domain = ? ");
$get_pass_from_db->execute($mailbox);
@row=$get_pass_from_db->fetchrow_array();
$passfromDB=@row[0];
$md5passFromConnect = md5_hex($r->header_in("Auth-Pass"));
if ( $passfromDB eq $md5passFromConnect ){
$auth_ok=1;
}
if ($auth_ok==1){
@domain = split('\@',$mailbox);
$get_server_from_maps = $Mapsdbh->prepare(
"SELECT storage1 FROM maps
INNER JOIN ldomain ON ( maps.ldomain_id = ldomain.id ) INNER JOIN mdomain ON ( maps.mdomain_id = mdomain.id ) WHERE ldomain.domain || '.' || mdomain.domain = ? "
);
$get_server_from_maps->execute(@domain[1]);
@row=$get_server_from_maps->fetchrow_array();
$server_from_maps = nslookup(host => $row[0], type => "A");
$r->header_out("Auth-Status", "OK") ;
$r->header_out("Auth-Server", $server_from_maps);
$r->header_out("Auth-Port", $protocol_ports->{$r->header_in("Auth-Protocol")});
} else {
$r->header_out("mail:", $r->header_in("Auth-User"));
$r->header_out("Auth-Status", "Invalid login or password") ;
}
$r->send_http_header("text/html");
return OK;
}
sub db_fail {
$r->header_out("Auth-Status", "OK") ;
$r->header_out("Auth-Server", '127.0.0.1');
$r->send_http_header("text/html");
}
1;
__END__
Настройка балансировки, и переключение на резервную ноду
Сейчас переключение на резервную ноду происходит в ручном режиме. Просто в таблице maps меняется значение в поле storage1. Т.К. все сервера увешены мониторингом этого пока было достаточно.
ЗАКЛЮЧЕНИЕ
Кластер работает уже 3 года. За это время пережил несколько падений одной из нод ( в результате эта нода переехала в другой ДЦ).
Кому-то может показатся эта конструкция сложной, запутанной и «велосипедом». Но хочу сделать акцент на том, что архитектура данного решения исходила из решения использовать дешевое, «ненадежное» железо. В результате имеем надежный сервис с минимальной стоимостью аренды серверов.
Возможно заметка получилась не достаточно понятной, и я не отобразил какие то важные детали. В комментариях, если будут, буду дополнять.
ПС. Статья получилась длинной, поэтому про дедупликацию и еще одном, не упомянутом здесь сервисе управления этим кластером расскажу в следующей заметке, если это вызовет интерес.
Спасибо за внимание!
Комментарии (15)
MagicEx
22.03.2017 11:23+1С dsync такие же проблемы возникли после 2.2.16 версии.
Используем его только в качестве hot backup, вторая нода не принимает юзеров. После 2.2.16 начались дубли писем. Опытным путем выяснили, что происходит это, когда на клиенте настроены фильтра, переносящие письма из Инбокса в другие папки. Если они успевают отработать достаточно быстро, то возникают дубли при репликации.
Решили это отключением синхронизации со вторичной ноды на первичную (ранее было active-active).
Полного решения проблемы не нашли :(
Про дедупликацию очень интересно, пишите.
a_tarsov
22.03.2017 12:56Интерес есть, да ещё какой!
Именно сейчас, готовимся к подобному действу.
Пишите продолжение! Мы обойдем ваши грабли и будем искать только свои))
casuss
22.03.2017 13:35+1Вместо
dsync
— lsyncd не пробовали для синхронизации стораджей?vc43
22.03.2017 22:06На FreeBSD его вроде нет, про аналоги не слышал. Если бы не распределение нагрузки, то для обычного «тёплого» бекапа хватило бы синхронизации через снапшоты zfs с последующим ручным переключением на резервный сервер.
vc43
22.03.2017 21:53На 1ТБ писем rsync будет работать минут 15, проверено.
vc43
22.03.2017 22:10Так же просьба уточнить насчет pop3, он корректно отрабатывает у клиентов при переключении на резервный сервер?
borisovEvg
22.03.2017 22:15pop3 клиентов ничтожное количество. А что с ними может быть не так? они же не держат сессию, подключаются периодически, забирают что есть и отключаются. Но могу специально попробовать, скажите только что воспроизводить, переключение в момент закачки клиентом большого письма?
vc43
23.03.2017 08:22Пришлось однажды восстановить почту dovecot 1.2 из бекапа. Бекап делался rsync и восстанавливался тоже через rsync. После восстановления клиенты, которые настроены на pop3, заново приняли все письма (стоял срок хранения писем на сервере 30 дней). То есть интересна ситуация: клиент1 получал почту с сервера1, потом сервер1 отключили и клиент1 стал получать почту с сервера2.
borisovEvg
23.03.2017 08:52такого не произойдет, если идет постоянная синхронизация. У вас там Maildir был? У него все просто. Видимо у вас в архиве эти письма были в каталоге new.a
borisovEvg
22.03.2017 22:18Чтобы полностью забить канал между нодами в 100мб/с приходится запускать rsync в несколько потоков. Вообще к нему вопросов никаких нет, отрабатывает всегда четко! Единственный минус, о чем писал в заметке, это окно между выполнениями.
brestows
22.03.2017 23:44Я сейчас что-то подобное реализуются, только PROXY у меня совмещен с web клиентом, и данные по пользователям беру из базы web клиента, это требует первого входа на новый адрес через web, но это и так требуется, что бы пользователь сменил одноразовый пароль и получить повареную книгу компании. Ваша схема интересная, спасибо, с удовольствием почитаю продолжения.
hexman
Всё время мучал вопрос, ответ на который Вы дали только в самом конце :)
«переключение на резервную ноду» -> " Просто в таблице maps меняется значение в поле storage1"
Как то много баз у Вас. Но на это можно взглянуть и со стороны отказоустойчивости. Быть может правильнее было б 2е ноды, с резервированием под Postgresql отдать? Так кончено дороже наверное, но архитектурно, как по мне правильнее. Но могу ошибаться. А вообще если настроить реплики и между Вашми 4мя Postgresql, то так и без 2х дополнительных нод выйдет наддёжнее. К примеру на SMTP, Postgresql друг друга подхватывают, и на Storage так же. Ну это как вариант, что первое в голову пришло, или я не внимательно прочёл, и у Вас так и есть?
Что касается продолжения статьи, то разумеется пишите. Любопытно.
borisovEvg
Бд на SMTP нодах нужны для того чтобы определить «свои» домены и отсеивать письма на несуществующие ящики. Можно конечно было бы подключаться к БД стораджей и от туда брать нужную информацию, но я подумал, что будет в случае если оба стораджа будут не доступны? в моем случае вся входящая почта будет скапливаться на SMTP нодах. Решил что так нормально. Синхронизации БД нет. Для себя решил что это излишнее, тк все манипуляции с добавлением, удалением, модификацией ящиков и доменов осуществляется с сервиса управления, который непосредственно подключается ко всем БД и модифицирует каждую из них. Мне показалось это достаточно.