Показаны сообщения с ярлыком mysql. Показать все сообщения
Показаны сообщения с ярлыком mysql. Показать все сообщения

воскресенье, 16 августа 2020 г.

История и тенденции Zabbix в TokuDB

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

Первая рекомендация, которой стоит попробовать воспользоваться - это, конечно-же, удаление ненужных элементов данных, пересмотр периодичности их съёма в пользу более длительных интервалов, уменьшение длительности хранения данных. Чем меньше данных в таблицах истории, тем быстрее происходит работа с данными. Ускоряется поиск, т.к. становятся короче индексы, ускоряется чтение, т.к. в выборку для отображения на графике попадает меньше данных, запись данных тоже ускоряется, т.к. чем меньше данных в таблице, тем быстрее обновляются индексы. Кроме того, если все часто требуемые данные будут умещаться в оперативной памяти СУБД, работа с данными существенно ускорится.

Если первая рекомендация не помогает, тогда нужно приступать к чуть более сложным методам методам: нужно заняться оптимизацией производительности СУБД и сервера.

В случае с MySQL первым делом нужно убедиться, что база данных не находится в одном файле и, при необходимости, разнести таблицы по разным файлам: сделать полную резервную копию, удалить базы данных, включить innodb_file_per_table=YES, перезапустить MySQL, восстановить базы данных из резервных копий.

Другой важный шаг: нужно убедиться, что основной буфер СУБД, размер которого настраивается через innodb_buffer_pool_size, имеет максимально доступный объём. Чем больше объём этого буфера, тем больше «горячих», часто требуемых данных, может в нём уместиться. Идеально, если вся СУБД умещается в оперативной памяти целиком. На практике, однако, это редко достижимо, т.к. таблицы истории и тенденций в базе данных Zabbix могут достигать сотен гигабайт. В любом случае, если есть возможность, лучше увеличить объём оперативной памяти на сервере с СУБД.

Также стоит обратить внимание на размеры журналов innodb_log_file_size: Zabbix пишет много данных и размер этих файлов должен соответствовать объёму данных, которые записываются системой в секунду (лимит для этой опции - 2 гигабайта). Оборотной стороной больших журналов является более длительный запуск сервера MySQL.

Когда выполнены предыдущие рекомендации - на контроле есть только самое необходимое, данные снимаются с разумными интервалами времени, произведена оптимизация настроек - следующим этапом обычно идёт отключение HouseKeeper'а и секционирование таблиц истории и тенденций. Понять, о том что настало время отключать HouseKeeper, можно обратившись ко внутреннему мониторингу Zabbix. Если на графиках процесс HouseKeeper почти постоянно используется на 100%, а увеличение настроек HouseKeepingFrequency и HouseKeeperDelete не приводят к желаемому эффекту, значит пора. Zabbix не имеет официальной поддержки секционирования таблиц, однако можно найти готовые инструкции для его настройки.

Ранее я использовал для разбивки таблиц на секции вот эту статью на wiki-странице Zabbix: Docs/howto/mysql partitioning, однако впоследствии стал пользоваться вот этой статьёй: Docs/howto/mysql partition. У второй статьи есть два преимущества:
  1. при её использовании в базе данных Zabbix не нужно создавать дополнительную нестандартную таблицу manage_partitions,
  2. при её использовании имеется возможность делить таблицы не только на секции месячного или суточного размера, но и на секции произвольного размера, в том числе более мелкого.
Наконец, в интернете можно встретить советы по смене движка таблиц истории и тенденций с родного для MySQL движка InnoDB на движок TokuDB с технологией «фрактальных индексов». Также вместе с этим движком рекомендуют использовать «кластерные индексы», когда индексы хранятся вместе с данными, и сжатие данных в таблицах.

Изначально TokuDB был ответвлением MySQL, в котором фирма Tokutek реализовала собственную технологию «фрактальных индексов». Позже исходные тексты TokuDB стали доступны под свободной лицензией и на их основе был реализован плагин, пригодный подключению как к оригинальной СУБД MySQL, так и к её ответвлениям - MariaDB и Percona.

Включение плагина TokuDB в MariaDB

Мне удавалось успешно настраивать TokuDB на Debian Stretch и Debian Buster. Установку и настройку MariaDB оставим за скобками нашего обсуждения. Будем считать, что система мониторинга уже развёрнута и использует MariaDB, а таблицы истории и тенденций пока что хранятся в таблицах формата InnoDB.

Первым делом установим пакет с плагином, который добавляет в MariaDB поддержку формата хранения таблиц TokuDB:
# apt-get install mariadb-plugin-tokudb
Вместе с пакетом будет установлен дополнительный файл конфигурации /etc/mysql/mariadb.conf.d/tokudb.cnf, в котором указан путь к библиотеке libjemalloc. В случае с Debian Stretch это будет путь /usr/lib/x86_64-linux-gnu/libjemalloc.so.1 В случае с Debian Buster это будет путь /usr/lib/x86_64-linux-gnu/libjemalloc.so.2 Прежде чем продолжать, стоит удостовериться, что этот файл действительно сущетсвует в системе, т.к. при обновлении операционной системы до свежего релиза в файле конфигурации мог остаться устаревший путь. В Debian Stretch этот файл устанавливается с пакетом libjemalloc1, а в Debian Buster - пакетом libjemalloc2. Необходимо установить соответствующий пакет и исправить путь к файлу в файле конфигурации.

Теперь нужно убедиться, что в системе отключена прозрачная поддержка огромных страниц (Transparent Hugepages). Для этого запускаем следующую команду:
$ cat /sys/kernel/mm/transparent_hugepage/enabled
Если команда поругалась на отсутствие файла, значит прозрачная поддержка огромных страниц уже отключена и делать больше ничего не нужно. Также ничего не нужно делать, если команда вывела следующее:
always madvise [never]
Если же команда вывела приведённый ниже текст, то прозрачная поддержка огромных страниц включена и её необходимо отключить:
[always] madvise never
Открываем файл /etc/default/grub, находим переменную GRUB_CMDLINE_LINUX и добавляем в список опций опцию transparent_hugepage=never. В результате должно получиться что-то такое:
GRUB_CMDLINE_LINUX="ipv6.disable=1 transparent_hugepage=never"
Теперь нужно обновить конфигурацию загрузчика следующей командой:
# update-grub
Осталось перезагрузить систему и убедиться в том, что прозрачная поддержка огромных страниц действительно отключилась.

Все описанные выше действия, необходимые для включения плагина TokuDB, можно найти в официальной документации MariaDB, на странице Installing TokuDB.

Создание новых таблиц истории и тенденций

Если база данных только создана и не содержит исторических данных и данных тенденций, то можно просто удалить существующие таблицы:
DROP TABLE history;
DROP TABLE history_uint;
DROP TABLE history_str;
DROP TABLE history_log;
DROP TABLE history_text;
DROP TABLE trends;
DROP TABLE trends_uint;
Если же нужно выполнить миграцию существующей инсталляции Zabbix, тогда лучше сначала переименовать существующие таблицы истории и тенденций:
RENAME TABLE history TO history_bak;
RENAME TABLE history_uint TO history_uint_bak;
RENAME TABLE history_str TO history_str_bak;
RENAME TABLE history_log TO history_log_bak;
RENAME TABLE history_text TO history_text_bak;
RENAME TABLE trends TO trends_bak;
RENAME TABLE trends_uint TO trends_uint_bak;
Вместо прежних таблиц нужно будет создать новые пустые таблицы истории и тенденций, сначала без разбивки на секции, с помощью следующих SQL-запросов:
CREATE TABLE `history` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `value`                  double(16,4)    DEFAULT '0.0000'          NOT NULL,
        `ns`                     integer         DEFAULT '0'               NOT NULL
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;
CREATE INDEX `history_1` ON `history` (`itemid`,`clock`) CLUSTERING=yes;

CREATE TABLE `history_uint` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `value`                  bigint unsigned DEFAULT '0'               NOT NULL,
        `ns`                     integer         DEFAULT '0'               NOT NULL
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;
CREATE INDEX `history_uint_1` ON `history_uint` (`itemid`,`clock`) CLUSTERING=yes;

CREATE TABLE `history_str` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `value`                  varchar(255)    DEFAULT ''                NOT NULL,
        `ns`                     integer         DEFAULT '0'               NOT NULL
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;
CREATE INDEX `history_str_1` ON `history_str` (`itemid`,`clock`) CLUSTERING=yes;

CREATE TABLE `history_log` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `timestamp`              integer         DEFAULT '0'               NOT NULL,
        `source`                 varchar(64)     DEFAULT ''                NOT NULL,
        `severity`               integer         DEFAULT '0'               NOT NULL,
        `value`                  text                                      NOT NULL,
        `logeventid`             integer         DEFAULT '0'               NOT NULL,
        `ns`                     integer         DEFAULT '0'               NOT NULL
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;
CREATE INDEX `history_log_1` ON `history_log` (`itemid`,`clock`) CLUSTERING=yes;

CREATE TABLE `history_text` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `value`                  text                                      NOT NULL,
        `ns`                     integer         DEFAULT '0'               NOT NULL
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;
CREATE INDEX `history_text_1` ON `history_text` (`itemid`,`clock`) CLUSTERING=yes;

CREATE TABLE `trends` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `num`                    integer         DEFAULT '0'               NOT NULL,
        `value_min`              double(16,4)    DEFAULT '0.0000'          NOT NULL,
        `value_avg`              double(16,4)    DEFAULT '0.0000'          NOT NULL,
        `value_max`              double(16,4)    DEFAULT '0.0000'          NOT NULL,
        PRIMARY KEY (itemid,clock) CLUSTERING=yes
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;

CREATE TABLE `trends_uint` (
        `itemid`                 bigint unsigned                           NOT NULL,
        `clock`                  integer         DEFAULT '0'               NOT NULL,
        `num`                    integer         DEFAULT '0'               NOT NULL,
        `value_min`              bigint unsigned DEFAULT '0'               NOT NULL,
        `value_avg`              bigint unsigned DEFAULT '0'               NOT NULL,
        `value_max`              bigint unsigned DEFAULT '0'               NOT NULL,
        PRIMARY KEY (itemid,clock) CLUSTERING=yes
) ENGINE=TokuDB COMPRESSION=TOKUDB_LZMA;
Эти таблицы пока не разбиты на секции, но уже используют движок TokuDB, сжатие данных по алгоритму LZMA и используют кластерные индексы - индексы, хранящиеся вместе с индексируемыми данными.

Разбивка таблиц на секции

Разбивку таблиц на секции я проводил в соответствии со статьёй Docs/howto/mysql partition.

Я подготовил скрипт, который выводит команды, необходимые для разбивки таблиц истории и тенденций на необходимые секции. Настройки начальной и конечной дат, а также размер каждой секции, задаются прямо в тексте скрипта:
#!/usr/bin/python
# -*- coding: UTF-8 -*-

from datetime import datetime, timedelta
from pytz import timezone

def table_partitions(table, start, stop, step):
    print 'ALTER TABLE `%s` PARTITION BY RANGE (`clock`) (' % table

    dt = start
    while dt < stop:
        name = dt.strftime('%Y%m%d%H%M')
        ts = dt.strftime('%s')
        dt += step
        print 'PARTITION p%s VALUES LESS THAN (%s) ENGINE = TokuDB,' % (name, ts)

    name = dt.strftime('%Y%m%d%H%M')
    ts = dt.strftime('%s')
    print 'PARTITION p%s VALUES LESS THAN (%s) ENGINE = TokuDB' % (name, ts)
    print ');'

tz = timezone('UTC')
# Для таблиц тенденций trends и trends_uint
start = datetime(2018, 9, 10, 0, 0, 0, tzinfo=tz)
stop = datetime(2019, 9, 22, 0, 0, 0, tzinfo=tz)
step = timedelta(days=1)
table_partitions('trends', start, stop, step)
table_partitions('trends_uint', start, stop, step)

# Для таблиц истории history и history_uint
start = datetime(2019, 6, 10, 0, 0, 0, tzinfo=tz)
stop = datetime(2019, 9, 22, 0, 0, 0, tzinfo=tz)
step = timedelta(hours=6)
table_partitions('history', start, stop, step)
table_partitions('history_uint', start, stop, step)

# Для таблиц истории history_str, history_text и history_log
start = datetime(2019, 9, 3, 0, 0, 0, tzinfo=tz)
stop = datetime(2019, 9, 22, 0, 0, 0, tzinfo=tz)
step = timedelta(days=1)
table_partitions('history_str', start, stop, step)
table_partitions('history_text', start, stop, step)
table_partitions('history_log', start, stop, step)
Запускаем скрипт, сохраняем выведенные им команды в файл:
$ ./partitions.py > partitions.sql
Затем подключаемся клиентом MySQL к базе данных zabbix:
$ mysql -uzabbix -p zabbix
И выполняем в нём команды из файла partitions.sql:
MariaDB [zabbix]> SOURCE partitions.sql
После выполнения команд таблицы будут разбиты на секции в соответствии с настройками, прописанными в скрипте partitions.py

Перенос имеющихся данных в новые таблицы

Можно было бы перенести данные из старых таблиц в новые простыми SQL-запросами вида INSERT INTO history_uint SELECT * FROM history_uint_bak, но такие запросы на время их работы будут полностью блокировать вставку новых данных в таблицу, поэтому надо переносить данные порциями. Я в этих целях пользуюсь командами следующего вида:
$ mysqldump -t -uroot -p zabbix trends_uint_bak | grep ^INSERT | sed 's/^INSERT INTO/INSERT IGNORE/g' | mysql -uroot -p zabbix
$ mysqldump -t -uroot -p zabbix trends_bak | grep ^INSERT | sed 's/^INSERT INTO/INSERT IGNORE/g' | mysql -uroot -p zabbix
$ mysqldump -t -uroot -p zabbix history_bak | grep ^INSERT | sed 's/^INSERT INTO/INSERT IGNORE/g' | mysql -uroot -p zabbix
$ mysqldump -t -uroot -p zabbix history_str_bak | grep ^INSERT | sed 's/^INSERT INTO/INSERT IGNORE/g' | mysql -uroot -p zabbix
$ mysqldump -t -uroot -p zabbix history_text_bak | grep ^INSERT | sed 's/^INSERT INTO/INSERT IGNORE/g' | mysql -uroot -p zabbix
$ mysqldump -t -uroot -p zabbix history_log_bak | grep ^INSERT | sed 's/^INSERT INTO/INSERT IGNORE/g' | mysql -uroot -p zabbix
Это не красивое решение, но оно меня вполне устраивает, т.к. не приводит к длительной блокировке таблиц.

После переноса данных в новые таблицы старые таблицы можно будет удалить:
DROP TABLE history_bak;
DROP TABLE history_uint_bak;
DROP TABLE history_str_bak;
DROP TABLE history_log_bak;
DROP TABLE history_text_bak;
DROP TABLE trends_bak;
DROP TABLE trends_uint_bak;

Настройки плагина TokuDB

Просмотрев видеоролик с выступлением Владислава Лесина - одного из нынешних разработчиков TokuDB, работающего над этим плагином в компании Percona - я составил для себя список настроек плагина, на которые следует обратить внимание:

tokudb_fanout - максимальное количество дочерних узлов

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

tokudb_block_size - размер узла в памяти

По умолчанию - 4 мегабайта.

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

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

tokudb_read_block_size - размер базового узла

По умолчанию - 64 килобайта.

Меньшие значения лучше для точечных чтений, но приводят к увеличению непоследовательных операций ввода-вывода.

tokudb_row_format - алгоритм сжатия колонок

Возможны следующие значения:
  • tokudb_default, tokudb_zlib - среднее сжатие при средней нагрузке на процессор.
  • tokudb_snappy - хорошее сжатие при низкой нагрузке на процессор.
  • tokudb_fast, tokudb_quicklz - слабое сжатие при низкой нагрузке на процессор.
  • tokudb_small, tokudb_lzma - лучшее сжатие при высокой нагрузке на процессор.
  • tokudb_uncompressed - сжатие не используется.

tokudb_directio - использование прямого ввода-вывода

Значение OFF позволяет использовать дисковый кэш операционной системы в качестве вторичного кэша для хранения сжатых узлов. Для ограничения использования памяти процессом mysqld нужно использовать cgroups.

В качестве пищи для размышлений можно принять во внимание настройки, использованные в тесте производительности TokuDB, описание которого доступно по ссылке LinkeBench MySQL:
tokudb_cache_size = 8G ; default = 12G ?
tokudb_directio = OFF
tokudb_empty_scan = disabled ; default - rl
tokudb_read_block_size = 16K ; default - 64K
tokudb_commit_sync = ON
tokudb_checkpointing_period = 900 ; default = 60
tokudb_block_size = 4M
tokudb_cleaner_iterations = 10000 ; default = 5
tokudb_fanout = 128 ; default = 16
Я ограничился указанием подходящего значения tokudb_cache_size и изменением следующих настроек:
tokudb_directio = ON
tokudb_row_format = tokudb_lzma
tokudb_empty_scan = disabled

Решение проблем

После обновления версии MariaDB пакетами из репозитория по неизвестным причинам планировщик перестаёт выполнять задачу по обслуживанию секций таблиц: не удаляет устаревшие секции и, что гораздо хуже, не создаёт новые секции таблиц. Последнее приводит к тому, что сервер Zabbix не может вставить в таблицы новые данные. Проявляется это в том, что после полуночи в последних данных на графиках нет данных, а сервер Zabbix ругается в журнал ошибками следующего вида:
6619:20200604:000100.756 [Z3005] query failed: [1526] Table has no partition for value 1591210860 [insert into history
(itemid,clock,ns,value) values (3827556,1591210860,519948235,0.012016),(3827601,1591210860,574265420,0.016382),
(3827553,1591210860,683308669,7.549000),(3827616,1591210860,684083178,7.715000),(3827591,1591210860,684848189,3.199600),
(3827583,1591210860,685585717,0.016474),(3827504,1591210860,689418268,24.000000),(3827564,1591210860,690132132,3.209600),
(3827610,1591210860,690862622,0.014954),(1284053,1591210860,732901317,3.000000),(1283392,1591210860,737607405,23.000000),
(352809,1591210860,737607405,35.000000),(1309072,1591210860,738428022,11.000000),(3827571,1591210860,740171802,7.187000),
(1308475,1591210860,740185955,3.000000),(1292277,1591210860,743020934,1.000000),(3827619,1591210860,743278260,0.014760),
(3827573,1591210860,743976749,3.254600),(3827598,1591210860,744811430,7.577000),(1284110,1591210860,745749025,21.000000),
(3827580,1591210860,746661186,7.580000),(1279841,1591210860,747623084,5.000000),(3827607,1591210860,748043948,7.717000),
(1282792,1591210860,749216640,15.000000);
]
Если новые секции таблиц не создаются автоматически, то первым делом вручную вызываем обслуживание таблиц, чтобы сервер Zabbix мог начать писать данные:
CALL partition_maintenance('zabbix', 'trends', 365, 24, 2);
CALL partition_maintenance('zabbix', 'trends_uint', 365, 24, 2);
CALL partition_maintenance('zabbix', 'history', 90, 6, 8);
CALL partition_maintenance('zabbix', 'history_uint', 90, 6, 8);
CALL partition_maintenance('zabbix', 'history_str', 7, 24, 2);
CALL partition_maintenance('zabbix', 'history_text', 7, 24, 2);
CALL partition_maintenance('zabbix', 'history_log', 7, 24, 2);
Далее, чтобы в дальнейшем заработала автоматика, могут помочь следующие действия.

Сначала обновляем таблицы в базах данных до текущей версии MySQL:
$ mysql_upgrade --force -uroot -p mysql
$ mysql_upgrade --force -uroot -p zabbix
Затем пересоздаём запланированное задание:
USE `zabbix`;
DELIMITER $$

CREATE EVENT IF NOT EXISTS `e_part_manage`
       ON SCHEDULE EVERY 1 DAY
       STARTS '2019-04-04 04:00:00'
       ON COMPLETION PRESERVE
       ENABLE
       COMMENT 'Управление созданием и удалением секций'
       DO BEGIN
              CALL partition_maintenance('zabbix', 'trends', 365, 24, 2);
              CALL partition_maintenance('zabbix', 'trends_uint', 365, 24, 2);
              CALL partition_maintenance('zabbix', 'history', 90, 6, 8);
              CALL partition_maintenance('zabbix', 'history_uint', 90, 6, 8);
              CALL partition_maintenance('zabbix', 'history_str', 7, 24, 2);
              CALL partition_maintenance('zabbix', 'history_text', 7, 24, 2);
              CALL partition_maintenance('zabbix', 'history_log', 7, 24, 2);
       END$$

DELIMITER ;
И напоследок перезапускаем сервер MariaDB:
# systemctl restart mariadb
Какое из приведённых решений помогает на самом деле, сказать точно не могу, т.к. я пробовал использовать каждый из советов поодиночке и не установил чёткой закономерности, какой из них помогает всегда. Иногда одно действие не лечит проблему и на следующий день можно заметить, что новые секции опять не создались.

воскресенье, 9 августа 2020 г.

Настройка сервера MySQL

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

Замечу между прочим, что не стоит уделять слишком много внимания оптимизации производительности одной лишь конфигурации сервера MySQL. Самые лучшие оптимизации можно сделать на стороне приложения, оптимизировав SQL-запросы, изменив структуру таблиц и их индексы. Ну и конечно, не следует ожидать многого от слабого сервера.

Неплохим подспорьем при настройке производительности могут оказаться рекомендации утилиты mysqltuner. Однако не стоит безоглядно копировать в файл конфигурации всё, что он порекомендует. В частности, нужно учитывать размер доступной на сервере оперативной памяти.

expire_logs_days

Срок хранения журнала транзакций. Если данные часто и помногу обновляются, то для экономии места стоит задать значение поменьше, например 1 день:
expire_logs_days = 1
Однако, если вы используете репликацию данных на другой сервер, журналы стоит хранить за такой период времени, который может понадобиться на восстановление репликации при её поломке. В противном случае придётся повторно копировать данные с ведущего сервера на ведомый.

transaction_isolation

Уровень изоляции транзакций. Значение REPEATABLE-READ не покажет внутри транзакции новые данные, добавленные в другой транзакции. READ-COMMITED - наоборот, позволяет читать внутри транзакции данные, изменённые в других транзакциях.

Большинству приложений важна целостность и непротиворечивость данных, поэтому они используют базу данных как транзакционную (OLTP). В таком случае лучше использовать значение REPEATABLE-READ.

Если же приложение использует базу данных для аналитических запросов, то лучше подойдёт значение READ-COMMITED.

Бывают и приложения со смешанной логикой. Например, Zabbix хранит в базе данных как собственную конфигурацию, что больше соответствует OLTP, так и исторические данные со значениями определённых показателей в конкретные моменты времени, что больше соответствует OLAP. Но, т.к. нужно обеспечить непротиворечивость данных конфигурации, то запросы к таблицам истории тоже придётся выполнять на уровне изоляции транзакций REPEATABLE-READ.
transaction_isolation = REPEATABLE-READ
Для уверенности стоит поискать настройки, рекомендуемые разработчиками приложения. Если информации найти не удалось, более безопасным выбором будет REPEATABLE-READ.

innodb_file_per_table


Настройка, предписывающая хранить каждую таблицу базы данных в отдельном файле. Перед её выставлением необходимо сделать резервную копию всех баз данных. Чтобы настройка вступила в силу, нужно:
  1. остановить MySQL,
  2. удалить файлы ibdata1, ib_logfile0 и ib_logfile1,
  3. запустить MySQL снова,
  4. восстановить базы данных из резервных копий.
При восстановлении данных MySQL поместит каждую таблицу в отдельный файл.

Когда данные таблиц находятся в отдельных файлах, можно сравнительно легко вернуть в файловую систему место, освободившееся в таблице при удалении данных. Для этого достаточно запустить команду OPTIMIZE TABLE или ALTER TABLE ... FORCE над таблицей, которую нужно ужать. В случае, если используется общее хранилище для всех таблиц, неиспользуемое место никогда не возвращается на диск.

Есть у раздельного хранения данных таблиц и отрицательная сторона: при большом количестве таблиц (и секций таблиц, если они есть) увеличивается время запуска сервера MySQL.

Также операционная система обычно ограничивает количество одновременно открытых одним пользователем файлов, то серверу MySQL может потребоваться закрывать неиспользуемые файлы, чтобы открыть нужные и уложиться в этот лимит. Этот недостаток можно смягчить использованием настройки table_cache, описанной ниже, и изменением ограничений со стороны операционной системы.

Если у базы данных небольшой размер, а работать MySQL придётся на каком-нибудь микрокомпьютере, то возможно не стоит хранить данные таблиц в отдельных файлах. Ещё лучше в подобных случаях будет воспользоваться какой-нибудь встраиваемой базой данных, например, SQLite.

innodb_file_per_table = 1

table_cache

Количество одновременно открытых файлов таблиц. Позволяет уменьшить количество открытий-закрытий файлов. Стоит оценить количество файлов в каталоге, где хранятся файлы с данными MySQL и выставить значение равного порядка.
table_cache = 512
Стоит учитывать, что операционная система ограничивает количество одновременно открытых одним пользователем файлов и значение, указанное в опции, не должно быть больше разрешённого операционной системой лимита.

event_scheduler

Настройка, включающая встроенный в сервер MySQL планировщик задач. Позволяет по расписанию запускать запросы или хранимые процедуры:
event_scheduler = 1

max_connections

Максимальное количество подключений к базе данных. Для обслуживания каждого подключения MySQL запускает по одному отдельному потоку. Если клиентов, подключающихся к базе данных, больше указанного числа, то подключения сверх лимита попадают в очередь, ожидая освобождения одного из занятых подключений.

В случае веб-приложений нет особого смысла выставлять эту настройку намного больше количества процессов сервера приложения. Если в php-fpm или uwsgi для работы приложения выделено 16 процессов, то как правило каждый процесс будет устанавливать не более одного подключения к базе данных.
max_connections = 140

query_cache_size

Кэш результатов прошлых запросов. Если содержимое таблиц, фигурирующих в запросе, не менялось с момента кэширования результата предыдущего такого же запроса, то этот кэш позволит серверу сразу выдать клиенту результат запроса из кэша:
query_cache_size = 64M
Если содержимое таблиц постоянно меняется, а вероятность повторного выполнения запроса низка, то отключение этого кэша никак не скажется на производительности СУБД, но позволит сэкономить немного оперативной памяти. Для отключения кэша запросов можно указать такие опции:
query_cache_type = 0
query_cache_size = 0

general_log_file

Общий журнал. Содержит, например, сведения о запусках и остановках сервера.
general_log_file = /var/log/mysql/mysql.log

log_error

Журнал ошибок:
log_error = /var/log/mysql/mysql.err

log_warnings

Не выводить предупреждающие сообщения в журнале ошибок:
log_warnings = 0

character-set-server и collation-server

Настройка кодировки сервера по умолчанию и настроек сортировки и сравнения символов:
character-set-server = utf8
collation-server = utf8_general_ci

join_buffer_size

Буфер, используемый для соединения таблиц друг с другом. При недостаточном объёме буфера соединение будет осуществляться с использованием диска:
join_buffer_size = 16M

innodb_buffer_pool_size и innodb_buffer_pool_instances

Размер буферного пула. Пожалуй самая важная настройка сервера. Указывает количество оперативной памяти, которую сервер может использовать для обработки данных. Если размер базы данных на диске меньше этого значения, то содержимое всей базы данных может уместиться в оперативной памяти, благодаря чему может быть достигнута максимально возможная производительность сервера.

Если под MySQL выделен отдельный сервер, можно рассчитать размер этого буфера исходя из общего размера доступной на сервере памяти и объёма буферов под обработку запросов от каждого из максимально возможного количества клиентов (max_connections):
innodb_buffer_pool_size = 512M
В интернете можно встретить рекомендации делить буферные пулы размерами больше гигабайта на несколько экземпляров, чтобы на каждый из экземпляров приходилось, например, по одному гигабайту:
innodb_buffer_pool_size = 10G
innodb_buffer_pool_instances = 10

innodb_flush_method

Метод записи данных в файловую систему. Часто встречается рекомендация не использовать дисковый кэш операционной системы, т.к. у сервера MySQL есть собственные буферы, а двойная буферизация замедляет работу и повышает вероятность повреждения данных:
innodb_flush_method = O_DIRECT

innodb_log_file_size, innodb_log_buffer_size и innodb_flush_log_at_trx_commit

Настройки журналов транзакций. Если в журнал данные пишутся помногу и часто, имеет смысл увеличить как размер самого журнала, чтобы серверу реже приходилось создавать новые файлы и переоткрывать их, так и увеличить размер буфера записи, чтобы снизить частоту блокирования сервера в ожидании записи в журнал транзакций:
innodb_log_file_size = 256M
innodb_log_buffer_size = 8M
Таких файлов у MySQL два. Рекомендуется, чтобы размер каждого из них составлял 1/4 от размера innodb_buffer_pool_size. Однако размер файла журнала должен быть меньше 2 гигабайт - это внутреннее ограничение MySQL.

У журнала транзакций имеется настройка, аналогичная настройке буферного пула innodb_flush_method:
innodb_flush_log_at_trx_commit = 2
Возможны следующие значения:
  • 1 - каждая транзакция после фиксации записывается на диск (наибольшая надёжность),
  • 2 - транзакция после фиксации записывается в кэш операционной системы (компромисс между надёжностью и производительностью),
  • 0 - нет требования специально сохранять данные транзакции после её фиксации, данные сохраняются по мере заполнения буфера транзакций (наибольшая производительность, но высокий риск потери данных).

Временные файлы

При необходимости сортировки больших выборок данных MySQL использует раздел для временных файлов. Из-за этого выполнение больших запросов может происходить заметно дольше. Чтобы ускорить обработку таких запросов, можно создать файловую систему для временных файлов в оперативной памяти.

Создаём точку монтирования, например /mysql-tmp:
# cd /
# mkdir mysql-tmp
Добавляем в файл /etc/fstab строчку для монитрования раздела размером, например, 512 мегабайт:
tmpfs /mysql-tmp tmpfs relatime,nodev,nosuid,noexec,uid=mysql,gid=mysql,mode=0760,size=512M 0 0
Смонтируем временный раздел:
# mount /mysql-tmp
Теперь нужно указать в файле конфигурации сервера MySQL внутри секции server соответствующую опцию:
tmpdir = /mysql-tmp
И перезапустить MySQL:
# systemctl restart mysql
Стоит учитывать, что если места на этом разделе окажется недостаточно, запрос не выполнится и MySQL сообщит об ошибке выполнения запроса.

воскресенье, 8 декабря 2019 г.

Резервное копирование файлов и баз данных через SSH

Когда-то давно на работе задумались о резервном копировании серверов. У меня к тому времени уже был наработанный подход к резервному копированию, которым пользуются многие системные администраторы Unix.

Полную резервную копию сервера снимать не имеет смысла, а стоит снимать резервные копии только самого важного, что может пригодиться при настройке сервера с нуля или для восстановления после ошибок. В случае аварий, когда бывает нужно восстановить что-то из резервной копии, меньше всего хочется бороться с какой-то системой резервного копирования, поэтому вместо какой-то готовой системы лучше ограничиться скриптом. При небольшом количестве серверов и небольшом объёме резервных копий скрипт, снимающий резервные копии последовательно, успевает полностью отработать за несколько часов.

Поэтому и в тот раз я решил воспользоваться тем же подходом. Единственное дополнение, которое захотелось сделать - это класть резервные копии сразу в два географически разнесённых хранилища. Одна беда - блог я тогда ещё не вёл и не имел привычки выкладывать куда-то полезные наработки, поэтому скрипт на новом рабочем месте пришлось писать заново. Сейчас подумал, что неплохо всё-таки привести этот скрипт в презентабельный вид и написать о нём статью.

Управление архивом

Начнём с того, что нужно организовать резервные копии в хранилище таким образом, чтобы можно было легко найти нужную резервную копию и удалять устаревшие резервные копии. Файлы будем именовать по схеме YYYYMMDD_filename. При добавлении новой резервной копии filename нужно будет удостовериться, что новый файл не пуст. Если с файлом всё в порядке, то можно снабдить его префиксом YYYYMMDD_, а затем найти и удалить устаревшие резервные копии этого файла. Для выполнения этих функций в скрипте предусмотрены переменная KEEP_DAYS и функция с не самым удачным названием clear_old, которая берёт на себя описанные задачи:
KEEP_DAYS=14

clear_old()
{
# $1 - backup filename

# Если файл с указанным именем не существует
if [ ! -f "$1" ]
then
  return 0
fi

size=`du --bytes "$1" | cut -f1`

# Если размер меньше 512 байт
if [ $size -lt 512 ]
then
  # Удаляем сам файл резервной, а старые резервные копии не трогаем
  rm $1
  return 1
else
  # Переименовываем новый файл резервной копии
  mv $1 `date "+%Y%m%d"`_$1
  
  # Удаляем предыдущие файлы старше KEEP_DAYS дней
  find . -name \*$1 -mtime +$KEEP_DAYS -delete
  return 0
fi
}
Чтобы добавить файл в архив, нужно вызвать функцию clear_old и передать ей имя добавляемого в архив файла.

Резервное копирование локальных файлов

Начнём с простого. Резервные копии нужно снимать не только с других компьютеров, но и с самого компьютера, на котором будет работать скрипт. В моём случае в разное время это был сервер FreeBSD и виртуальная машина с Linux. В дальнейшем скрипт был перемещён на выделенную виртуальную машину с Linux, которая выполняла только функции резервного копирования. Острая необходимость снимать резервные копии самой виртуальной машины, где работал скрипт резервного копирования, отпала, но функции для этого уже были разработаны, поэтому на всякий случай резервные копии локальных файлов продолжают сниматься.

В случае с FreeBSD функция резервного копирования файлов выглядела следующим образом:
BACKUP_USER=rbackup
BACKUP_GROUP=rbackup

freebsd_files_local()
{
# $1 - backup filename

touch "$1"
chown $BACKUP_USER:$BACKUP_GROUP "$1"
chmod 0600 "$1"

tar -cjf - -T- --exclude '*.sql.gz' --exclude 'etc/zabbix/xbackup/*' <<END 2>/dev/null > "$1"
/etc/
/usr/local/etc/
/root/
/usr/home/
/usr/local/www/
END

clear_old "$1"
if [ $? -ne 0 ]
then
  echo "Backing up local system to file $1 failed"
fi
}
Функция принимает один аргумент - имя файла, в котором нужно сохранить резервную копию файлов. Создаваемый файл архива будет иметь формат .tbz (или .tar.bz2).

Перед созданием архива сначала создаётся пустой файл, владельцем которого становится пользователь, указанный в переменной BACKUP_USER, группой-владельцем становится группа, указанная в переменной BACKUP_GROUP, права доступа к файлу выставляются таким образом, что читать и писать его может только пользователь, указанный в переменной BACKUP_USER. Это делается для того, чтобы предотвратить чтение файла резервной копии посторонними пользователями в процессе его создания.

В архив помещаются файлы из каталогов /etc/, /usr/local/etc/, /usr/local/www/, /root/ и /usr/home/, кроме файлов с расширением .sql.gz и файлов из каталога /etc/zabbix/xbackup/. По-сути, в архив сохраняются файлы конфигурации, файлы из домашних каталогов пользователей и файлы веб-приложений, за исключением файлов с резервными копиями баз данных.

После создания архива для файла вызывается функция clear_old, которая переименовывает файл, снабжая его имя префиксом YYYYMMDD_ с текущей датой, и удаляет устаревшие экземпляры этого файла.

В переменных BACKUP_USER и BACKUP_GROUP выше указаны пользователь и группа с именем rbackup. Чтобы создать их, можно воспользоваться такими командами:
# pw add group rbackup
# pw add user rbackup -g rbackup -c "User for backup purposes" -d /usr/home/rbackup -m
В случае с Linux функция резервного копирования файлов была такой:
linux_files_local()
{
# $1 - backup filename

touch "$1"
chown $BACKUP_USER:$BACKUP_GROUP "$1"
chmod 0600 "$1"

tar -cjf - --files-from=- --exclude 'home/*/.pycharm_helpers/*' --exclude 'root/.cpan/*' --exclude 'root/.cache/*' --exclude 'home/*/.cache/*' --exclude 'usr/local/lib/*' <<END 2>/dev/null > "$1"
/etc/
/root/
/home/
/usr/local/
/usr/lib/zabbix/
/usr/share/mapnik/
/var/www/
/var/lib/dokuwiki/
END

clear_old "$1"
if [ $? -ne 0 ]
then
  echo "Backing up local system to file $1 failed"
fi
}
В целом эта функция не отличается от функции для FreeBSD. Резервному копированию подвергаются каталог с файлами конфигурации, домашние каталоги пользователей, каталог веб-приложений, а также каталог /usr/local/, и каталоги с файлами Zabbix и DokuWiki. Из резервного копирования исключаются файлы, создаваемые средой разработки PyCharm (она умеет работать по SSH), каталоги с кэшированными данными, модулями Perl.

Аналогично FreeBSD, в Linux нужно создать пользователя и группу rbackup, который будут использоваться в качестве владельца резервных копий:
# groupadd rbackup
# useradd -c "User for backup purposes" -d /home/rbackup -m -g rbackup rbackup

Резервное копирование файлов с удалённых систем

Резервное копирование удалённых файловых систем работает аналогично, с той лишь разницей, что команды резервного копирования запускаются по SSH, а их стандартный вывод сохраняется в файл в локальной файловой системе.
SSH_PRIVKEY=/root/.ssh/id_rsa

freebsd_files()
{
# $1 - server ip or dns-name
# $2 - server ssh port
# $3 - backup filename

touch "$3"
chown $BACKUP_USER:$BACKUP_GROUP "$3"
chmod 0600 "$3"

ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o Port=$2 -i $SSH_PRIVKEY $BACKUP_USER@$1 "/usr/local/bin/sudo /usr/bin/tar -cjf - -T- --exclude '*.sql.gz' --exclude 'etc/zabbix/xbackup/*'" <<END 2>/dev/null > "$3"
/etc/
/usr/local/etc/
/root/
/usr/home/
/usr/local/www/
END
  
clear_old "$3"
if [ $? -ne 0 ]
then
  echo "Backing up remote system $1:$2 to file $3 failed"
fi
}
Функции передаются три аргумента:
  1. IP-адрес или доменное имя удалённой системы, резервную копию файлов с которой нужно снять,
  2. порт SSH-сервера на этой системе (было время, когда использовались системы с SSH-сервером на нестандартном порту),
  3. имя файла создаваемого архива.
Файлы, подлежащие резервному копированию, без особых проблем сможет прочитать только пользователь root. Однако, в целях безопасности, не хотелось бы разрешать удалённый доступ по SSH для пользователя root, пусть аутентификация и производится с использованием ключей. Не хочется также разрешать скрипту резервного копирования выполнять по SSH какие угодно команды. Для того, чтобы скрипт резервного копирования не смог повредить систему, случайно - из-за ошибки администратора, или специально - если доступ к скрипту получил злоумышленник, резервное копирование производится с использованием учётной записи из переменной BACKUP_USER и sudo.

Для копирования публичного SSH-ключа в домашний каталог пользователя из переменной BACKUP_USER на FreeBSD я пользовался такими командами, которые просто копировал в терминал при настройке нового сервера или виртуальной машины:
# chown rbackup:rbackup /usr/home/rbackup
# mkdir /home/rbackup/.ssh
# chown rbackup:rbackup /usr/home/rbackup/.ssh
# cat <<END > /usr/home/rbackup/.ssh/authorized_keys
ТУТ ПУБЛИЧНЫЙ SSH-КЛЮЧ
END
# chown rbackup:rbackup /usr/home/rbackup/.ssh/authorized_keys
Для того, чтобы разрешить пользователю rbackup запускать через sudo команду tar для резервного копирования файлов, я использовал запускал visudo и вставлял такие настройки:
Defaults:rbackup !requiretty
rbackup    ALL=(root:ALL) NOPASSWD:/usr/bin/tar -cjf - -T- *
Для Linux аналогичная функция выглядит следующим образом:
linux_files()
{
# $1 - server ip or dns-name
# $2 - server ssh port
# $3 - backup filename

touch "$3"
chown $BACKUP_USER:$BACKUP_GROUP "$3"
chmod 0600 "$3"

ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o Port=$2 -i $SSH_PRIVKEY $BACKUP_USER@$1 "/usr/bin/sudo /bin/tar -cjf - --files-from=- --exclude 'home/*/.pycharm_helpers/*' --exclude 'root/.cpan/*' --exclude 'root/.cache/*' --exclude 'home/*/.cache/*' --exclude 'usr/local/lib/*'" 2>/dev/null <<END > "$3"
/etc/
/root/
/home/
/usr/local/
/usr/lib/zabbix/
/usr/share/mapnik/
/var/www/
/var/lib/dokuwiki/
END

clear_old "$3"
if [ $? -ne 0 ]
then
  echo "Backing up remote system $1:$2 to file $3 failed"
fi
}
Для настройки публичных SSH-ключей использовались аналогичные команды:
# mkdir /home/rbackup/.ssh
# chown rbackup:rbackup /home/rbackup/.ssh
# cat <<END > /home/rbackup/.ssh/authorized_keys
ТУТ ПУБЛИЧНЫЙ SSH-КЛЮЧ  
END
# chown rbackup:rbackup /home/rbackup/.ssh/authorized_keys
Для настройки прав в sudo использовались такие строчки:
Defaults:rbackup !requiretty
rbackup ALL=(root:ALL) NOPASSWD:/bin/tar -cjf - --files-from=- *

Резервное копирование удалённой базы данных MySQL

MySQL является сетевым сервером, поэтому резервные копии баз данных можно снимать по сети, не прибегая к помощи SSH. Однако, для того, чтобы не заниматься настройкой фильтрации пакетов, а также не гонять по сети лишний объём данных в открытом виде, резервное копирование баз данных было решено выполнять тоже через SSH.
PASSWORD=тут-пароль

generic_mysql()
{
# $1 - server ip or dns-name
# $2 - server ssh port
# $3 - database name
# $4 - backup filename

touch "$4"
chown $BACKUP_USER:$BACKUP_GROUP "$4"
chmod 0600 "$4"
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o Port=$2 -i $SSH_PRIVKEY $BACKUP_USER@$1 "mysqldump --single-transaction -u$BACKUP_USER -p$PASSWORD $3 | bzip2" 2>/dev/null > "$4"
  
clear_old "$4"
if [ $? -ne 0 ]
then
  echo "Backing up $3 database from system $1:$2 to file $4 failed"
fi
}
Функция принимает 4 аргумента:
  1. IP-адрес или доменное имя удалённой системы, резервную копию файлов с которой нужно снять,
  2. порт SSH-сервера на этой системе (было время, когда использовались системы с SSH-сервером на нестандартном порту),
  3. имя базы данных, резервную копию которой нужно снять,
  4. имя файла создаваемого архива.
Функция вызывает mysqldump для получения резервной копии требуемой базы данных, а для сжатия данных перед отправкой через сеть используется компрессор bzip2. Соответственно, резервная копия, которая будет получена при помощи этой функции, имеет формат .sql.bz2.

Для снятия резервной копии используется пользователь с тем же именем, который является владельцем архивов и используется для подключения к удалённым системам по SSH. Его имя настроено в переменной BACKUP_USER. А для аутентификации этого пользователя используется пароль, указанный в переменной PASSWORD. Понятно, что этот пользователь должен создан и ему должны быть предоставлены права доступа к указанным базам данных. Для этого можно воспользоваться такими запросами:
CREATE USER 'rbackup'@'localhost' IDENTIFIED BY 'тут-пароль';
FLUSH PRIVILEGES;
GRANT SHOW DATABASES, SELECT, LOCK TABLES, RELOAD ON *.* TO 'rbackup'@'localhost';
FLUSH PRIVILEGES;
Т.к. в функции не используется каких-то специфичных путей к файлам и нет необходимости указывать полный путь к команде, выполняемой через sudo, то эта функция пригодна для использования и с FreeBSD и с Linux.

Резервное копирование удалённой базы данных PostgreSQL

Функция резервного копирования базы данных PostgreSQL в целом аналогична функции для резервного копирования базы данных MySQL. Для снятия резервной копии используется утилита pg_dump:
generic_pgsql()
{
# $1 - server ip or dns-name
# $2 - server ssh port
# $3 - database name
# $4 - backup filename

touch "$4"
chown $BACKUP_USER:$BACKUP_GROUP "$4"
chmod 0600 "$4"
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o Port=$2 -i $SSH_PRIVKEY $BACKUP_USER@$1 "env PGPASSWORD=$PASSWORD pg_dump -U$BACKUP_USER $3 | bzip2" > "$4" 2>/dev/null

clear_old "$4"
if [ $? -ne 0 ]
then
  echo "Backing up $3 database from system $1:$2 to file $4 failed"
fi
}
Для снятия резервной копии нужно создать пользователя, указанного в переменной BACKUP_USER, и предоставить ему права доступа ко всем базам данных, резервные копии которых будет необходимо снимать. Войдя в систему под пользователем postgres, создаём пользователя для резервного копирования и вводим его пароль в процессе его создания:
$ createuser -D -R -I -S -P rbackup
Теперь нужно подключиться к каждой из баз данных при помощи команды psql -d база-данных и выполнить следующие запросы:
GRANT SELECT ON ALL TABLES IN SCHEMA public TO rbackup;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO rbackup;

Группировка действий по резервному копированию

Для снятия всех необходимых резервных копий с одного сервера можно создавать функции следующего вида:
backup_server()
{
linux_files 'server.domain.tld' 22 'server.tbz'

generic_mysql 'server.domain.tld' 22 'mysql' 'server_mysql.sql.bz2'
generic_mysql 'server.domain.tld' 22 'base' 'server_base.sql.bz2'

generic_pgsql 'server.domain.tld' 22 'database' 'server_database.sql.bz2'
}

Выполнение резервного копирования

Теперь в скрипте имеются функции для снятия резервных копий каждого сервера. Осталось только создать каталог для хранения резервных копий и выполнить резервное копирование. Для этого создадим каталог и выставим права доступа к нему:
# mkdir /backups
# chown rbackup:rbackup /backups
# chmod u=rwx,g=rx,o= /backups
Теперь впишем в скрипт команды перехода в каталог с резервными копиями, на всякий случай поменяем маску создаваемых файлов и последовательно вызовем функции резервного копирования всех требуемых серверов:
cd /backups
umask 0077

backup_server1
backup_server2

Резервное копирование резервных копий

Не стоит складывать все яйца в одну корзину. На случай, если с резервными копиями в основном месте хранения что-нибудь случится, можно выполнить резервное копирование резервных копий на другой сервер. Для этого на другом сервере создаётся аналогичный каталог, в который при помощи rsync синхронизируются изменения из каталога на основном сервере:
/usr/bin/rsync -a --delete-after -e "ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o Port=22 -i $SSH_PRIVKEY" /backups/ $BACKUP_USER@backup-server.domain.tld.:/backups/
Один процесс rsync запускается локально, один процесс rsync запускается удалённо по SSH. Оба процесса обмениваются друг с другом информацией через SSH. Взаимодействуя друг с другом, они копируют недостающие файлы, обновляют фрагменты изменившихся файлов, удаляют файлы, ставшие не нужными.

Отладочная информация

Для того, чтобы иметь представление о времени начала и завершения тех или иных этапов резервного копирования, можно добавлять в функции или в тело скрипта отладочные сообщения с отметками времени. Например, в свой скрипт я вставил отладочные сообщения, фиксирующие моменты начала и завершения обновления основного архива и моменты начала и завершения синхронизации резервного архива:
echo "BACKUP FINISHED: "`date "+%Y-%m-%d %H:%M:%S"`
# тут резервное копирование серверов
echo "BACKUP STARTED: "`date "+%Y-%m-%d %H:%M:%S"`
  
echo "RSYNC STARTED: "`date "+%Y-%m-%d %H:%M:%S"`
# тут вызов rsync
echo "RSYNC FINISHED: "`date "+%Y-%m-%d %H:%M:%S"`

Заключение

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

воскресенье, 9 августа 2015 г.

Перенос сайта Wordpress на другой домен

Не так давно возникла задача перенести блог на Wordpress на другой домен. Имелись только резервная копия базы данных и файлов. Задача тривиальная, за исключением нескольких моментов: 1. в базе данных имеется много ссылок на старый домен, 2. перенос осуществлялся с Apache на nginx + php-fpm, поэтому нужно было правильно настроить веб-сервер, 3. на старом хостинге отправлять письма можно было без аутентификации, на новом - только с аутентификацией.

На всякий случай, пройдусь полностью по всей процедуре восстановления.

1. Установка пакетов

Установим пакеты, необходимые для работы Wordpress:
# apt-get install nginx mysql-server php5-fpm php5-gd php5-mysql libphp-phpmailer
Возможно понадобится что-то ещё, но мне хватило этого.

2. Восстановление базы данных

Подключимся к только что установленному серверу MySQL:
# mysql -uroot -p mysql
Создадим базу данных blog, в которую будем восстанавливать резервную копию:
mysql> CREATE DATABASE blog CHARSET utf8;
Восстановим резервную копию:
mysql> use blog
mysql> source backup.sql
Теперь создадим пользователя, от имени которого Wordpress будет работать с базой данных:
mysql> use mysql
mysql> INSERT INTO user(user, password, host) VALUES('blog', PASSWORD('blog_password'), 'localhost');
mysql> FLUSH PRIVILEGES;
mysql> GRANT ALL ON blog.* TO blog@localhost;
mysql> FLUSH PRIVILEGES;
Теперь исправим содержимое базы данных так, чтобы все ссылки указывали на новый домен (domain.old - старый домен, domain.new - новый домен):
mysql> UPDATE wp_options SET option_value = REPLACE(option_value, 'domain.old', 'domain.new') WHERE option_name IN ('home', 'siteurl');
mysql> UPDATE wp_posts SET post_content = REPLACE(post_content, 'http://domain.old', 'http://domain.new');
mysql> UPDATE wp_posts SET guid = REPLACE(guid, 'http://domain.old', 'http://domain.new');
mysql> UPDATE wp_posts SET pinged = REPLACE(pinged, 'domain.old', 'domain.new');
mysql> UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, 'domain.old', 'domain.new');
mysql> UPDATE wp_comments SET comment_author_url = REPLACE(comment_author_url, 'domain.old', 'domain.new');
Выйдем из клиента mysql:
mysql> \q
3. Настройка php5-fpm

Создадим новый или отредактируем имеющийся файл /etc/php5/fpm/pool.d/default.conf:
[default]

# Блог будет работать с правами пользователя www-data и группы www-data
user = www-data
group = www-data

# Этот пул php5-fpm будет ждать соединений на указанном UNIX-сокете
listen = /var/run/default.sock

# Права доступа к UNIX-сокету
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

# Динамическое количество процессов в пуле
pm = dynamic
# Максимальное количество процессов в пуле - 6
pm.max_children = 6
# При запуске php5-fpm создаётся 2 процесса в пуле
pm.start_servers = 2
# Если все процессы заняты, значит ожидается увеличение нагрузки, нужно создать ещё один процесс
pm.min_spare_servers = 1
# Если свободно больше 4 процессов, завершаем лишние
pm.max_spare_servers = 4
 
# Журнал запросов, обработанных php5-fpm
access.log = /var/log/php5-fpm.access.log

# Журнал для отладки, сюда попадают сообщения об ошибках
php_value[log_errors] = On
php_value[error_log] = /var/log/php5-fpm.error.log

# Отключаем возможность использовать URL'ы вида file.php/section/page/1
php_value[cgi.fix_pathinfo] = 0

# Страница может генерироваться максимум 30 секунд
php_value[max_execution_time] = 30
# Максимальный объём POST-запроса - 16 мегабайт
php_value[post_max_size] = 16M
# Максимальный размер вложений в POST-запросе - 8 мегабайт
php_value[upload_max_filesize] = 8M
# Выставляем местный часовой пояс
php_value[date.timezone] = Asia/Yekaterinburg
Теперь запустим php5-fpm:
# systemctl start php5-fpm.service
4. Подготовка файлов сайта

После распаковки файлов из резервной копии нужно положить их туда, где с ними будут работать nginx и php5-fpm (в нашем случае это путь /home/www01/domain.new) и выставить правильные права доступа:
# cd /home/www01/domain.new/
# chown www-data:www-data -R *
# find . -type d -exec chmod u=rwx,g=rx,o= \{\} \;
# find . -type f -exec chmod u=rw,g=r,o= \{\} \;
5. Настройка nginx

Пропишем в файл /etc/nginx/sites-available/default такие настройки:
server {
  listen 80;

  server_name domain.new;

  root /home/www01/domain.new/;
  index index.php;

  location / {
    try_files $uri $uri/ /index.php?$args;
  }

  location ~ \.php$ {
    include fastcgi.conf;
    fastcgi_pass unix:/var/run/default.sock;
  }
}
Включим использование этого файла веб-сервером:
# cd /etc/nginx/sites-enabled/
# ln /etc/nginx/sites-available/default .
И запустим сам веб-сервер:
# systemctl nginx start
6. Правка шаблонов Wordpress

Если шаблоны для сайта были разработаны или доработаны специально, нужно поискать и заменить в шаблонах возможно имеющиеся в них ссылки на старый домен. Поиск можно осуществить, например, так:
# cd /home/www/domain.new/
# grep -R domain.old *
Будут выведены имена всех файлов, в которых встречается старый домен и строчки, в которых этот домен встретился. Далее нужно последовательно открыть каждый файл и исправить в нём домен на новый.

По идее, уже сейчас сайт должен работать правильно. Однако, стоит открыть сайт в веб-браузере с активированной функцией отладки и посмотреть, не происходит ли обращений по старому адресу. Если такие обращения происходят, то скорее всего нужно исправить настройки используемой темы или какого-либо плагина Wordpress.

7. Перенаправление со старого домена

Может случиться так, что старый домен проплачен и будет работать ещё некоторое время. Чтобы не терять посетителей, пришедших на старый домен, можно перенастроить его DNS-серверы так, чтобы они указывали на новый хостинг. В таком случае в конфигурацию nginx можно внести небольшое дополнение, которое будет перенаправлять посетителей со старого адреса на новый:
server {
  listen 80;

  server_name domain.old;

  return 301 http://domain.new$request_uri;
}
Можно ещё немного подкорректировать этот фрагмент конфигурации. У многих интернет-пользователей старой закалки сложился стереотип, что имя любого сайта обязано начинаться с www. Чтобы не терять и таких посетителей, поменяем предыдущий фрагмент файла конфигурации следующим образом:
server {
  listen 80;

  server_name domain.old www.domain.old www.domain.new;

  return 301 http://domain.new$request_uri;
}
Чтобы новые настройки вступили в силу, перезагрузим веб-сервер:
# systemctl nginx reload
8. Настройка аутентификации при отправке почты

Как я уже говорил, на старом хостинге почта отправлялась без аутентификации, а на новом требуется аутентификация. Как вариант, на новом хостинге вообще может быть не настроен почтовый сервер. В таком случае всё-же можно отправлять почту, но для этого придётся аутентифицироваться на каком-либо стороннем сервере (gmail.com, yandex.ru, mail.ru и т.п.).

Для решения этой проблемы можно воспользоваться плагином для Wordpress, который называется WP SMTP. Ставим плагин и настраиваем почтовый ящик, который будет использоваться для отправки уведомлений:



На этом поставленные цели достигнуты, задача решена.

воскресенье, 26 июля 2015 г.

Миграция Redmine с MySQL на PostgreSQL

На этот раз рассмотрим перенос данных из MySQL в PostgreSQL, пригодный, пожалуй, для любых приложений, предоставляющих выбор из этих двух СУБД.

pgloader может перенести содержимое базы данных полностью в автоматическом режиме, воссоздавая необходимые таблицы, индексы и внешние ключи. Но этот подход не самый лучший, потому что в дальнейшем могут возникнуть непредвиденные проблемы при работе приложения или в процессе миграции на его более свежие версии. Поэтому лучше будет создать пустую базу данных, взять структуру этой новой пустой базы данных и наполнить её существующими данными.

Рассмотрим эту стратегию миграции на примере Redmine.

Получение схемы базы данных

Схема базы данных - это её структура, то есть таблицы, индексы, ключи, без данных.

Установим дополнительно пакет, отвечающий за работу Redmine совместно с СУБД PostgreSQL:
# apt-get install redmine-pgsql
Для начала перейдём в каталог /etc/redmine/, в котором есть подкаталоги, соответствующие экземплярам redmine:
# cd /etc/redmine/
Скопируем каталог одного из экземпляров в другой каталог, создав таким образом новый экземпляр. В данном случае копируем экземпляр default и создаём на его основе экземпляр pgsql:
# rsync -rogp default/ pgsql/
Открываем файл /etc/redmine/pgsql/database.yml и заменяем в нём имя пользователя в поле username, имя базы данных в поле database и тип базы данных в поле adapter. У меня получился такой файл:
production:
  adapter: postgresql
  database: redmine_pgsql
  host: localhost
  port:
  username: redmine_pgsql
  password: password
  encoding: utf8
Теперь нужно создать соответствующего пользователя и базу данных:
# su - postgres
$ createuser -P redmine_pgsql
$ createdb -E UTF-8 -O redmine_pgsql redmine_pgsql
$ exit
Пустая база данных и пользователь созданы. Теперь нам нужно наполнить эту базу данных структурой и первичными данными. Для этого переходим в каталог /usr/share/redmine/, в котором находится Redmine, и запускаем команду миграции экземпляра pgsql, которая сделает всё необходимое:
# cd /usr/share/redmine
# rake db:migrate RAILS_ENV=production X_DEBIAN_SITEID=pgsql
Теперь, если всё прошло успешно, создаём резервную копию этой базы данных, но без собственно данных:
# su - postgres
$ pg_dump -s -d redmine_pgsql > redmine_pgsql.sql
Теперь можно удалить этого пользователя и его базу данных, от них нам больше ничего не нужно:
$ dropdb redmine_pgsql
$ dropuser redmine_pgsql
$ exit
Теперь можно удалить и каталог с настройками экземпляра pgsql:
# cd /etc/redmine
# rm -R pgsql
Как стало ясно в процессе дальнейших попыток воспользоваться дампом, его нужно откорректировать:
  • Удалить комментарии и пустые строки, т.к. pgloader ожидает, что в каждой строчке будет указан какой-то запрос, который что-то изменяет,
  • Удалить запрос, добавляющий описание языка PL/PgSQL,
  • Удалить двойные кавычки вокруг имён полей, совпадающих с зарезервированными словами,
  • Сменить владельца базы данных с redmine_pgsql на владельца базы данных, которую будем переносить из MySQL в PostgreSQL.
Сделать это можно вот так:
$ cat redmine_pgsql.sql | sed -e 's/^--.*$//g; s/^COMMENT .*$//; /^$/d; s/"//g; s/TO redmine_pgsql/TO redmine_default/g' > redmine_default.sql
Теперь нужно поделить файл redmine_default.sql на две части. Первый должен создать структуру базы данных без ограничений и внешних ключей, а второй - добавлять их. К счастью, стандартный дамп, созданный pg_dump, можно легко поделить на нужные нам части. Первая часть заканчивается запросами вида "ALTER TABLE ONLY ... ALTER COLUMN ... SET DEFAULT ...", а вторая часть начинается запросами вида "ALTER TABLE ONLY ... ADD CONSTRAINT ...". В результате должны получиться файлы redmine_default1.sql и redmine_default2.sql

Приведу ссылки на эти файлы: redmine_default1.sql и redmine_default2.sql Они соответствуют версии Remine, поставляющейся в репозиториях Debian Jessie (версии пакетов - 3.0~20140825-5). Если у вас именно эта версия Redmine, можно пропустить весь этот раздел и сразу воспользоваться этими файлами.

Собственно перенос данных

Если pgloader ещё не установлен, установим его:
# apt-get install pgloader
Теперь создадим файл redmine_default.sql с настройками миграции:
LOAD DATABASE
  FROM mysql://redmine_default:password@localhost/redmine_default
  INTO postgresql://redmine_default:password@localhost/redmine_default

WITH include no drop,
     truncate,
     create no tables,
     create no indexes,
     no foreign keys,
     reset sequences,
     data only

SET maintenance_work_mem TO '128MB',
    work_mem to '12MB'

BEFORE LOAD EXECUTE redmine_default1.sql
Теперь отключаем Redmine, чтобы данные не менялись в процессе переноса в новую СУБД:
# /etc/init.d/uwsgi stop redmine
Открываем файл с настройками подключения к базе данных мигрируемого экземпляра default. В данном случае это файл /etc/redmine/default/database.yml, который в данный момент настроен на использование MySQL. Нужно создать точно такого же пользователя и базу данных в PostgreSQL:
# su - postgres
$ createuser -P redmine_default
$ createdb -E UTF-8 -O redmine_default redmine_default
$ exit
Запускаем миграцию:
$ pgloader redmine_default.load
После миграции открываем снова файл с настройками подключения к базе данных мигрируемого экземпляра default. Это файл /etc/redmine/default/database.yml, заменяем в нём тип базы данных с mysql2 на postgresql.

Теперь можно запустить uwsgi, чтобы Redmine вновь стал доступен для пользователей. Тестируем, всё ли правильно работает. Дальше можно перенести другие экземпляры Redmine и по окончании миграции удалить пакет Redmine для работы с MySQL:
# apt-get purge redmine-mysql

воскресенье, 12 июля 2015 г.

Миграция Zabbix с MySQL на PostgreSQL

Периодически замечал в iotop на своём домашнем компьютере, что самую высокую нагрузку по вводу-выводу создаёт MySQL. В интернете встречал мнения, что PostgreSQL по сравнению с MySQL более стабильно ведёт себя в условиях дефицита производительности дисковой подсистемы. Больше всего MySQL на этом компьютере нагружался Zabbix'ом, поэтому ради эксперимента решил попробовать перевести Zabbix на использование PostgreSQL.

Довольно долго я пытался экспортировать данные из MySQL при помощи штатного инструмента mysqldump в виде, пригодном для последующего экспорта в PostgreSQL. У этой утилиты имеется опция, позволяющая экспортировать в формате, совместимом с PostgreSQL. Несколько дополнительных опций, чтобы отключить директивы, отключить создание таблиц, добавить явные имена колонок в запросы INSERT, позволили получить результат с виду пригодный для импорта в PostgreSQL.

Тут, однако, возникла проблема. Данные таблиц в дампе шли в порядке, соответствюущем алфавитному порядку имён таблиц. Это вызывало проблемы с обработкой внешних ключей. Почему-то на тот момент я не догадался заглянуть в файл scheme.sql и создавал схему базы данных сразу с ограничениями и внешними ключами. Для решения этой проблемы я написал скрипт, который брал информацию о внешних ключах из базы данных information_schema и сортировал таблицы так, чтобы ссылающиеся таблицы шли после тех, на которые они ссылаются. Экспортировал данные я именно в этом порядке.

Однако когда проблема с внешними ключами была решена не самым простым путём, всплыло несколько досадных мелочей:
  • в полученном дампе внутри одинарных кавычек двойные кавычки экранировались обратным слэшем, в то время как PostgreSQL принимал их без экранирования,
  • одинарные кавычки в тех же строках тоже экранировались обратным слэшем, но PostgreSQL ждал, что они просто будут продублированы,
  • двоичные данные сохранялись в виде закавыченной последовательности байтов, в то время как PostgreSQL ждал двоичные данные в виде шестнадцатеричных цифр с префиксом 0x.
Попытки воспользоваться sed'ом для исправления недостатков не привели к успеху и я решил попробовать pgloader.

После продолжительных мучений с документацией pgloader и PostgreSQL в поисках правильной обработки зависимостей внешних ключей, я наконец-то догадался заглянуть в файл schema.sql и поделил его на две части. После этого pgloader отработал без запинки. Получившимся рецептом миграции и спешу поделиться.

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

1. Установка пакетов

Установим СУБД, если она ещё не установлена:
# apt-get install postgresql
Установим пакет pgloader, с помощью которого будем переносить содержимое базы из MySQL в PostgreSQL:
# apt-get install pgloader
Доустановим пакет для работы веб-интерфейса с СУБД PostgreSQL:
# apt-get install php5-pgsql
Чтобы новый модуль можно было использовать из PHP, перезапустим php5-fpm:
# systemctl restart php5-fpm
2. Подготовка СУБД

Правим файл конфигурации аутентификации пользователей /etc/postgresql/9.4/pg_hba.conf, заменяя первую строку на вторую:
local   all             all                                     peer
local   all             all                                     md5
По умолчанию, при подключении к UNIX-сокету, PostgreSQL определяет учётную запись, под которой работает подключившийся процесс и автоматически создаёт подключение от имени одноимённого пользователя из СУБД. Пароль при этом не запрашивается. Меняя эту строчку, мы будем требовать у подключившегося процесса явным образом указать имя пользователя СУБД и его пароль.

После этого перезапустим сервер базы данных, чтобы новые настройки вступили в силу:
# systemctl restart postgresql
Теперь из сеанса пользователя root заходим под пользователем postgres:
# su - postgres
От имени пользователя postgres создаём пользователя базы данных с именем zabbix:
$ createuser -P zabbix
Ключ -P означает, что будет запрошен пароль нового пользователя.

От имени пользователя postgres создаём саму базу данных с именем zabbix, владеть которой будет только что созданный пользователь с именем zabbix:
$ createdb -E UTF-8 -O zabbix zabbix
Опция -E UTF-8 означает, что текстовая информация в базе данных будет храниться в кодировке UTF-8, а опция -O задаёт пользователя, который будет владельцем базы данных.

3. Подготовка к переносу данных

Теперь возьмём файл database/postgresql/schema.sql, имеющийся в дистрибутиве Zabbix и поделим его на две части. В первой части будут запросы, создающие таблицы (CREATE TABLE), а во второй - создающие внешние ключи и ограничения (ALTER TABLE). Назовём эти файлы schema1.sql и schema2.sql

Создадим файл zabbix.load, содержащий настройки для переноса данных:
LOAD DATABASE
  FROM mysql://zabbix:zabbix_password@localhost/zabbix
  INTO postgresql://zabbix:zabbix_password@localhost/zabbix

WITH include no drop,
     truncate,
     create no tables,
     create no indexes,
     no foreign keys,
     reset sequences,
     data only

SET maintenance_work_mem TO '128MB',
    work_mem to '12MB'

BEFORE LOAD EXECUTE schema1.sql

AFTER LOAD EXECUTE schema2.sql;
Подготовим пакет с Zabbix-сервером, работающим с PostgreSQL или подключим репозиторий с этим пакетом. Как это сделать - решайте сами.

4. Перенос данных

Перед переносом данных остановим Zabbix-сервер:
# systemctl stop zabbix-server
Закроем доступ к веб-интерфейсу Zabbix, остановив php5-fpm (можно просто запретить доступ к веб-интерфейсу Zabbix через настройки nginx):
# systemctl stop php5-fpm
Теперь приступим к собственно переносу данных (в текущем каталоге должны быть подготовленные ранее файлы schema1.sql, schema2.sql, zabbix.load):
$ pgload zabbix.load
Пока данные переносятся, удалим старый Zabbix-сервер для MySQL:
# dpkg -r zabbix-server-mysql
Установим Zabbix-сервер для PostgreSQL :
# dpkg -i zabbix-server-pgsql_2.4.5-1+jessie_amd64.deb
Теперь, если данные уже перенеслись, можно запускать новый Zabbix-сервер:
# systemctl start zabbix-server
Отредактируем файл /etc/zabbix/web/zabbix.conf.php, заменив первую строчку на вторую:
$DB['TYPE']     = 'MYSQL';
$DB['TYPE']     = 'POSTGRESQL';
Запускаем php5-fpm (или открываем доступ к веб-интерфейсу Zabbix через настройки nginx):
# systemctl start php5-fpm
5. Проверка результата

Заглядываем в журналы Zabbix-сервера /var/log/zabbix/zabbix_server.log и заходим в веб-интерфейс Zabbix, проверяя, что всё работает нормально.

Кстати, наблюдение за нагрузкой на дисковую подсистему по такому не совсем чёткому параметру как iowait, показало, что нагрузка действительно упала:

воскресенье, 20 апреля 2014 г.

Настройка Exim

На сей раз я опишу альтернативный вариант настройки ранее описанной мной почтовой системы, в которой место Postfix займёт Exim.

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

Эти системы отличаются по архитектуре и целям. Postfix исповедует подход Unix - состоит из небольших взаимодействующих между собой программ. Большое внимание в Postfix уделяется безопасности и надёжности. Каждая программа решает небольшую задачу и использует минимум необходимых прав, зачастую работая в chroot. Есть центральный процесс-диспетчер, который при необходимости перезапустит неожиданно завершившуюся программу. Процесс-диспетчер периодически перезапускает и нормально работающие программы, чтобы защититься от потенциальных утечек памяти. В Postfix есть множество настроек на разные случаи жизни, однако если нужной настройки нет, то скорее всего без дополнительной программы не обойтись.

Exim концентрируется на богатстве возможностей обработки почты. У него тоже есть процесс-диспетчер, но процессы не настолько узко специализированы и не запускаются в chroot. Он менее безопасен по дизайну, однако написан довольно аккуратно. Поэтому на практике он не сильно проигрывает в безопасности. Но и рисковать лишний раз не стоит: если вам не нужно чего-то особого и хватает возможностей Postfix, я бы порекомендовал пользоваться Postfix. У Exim нет жёстко фиксированных настроек, в большинстве случаев необходимое поведение можно сконструировать, используя различные встроенные функции.

В сети не так часто встречается документация по настройке Exim, однако он хорошо документирован и, на мой взгляд, понять принципы его настройки гораздо проще. Имеются даже переводы документации на русский язык.

С переводами документации связана неприятная история. Есть два человека, каждый из которых считает себя настоящим автором перевода, обвиняя другого в воровстве. Первый - Алекс Кеда, Exim 4.70 и второй - Алексей Паутов, Exim 4.80. Я долго изучал оба перевода, искал различные варианты в сети, изучал содержимое web.archive.org с целью понять, чем отличаются переводы и кому принадлежит первенство. Оказалось, что переводы имеют общую основу, отличаясь небольшими деталями. Перевод, сделанный Алексом Кеда, был довольно неуклюжим, но появился в сети в 2007 году. Более того - по веб-архиву можно даже заметить, что он появлялся поэтапно. На сайте Алексея Паутова перевод появился целиком в 2008 году, но был хорошо отредактирован. Конечно, на основании данных одного лишь вебархива нельзя делать далеко идущие выводы. Может быть Алексей Паутов ранее размещал перевод на каком-то другом сайте, упоминания которого сейчас в интернете исчезли полностью. Но если не усложнять, то автором оригинала определённо был Алекс Кеда. Однако, на текущий момент более актуальный и аккуратный перевод можно найти на сайте Алексея Паутова.

1. Установка

Установим SMTP-сервер Exim:
# apt-get install exim4-daemon-heavy
Я решил не пользоваться системой конфигурации Exim, которая принята Debian. В ней нет некоторых частей конфигурации, которые мне понадобятся. Добавить их не проблема, однако придётся ещё исправлять другие файлы, идущие в дистрибутиве, чтобы отключить их обработку в случае использования необходимых мне файлов. А это уже показалось мне неоправданной тратой времени - много хлопот без явной выгоды.

К счастью, каких-то особых действий для отключения этой системы конфигурирования не требуется - достаточно просто создать файл /etc/exim4/exim4.conf, в который и вписать необходимые настройки. В качестве прототипа этого файла я воспользовался информацией из документации: Chapter 7 - The default configuration file. Для интеграции Exim и Dovecot я воспользовался информацией из wiki-системы Dovecot: Dovecot LDA with Exim, Exim and Dovecot SASL. Для интеграции Exim и базы данных Postfixadmin я воспользовался своей предыдущей заметкой Установка и настройка Postfix, OpenDKIM, ClamAV-Milter, Milter-Greylist.

Получился такой файл /etc/exim4/exim4.conf:
# Имя нашей почтовой системы
primary_hostname = mail.domain.tld

# База данных MySQL и учётные данные для работы с ней
hide mysql_servers = localhost/postfixadmin/exim/exim_password

# Список доменов нашей почтовой системы
domainlist local_domains = ${lookup mysql{SELECT domain \
                                          FROM domain \
                                          WHERE domain = '${quote_mysql:$domain}' \
                                            AND backupmx = 0 \
                                            AND active = 1}}

# Список доменов, для которых наша почтовая система является резервной
domainlist relay_domains = ${lookup mysql{SELECT domain \
                                          FROM domain \
                                          WHERE domain = '${quote_mysql:$domain}' \
                                            AND backupmx = 1 \
                                            AND active = 1}}
# Список узлов, почту от которых будем принимать без проверок
hostlist relay_from_hosts = 

# Правила для проверок
acl_not_smtp = acl_check_not_smtp
acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data

# Сокет-файл антивируса ClamAV
av_scanner = clamd:/var/run/clamav/clamd.ctl
# Сокет-файл SpamAssassin
# spamd_address =

# Отключаем IPv6, слушаем порты 25 и 587
disable_ipv6
daemon_smtp_ports = 25 : 587

# Дописываем домены отправителя и получателя, если они не указаны
qualify_domain = domain.tld
qualify_recipient = domain.tld

# Exim никогда не должен запускать процессы от имени пользователя root
never_users = root

# Проверять прямую и обратную записи узла отправителя по DNS
host_lookup = *

# Отключаем проверку пользователей узла отправителя по протоколу ident
rfc1413_hosts = *
rfc1413_query_timeout = 0s

# Только эти узлы могут не указывать домен отправителя или получателя
sender_unqualified_hosts = +relay_from_hosts
recipient_unqualified_hosts = +relay_from_hosts

# Лимит размера сообщения, 30 мегабайт
message_size_limit = 30M

# Запрещаем использовать знак % для явной маршрутизации почты
percent_hack_domains =

# Настройки обработки ошибок доставки, используются значения по умолчанию
ignore_bounce_errors_after = 2d
timeout_frozen_after = 7d

begin acl

  # Проверки для локальных отправителей
  acl_check_not_smtp:
     accept

  # Проверки на этапе RCPT
  acl_check_rcpt:
    accept hosts = :

    # Отклоняем неправильные адреса почтовых ящиков  
    deny message = Restricted characters in address
         domains = +local_domains
         local_parts = ^[.] : ^.*[@%!/|]

    # Отклоняем неправильные адреса почтовых ящиков  
    deny message = Restricted characters in address
         domains = !+local_domains
         local_parts = ^[./|] : ^.*[@%!] : ^.*/\\.\\./

    # В локальные ящики postmaster и abuse принимает почту всегда
    accept local_parts = postmaster : abuse
           domains = +local_domains

    # Проверяем существование домена отправителя
    require verify = sender

    # Принимаем почту от доверенных узлов, попутно исправляя заголовки письма
    accept hosts = +relay_from_hosts
           control = submission

    # Принимаем почту от аутентифицированных узлов, попутно исправляя заголовки письма
    accept authenticated = *
           control = submission/domain=

    # Для не доверенных и не аутентифицированных требуется, чтобы получатель был в домене,
    # ящик которого находится у нас или для которого мы являемся резервным почтовым сервером
    require message = Relay not permitted
            domains = +local_domains : +relay_domains

    # Если домен правильный, то проверяем получателя
    require verify = recipient

    accept

begin routers

  # Поиск транспорта для удалённых получателей
  dnslookup:
    driver = dnslookup
    domains = ! +local_domains
    transport = remote_smtp
    ignore_target_hosts = 0.0.0.0 : 127.0.0.0/8
    no_more

  # Пересылки для локальных получателей из файла /etc/aliases
  system_aliases:
    driver = redirect
    allow_fail
    allow_defer
    domains = domain.tld
    data = ${lookup{$local_part}lsearch{/etc/aliases}}

  # Пересылки с ящика на ящик в локальных доменах из Postfixadmin
  aliases:
    driver = redirect
    allow_fail
    allow_defer
    data = ${lookup mysql{SELECT LCASE(goto) \
                          FROM alias \
                          WHERE address = LCASE('${quote_mysql:$local_part@$domain}') \
                            AND active = 1}}

  # Пересылки на одноимённые ящики в другом домене из Postfixadmin
  alias_domain:
    driver = redirect
    allow_fail
    allow_defer
    data = ${lookup mysql{SELECT alias.goto \
                          FROM alias_domain \
                          JOIN alias ON alias.address = LCASE('${quote_mysql:$local_part@$domain}') \
                            AND alias.active = 1 \
                          WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                            AND alias_domain.active = 1}}

  # Пересылки на ящик по умолчанию в локальном домене из Postfixadmin
  alias_domain_catchall:
    driver = redirect
    allow_fail
    allow_defer
    data = ${lookup mysql{SELECT alias.goto \
                          FROM alias_domain \
                          JOIN alias ON alias.address = LCASE('${quote_mysql:@$domain}') \
                            AND alias.active = 1 \
                          WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                            AND alias_domain.active = 1}}
  
  # Получение почты на локальный ящик из Postfixadmin
  mailbox:
    driver = accept
    condition = ${lookup mysql{SELECT maildir \
                               FROM mailbox \
                               WHERE username = LCASE('${quote_mysql:$local_part@$domain}') \
                                 AND active = 1}{yes}{no}}
    transport = dovecot_virtual_delivery

  # Получение почты на локальный ящик с альтернативным доменным именем из Postfixadmin
  alias_domain_mailbox:
    driver = accept
    condition = ${lookup mysql{SELECT mailbox.maildir \
                               FROM alias_domain \
                               JOIN mailbox ON mailbox.local_part = LCASE('${quote_mysql:$local_part}') \
                                 AND mailbox.domain = alias_domain.target_domain \
                                 AND mailbox.active = 1 \
                               WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                                 AND alias_domain.active = 1}{yes}{no}}
    transport = dovecot_virtual_delivery
    cannot_route_message = Unknown user

begin transports

  # Транспорт для удалённых получателей
  remote_smtp:
    driver = smtp

  # Транспорт для локальных получателей из Dovecot
  dovecot_virtual_delivery:
    driver = pipe
    command = /usr/lib/dovecot/dovecot-lda -d $local_part@$domain -f $sender_address
    message_prefix =
    message_suffix =
    delivery_date_add
    envelope_to_add
    return_path_add
    log_output
    user = vmail
    temp_errors = 64 : 69 : 70: 71 : 72 : 73 : 74 : 75 : 78

begin retry

  *   *   F,2h,15m; G,16h,1h,1.5; F,4d,6h

begin rewrite

begin authenticators

  # Использование LOGIN-аутентификации из Dovecot
  dovecot_login:
    driver = dovecot
    public_name = LOGIN
    server_socket = /var/run/dovecot/auth-client
    server_set_id = $auth1

  # Использование PLAIN-аутентификации из Dovecot  
  dovecot_plain:
    driver = dovecot
    public_name = PLAIN
    server_socket = /var/run/dovecot/auth-client
    server_set_id = $auth1
Сразу поменяем права доступа к файлу конфигурации, чтобы обычные пользователи системы не смогли увидеть пароль доступа к базе данных:
# chmod u=rw,g=r,o= /etc/exim4/exim4.conf
# chown root:Debian-exim /etc/exim4/exim4.conf
Для того, чтобы exim мог извлекать данные из базы данных Postfixadmin, добавим пользователя exim:
USE mysql;

INSERT INTO user(user, password, host) VALUES('exim', PASSWORD('exim_password'), 'localhost');

FLUSH PRIVILEGES;
Дадим пользователю exim доступ к таблице ящиков, доменов, псевдонимов, псевдонимов доменов и квот:
USE mysql;

INSERT INTO tables_priv(host, db, user, table_name, table_priv, column_priv) VALUES
('localhost', 'postfixadmin', 'exim', 'alias', '', 'Select'),
('localhost', 'postfixadmin', 'exim', 'alias_domain', '', 'Select'),
('localhost', 'postfixadmin', 'exim', 'mailbox', '', 'Select'),
('localhost', 'postfixadmin', 'exim', 'domain', '', 'Select'),
('localhost', 'postfixadmin', 'exim', 'quota2', '', 'Select');

INSERT INTO columns_priv(host, db, user, table_name, column_name, column_priv) VALUES
('localhost', 'postfixadmin', 'exim', 'alias', 'goto', 'Select'),
('localhost', 'postfixadmin', 'exim', 'alias', 'address', 'Select'),
('localhost', 'postfixadmin', 'exim', 'alias', 'active', 'Select');

INSERT INTO columns_priv(host, db, user, table_name, column_name, column_priv) VALUES
('localhost', 'postfixadmin', 'exim', 'alias_domain', 'target_domain', 'Select'),
('localhost', 'postfixadmin', 'exim', 'alias_domain', 'alias_domain', 'Select'),
('localhost', 'postfixadmin', 'exim', 'alias_domain', 'active', 'Select');

INSERT INTO columns_priv(host, db, user, table_name, column_name, column_priv) VALUES
('localhost', 'postfixadmin', 'exim', 'mailbox', 'maildir', 'Select'),
('localhost', 'postfixadmin', 'exim', 'mailbox', 'username', 'Select'),
('localhost', 'postfixadmin', 'exim', 'mailbox', 'active', 'Select'),
('localhost', 'postfixadmin', 'exim', 'mailbox', 'quota', 'Select'),
('localhost', 'postfixadmin', 'exim', 'mailbox', 'local_part', 'Select'),
('localhost', 'postfixadmin', 'exim', 'mailbox', 'domain', 'Select');

INSERT INTO columns_priv(host, db, user, table_name, column_name, column_priv) VALUES
('localhost', 'postfixadmin', 'exim', 'domain', 'domain', 'Select'),
('localhost', 'postfixadmin', 'exim', 'domain', 'backupmx', 'Select'),
('localhost', 'postfixadmin', 'exim', 'domain', 'active', 'Select');

INSERT INTO columns_priv(host, db, user, table_name, column_name, column_priv) VALUES
('localhost', 'postfixadmin', 'exim', 'quota2', 'username', 'Select'),
('localhost', 'postfixadmin', 'exim', 'quota2', 'bytes', 'Select');

FLUSH PRIVILEGES;
Чтобы Exim мог использовать механизмы аутентификации из Dovecot, нужно вписать в файл /etc/dovecot/conf.d/10-master.conf, в секцию service auth следующие настройки:
unix_listener auth-client {
  mode = 0660
  user = Debian-exim
}
Осталось перезапустить Exim и Dovecot, чтобы Exim начали работать в минимальной конфигурации:
# /etc/init.d/dovecot restart
# /etc/init.d/exim4 restart
2. Настройка антивируса

Устанавливаем демон ClamAV для проверки файлов на вирусы:
# apt-get install clamav-daemon
Сразу же обновляем антивирусную базу:
# freshclam
Включим clamav в группу Debian-exim, чтобы он мог сканировать файлы, созданные Exim'ом:
# usermod -aG Debian-exim clamav
Добавим в главную секцию файла /etc/exim4/exim4.conf путь к сокет-файлу ClamAV и ACL для этапа DATA:
av_scanner = clamd:/var/run/clamav/clamd.ctl
acl_smtp_data = acl_check_data
В секцию acl файла /etc/exim4/exim4.conf добавим правило, запрещающее приём писем, содержащих вирусы:
acl_check_data:

  deny message = message contains a virus ($malware_name)
       malware = *

  accept
Перезагрузим Exim, чтобы настройки вступили в силу:
# /etc/init.d/exim4 reload
Осталось проверить, что антивирусная система используется. Для этого создадим специально предназначенный для таких целей тестовый файл EICAR:
$ echo -n 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar.txt
И попробуем его отправить во вложении с какого-нибудь почтового ящика почтовой системы на тот же ящик. Если письмо не пришло, значит антивирусная система работает. Для полной уверенности можно ещё заглянуть в журнал почтовой системы /var/log/exim4/mainlog, где должна появиться строчка вида:
2014-04-12 22:06:06 1WZ0RR-0007TO-RM H=localhost (domain.tld) [127.0.0.1] F= A=dovecot_plain:box@domain.tld rejected after DATA: message contains a virus (Eicar-Test-Signature)
3. Проверка квот

Добавим в файл /etc/exim4/exim4.conf в самый конец ACL acl_check_rcpt, но перед финальным правилом accept, следующие правила:
discard message = 422 Mailbox $local_part@$domain is over quota
        domains = +local_domains
        condition = ${lookup mysql{SELECT 1 \
                                   FROM mailbox \
                                   JOIN quota2 ON quota2.username = mailbox.username \
                                     AND quota2.bytes + ${if ={$message_size}{-1}{${expand:message_size_limit}}{$message_size}} >= mailbox.quota \
                                   WHERE mailbox.username = LCASE('${quote_mysql:$local_part@$domain}') \
                                     AND mailbox.active = 1}}

discard message = 422 Mailbox $local_part@$domain is over quota
        domains = +local_domains
        condition = ${lookup mysql{SELECT 1 \
                                   FROM alias_domain \
                                   JOIN mailbox ON mailbox.local_part = LCASE('${quote_mysql:$local_part}') \
                                     AND mailbox.domain = alias_domain.target_domain \
                                     AND mailbox.active = 1 \
                                   JOIN quota2 ON quota2.username = mailbox.username \
                                     AND quota2.bytes + ${if ={$message_size}{-1}{${expand:message_size_limit}}{$message_size}} >= mailbox.quota \
                                   WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                                     AND alias_domain.active = 1}}
Эти правила используют размер сообщения, анонсированного системой отправителя на этапе MAIL FROM, чтобы проверить превышение квоты почтового ящика локального получателя. Если на этапе MAIL FROM не был анонсирован размер сообщения, то для проверки квоты используется максимальный размер сообщения, который может быть принят почтовой системой. Правило discard позволяет не отклонять сообщение полностью, а только лишь отказаться от одного из получателей, если его квота будет превышена. Если письмо адресовано нескольким получателям, то письмо будет принято только для тех получателей, у которых квота не превысится.

Тут стоит отметить, что окончательный размер письма может оказаться другим. Например, наш почтовый сервер добавит к письму несколько заголовков, в результате чего письмо получится немного больше. Отправитель может случайно или намеренно указать размер письма, не соответствующий действительности. На этапе DATA, когда известен точный размер письма, можно отказаться от всего письма целиком, но не от отдельных его получателей, что мне кажется неправильным. Поэтому я не проверяю квоты на этапе DATA, а полагаюсь на обработку ошибок Dovecot'ом, который инициирует отправку писем об ошибке доставки в адрес отправителя.

Осталось перезагрузить Exim, чтобы новые правила вступили в силу:
# /etc/init.d/exim4 reload
4. Настройка SSL/TLS

Изменим настройки прослушиваемых портов в главной секции файла /etc/exim4/exim4.conf, добавив в список порт 465:
daemon_smtp_ports = 25 : 465 : 587
Добавим настройки TLS в главную секцию файла /etc/exim4/exim4.conf:
tls_on_connect_ports = 465
tls_advertise_hosts = *
tls_certificate = /etc/ssl/mail.domain.tld.public.pem
tls_privatekey = /etc/ssl/mail.domain.tld.private.pem
Порт 465 используется для подключения сразу по шифрованному каналу SSL, без явного согласования шифрования. Порт 587 обычно используется для подключений со стороны почтовых клиентов, как правило, с обязательным использованием аутентификации. В рассматриваемой конфигурации порты 25 и 587 никак не различаются, поведение сервера одинаково на обоих портах.

Для задействования добавленных настроек TLS, перезапустим почтовый сервер:
# /etc/init.d/exim4 restart
5. Настройка DKIM-подписей

Для удобного создания ключей DKIM-подписей можно установить пакет opendkim-tools:
# apt-get install opendkim-tools
На самом деле необходимые ключи можно генерировать и при помощи openssl, т.к. пакет opendkim-tools содержит набор shell-скриптов, являющихся обёрткой над утилитой openssl.

Теперь создадим каталог для ключей и сгенерируем пару ключей для домена domain.tld:
# mkdir /etc/exim4/dkim
# cd /etc/exim4/dkim
# opendkim-genkey -D /etc/exim4/dkim/ -d domain.tld -s mail
# mv mail.private mail.domain.tld.private
# mv mail.txt mail.domain.tld.public
Далее можно сгенерировать ключи для других доменов, обслуживаемых нашей почтовой системой.

Выставим права доступа к файлам приватных ключей:
# cd /etc/exim4/dkim
# chmod u=rw,g=r,o= *
# chown root:Debian-exim *
Добавляем в секцию транспортов файла /etc/exim4/exim4.conf, в транспорт remote_smtp, настройки для добавления DKIM-подписей к письмам:
remote_smtp:
  driver = smtp
  dkim_domain = ${lc:${domain:$h_from:}}
  dkim_selector = mail
  dkim_private_key = ${if exists{/etc/exim4/dkim/$dkim_selector.$dkim_domain.private} \
                                {/etc/exim4/dkim/$dkim_selector.$dkim_domain.private}{}}
Достаточно перезагрузить конфигурацию, чтобы письма во внешние домены начали подписываться DKIM-ключами:
# /etc/init.d/exim4 reload
6. Проверка DKIM-подписей

Воспользуемся встроенной в Exim возможностью проверки DKIM-подписей входящих писем. Я буду проверять подписи у тех писем, в которых они есть. Плюс к тому, будем требовать наличия правильной DKIM-подписи для доменов публичных почтовых сервисов, о которых заведомо известно, что они добавляют DKIM-подписи к своим письмам. Это позволит защититься от поддельных писем, якобы исходящих из доменов этих почтовых сервисов.

Зададим в главной секции файла /etc/exim4/exim4.conf домены, для которых требуется правильная DKIM-подпись:
domainlist dkim_required_domains = gmail.com : yandex.ru : rambler.ru : \
                                   mail.ru : bk.ru : list.ru : inbox.ru
В эту же главную секцию файла /etc/exim4/exim4.conf добавим имя списка управления доступом, который будет проверять DKIM-подпись:
acl_smtp_dkim = acl_check_dkim
В секцию acl файла /etc/exim4/exim4.conf добавим описание самого списка управления доступом:
acl_check_dkim:

  # Отклоняем письма с неправильной DKIM-подписью
  deny message = Wrong DKIM signature 
       dkim_status = fail

  # Для выбранных доменов требуем наличия DKIM-подписи
  deny message = Valid DKIM signature needed for mail from $sender_domain
       sender_domains = +dkim_required_domains
       dkim_status = none

  accept
Перезагрузим файл конфигурации Exim, чтобы настройки вступили в силу:
# /etc/init.d/exim4 reload
7. Настройка грейлистинга

Для грейлистинга воспользуемся демоном greylistd, написанном на Python. Этот демон не настолько сложен, как milter-greylist, которым я воспользовался для настройки грейлистинга в Postfix, однако его простота с лихвой компенсируется возможностями Exim. Установим пакет greylistd:
# apt-get install greylistd
greylistd предоставляет механизм, а политику можно определить в конфигурации Exim. Я придерживаюсь политики подвергать грейлистингу те узлы, которые оказались в чёрном списке. Для того, чтобы включить грейлистинг, нужно в самый конец списка управления доступом acl_check_rcpt до финального правила accept добавить следующую проверку:
defer message = Greylisting in action, try later
      !senders = :
      !hosts = ${if exists{/etc/greylistd/whitelist-hosts}\
                          {/etc/greylistd/whitelist-hosts}{}} : \
               ${if exists{/var/lib/greylistd/whitelist-hosts}\
                          {/var/lib/greylistd/whitelist-hosts}{}}
      dnslists = zen.spamhaus.org
      condition = ${readsocket{/var/run/greylistd/socket}\
                              {--grey $sender_host_address $sender_address $local_part@$domain}\
                              {5s}{}{false}}
В поле !senders можно прописать адреса тех отправителей, которые не должны подвергаться грейлистингу. Соответственно, чтобы узел с определённым IP-адресом не подвергался грейлистингу, его можно добавить в файл /etc/greylistd/whitelist-hosts.

Осталось попросить Exim перезагрузить файл конфигурации, чтобы новые настройки вступили в силу:
# /etc/init.d/exim4 reload
8. Настройка SPF-записи

SPF-запись - это TXT-запись следующего вида:
domain.tld. IN TXT "v=spf1 +mx ~all"
Если указанный домен обслуживает Sender Policy Framework, описывающему синтаксис SFP-записи - SPF Record Syntax. Стоит также прочесть о наиболее частых ошибках, допускаемых при создании SFP-записи - Common mistakes.

9. Проверка SPF-записей

Имеются разные способы проверки SPF-записей почтовой системой Exim. Сейчас в официальном дитрибутиве Debian поставлюятся конфигурационные файлы, проверяющие SPF-записи при помощи Perl-программы из пакета libmail-spf-perl. При этом каждая проверка инициирует новый запуск программы. На мой взгляд это довольно расточительно. Ранее существовал пакет libmail-spf-query-perl, в составе которого имелся демон, к которому можно было обратиться через Unix-сокет. Этот способ уже гораздо лучше и по сути ничем не отличается от грейлистинга при помощи демона greylistd на Python'е. Однако сейчас этот пакет не поставляется в репозитории Debian и, похоже, вообще не поддерживается авторами.

Имеется ещё один способ проверки SPF-записей - при помощи самого Exim. Однако эта опция считается экспериментальной и поэтому отключена по умолчанию. Пакеты в Debian собраны тоже без нативной поддержки проверки SPF-записей. Поддержка эта имеется в Exim уже многие годы и многие годы носит статус экспериментальной. Я решил попробовать собрать пакет, в котором поддержка проверки SPF-записей включена.

Для начала скачиваем необходимое для сборки Exim:
# apt-get build-dep exim4
# apt-get source exim4
# cd exim4-4.80
Открываем файл src/EDITME в текстовом редакторе и раскомментируем строчки, включающие поддержку SPF:
EXPERIMENTAL_SPF=yes
CFLAGS  += -I/usr/local/include
LDFLAGS += -lspf2
Вызываем команду редактирования журнала изменений пакета:
# dch -i
Отмечаем изменения, которые внесли в пакет:
exim4 (4.80-7.1) UNRELEASED; urgency=low

  * Non-maintainer upload.
  * Enabled experimental SPF support.

 -- Vladimir Stupin <vladimir@stupin.su>  Sat, 12 Apr 2014 19:45:04 +0600
Собираем пакет:
# dpkg-buildpackage -us -uc -b -rfakeroot
Создаём файл /etc/apt/preferences.d/exim4 в текстовом редакторе и вносим настройки, фиксирующие пакет в системе:
Package: exim4-daemon-heavy
Pin: version 4.80-7.1
Pin-Priority: 1003
Зафиксировать пакет нужно для того, чтобы пакет из дистрибутива не заменил собранный нами вручную. Поскольку дистрибутивный пакет собран без поддержки SPF, он не сможет понять правило проверки SPF в файле конфигурации и не запустится. В результате почтовая система не будет работать. Если в дистрибутиве появится обновление пакета, пакет придётся пересобрать и установить самостоятельно.

Теперь установим пакет с поддержкой SPF:
# cd ..
# dpkg -i exim4-daemon-heavy_4.80-7.1_amd64.deb
SPF-записи могут классифицировать IP-адрес одним из следующих образов:
  • pass (+) - рекомендуется принять почту,
  • fail (-) - рекомендуется отклонить почту,
  • softfail (~) - рекомендуется принять почту, но пометить её как подозрительную,
  • neutral (?) - рекомендуется обрабатывать почту таким образом, как будто SPF-записи не существует.
Дополнительно, есть ещё два статуса, которые сообщают о постоянной или временной ошибке проверки IP-адреса.

Когда SPF-записи были только придуманы, некоторые системные администраторы слишком буквально воспринимали их рекомендации. Случались ситуации, когда первичный почтовый сервер не принимал почту от своего резервного сервера лишь потому, что его IP-адресу соответствовала SPF-запись, предписывающая не принимать письмо. Поэтому сложилась практика не указывать политику fail, а использовать вместо неё политики softfail или neutral. На мой взгляд, если бы не было таких прямолинейных системных администраторов, не было бы никакого смысла в политиках, отличных от pass и fail.

Нормальная почта может исходить только от серверов отправителя и должна приходить на серверы получателя без каких-либо посторонних промежуточных серверов. Сервер получателя должен проверять соответствие отправителя SPF-политике на основных и резервных серверах, а при приёме писем с резервного сервера на основной уже не обращать внимания на то, что его резервный сервер не удовлетворяет политике SPF. Именно поэтому я воспринимаю политики softfail и neutral точно так же, как воспринимаю политику fail. Я в любом случае приму почту от резервного сервера, не смотря на рекомендации SPF-записи, но на резервном сервере я обязательно их проверю.

Перед правилами проверки квот почтовых ящиков в списке управления доступом acl_check_rcpt в секции acl файла /etc/exim4/exim4.conf добавим следующее правило, соответствующее описанным выше соображениям:
deny message = Reject due SPF policy
     spf = fail : softfail : neutral
Осталось лишь перезапустить Exim, чтобы заработал демон из собранного нами пакета и вступили в силу новые настройки:
# /etc/init.d/exim4 stop
# /etc/init.d/exim4 start
10. Требование аутентификации

Чтобы запретить локальным пользователям отправлять почту без аутентификации, добавим в конфигурацию такое правило:
deny message = Local sender must be authenticated
     sender_domains = +local_domains
     !authenticated = *
Чтобы аутентифицированный пользователь не пытался подставить чужой адрес отправителя, добавим в конфигурацию такое правило:
deny message = Send your own mail from yourself
     condition = ${if eq{$authenticated_id}{$sender_address}{no}{yes}}
     authenticated = *
Оба правила нужно добавить в секцию acl файла /etc/exim4/exim4.conf, в правило acl_check_rcpt перед принятием почты от аутентифицированных пользователей.

Осталось перезагрузить файл конфигурации, чтобы она вступила в силу:
# /etc/init.d/exim4 reload
11. Итоговый файл конфигурации
В конечном итоге у меня получился такой файл конфигурации /etc/exim4/exim4.conf:
# Имя нашей почтовой системы
primary_hostname = mail.domain.tld

# База данных MySQL и учётные данные для работы с ней
hide mysql_servers = localhost/postfixadmin/exim/exim_password

# Список доменов нашей почтовой системы
domainlist local_domains = ${lookup mysql{SELECT domain \
                                          FROM domain \
                                          WHERE domain = '${quote_mysql:$domain}' \
                                            AND backupmx = 0 \
                                            AND active = 1}}

# Список доменов, для которых наша почтовая система является резервной
domainlist relay_domains = ${lookup mysql{SELECT domain \
                                          FROM domain \
                                          WHERE domain = '${quote_mysql:$domain}' \
                                            AND backupmx = 1 \
                                            AND active = 1}}

# Список узлов, почту от которых будем принимать без проверок
hostlist relay_from_hosts = 

# Домены, для которых требуется наличие правильной DKIM-подписи
domainlist dkim_required_domains = gmail.com : yandex.ru : rambler.ru : \
                                   mail.ru : bk.ru : list.ru : inbox.ru

# Правила для проверок
acl_not_smtp = acl_check_not_smtp
acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data
acl_smtp_dkim = acl_check_dkim

# Сокет-файл антивируса ClamAV
av_scanner = clamd:/var/run/clamav/clamd.ctl

# Сокет-файл SpamAssassin
# spamd_address =

# Отключаем IPv6, слушаем порты 25, 465 и 587
disable_ipv6
daemon_smtp_ports = 25 : 465 : 587
tls_on_connect_ports = 465

# Настройки сертификатов SSL/TLS
tls_advertise_hosts = *
tls_certificate = /etc/ssl/mail.domain.tld.public.pem
tls_privatekey = /etc/ssl/mail.domain.tld.private.pem

# Дописываем домены отправителя и получателя, если они не указаны
qualify_domain = domain.tld
qualify_recipient = domain.tld

# Exim никогда не должен запускать процессы от имени пользователя root
never_users = root

# Проверять прямую и обратную записи узла отправителя по DNS
host_lookup = *

# Отключаем проверку пользователей узла отправителя по протоколу ident
rfc1413_hosts = *
rfc1413_query_timeout = 0s

# Только эти узлы могут не указывать домен отправителя или получателя
sender_unqualified_hosts = +relay_from_hosts
recipient_unqualified_hosts = +relay_from_hosts

# Лимит размера сообщения, 30 мегабайт
message_size_limit = 30M

# Запрещаем использовать знак % для явной маршрутизации почты
percent_hack_domains =

# Настройки обработки ошибок доставки, используются значения по умолчанию
ignore_bounce_errors_after = 2d
timeout_frozen_after = 7d

begin acl

  # Проверки для локальных отправителей
  acl_check_not_smtp:
     accept

  # Проверки на этапе RCPT
  acl_check_rcpt:

    accept hosts = :

    # Отклоняем неправильные адреса почтовых ящиков
    deny message = Restricted characters in address
         domains = +local_domains
         local_parts = ^[.] : ^.*[@%!/|]

    # Отклоняем неправильные адреса почтовых ящиков
    deny message = Restricted characters in address
         domains = !+local_domains
         local_parts = ^[./|] : ^.*[@%!] : ^.*/\\.\\./

    # В локальные ящики postmaster и abuse принимает почту всегда
    accept local_parts = postmaster : abuse
           domains = +local_domains

    # Проверяем существование домена отправителя
    require verify = sender

    # Принимаем почту от доверенных узлов, попутно исправляя заголовки письма
    accept hosts = +relay_from_hosts
           control = submission

    # Не даём локальным отправителям слать почту без аутентификации
    deny message = Local sender must be authenticated
         sender_domains = +local_domains
         !authenticated = *

    # Не даём локальным отправителям представляться чужим именем
    deny message = Send your own mail from yourself
         condition = ${if eq{$authenticated_id}{$sender_address}{no}{yes}}
         authenticated = *

    # Принимаем почту от аутентифицированных узлов, попутно исправляя заголовки письма
    accept authenticated = *
           control = submission/domain=

    # Для не доверенных и не аутентифицированных требуется, чтобы получатель был в домене,
    # ящик которого находится у нас или для которого мы являемся резервным почтовым сервером
    require message = Relay not permitted
            domains = +local_domains : +relay_domains

    # Проверяем домена удалённого получателя или адрес локального получателя
    require verify = recipient

    # Отклоняем письма, не соответствующие политике домена отправителя
    deny message = Reject due SPF policy
         spf = fail : softfail : neutral

    # Проверка квот получателя
    discard message = 422 Mailbox $local_part@$domain is over quota
            domains = +local_domains
            condition = ${lookup mysql{SELECT 1 \
                                       FROM mailbox \
                                       JOIN quota2 ON quota2.username = mailbox.username \
                                         AND quota2.bytes + ${if ={$message_size}{-1}{${expand:message_size_limit}}{$message_size}} >l;= mailbox.quota \
                                       WHERE mailbox.username = LCASE('${quote_mysql:$local_part@$domain}') \
                                         AND mailbox.active = 1}}

    # Проверка квот в случае использования альтернативного домена ящика получателя
    discard message = 422 Mailbox $local_part@$domain is over quota
            domains = +local_domains
            condition = ${lookup mysql{SELECT 1 \
                                       FROM alias_domain \
                                       JOIN mailbox ON mailbox.local_part = LCASE('${quote_mysql:$local_part}') \
                                         AND mailbox.domain = alias_domain.target_domain \
                                         AND mailbox.active = 1 \
                                       JOIN quota2 ON quota2.username = mailbox.username \
                                         AND quota2.bytes + ${if ={$message_size}{-1}{${expand:message_size_limit}}{$message_size}} >= mailbox.quota \
                                       WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                                         AND alias_domain.active = 1}}

    # Если отправитель попал в чёрный список, отправляем его в грейлистинг
    defer message = Greylisting in action, try later
          log_message = greylisted.
          !senders = :
          !hosts = ${if exists{/etc/greylistd/whitelist-hosts}\
                              {/etc/greylistd/whitelist-hosts}{}} : \
                   ${if exists{/var/lib/greylistd/whitelist-hosts}\
                              {/var/lib/greylistd/whitelist-hosts}{}}
          dnslists = zen.spamhaus.org
          condition = ${readsocket{/var/run/greylistd/socket}\
                                  {--grey $sender_host_address $sender_address $local_part@$domain}\
                                  {5s}{}{false}}

    accept

  acl_check_data:

    # Отклоняем письма, содержащие вирусы
    deny message = Message contains a virus ($malware_name)
         malware = *

    accept

  acl_check_dkim:

    # Отклоняем письма, содержащие DKIM-подпись, если она не правильная
    deny message = Wrong DKIM signature
         dkim_status = fail

    # Отклоняем письма, не содержащие DKIM-подпись, предотвращая подделку писем
    # из крупных почтовых систем, которые всегда добавляют DKIM-подпись
    deny message = Valid DKIM signature needed for mail from $sender_domain
         sender_domains = +dkim_required_domains
         dkim_status = none

    accept

begin routers

  # Поиск транспорта для удалённых получателей
  dnslookup:
    driver = dnslookup
    domains = ! +local_domains
    transport = remote_smtp
    ignore_target_hosts = 0.0.0.0 : 127.0.0.0/8
    no_more

  # Пересылки для локальных получателей из файла /etc/aliases
  system_aliases:
    driver = redirect
    allow_fail
    allow_defer
    domains = domain.tld
    data = ${lookup{$local_part}lsearch{/etc/aliases}}

  # Пересылки с ящика на ящик в локальных доменах из Postfixadmin
  aliases:
    driver = redirect
    allow_fail
    allow_defer
    data = ${lookup mysql{SELECT LCASE(goto) \
                          FROM alias \
                          WHERE address = LCASE('${quote_mysql:$local_part@$domain}') \
                            AND active = 1}}
  
  # Пересылки на одноимённые ящики в другом домене из Postfixadmin
  alias_domain:
    driver = redirect
    allow_fail
    allow_defer
    data = ${lookup mysql{SELECT alias.goto \
                          FROM alias_domain \
                          JOIN alias ON alias.address = LCASE('${quote_mysql:$local_part@$domain}') \
                            AND alias.active = 1 \
                          WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                            AND alias_domain.active = 1}}

  # Пересылки на ящик по умолчанию в локальном домене из Postfixadmin
  alias_domain_catchall:
    driver = redirect
    allow_fail
    allow_defer
    data = ${lookup mysql{SELECT alias.goto \
                          FROM alias_domain \
                          JOIN alias ON alias.address = LCASE('${quote_mysql:@$domain}') \
                            AND alias.active = 1 \
                          WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                            AND alias_domain.active = 1}}

  # Получение почты на локальный ящик из Postfixadmin
  mailbox:
    driver = accept
    condition = ${lookup mysql{SELECT maildir \
                               FROM mailbox \
                               WHERE username = LCASE('${quote_mysql:$local_part@$domain}') \
                                 AND active = 1}{yes}{no}}
    transport = dovecot_virtual_delivery

  # Получение почты на локальный ящик с альтернативным доменным именем из Postfixadmin
  alias_domain_mailbox:
    driver = accept
    condition = ${lookup mysql{SELECT mailbox.maildir \
                               FROM alias_domain \
                               JOIN mailbox ON mailbox.local_part = LCASE('${quote_mysql:$local_part}') \
                                 AND mailbox.domain = alias_domain.target_domain \
                                 AND mailbox.active = 1 \
                               WHERE alias_domain.alias_domain = LCASE('${quote_mysql:$domain}') \
                                 AND alias_domain.active = 1}{yes}{no}}
    transport = dovecot_virtual_delivery
    cannot_route_message = Unknown user

begin transports

  # Транспорт для удалённых получателей
  # Добавляем к исходящим письмам DKIM-подпись
  remote_smtp:
    driver = smtp
    dkim_domain = ${lc:${domain:$h_from:}}
    dkim_selector = mail
    dkim_private_key = ${if exists{/etc/exim4/dkim/$dkim_selector.$dkim_domain.private} \
                                  {/etc/exim4/dkim/$dkim_selector.$dkim_domain.private}{}}

  # Транспорт для локальных получателей из Dovecot
  dovecot_virtual_delivery:
    driver = pipe
    command = /usr/lib/dovecot/dovecot-lda -d $local_part@$domain -f $sender_address
    message_prefix =
    message_suffix =
    delivery_date_add
    envelope_to_add
    return_path_add
    log_output
    user = vmail
    temp_errors = 64 : 69 : 70: 71 : 72 : 73 : 74 : 75 : 78

begin retry

  *   *   F,2h,15m; G,16h,1h,1.5; F,4d,6h

begin rewrite

begin authenticators

  # Использование LOGIN-аутентификации из Dovecot
  dovecot_login:
    driver = dovecot
    public_name = LOGIN
    server_socket = /var/run/dovecot/auth-client
    server_set_id = $auth1

  # Использование PLAIN-аутентификации из Dovecot
  dovecot_plain:
    driver = dovecot
    public_name = PLAIN
    server_socket = /var/run/dovecot/auth-client
    server_set_id = $auth1
Преимущества получившейся системы, на мой взгляд, заключаются в том, что используется небольшое количество дополнительных демонов, а всю логику работы можно понять, изучив всего один файл конфигурации. Если говорить о подсистеме SMTP, то в её состав входят лишь Exim, ClamAV и greylistd. Я намеренно не стал использовать систему оценки подозрительности письма по балльной системе (см. например Настройка почтовой системы. Агент пересылки почты Exim), чтобы в случае проблем можно было легко понять, почему определённое письмо не проходит. В случае с балльной системой понять точную причину сложнее, т.к. критическая сумма баллов может складываться из большого сочетания разных признаков.

При использовании Postfix в подсистему SMTP входили: Postfix, ClamAV, ClamAV-Milter, Milter-Greylist, OpenDKIM. Каждый демон имеет свой синтаксис конфигурационного файла, даже настройки Postfix разбросаны по десятку разных файлов. Логика работы Postfix в конечном счёте оказывается не столь очевидной, поскольку Milter-проверки вмешиваются в проверки собственно Postfix.

Если у вас есть замечания и предложения по описанным настройкам Exim, прошу высказываться, т.к. возможно я что-то упустил.

Дополнение от 3 мая 2014 года:
  1. Добавил правило проверки acl_not_smtp = acl_check_not_smtp для локальных отправителей, отправляющих почту не по протоколу SMTP, как правило, при помощи команды sendmail.
  2. Убрал лишние транспорты из маршрута system_aliases - эти транспорты в рассматриваемой конфигурации не настраивались.
  3. Уточнил маршрут system_aliases. Если система обслуживает несколько доменов, то файл /etc/aliases должен действовать только для домена, в котором находтся доменное имя почтовой системы.