Почтовый кластер своими руками

в 22:26, , рубрики: dovecot, email, exim, nginx, децентрализованные сети, кластер, метки:

Сразу оговорюсь что поставленную задачу можно решить несколькими способами. Этот один из возможных.
Статья рассчитана на тех, кто знает как настраиваются и работают Exim и Dovecot, и в ней я не будут останавливаться на базовых настройках этих сервисов.

Надеюсь что кто-то, прочитав замету, получит необходимые знания или идеи для воплощения своего решения.

Задача построить отказоустойчивый сервис, с хранением почты на серверах, с доступом по IMAP.
Кластер будет обслуживать компанию с порядком 60-ти филиалов, каждый из которых имеет свой домен 3-го уровня.

Главная задача сервиса, беспрерывный доступ к почте. Поэтому для хранилища будем использовать два географически разнесенных сервера, с синхронизацией почтовых каталогов.
Оба сервера будут активными, это значит что мы будем распределять нагрузку между нодами. Часть доменов будет обслуживать одна нода, часть доменов другая. В случае выхода из строя одной из нод, клиенты переключаются на другую.
В качестве фронтенда для распределения нагрузки маршрутизации клиентов будем использовать Nginx с модулем mail. Для приема почты, будем использовать два smtp сервера.

Схема:
Почтовый кластер своими руками - 1

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 "syncn";
   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 года. За это время пережил несколько падений одной из нод ( в результате эта нода переехала в другой ДЦ).

Кому-то может показатся эта конструкция сложной, запутанной и «велосипедом». Но хочу сделать акцент на том, что архитектура данного решения исходила из решения использовать дешевое, «ненадежное» железо. В результате имеем надежный сервис с минимальной стоимостью аренды серверов.

Возможно заметка получилась не достаточно понятной, и я не отобразил какие то важные детали. В комментариях, если будут, буду дополнять.

ПС. Статья получилась длинной, поэтому про дедупликацию и еще одном, не упомянутом здесь сервисе управления этим кластером расскажу в следующей заметке, если это вызовет интерес.

Спасибо за внимание!

Автор: borisovEvg

Источник


  1. Алексей:

    borisovEvg, благодарю за статью!

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js