mailcow и MTU 1450: почему контейнеры не качают обновления
АйТи Фреш
Сети и VPN

После переноса mailcow в сеть с MTU 1450 контейнеры перестали скачивать обновления: где режется пакет

Автор: , директор ООО «АйТи-Фреш» · · ~12 мин чтения
Крупные пакеты застревают при переходе из сети Docker с MTU 1500 в реальную сеть хоста с MTU 1450
Мелкие пакеты проходят, крупные — застревают на разнице MTU между сетью Docker и хостом.

Сервер выглядит живым — пинги ходят, 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-подписями, которые бывают крупнее одного пакета.

Схема прохождения пакета от контейнера mailcow через мост Docker к физическому интерфейсу хоста с меньшим MTU
Docker не читает MTU хоста автоматически — мост br-mailcow остаётся на 1500, пока вы не зададите его сами.

Почему сеть 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.

Пошаговый чек-лист применения нового MTU для сети mailcow-network в Docker Compose
Одного docker compose up -d мало — сеть нужно пересоздать.

Почему 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 -d

docker 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-network лучше хранить в docker-compose.override.yml, а не в основном docker-compose.yml
Override-файл переживёт любое количество обновлений mailcow — основной файл нет.

Как я проверяю 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). Все три должны совпадать.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи