TL;DR

За облачным CDN у нас стоял Xray с транспортом XHTTP. Пользователи жаловались двумя фразами: «тормозит» и «отваливается насовсем». В августе я померил и увидел, что граница CDN теряет 2–5 % запросов под параллельной нагрузкой. Версия сходилась идеально: для XHTTP в режиме packet-up потерянный запрос — это дыра в последовательности, а дыра — это разрыв сессии.
Второго сентября я повторил тот же замер на том же стенде. Ноль потерь на 1350 запросах.
Ниже — обе серии, объяснение расхождения и та часть выводов, которая уцелела: четыре ограничения границы, о которые HTTP-туннель спотыкается независимо от того, теряет она что-нибудь или нет.
Что за стенд
Origin — Hetzner CX23 в Нюрнберге, 4 ГБ, nginx слушает :7443 и проксирует на Xray, сидящий на 127.0.0.1:4443. Ресурс CDN настроен CNAME-записью с нашего поддомена, Host подменяется на origin. Кэш на ресурсе фактически отключён: на всех ответах Cache-Status: MISS. Так и надо — кэшировать туннель незачем.
Транспорт: XHTTP, режим packet-up, путь вида /content/edge/fetch/<random>/. Мерил я с отдельного сервера в Стокгольме, у которого симметричный канал и прямой маршрут до обеих точек.
Имя провайдера называть не будем в этой статье, да и в целом после второй серии у меня нет к нему претензий.
Но кое-что стоит сказать до всех замеров, потому что на ней спотыкаются довольно часто: XHTTP по умолчанию отправляет uplink методом POST, а наш ресурс на POST отвечает своим собственным 405 Not Allowed. Ловится это довольно просто: берём заведомо несуществующий путь и стучимся в обе точки. Origin честно отдаёт 404, CDN — 405. Значит, до origin запрос не долетал вообще, его рубила граница. Мы переключили uplink на GET, и всё поехало. Если кто-нибудь однажды вернёт POST обратно — конфиг умрёт, а выглядеть это будет как «CDN сломался».
Как мерили
В целом все очень просто: +- 150 параллельных curl, таймаут тридцать секунд, считаем распределение кодов и отдельно — с каким кодом завершился сам curl.
# путь туннеля через CDN seq 1 150 | xargs -P150 -I{} \ curl -sk --http1.1 -o /dev/null -m 30 \ -w "%{http_code} %{time_total}\n" \ "https://$CDN_DOMAIN/content/edge/fetch/$PATH_ID/" # то же самое напрямую в origin, с подменой Host seq 1 150 | xargs -P150 -I{} \ curl -sk --http1.1 -o /dev/null -m 30 \ --resolve "$ORIGIN:443:$ORIGIN_IP" \ -w "%{http_code} %{time_total}\n" \ "https://$ORIGIN/content/edge/fetch/$PATH_ID/"
Что здесь называется потерей ( это не пятисотые): потеря — это exit=28 у curl, когда за тридцать секунд не пришло вообще ничего, плюс изредка 504. Живой ответ туннельного пути — 400 с заголовком X-Padding, он и работал маркером «долетело».
И еще момент, чтобы дальше быть максимально честным: первые прогоны я делал с рабочего ноутбука и получал трёхкратный разброс между запусками. Причина оказалась неожиданной: я сам сидел под туннелем, и трафик ходил Финляндия → Москва → Нюрнберг. Цифры с такого устройства не значат ничего, мерить надо оттуда, откуда маршрут до обеих точек прямой.
Серия первая, 12 августа
RTT до точки присутствия — 37 мс, до origin — 26.
Потери были, но их поведение было странным. Они не росли с параллельностью. Я специально искал порог, за которым «начинается», что-то вроде «после сотни соединений сыплется». Порога не было: на одной и той же нагрузке разные прогоны давали то ноль, то пять процентов.
Самая крупная доля, 5,4 %, была на обычную статику — файл, который до Xray вообще не доезжает. И это довольно важный момент: если потери и были, то создавал их точно не наш прокси.
Тут и была моя ошибка, после которой и пришла мысль написать эту статью. Три прогона с разбросом от нуля до пяти процентов — это не «в среднем 2,7 %». Это «неизвестное распределение». Я взял три точки и назвал их характеристикой системы.
Серия вторая, 2 сентября
Тот же сервер, та же команда, девять прогонов по 150 параллельных. 1350 запросов и Ни одной потери.
Решил проверить, могло ли так совпасть. Если бы система продолжала терять запросы с августовской долей 2,7 %, вероятность увидеть подряд 1350 успехов равна (1 − 0,027)^1350, то есть примерно e⁻³⁷. Число такого порядка означает ровно одно: система ведёт себя иначе, а не «повезло».
Объяснение я нашел быстро, оно оказалось примитивным: RTT до PoP упал с 37 мс до 17,6. Нас обслуживает другая точка присутствия. Конфигурацию ресурса между замерами никто не трогал, origin тоже, — поменялось то, что на стороне провайдера.
Отсюда мораль, которая для меня оказалась важнее, чем сама претензия. У распределённой сети нет одного поведения, которое можно измерить. Вы меряете конкретный PoP конкретного дня. Завтра трафик пойдёт через соседний, и цифры будут другие — при том что ни вы, ни провайдер ничего не меняли. Замер CDN без фиксации того, кто именно вас обслуживал, — это каксмотреть прогноз погоды, просто выглядывая в окно.
Как надо было сделать: писать RTT и идентификатор PoP в каждый прогон, гонять серию не за один вечер, а хотя бы неделю по расписанию, и не выносить вердикт про долю потерь, пока распределение не устаканится.

