Миграция с pipe/dovecot-lda на LMTP между Postfix и Dovecot: скорость, sieve и честные коды ошибок
Доставка через pipe + dovecot-lda форкает процесс на каждое письмо, теряет внятные коды ответа и захлёбывается на fan-out. LMTP — это постоянный демон, одно соединение на пачку получателей и настоящая SMTP-семантика defer/bounce. Разбираем миграцию на ~15 строк конфига без downtime.
EvilMail Team10 июля 2026 г.11 мин чтения
Письмо на 40 локальных ящиков висело в очереди почти 8 секунд, и в логах Postfix не было ни одной строчки с внятной причиной — только status=sent, растянутый на десятки отдельных доставок. Виноват оказался не диск и не антиспам, а сама схема последней мили: pipe + dovecot-lda. Каждый получатель — это отдельный fork, отдельный exec, отдельный setuid vmail и повторный парсинг dovecot.conf. Сорок получателей — сорок таких циклов подряд.
LMTP убирает этот класс проблем целиком. Ниже — что именно ломается в схеме с dovecot-lda, чем LMTP принципиально другой, и как переехать за пятнадцать строк конфига и два reload без единой секунды простоя.
Почему pipe/dovecot-lda тормозит и врёт в логах
Классическая связка выглядит так. В master.cf живёт транспорт-обёртка:
dovecot unix - n n - - pipe
flags=DRhu user=vmail:vmail
argv=/usr/lib/dovecot/dovecot-lda -f ${sender} -d ${recipient}
Ключевая строка здесь — dovecot_destination_recipient_limit = 1. Она обязательна для pipe-транспорта, потому что dovecot-lda принимает ровно одного получателя за вызов (-d ${recipient}). А значит, Postfix физически не может сгруппировать адреса: письмо на N локальных получателей превращается в N независимых доставок, и на каждую он запускает новый процесс dovecot-lda.
Что делает dovecot-lda при каждом запуске:
fork + exec нового процесса;
setuid на vmail;
полный повторный разбор dovecot.conf и всех conf.d/*.conf;
открытие индексов ящика и, при необходимости, компиляцию Sieve-скрипта.
Всё это — десятки миллисекунд на письмо, которые под fan-out складываются в те самые секунды. Но производительность — полбеды. Хуже то, что dovecot-lda сообщает Postfix результат только кодом возврата процесса по конвенции sysexits.h, и маппинг этих кодов в defer/bounce непрозрачен. Over-quota, временный lock индекса и синтаксическая ошибка в Sieve могут схлопнуться в один и тот же малоинформативный результат. В логе вместо кода ответа получаешь что-то вроде:
status=bounced (command line usage error. Command output: ...)
command line usage error — это EX_USAGE=64, и он не имеет никакого отношения к тому, что реально случилось внутри доставки. Разбирать такие инциденты по логам — боль.
Что такое LMTP и чем он отличается
LMTP (Local Mail Transfer Protocol, RFC 2033) — это SMTP, у которого отрезали две вещи: собственную очередь и разрешение MX. Он спроектирован ровно под последнюю милю — когда письмо уже приехало на сервер и его нужно разложить по локальным ящикам. Не нужно резолвить домены, не нужно самому уметь откладывать и ретраить: за очередь и backoff отвечает MTA, то есть Postfix.
Два отличия от обычного SMTP, которые важны на практике:
Приветствие — LHLO вместо EHLO. Сервер, получивший EHLO, обязан ответить ошибкой: это не SMTP.
В фазе DATA сервер возвращает отдельный статус на каждого получателя, а не один ответ на всю транзакцию. Именно это делает LMTP пригодным для fan-out: часть адресов может быть доставлена, часть отложена — и всё это внутри одного соединения.
От pipe отличие ещё резче. Dovecot держит постоянный демон lmtp, Postfix переиспользует соединение, а один DATA доставляет письмо сразу всем получателям пачки. Нет fork, нет exec, нет повторного парсинга конфига — процесс уже запущен и прогрет.
Настройка Dovecot: поднимаем LMTP-сокет
Сокет должен жить внутри chroot Postfix, иначе Postfix до него не достучится. Правим conf.d/10-master.conf:
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
Права user = postfix / group = postfix критичны: если сокет принадлежит root или dovecot, Postfix получит connection refused. Для split-архитектуры, где Postfix и Dovecot на разных хостах, вместо unix-сокета берут TCP:
service lmtp {
inet_listener lmtp {
address = 127.0.0.1
port = 24
}
}
Но на одном хосте unix-сокет быстрее и не светит лишний порт. Дальше подключаем плагины к протоколу lmtp — это самое частое место, где потом «молча не работает Sieve». В conf.d/20-lmtp.conf:
Обратите внимание: плагины теперь грузятся в protocol lmtp, а не в protocol lda. Настройки lda_mailbox_autocreate и lda_mailbox_autosubscribe, несмотря на префикс lda_, действуют и для LMTP — папки, в которые кладёт письма fileinto, будут создаваться автоматически. Применяем: doveadm reload.
Настройка Postfix: переключаем транспорт
В main.cf меняем направление доставки на LMTP-сокет. Путь указывается относительно /var/spool/postfix:
Если доставка идёт через mailbox_transport (local-адреса, а не virtual), пропишите LMTP туда же. Старый транспорт dovecot из master.cf удаляем или комментируем — он больше не нужен, вместе с ним уходит и строка dovecot_destination_recipient_limit.
lmtp_destination_recipient_limit = 50 — это и есть источник выигрыша. Postfix теперь группирует до 50 получателей одного письма в одну LMTP-транзакцию вместо 50 отдельных доставок. lmtp_destination_concurrency_limit ограничивает число параллельных соединений к Dovecot — 20 хватает почти всем, поднимать выше стоит только под реальной нагрузкой и с оглядкой на default_process_limit.
Применяем: postfix reload. Downtime нулевой — письма, уже стоящие в очереди, уедут новым транспортом при следующей попытке.
Sieve через LMTP: что меняется
Pigeonhole работает точно так же — sieve_before, sieve_after, sieve_default, пользовательские скрипты. Поменялась одна вещь: точка входа теперь протокол lmtp, поэтому и sieve в mail_plugins мы прописали именно там.
Практическая разница — в обработке результата. Когда Sieve делает reject или discard, а vacation генерирует автоответ, эти действия превращаются в корректные LMTP-коды, и уже Postfix решает, что делать с письмом: bounce, discard или доставить. А главное — если Sieve-скрипт не скомпилировался (синтаксическая ошибка после ручной правки), LMTP отдаёт temp-fail, Postfix ставит письмо в defer и ретраит. С dovecot-lda такая ошибка в неудачных конфигурациях могла обернуться тихой потерей письма. LMTP по умолчанию безопаснее: сомнительный случай — это defer, а не молчаливый дроп.
Обработка ошибок: defer vs bounce по-настоящему
Вот где LMTP реально меняет качество эксплуатации. Сравните две модели.
dovecot-lda сообщает результат кодом процесса из sysexits.h:
EX_TEMPFAIL = 75 → Postfix делает defer;
EX_NOUSER = 67 → нет получателя;
EX_NOPERM = 77 → нет прав;
EX_USAGE = 64 → ошибка вызова (та самая мусорная строка в логе).
LMTP отдаёт явные SMTP-подобные коды, отдельно на каждого получателя:
превышена квота → 552 5.2.2 → Postfix делает bounce;
временный lock индекса или иная временная ошибка → 451 4.3.0 (или другой 4.x.x) → defer и ретрай по backoff Postfix;
Обратите внимание на нижнюю часть диаграммы: адрес a сохранён, b получил temp-fail — и Postfix ретраит толькоb, не пересылая письмо в уже доставленный ящик. С dovecot-lda такой пер-получательной гранулярности нет в принципе: каждая доставка изолирована и о соседях ничего не знает.
Проверка и грубый бенчмарк
Сокет проверяется руками через nc. Наберите LMTP-диалог вручную:
bash
nc -U /var/spool/postfix/private/dovecot-lmtp
LHLO test
MAIL FROM:<[email protected]>
RCPT TO:<[email protected]>
DATA
Subject: lmtp probe
body
.
QUIT
Ожидаемый финал — 250 2.0.0 <[email protected]> ... Saved. Для TCP-варианта удобен swaks:
Логи смотрим в doveadm log find, journalctl -u dovecot и на стороне Postfix — там теперь должно быть status=sent (250 2.0.0 ... Saved). Главная регрессионная проверка: grep dovecot-lda /var/log/mail.log. После миграции вызовы dovecot-lda обязаны исчезнуть полностью — если они ещё мелькают, значит какой-то транспорт всё ещё указывает на старый pipe.
Грубый замер эффекта: отправьте письмо на 2–3 локальных получателя и убедитесь, что в логе это одна LMTP-транзакция, а не три доставки. Для over-quota сценария искусственно занизьте quota_rule на тестовом ящике и проверьте, что приходит 552 5.2.2 и Postfix делает bounce, а не бесконечный defer.
Чеклист миграции
Сделать бэкап master.cf и main.cf перед правками.
Добавить service lmtp с unix_listener в /var/spool/postfix/private/ (внутри chroot), mode 0600, user/group postfix.
Прописать sieve в protocol lmtp { mail_plugins = $mail_plugins sieve } — иначе скрипты молча не применятся.
Включить lda_mailbox_autocreate и lda_mailbox_autosubscribe.
В main.cf: virtual_transport = lmtp:unix:private/dovecot-lmtp, поднять lmtp_destination_recipient_limit до 50.
Убрать старый транспорт dovecot из master.cf и строку dovecot_destination_recipient_limit.
Отправить тестовое письмо на 2+ получателей, убедиться в одной транзакции в логе.
Проверить Sieve fileinto с autocreate и over-quota сценарий (552 5.2.2 → bounce).
grep dovecot-lda в логах — вызовы должны пропасть.
Три частые грабли на выходе: сокет вне chroot Postfix даёт connect ... Permission denied; забытый sieve в protocol lmtp — скрипты не срабатывают без единой ошибки в логе; неверный владелец unix_listener — connection refused. Все три ловятся первым же тестовым письмом, поэтому не пропускайте шаг с проверкой на 2+ получателей — именно он показывает и группировку транзакций, и работу Sieve разом.