Пятница, 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 и настраивать его при деплое в облако. Скучно? Да. Зато пайплайн не падает в пятницу вечером.