MAATRIX / Блог / Полдня искали проблему в приложении, а дело было в MTU

Полдня искали проблему в приложении, а дело было в MTU

MAATRIX

Приложение работает, пока не работает — маленькие запросы проходят как часы, а те, что покрупнее, просто зависают на середине без единой строчки в логах. Первая реакция — искать баг в коде, потому что «сеть же вроде на месте, пинг ходит». В этом разборе — реальный сценарий, где полдня ушло на код, таймауты и конфиги веб-сервера, а причина всё это время сидела в MTU туннеля между офисом и сервером.

С чего всё началось: жалоба, которая не воспроизводится «нормально»

Симптом звучал так: часть запросов через VPN-туннель между офисом и сервером отваливается по таймауту, а часть — нет. Не все, не всегда, без явной системы на первый взгляд. Пользователи жаловались, что «иногда приложение зависает», разработчики пожимали плечами — у них локально всё работало, через тот же туннель, с теми же учётками.

Первым делом полезли в код клиента и сервера: искали блокирующие вызовы, гонки, неверную обработку исключений при разрыве соединения. Ничего подозрительного — в логах приложения не было даже намёка на то, что запрос вообще дошёл до бизнес-логики. Он просто не прилетал целиком, и обработчик молча ждал данных, которые не прийдут никогда, пока не сработает таймаут уровня приложения (там он был выставлен в 30 секунд).

Дальше проверили таймауты на всех уровнях — proxy_read_timeout, proxy_send_timeout, client_body_timeout в nginx, keep-alive на бэкенде, таймауты в клиентской библиотеке HTTP-запросов. Всё было выставлено разумно и совпадало с тем, что реально происходило: соединение не рвалось быстро, оно зависало ровно до истечения таймаута — то есть само по себе ничего специфического для конкретного таймаута не искали, просто перебирали все точки, где теоретически мог быть виновник.

Проверили и конфиги веб-сервера на предмет лимитов — client_max_body_size, large_client_header_buffers, буферы на upstream. Всё в пределах нормы, лимиты явно не срабатывали (иначе была бы ошибка 413 или что-то в логах nginx, а там было пусто). На этом этапе прошло уже часа три-четыре: код, таймауты, конфиги — везде чисто, а проблема осталась.

Переломный момент: закономерность не по эндпоинту, а по размеру

Стандартный ход в такой ситуации — собрать десяток «зависших» запросов и десяток нормальных и сравнить. Сначала смотрели на эндпоинт: не совпадало, зависали запросы к разным ручкам API. Смотрели на пользователя, на время суток, на конкретный сервер за балансировщиком — тоже не совпадало.

Совпадение нашлось, когда кто-то от отчаяния отсортировал список по размеру ответа. Все зависшие запросы были с ответом больше определённого порога — где-то в районе 1400-1500 байт полезной нагрузки, всё, что меньше, проходило стабильно. Небольшие JSON-ответы, статус-коды, лёгкие GET-запросы — без проблем. Ответы с более объёмным телом — списки, выгрузки, файлы — подвисали.

Это было первое реальное открытие за весь день: закономерность держалась не на уровне кода или логики приложения, а на уровне «сколько байт летит по проводу за один пакет». Как только это заметили, стало ясно, что искать нужно было не в приложении с самого начала, а в сети — просто раньше не было зацепки, за которую можно ухватиться.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему это так долго маскировалось под баг в коде

Задним числом понятно, почему диагностика по коду и таймаутам съела столько времени: симптом ведёт себя ровно как классическое зависание на уровне приложения. Соединение не рвётся сразу с ошибкой «connection reset» — оно просто не получает данные, и вся цепочка (клиент → nginx → бэкенд) честно ждёт своего таймаута. Ни один компонент явно не жалуется, потому что формально никто ничего не нарушает — пакет просто не доехал.

Ещё один фактор — маленькие запросы всегда работали, а их в трафике абсолютное большинство: health-check, авторизация, лёгкие GET. Поэтому «в целом всё работает», а проблема выглядит как редкий, случайный сбой конкретных операций — то есть ровно то, на что обычно грешат на баг в коде или на гонку состояний.

Настоящая причина: туннель режет эффективный MTU, а фаервол — ICMP для PMTUD

Реальная причина — классическая проблема с MTU через туннель, просто она не была очевидна, пока не появилась зацепка по размеру. Механика такая:

  1. Обычный Ethernet-канал работает с MTU 1500 байт. Это норма по умолчанию почти везде.
  2. VPN-туннель (не важно, IPsec, OpenVPN или WireGuard) добавляет свои заголовки поверх исходного пакета — инкапсуляцию, шифрование, служебные поля. В зависимости от протокола это от 40-60 до 80+ байт накладных расходов.
  3. Это означает, что эффективный MTU внутри туннеля меньше, чем 1500 — обычно в районе 1400-1460, но точное число зависит от протокола, версии IP (v4/v6) и настроек шифрования.
  4. Когда приложение отправляет пакет размером до 1500 байт с установленным битом DF (Don't Fragment — «не фрагментировать»), а туннель может пропустить меньше, пакет должен либо фрагментироваться, либо роутер на входе в туннель обязан вернуть ICMP-сообщение «Fragmentation Needed» (type 3, code 4) — это и есть механизм Path MTU Discovery.
  5. Если по пути стоит файрвол (на сервере, на промежуточном узле провайдера, в офисной сети), который режет ICMP «за ненадобностью» или из общих соображений безопасности — этот сигнал никогда не доходит до отправителя. Отправитель продолжает слать пакеты нужного (слишком большого) размера, они молча теряются на входе в туннель, а PMTUD, который должен был подсказать «уменьши размер», не работает, потому что ему нечем ответить.

В итоге получается ровно то, что наблюдали: маленькие пакеты (запросы, короткие ответы) укладываются в туннель без проблем, а как только полезная нагрузка приближается к границе MTU — пакет с DF-битом улетает в пустоту без единого сообщения об ошибке. TCP-соединение не рвётся явно, оно просто перестаёт получать ACK на конкретный сегмент и в итоге виснет до таймаута — то есть именно то поведение, которое несколько часов пытались найти в коде приложения.

Отдельно стоит подчеркнуть: сама по себе фрагментация тоже не панацея — если DF-бит не установлен, пакет фрагментируется, но фрагментация на туннелях (особенно с шифрованием) — штука дорогая и capable of собственных проблем с потерями и порядком доставки. Поэтому корректный путь — не полагаться на фрагментацию, а не создавать пакеты крупнее реального MTU туннеля в принципе.

Как это подтвердили: ping с разным размером и DF-битом

Гипотезу проверяли методично — пинговали конечную точку по ту сторону туннеля с постепенно увеличивающимся размером пакета и явно выставленным DF-битом, чтобы увидеть, на каком размере начинаются потери.

На Linux:

# -M do — выставить DF-бит, -s — размер полезной нагрузки ICMP (без заголовков)
ping -M do -s 1472 -c 5 10.10.0.1
ping -M do -s 1400 -c 5 10.10.0.1
ping -M do -s 1300 -c 5 10.10.0.1

Логика проста: 1472 байта полезной нагрузки + 28 байт заголовков ICMP/IP = 1500 байт целиком. Если на 1472 пакеты пропадают, а на 1300 — проходят, значит где-то между этими значениями лежит фактический предел. Дальше сузили диапазон бинарным поиском (1400 → не проходит, 1350 → проходит, 1370 → не проходит и так далее), пока не нашли точную границу.

На Windows аналогично:

ping -f -l 1472 10.10.0.1

Флаг -f здесь и есть аналог DF-бита («don't fragment»), -l — размер данных. Если система отвечает Packet needs to be fragmented but DF set — значит хотя бы локальный узел ICMP не режет, и проблема дальше по маршруту. В нашем случае никакого сообщения не было вообще — пакет просто пропадал, что как раз и указывало на файрвол, съедающий сам ICMP-ответ где-то на пути.

Дополнительно проверили традиционным способом:

traceroute -F -M 1472 10.10.0.1     # Linux, DF-бит через -F
tracepath 10.10.0.1                 # сам находит MTU по пути, если ICMP доходит
tcpdump -i wg0 icmp                 # смотрим, приходят ли вообще ICMP type 3 code 4

tcpdump на туннельном интерфейсе во время серии пингов с разным размером — самый честный способ увидеть происходящее: либо в дампе видно исходящий ICMP unreachable/fragmentation needed (и тогда проблема в клиенте, который его игнорирует), либо этого пакета нет вообще (и тогда он отфильтрован раньше — на маршрутизаторе провайдера, в облачном файрволе или в правилах самого сервера). В нашем случае ICMP просто не появлялся в дампе — сообщение резалось до того, как достигало отправителя.

Проверили и MTU самого туннельного интерфейса:

ip link show wg0
# или для другого интерфейса
ip -s link show tun0

В выводе mtu показывал классические 1500 — то есть система была уверена, что можно слать пакеты полного размера, хотя реальный путь этого не выдерживал. Это несоответствие между заявленным MTU интерфейса и фактически проходимым размером пакета — и есть корень проблемы.

Фикс: понижаем MTU на туннельном интерфейсе или включаем MSS clamping

Есть два рабочих подхода, их можно комбинировать.

Вариант 1 — понизить MTU напрямую на туннельном интерфейсе. Для WireGuard:

ip link set mtu 1380 dev wg0

Или постоянно, в конфиге интерфейса (/etc/wireguard/wg0.conf):

[Interface]
MTU = 1380

Значение 1380 — рабочая отправная точка для WireGuard с запасом под IPv6-инкапсуляцию и дополнительные заголовки на некоторых маршрутах, но точное безопасное число у вас может быть другим — оно зависит от протокола туннеля, наличия дополнительной инкапсуляции (например, если туннель сам идёт поверх ещё одного VPN или через провайдера с собственным оверлеем) и от MTU на самом «узком» участке пути. Найденную вами границу (в примере выше — из бинарного поиска ping) стоит взять с запасом в 20-28 байт вниз, а не использовать впритык.

Для OpenVPN аналогичная настройка через tun-mtu и mssfix в конфиге сервера и клиента:

tun-mtu 1400
mssfix 1360

Вариант 2 — MSS clamping на туннельном шлюзе. Это решение сильнее тем, что не зависит от того, правильно ли настроен MTU на клиентской стороне — оно подрезает MSS прямо в момент установления TCP-соединения, автоматически, для любого клиента, который подключается через этот узел:

iptables -t mangle -A FORWARD -o wg0 -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --clamp-mss-to-pmtu

Или с явным значением, если хочется зафиксировать число, а не полагаться на автоматику:

iptables -t mangle -A FORWARD -o wg0 -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --set-mss 1340

MSS clamping решает проблему именно для TCP — он подменяет значение MSS в SYN-пакете так, чтобы обе стороны с самого начала сессии договорились о сегментах, которые точно пройдут через туннель, не дожидаясь ICMP-подсказки, которая всё равно может быть отфильтрована по дороге. Для UDP-трафика (например, если через тот же туннель ходит что-то поверх UDP) clamping не поможет — там нужно либо снижать MTU интерфейса, либо явно настраивать размер payload на уровне приложения.

Стоит подчеркнуть честно: ни один из этих фиксов не «чинит» файрвол, который режет ICMP где-то на промежуточном узле — он обходит проблему, убирая саму зависимость от PMTUD. Если у вас есть контроль над этим файрволом (например, это ваш собственный сервер или облачный security group), правильнее дополнительно разрешить ICMP type 3 (destination unreachable, включая code 4 — fragmentation needed) — тогда PMTUD снова заработает штатно, а понижение MTU/MSS останется просто подстраховкой, а не единственной линией обороны.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро понять, что дело в MTU, а не в коде приложения?

Первый признак — маленькие запросы всегда проходят, а зависают именно крупные ответы или запросы с большим телом. Если такая корреляция по размеру подтверждается на десятке примеров — почти наверняка это MTU или фрагментация, а не логическая ошибка.

Почему ping маленького размера проходит нормально, а обычные запросы всё равно виснут?

Обычный ping без выставленного DF-бита разрешает фрагментацию, и небольшие ICMP-пакеты в неё даже не упираются. Чтобы воспроизвести реальную проблему, нужно явно ставить DF-бит (-M do в Linux, -f в Windows) и постепенно увеличивать размер, пока не поймаете границу.

MSS clamping снижает производительность?

Разница по сравнению с оптимальным MTU минимальна — вы просто чуть чаще режете данные на сегменты. Это несравнимо дешевле, чем таймауты и повторные попытки из-за молча теряющихся пакетов, поэтому в продакшене clamping почти всегда оправдан как защитная мера, даже если основной MTU настроен правильно.

Может быть проблема не в MTU, а просто в потере пакетов на канале?

Да, стоит исключить и это — общая деградация канала тоже может выглядеть похоже, но обычно без чёткой корреляции именно с размером пакета. Если утилизация канала и обычный packet loss в норме, а зависают именно крупные ответы — версия с MTU выглядит куда убедительнее.

Нужно ли одинаково настраивать MTU на обеих сторонах туннеля?

Да, несовпадение MTU на клиенте и сервере туннеля — частый источник именно такой асимметричной проблемы, когда в одну сторону всё летает, а в другую подвисает. Проверяйте оба конца и держите значение одинаковым либо явно рассчитанным под самый узкий участок маршрута.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →