Пятница, 17:40, билд красный

Пятница. До конца рабочего дня двадцать минут. И тут прилетает: «билд упал». Ну, бывает - ребейзнемся и перезапустим. Открываю пайплайн. Джоба висит на get_sources, тянется секунд сто двадцать, и умирает с лаконичным:
fatal: unable to access '...': Recv failure: Connection reset by peer
Первая мысль: GitLab лёг. Открываю веб-интерфейс - живой. curl с хост-машины - 200 за полсекунды. Клонирую репозиторий по SSH - летит. Всё работает. Кроме Docker-контейнера, из которого GitLab Runner пытается сделать git clone.
Пятничный вечер обещал быть длинным. Но хотя бы интересным.
Улики: что работает, а что - нет
Захожу в контейнер и начинаю тыкать:
# Маленький запрос - работает curl -I https://gitlab.example.com # HTTP/2 200 # Большой запрос - тишина 120 сек, потом обрыв git clone https://gitlab.example.com/group/repo.git
Стоп. Маленькие запросы пролетают, большие - стабильно рвутся. Не DNS (резолвится мгновенно). Не файрвол (порт открыт - curl же проходит). Не сертификаты (TLS-хендшейк завершается). Есть только одна вещь, которая ведёт себя по-разному в зависимости от размера данных.
Размер пакета.
И тут в голове начинает складываться картинка.
Подозреваемый: 50 байт, которых никто не звал
MTU (Maximum Transmission Unit) - максимальный размер IP-пакета, который сетевой интерфейс отправит, не разрезая на куски. Стандарт для Ethernet - 1500 байт. Все к этому привыкли, как к тому, что ls показывает файлы. Просто работает.
Но наша VM живёт не на голом железе. Она живёт в OpenStack. А OpenStack, чтобы дать каждому тенанту свою изолированную сеть без физического разделения, использует overlay-технологии - VXLAN или GRE. Суть: каждый ваш пакет оборачивается в ещё один пакет. Конверт в конверте. Матрёшка уровня L2.
Выглядит это так:

Внешняя обёртка съедает ~50 байт. Физическая сеть по-прежнему держит MTU 1500, но для вашего пакета внутри остаётся только 1450. Проверяю:
ip link show ens3 # ... mtu 1450 ...
Бинго. Хостовой интерфейс знает, что потолок - 1450. А вот Docker - не знает.
Почему Docker живёт в своём мире
Docker при создании bridge-сети (docker0) всегда ставит MTU 1500. Не потому что он глупый - просто он не обязан знать про вашу инфраструктуру. Он берёт стандартное значение Ethernet и идёт дальше. В результате имеем разрыв:
ens3 → MTU 1450 (реальный потолок) docker0 → MTU 1500 (дефолт Docker) veth*** → MTU 1500 (интерфейс контейнера)
Контейнер формирует TCP-сегмент. С учётом MSS (Maximum Segment Size - максимум полезных данных в одном TCP-сегменте) он дорастает до IP-пакета ~1500 байт. Пакет долетает до ens3 и... не проходит. В IP-заголовке стоит DF-бит (Don't Fragment) - стандартное поведение для TCP. Резать пакет нельзя, протащить целиком невозможно.
По идее, тут должен сработать Path MTU Discovery - механизм красивый, как космический корабль. Маршрутизатор, который не может протащить пакет, шлёт обратно ICMP-сообщение «Fragmentation Needed» (тип 3, код 4). Отправитель получает его, говорит «ага, слишком большой», уменьшает размер сегментов - и всё работает.
В теории. На практике этот космический корабль разбивается о три рифа:
Файрволы и security groups режут ICMP. «Зачем нам пинги в продакшене?» - говорят они. И вместе с пингами убивают PMTUD.
OpenStack-роутеры не всегда корректно генерируют эти ICMP-ответы.
Network namespaces: даже если ICMP долетает до хоста, он не всегда доходит до контейнера.
Итог: отправитель не узнаёт, что пакет слишком большой. TCP не получает ACK, ждёт, retransmit тем же огромным пакетом, снова тишина, и через ~120 секунд - Connection reset by peer. Два километра ожидания ради одной строки ошибки.
Почему маленькие запросы проходят (и почему это сбивает с толку)
Вот что делает этот баг по-настоящему подлым.
curl -I - это HEAD-запрос: несколько сотен байт туда, пара заголовков обратно. Всё помещается в 1450 байт, пролетает как SMS.
git clone - совсем другой зверь. Сервер отдаёт packfile потоком, TCP-сегменты максимального размера (MSS ≈ 1460 → IP-пакет ≈ 1500). Каждый из них врезается в MTU overlay-сети, как грузовик в низкий тоннель.
Итого:
ping- работает (пакеты крошечные).curlна лёгкие эндпоинты - работает.Любой health check - работает.
А
git clone, скачивание артефактов,docker pull- ломается.
Базовая проверка сети всегда проходит. Все инструменты говорят «всё ОК». И только когда данных становится много, система молча падает. Именно поэтому до MTU додумываешься не сразу - сначала успеваешь проверить DNS, прокси, сертификаты и даже фазу луны.
Диагностика: 3 команды, 30 секунд
Хватит теории. Если вы сейчас читаете это с горящим пайплайном - вот быстрый чек:
# 1. Узнаём MTU хостового интерфейса ip link show ens3 | grep mtu # mtu 1450 # 2. Узнаём MTU внутри контейнера docker run --rm alpine ip link show eth0 | grep mtu # mtu 1500 ← если тут больше, чем в пункте 1 - вы нашли проблему # 3. Контрольный выстрел - пинг с запретом фрагментации # 1422 = 1450 − 20 (IP header) − 8 (ICMP header) docker run --rm alpine ping -s 1422 -M do -c 3 gitlab.example.com # PING gitlab.example.com: 1422 data bytes - OK, прошёл docker run --rm alpine ping -s 1423 -M do -c 3 gitlab.example.com # ping: sendmsg: Message too long ← вот она, граница
Флаг -M do запрещает фрагментацию. Если пакет хотя бы на 1 байт больше допустимого - ping скажет об этом мгновенно. Никакого двухминутного ожидания в духе «а может, сейчас пройдёт».
Если команды 1 и 2 показали разный MTU - дело раскрыто. Переходим к фиксу.
Фикс: одна строка, ноль магии
Шаг 1. Сказать Docker правду о сети
Создаём (или редактируем) /etc/docker/daemon.json:
{ "mtu": 1450 }
Одна строка. Вся разница между «билд красный» и «билд зелёный» - 16 символов в JSON-файле.
Шаг 2. Перезапустить Docker
sudo systemctl restart docker
После рестарта Docker создаст bridge с правильным MTU. Все новые контейнеры унаследуют его, TCP-сегменты станут чуть меньше - и спокойно пролезут через overlay-сеть.
Шаг 3. Разобраться с существующими сетями
Тут засада: daemon.json влияет на дефолтный bridge. Кастомные Docker Compose-сети, которые уже были созданы, сохраняют старый MTU. Два варианта:
Вариант A - простой: пересоздать всё.
docker compose down && docker compose up -d
Вариант B - надёжный: явно указать MTU в docker-compose.yml.
networks: app-net: driver: bridge driver_opts: com.docker.network.driver.mtu: 1450
Шаг 4. Перезапустить GitLab Runner
sudo gitlab-runner restart
Шаг 5. Убедиться, что всё живое
docker run --rm alpine ping -s 1422 -M do -c 3 gitlab.example.com # Должен пройти без ошибок
Запускаем пайплайн. get_sources пролетает за секунды. Билд зелёный. Пятничный вечер спасён.
Грабли, на которые наступают все (и как их обойти)
«Поменял daemon.json, а проблема осталась»
Проверьте Compose-сети. daemon.json не перезаписывает MTU уже созданных кастомных сетей. docker network inspect <имя> покажет реальный MTU - если там 1500, сеть нужно пересоздать.
«Поставлю MTU 1300 - с запасом»
Не надо. Каждый лишний байт, вычтенный из MTU, - это лишний пакет на тот же объём данных. MTU 1300 вместо 1450 - это ~10% падение пропускной способности «на ровном месте». Узнайте реальный MTU хоста и ставьте его.
«Наверное, это DNS / прокси / TLS»
Классические подозреваемые. Но у них другой почерк: DNS-проблемы дают таймаут 5–30 секунд, прокси возвращает явную ошибку, TLS ломается на хендшейке. А вот 120 секунд тишины и Connection reset на больших запросах при работающих мелких - это визитная карточка MTU mismatch.
«Path MTU Discovery же должен был всё починить!»
Должен. Но не починил. В облачных overlay-сетях PMTUD - скорее приятная теория, чем рабочий механизм. ICMP режут файрволы, режут security groups, режут роутеры. Не рассчитывайте на него.
«Починил руками, поехали дальше»
Через месяц поднимаете новую VM - и всё по кругу. MTU - это параметр инфраструктуры. Он должен жить в Terraform, Ansible или хотя бы в cloud-init. Не в голове инженера, который уже забыл ту пятницу.
Почему никто не виноват (а проблема есть)
Хочется найти виноватого. Docker мог бы подхватывать MTU хоста автоматически! Но:
Docker может работать с несколькими интерфейсами с разным MTU. Какой брать?
Контейнер может общаться с подсетями, у каждой из которых свой потолок.
Автоматическое определение MTU - это тот самый PMTUD, который, как мы выяснили, в облаке работает через раз.
OpenStack тоже делает своё дело правильно. VXLAN overlay - способ дать тысячам тенантов изолированные сети на общем железе. 50 байт заголовка - честная цена за эту абстракцию.
Можно попросить хостера включить Jumbo Frames (MTU 9000 на физической сети) - тогда после VXLAN-обёртки останется ~8950 байт, хватит всем. Но это решение на стороне инфраструктуры, и не каждый провайдер на это пойдёт.
В итоге ответственность - на нас. Знать про MTU и настраивать его при деплое в облако. Скучно? Да. Зато пайплайн не падает в пятницу вечером.
