После переноса mailcow в сеть с MTU 1450 контейнеры перестали скачивать обновления: где режется пакет
Сервер выглядит живым — пинги ходят, SSH открыт, — а контейнеры mailcow вдруг перестают скачивать обновления и резолвить корневые DNS-серверы. Обычно это происходит сразу после переноса сервера в сеть с MTU меньше 1500, например 1450. Разбираю на кейсе магазина «Мелочи стиля», почему сеть Docker не наследует MTU хоста автоматически, где именно режется пакет и почему просто прописать MTU в docker-compose.yml мало.
Кейс «Мелочи стиля»: после переноса в облако обновления перестали скачиваться
Магазин аксессуаров «Мелочи стиля», 14 рабочих мест, переехал с корпоративной почтой на mailcow на новую облачную площадку — старый выделенный сервер заменили на VPS у другого провайдера, у которого внутренняя сеть работает поверх наложенной (overlay) виртуальной сети с MTU 1450 вместо стандартных 1500 байт. Почта первые дни ходила нормально — сотрудники ничего не заметили. Проблема всплыла на плановой проверке через неделю: freshclam в clamd-mailcow не мог скачать свежие сигнатуры и падал по таймауту, unbound-mailcow при рестарте не подтягивал root hints с internic.net, а часть внешних DNS-запросов из контейнеров повисала. При этом ./update.sh отработал без единой ошибки — и это сбивало с толку сильнее всего. Объяснение простое: образы скачивает сам демон Docker в сетевом пространстве хоста, где MTU 1450 уже согласован, а сигнатуры, root hints и DNS-ответы идут изнутри контейнеров, через мост mailcow.
Сеть на хосте при этом была настроена правильно — ip link show ens3 показывал MTU 1450, провайдер честно предупредил об этом при выдаче VPS. Ошибка была не в хосте, а в предположении, что раз хост знает про MTU 1450, то и виртуальная сеть Docker, в которой крутится весь mailcow, унаследует это значение сама. Docker так не делает.
Первые дни симптом маскировался тем, что обычная переписка ходила нормально — короткие письма и служебный обмен SMTP укладываются в небольшие пакеты. Письма с крупными вложениями от части отправителей, как выяснилось потом, приходили с задержкой: их серверы обрывались на передаче и повторяли попытки, но это списали на сами серверы отправителей. Быстрее всего проблему вскрыли именно закачки изнутри контейнеров — базы ClamAV, root hints — и DNS-ответы с DNSSEC-подписями, которые бывают крупнее одного пакета.
Почему сеть Docker не наследует MTU хоста автоматически
MTU меньше стандартных 1500 байт на хосте почти всегда означает, что поверх физической сети уже работает какая-то инкапсуляция — VXLAN в OpenStack (типичная накладка 50 байт даёт ровно 1450), VPN-туннель, GRE или собственная overlay-сеть облачного провайдера. Хост об этом MTU знает, потому что он указан на сетевом интерфейсе. А вот собственный bridge-интерфейс Docker (br-mailcow у сети mailcow-network) создаётся отдельно и по умолчанию получает MTU 1500 — Docker не читает MTU физического интерфейса хоста автоматически.
В результате получается классическое рассогласование: контейнер считает, что у него MTU 1500, и при установке TCP-соединения объявляет удалённой стороне MSS 1460. Удалённый сервер честно шлёт сегменты такого размера, а на последнем участке до хоста проходит только 1450. Исходящие пакеты самого контейнера хост ещё может «поправить» — он рядом и вернёт ICMP локально, — а вот входящий поток при закачке упирается в лимит. Мелкие пакеты — DNS-запросы, ICMP, начало TCP-соединения — обычно проходят без проблем, поэтому ping и первые секунды соединения выглядят нормально. Крупные пакеты при закачке файлов изнутри контейнеров упираются в лимит, требуют фрагментации, которую где-то по пути режут (часто сами облачные провайдеры блокируют ICMP Fragmentation Needed, ломая Path MTU Discovery), и соединение просто виснет или обрывается.
Отдельно стоит помнить про блокировку ICMP как отдельную причину, из-за которой классический механизм Path MTU Discovery не спасает ситуацию сам собой: в норме промежуточный узел с меньшим MTU должен вернуть отправителю ICMP-сообщение «Fragmentation Needed», и стек TCP/IP на источнике сам уменьшит размер пакетов. Но многие облачные площадки и корпоративные файрволы режут ICMP целиком из соображений безопасности — тогда пакет просто теряется молча, без единого сообщения об ошибке, а соединение зависает на таймауте, а не завершается понятной ошибкой.
Что не так с docker-compose.yml из коробки
В текущем docker-compose.yml mailcow сеть mailcow-network описана так: driver_opts содержит только com.docker.network.bridge.name: br-mailcow — параметра MTU там по умолчанию нет вообще, то есть Docker берёт значение по умолчанию (обычно 1500) независимо от того, какой MTU у хоста. Официальная документация mailcow отдельно оговаривает это в разделе про нестандартный MTU (пример — OpenStack) и даёт готовую вставку.
networks:
mailcow-network:
driver_opts:
com.docker.network.driver.mtu: 1450Строку com.docker.network.driver.mtu: 1450 нужно добавить в блок driver_opts рядом с уже существующим com.docker.network.bridge.name: br-mailcow, а не вместо него — оба параметра относятся к одному и тому же bridge-интерфейсу и не конфликтуют.
Значение MTU в driver_opts должно быть числом без единиц измерения и без кавычек — именно так, как в примере официальной документации. Кавычки, кстати, не страшны: driver_opts по спецификации Compose — это словарь строк, и "1450" сработает так же, как 1450. Опаснее другое — отступы и место вставки: я видел, как строку с MTU ставили на уровень driver: bridge или вообще в блок другого сервиса, и Compose молча принимал файл, но MTU не применял. Проверить проще всего командой docker compose config: она выводит финальный собранный конфиг, и com.docker.network.driver.mtu должен стоять внутри networks → mailcow-network → driver_opts рядом с br-mailcow.
Почему docker compose up -d не применяет новый MTU сразу
Здесь у «Мелочи стиля» и случилась вторая часть проблемы: после правки docker-compose.yml и обычного docker compose up -d ничего не изменилось — обновления по-прежнему не шли. Сеть с таким именем уже создана с прежним MTU, а MTU существующей bridge-сети Docker на лету не меняет — её можно только удалить и создать заново, пока к ней подключены контейнеры, этого не произойдёт. В зависимости от версии Compose вы получите либо явную ошибку, либо тихо оставшуюся старую сеть, поэтому я не полагаюсь на up -d и всегда проверяю результат через docker network inspect.
В официальном форуме mailcow разбирался ровно этот случай: пользователь после правки MTU получал явную ошибку от тогдашнего docker-compose (v1) — ERROR: Network "mailcowdockerized_mailcow-network" needs to be recreated - option "com.docker.network.driver.mtu" has changed — и решил её только полной пересборкой сети:
docker compose down
docker compose up -ddocker compose down останавливает контейнеры и удаляет сеть целиком, а следующий up -d создаёт её заново уже с новым значением MTU из файла. Кратковременный простой почты при этом неизбежен — я планирую это окно заранее и предупреждаю клиента, а не делаю такую операцию посреди рабочего дня без предупреждения.
У «Мелочи стиля» простой занял около полутора минут — контейнеры mailcow стартуют не мгновенно, Postfix, например, ждёт, пока unbound-mailcow станет healthy, — и я делал это поздно вечером, когда заказы через сайт не оформляются. Письма, которые пришли бы точно в это окно, просто подождали бы на стороне отправителя и доставились через несколько минут — это нормальное поведение SMTP при кратковременной недоступности сервера, а не потеря почты и не повод для паники у клиента.
Почему правка может пропасть при следующем обновлении
Есть нюанс, которого нет в официальной инструкции, но который важен на практике: docker-compose.yml в mailcow — не локальный файл, который вы редактируете и забываете, а часть git-репозитория проекта. ./update.sh сначала коммитит локальные изменения, а потом сливает новую версию из апстрима командой git merge -X"${MERGE_STRATEGY:-theirs}" — то есть при конфликте побеждают изменения из репозитория mailcow, а не ваша ручная правка. Правка, которая ни с чем не конфликтует, переживёт слияние, но полагаться на удачу я бы не стал: стоит апстриму поменять соседние строки сетевого блока mailcow-network (а он менялся, например, вокруг IPv6), и спорный кусок молча уйдёт в пользу апстрима вместе с MTU 1450.
Правильное место для таких изменений — docker-compose.override.yml, отдельный файл, который git не трогает (в отличие от основного docker-compose.yml, он в .gitignore репозитория mailcow) и который Docker Compose подмешивает поверх основного файла автоматически, без дополнительных флагов:
cat > docker-compose.override.yml << 'EOF'
networks:
mailcow-network:
driver_opts:
com.docker.network.driver.mtu: 1450
EOF
docker compose down
docker compose up -dТак значение переживёт любое количество ./update.sh и не потребует каждый раз вспоминать, что именно и зачем было изменено в основном файле.
Тот же принцип — разделять то, что «принадлежит» апстриму, и то, что относится к конкретному серверу, — я закладываю в любую инсталляцию на Docker Compose, не только mailcow. В практиках эксплуатации Docker Compose в продакшне override-файл — стандартный инструмент именно для таких локальных отличий: MTU, дополнительные volume, лимиты ресурсов под конкретное железо. Основной compose-файл остаётся точной копией апстрима, и его можно спокойно перезаписывать при каждом обновлении, не боясь потерять локальную специфику.
Как я проверяю MTU у клиентов на mailcow по шагам
Первым делом смотрю MTU физического интерфейса хоста — это отправная точка, с которой нужно синхронизировать всё остальное:
ip link show ens3 | grep mtuДальше сверяю, что реально применено к сети Docker — значение в файле и то, что действительно создано, могут не совпадать, если после правки не было down/up:
docker network inspect mailcowdockerized_mailcow-network --format '{{json .Options}}'И последним шагом проверяю MTU внутри самого контейнера, а не только на уровне сети Docker — именно то значение, с которым реально работает служба:
docker compose exec unbound-mailcow ip link show eth0 | grep mtuЕсли все три значения совпадают с MTU хоста — 1450 у «Мелочи стиля» — можно быть уверенным, что фрагментация больше не режет крупные пакеты на этом участке. Если хоть одно отличается, я не гадаю, а прохожу цепочку заново с той точки, где разошлось: обычно это либо не выполненный down/up после правки, либо правка не в том файле, которую перезаписал update.sh.
Такое же расследование «где реально режется пакет, а не где кажется» я провожу и за пределами Docker — например, когда пакет виден в tcpdump, но всё равно не доходит до цели: причина там обычно не MTU, а асимметричный маршрут и rp_filter на хосте. Симптом похож — «сеть вроде работает, а конкретный трафик нет», — а причина и диагностика совсем другие, и я всегда сначала разделяю эти два класса проблем, а не начинаю с самой знакомой гипотезы.
После переноса на новую площадку я вообще стараюсь заранее спрашивать провайдера про MTU внутренней сети, а не узнавать это постфактум по симптомам. Это ровно один вопрос в тикете технической поддержки — «какой MTU у виртуальной сети, в которой будет работать VPS» — а экономит он потом часы диагностики зависших закачек и непонятных таймаутов DNS. У «Мелочи стиля» я добавил эту проверку в собственный чек-лист переноса mailcow на новую площадку рядом с проверкой файрвола и открытых портов — MTU обычно никто не упоминает в списке того, что нужно свериться при миграции, а зря — пока не столкнёшься с этим на живом клиенте хотя бы раз.
Частые вопросы
Почему после смены MTU на хосте контейнеры mailcow перестают скачивать обновления?
Docker не наследует MTU физического интерфейса хоста автоматически — сеть mailcow-network по умолчанию создаётся с MTU 1500. Контейнер объявляет удалённой стороне MSS под 1500, крупные входящие пакеты не проходят участок с MTU 1450, и при заблокированном ICMP закачка изнутри контейнера виснет. Образы при этом качаются нормально — их тянет демон Docker в сети хоста.
Достаточно ли прописать MTU в docker-compose.yml и выполнить docker compose up -d?
Нет. Compose не пересоздаёт уже существующую сеть только из-за изменения driver_opts в файле — нужно выполнить docker compose down, а затем docker compose up -d, чтобы сеть была создана заново с новым MTU.
Почему правка MTU в docker-compose.yml mailcow может пропасть?
docker-compose.yml — часть git-репозитория mailcow, и update.sh сливает новую версию через git merge -X theirs по умолчанию. При конфликте в сетевом блоке ваша ручная правка проигрывает изменениям из апстрима.
Как сделать настройку MTU постоянной в mailcow?
Вынести её в docker-compose.override.yml — этот файл в .gitignore репозитория mailcow, update.sh его не трогает, а Docker Compose подмешивает поверх основного файла автоматически при каждом up.
Как проверить, что MTU реально применился к сети mailcow?
Сравнить три значения: MTU хоста (ip link show), MTU сети Docker (docker network inspect ... --format '{{json .Options}}') и MTU интерфейса внутри контейнера (docker compose exec <сервис> ip link show eth0). Все три должны совпадать.
Источники
- Install mailcow — MTU not equal to 1500 (e.g. OpenStack) — Проверено дословно: раздел про нестандартный MTU, вставка driver_opts с com.docker.network.driver.mtu: 1450 в docker-compose.yml. https://docs.mailcow.email/getstarted/install/#mtu-not-equal-to-1500-eg-openstack
- Recreate network to change mtu? — mailcow community — Проверено дословно: точный текст ошибки ERROR: Network «mailcowdockerized_mailcow-network» needs to be recreated и решение через docker-compose down / up -d. https://community.mailcow.email/d/1553-recreate-network-to-change-mtu
- docker-compose.yml, сетевой блок mailcow-network — Проверено: driver_opts по умолчанию содержит только com.docker.network.bridge.name: br-mailcow, без параметра MTU. https://github.com/mailcow/mailcow-dockerized/blob/master/docker-compose.yml
- update.sh, исходник mailcow-dockerized — Проверено: update.sh делает git commit -am «Before update», затем git merge -X"${MERGE_STRATEGY:-theirs}" -Xpatience — при конфликте побеждает апстрим. https://github.com/mailcow/mailcow-dockerized/blob/master/update.sh
- .gitignore, mailcow-dockerized — Проверено: docker-compose.override.yml исключён из git-репозитория, в отличие от основного docker-compose.yml. https://github.com/mailcow/mailcow-dockerized/blob/master/.gitignore