Что повторилось в обеих сериях
Потери ушли. Задержки остались, и они ровно те же:
Режим | Через CDN | Origin напрямую |
|---|---|---|
Последовательно | 80 мс | 33 мс |
150 параллельных | 0,94 с | 0,14 с |

Почти порядок разницы под нагрузкой, стабильно в обоих замерах. Для раздачи статики 0,94 секунды на полутора сотнях одновременных запросов — вполне рабочая цифра, никто это почти не заметит. Для транспорта, где каждый пакет уезжает отдельным HTTP-запросом, это и есть «тормозит вообще всё».
Четыре ограничения границы
Эти четыре штуки воспроизводятся независимо от погоды, и каждая на живом трафике выглядит как случайный сбой.
POST отбивается 405
Про это писал выше. Отдельно стоит подчеркнуть, из-за чего такие вещи иногда приходится искать часами: ответ приходит не от вашего сервера, и в логах вашего сервера его нет. Origin этого запроса не видел никогда.
Idle-таймаут — 15 секунд против 75 у origin
Здесь надо различать два таймаута, которые легко перепутать (я их не раз мешал в одну цифру). Пауза до первого запроса и пауза между запросами внутри keep-alive живут в разных настройках, и значения у них разные. Перемерил обе, по два прогона на каждую:
Что меряем | CDN | Origin |
|---|---|---|
Тишина до первого запроса | 11,0 с | 60,0 с |
Тишина после ответа, keep-alive | 15,0 с | 75,0 с |
Origin здесь просто отдаёт дефолты nginx: 60 секунд client_header_timeout и 75 keepalive_timeout. Граница CDN закрывает соединение в пять раз раньше, причём в обоих режимах.
Всё, что держит длинное соединение с паузами в трафике, будет регулярно рваться на границе, — а downlink-GET в туннеле именно этим и занимается. Проверить можно в лоб: открыть соединение, ничего не отправлять и засечь, через сколько прилетит FIN.
Потолок тела запроса — ровно 1 MiB, до байта
Границу удалось определить максимально точно, и получилось красиво: 1 048 576 байт доезжают до проверки метода и получают 405, а 1 048 577 отбиваются раньше, с 413. То есть лимит — не «около мегабайта», а ровно 1024 × 1024, и проверка размера срабатывает прежде проверки метода.
Дефолтный scMaxEachPostBytes у Xray равен 1 000 000 — проезжает впритык, до потолка остаётся 4,6 %. Любая попытка поднять эту настройку «чтобы шустрее» упрётся в 413 немедленно.
Хрупкость packet-up
Этот пункт условный: он важен ровно настолько, насколько граница теряет запросы. Но механику стоит понимать заранее, потому что она не очевидна.
В packet-up поток режется на пакеты, каждый пакет едет своим HTTP-запросом со своим номером. Сервер собирает их обратно и обязан отдавать строго по порядку — для этого держит буфер, у нас scMaxBufferedPosts: 30. Потерялся запрос номер N — образовалась дыра. Пакеты N+1, N+2 и дальше приезжают и складываются в буфер, потому что вперёд N их отдавать нельзя. Набралось 30 — сервер сдаётся и рвёт сессию. Со стороны клиента это выглядит как «умерло насовсем», потому что сам он переподключаться не пытается.

То есть 2% потерь для такого транспорта — это не «на два процента хуже». Это гарантированный обрыв через несколько секунд активного обмена.
Обобщение, которое переживает наш частный случай: протокол, который режет поток на независимые HTTP-запросы и требует упорядоченной сборки, ломается о потерю запроса несоизмеримо больнее обычного сайта. CDN проектировали под другую нагрузку — там непришедший запрос за картинкой означает одну неотрисованную картинку и тихий ретрай браузера. Здесь он означает конец сессии.
Что с этим делать
Правильное лечение — уйти с packet-up на stream-up. Там uplink это один длинный запрос, и потери внутри него чинит ретрансмиссия TCP, а не прикладной уровень. Дыра в нумерации перестаёт существовать как явление.
Блокер ровно один: stream-up — это POST, а POST на ресурсе отбивается 405. Поэтому порядок такой: сперва выяснить в консоли провайдера, можно ли разрешить методы на ресурсе, и только потом трогать транспорт.
Что точно не помогает — крутить scMaxBufferedPosts вверх. Больший буфер смягчает переупорядочивание, но не воскрешает потерянный запрос. Разрыв просто случится позже.
Общий вывод: CDN плох как транспорт для подобных протоколов не из-за ненадёжности, а из-за того, что его SLA считает запросы, а не сессии. Один потерянный запрос из сотни — отличный показатель для раздачи файлов и неприемлемый для транспорта. А задержка под нагрузкой, которая повторилась в обеих сериях, портит жизнь даже когда не теряется ничего.
Оговорки
Две серии, одна точка замера, один ресурс, один провайдер, два конкретных дня. Это не бенчмарк вендора и не «у всех так» — это диагностика одной поломки, доведённая до цифр, и честный отчёт о том, что половина этих цифр не повторилась.
Методика выше воспроизводится минут за десять. Если у вас похожая архитектура, буду рад увидеть в комментариях числа с других площадок и других CDN — особенно с пометкой, какой PoP вас обслуживал.
