0.0000008 Мбит/с при оценке A+: как тихий 403 от Cloudflare сломал половину теста bufferbloat

—

от автора

В прошлой статье я показывал классификатор bufferbloat из своего iOS-приложения NetDiag+, в том числе такую ветку:

if dnN >= upN * 2 { return .ispDownstream }

«Задержка под загрузкой выросла намного сильнее, чем под отдачей, значит, очередь у провайдера на входящем направлении». Звучит убедительно. Но сработать эта ветка почти не могла: фаза загрузки в тесте не нагружала линию вообще. speed.cloudflare.com отвечал на запрос гигабайта кодом 403 и одним байтом, а код этого не замечал.

Ниже — как так получилось, что это сделало с вердиктами и как ошибка всплыла: новый счётчик байтов показал загрузку 0.0000008 Мбит/с у соединения с оценкой A+.

Как устроен тест буферизации

Буферизация (bufferbloat) — это задержка, которая появляется только на занятой линии. Пинг до 1.1.1.1 в покое показывает 14 мс. Запустите рядом большую загрузку, и тот же пинг покажет 180 мс: пакеты стоят в раздутом буфере где-то между вами и интернетом. Именно из-за этого заикается видеозвонок, пока кто-то дома обновляет игру.

Тест простой по идее:

  1. Покой: пять секунд пингуем домашний шлюз и точку в интернете.

  2. Загрузка: десять секунд забиваем входящий канал, оба пинга продолжают идти.

  3. Пауза: две секунды тишины, чтобы буферы опустели.

  4. Отдача: десять секунд забиваем исходящий канал, пинги идут.

Оценка от A+ до F — это то, насколько интернет-пинг вырос под нагрузкой относительно покоя. Атрибуция — это пинг шлюза. Если растёт и он, очередь между телефоном и роутером, то есть Wi-Fi. Если шлюз ровный, а растёт интернет-пинг, очередь за роутером. Сравнение двух фаз нагрузки дальше отделяет буфер отдачи роутера от входящей очереди провайдера.

Физика здесь та же, что в любом споре про bufferbloat: очередь образуется на самом узком месте. В сторону дома узкое место обычно у провайдера, поэтому раздутый буфер на загрузку — его. В сторону интернета узкое место обычно ваш модем или роутер, и буфер на отдачу — ваш. Именно эта асимметрия и позволяет вообще различать их по двум фазам.

От неё же зависит совет. Буферизация на отдачу лечится умным управлением очередью (fq_codel или cake) на роутере. Входящую тоже можно приручить из дома: ограничить входящий трафик на несколько процентов ниже тарифа, и очередь переедет из раздутого буфера провайдера на ваш роутер, который отбрасывает пакеты рано, а не копит их. Цена — 5–10% скорости. Если платить её не хочется, остаётся идти к провайдеру с отчётом.

Каждый шаг здесь предполагает, что фазы нагрузки действительно что-то нагружают.

Нагрузка, которой не было

Вот так выглядело насыщение загрузки в релизе:

private static let downloadURL =    URL(string: "https://speed.cloudflare.com/__down?bytes=1073741824")!  // 1 ГБ, отменяем по времениprivate static func saturateDownload(duration: TimeInterval) async {    let session = URLSession(configuration: .ephemeral)    defer { session.invalidateAndCancel() }    do {        let (bytes, _) = try await session.bytes(from: downloadURL)        let deadline = ContinuousClock.now.advanced(by: .seconds(duration))        for try await _ in bytes {            if ContinuousClock.now >= deadline { break }            if Task.isCancelled { break }        }    } catch {        // network error — measurement still yields whatever ICMP got.    }}

Просим гигабайт, сливаем его десять секунд, отменяем. Как код на Swift — корректно. Как измерение — три ошибки:

  • Ответ никто не смотрит. (bytes, _) выбрасывает URLResponse. Какой бы статус ни пришёл, цикл отработает.

  • Ничего не считается. Функция возвращает Void. Вызывающий код никак не узнает, прокачали за десять секунд десять гигабит или десять байт.

  • Ошибка молчит по замыслу. Комментарий прямо это говорит: что бы ни случилось, «замер всё равно получит то, что собрал ICMP».

Проверил endpoint curl’ом:

$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \    "https://speed.cloudflare.com/__down?bytes=99999999"200 99999999$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \    "https://speed.cloudflare.com/__down?bytes=104857600"403 1

Всё от 100 МБ получает 403 и тело в один байт, с любым User-Agent. Был ли этот лимит, когда я писал код, сказать не могу. Код не оставил себе способа это заметить. «Фаза загрузки» была десятью секундами пингов по свободной линии.

Что это сделало с вердиктами

Самое неприятное — проследить это через классификатор. Без нагрузки на загрузку рост задержки на загрузке примерно ноль. Дальше:

let worstRise = [dnNetRise, upNetRise].compactMap { $0 }.max()let grade = Grade.from(latencyRiseMs: worstRise)   // по факту: только отдача...if grade == .aPlus || grade == .a || grade == .b { return .clean }if upN >= dnN * 2 { return .uplinkQueue }          // верно при любой буферизации отдачиif dnN >= upN * 2 { return .ispDownstream }        // недостижимоreturn .modemOrLine                                 // почти недостижимо
  • Оценка была оценкой только по отдаче. Линия, чистая на отдачу и ужасная на загрузку, получала A.

  • Любая реальная буферизация приписывалась буферу отдачи роутера, потому что «отдача выросла хотя бы вдвое сильнее загрузки» верно, когда загрузка не выросла вовсе.

  • «Очередь у провайдера на входящем» — ровно тот вердикт, ради которого инструмент нужен для заявки провайдеру, — сработать не мог.

Атрибуция по шлюзу при этом работала: фаза отдачи линию нагружала, так что проблемы Wi-Fi ловились. Но главное число, которое люди скриншотят для провайдера, было половиной замера.

Как это вылезло

Не из жалобы пользователя. Вылезло, когда я начал переиспользовать фазы нагрузки для нового.

Новой функции, Индексу здоровья, нужна скорость, а запускать отдельный спидтест рядом с тестом буферизации расточительно: оба забивают одну и ту же линию. План был считать байты, которые фазы нагрузки и так прокачивают. Первый прогон на симуляторе вернул:

score 89/100 · Excellent · grade A+download 0.0000008 Mbps · upload 65.0 Mbps

«Отличное» соединение, которое качает меньше бита в секунду (один байт за десять секунд), — такое противоречие не пропустишь. Отдача правдоподобная, а загрузка — это тот самый байт, который пришёл с 403. Как только нагрузке пришлось сообщать собственный объём, спрятаться ошибке стало негде.

Урок, который я из этого вынес: генератор нагрузки обязан измерять собственную нагрузку. Если часть теста, которая должна нагружать систему, не умеет заметно ломаться, однажды она перестанет нагружать что-либо, а все зависящие от неё числа продолжат выглядеть как данные.

Исправление

Загрузка теперь — запросы по 50 МБ подряд, пока не закроется окно, байты считаются в делегате:

private static let downloadURL =    URL(string: "https://speed.cloudflare.com/__down?bytes=52428800")!  // 50 МБ, ниже лимитаprivate static func saturateDownload(duration: TimeInterval) async -> Double? {    let counter = ByteCounter()    let session = URLSession(configuration: .ephemeral, delegate: counter, delegateQueue: nil)    let start = ContinuousClock.now    let deadline = start.advanced(by: .seconds(duration))    let loop = Task {        while ContinuousClock.now < deadline, !Task.isCancelled {            await counter.run(session.dataTask(with: downloadURL))   // кусок 50 МБ        }    }    try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))    loop.cancel()    session.invalidateAndCancel()    let seconds = elapsedSeconds(since: start)    guard seconds > 1, counter.bytes > 0 else { return nil }    return Double(counter.bytes) * 8 / seconds / 1_000_000}

Две важные детали:

  • Делегат считает целые буферы. didReceive data: отдаёт куски по десяткам килобайт. Старый for try await _ in bytes шёл по одному байту, и на быстрой линии это бенчмарк процессора, а не сети: телефон сдаётся раньше гигабитного канала.

  • Функция возвращает скорость или nil. nil уходит в результат как «скорость не измерена», и логика оценки это видит.

Отдачу переделал по другой причине. Раньше это был один POST на 32 МБ. На любой линии быстрее ~25 Мбит/с он заканчивается раньше срока, и остаток фазы канал простаивает — ровно тогда, когда тесту нужна полная очередь. Теперь это POST по 8 МБ подряд до конца окна, с подсчётом в didSendBodyData.

После исправления на том же Wi-Fi: 455 Мбит/с загрузка, 62 Мбит/с отдача, оценка A вместо A+, потому что фаза загрузки наконец добавляет несколько миллисекунд.

Результат Индекса здоровья: кольцо со счётом и строки Wi-Fi, Роутер, Провайдер

Результат Индекса здоровья: кольцо со счётом и строки Wi-Fi, Роутер, Провайдер

Индекс здоровья: из чего складывается число

Когда фазы нагрузки честные, Индекс здоровья — это в основном оркестровка уже существующих инструментов:

Фаза

Что работает

Время

Покой

ICMP до 1.1.1.1 и 8.8.8.8, TCP-connect к 1.1.1.1:443, пинг шлюза

~8 с

Нагрузка

проба Буферизации, теперь со счётом байтов

~27 с

Восстановление

ещё десять пингов до 1.1.1.1

~3 с

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

Индекс — взвешенная сумма пяти компонентов:

Компонент

Максимум

За что очки

Отклик под нагрузкой

35

оценка буферизации: A+ 35, A 30, B 22, C 12, D 5, F 0

Задержка

20

меньше 20 мс — 20, меньше 40 — 16, меньше 80 — 10

Стабильность

20

джиттер в покое меньше 5 мс — 20; любые потери −6, от 10% — ноль

Скорость

15

загрузка до 12, отдача до 3; для сотовой сети мягче

Локальное звено

10

RTT и джиттер до шлюза; на сотовой начисляются

Самый большой вес у отклика под нагрузкой намеренно. Обычная жалоба — не «медленный интернет» в смысле спидтеста, а «в какие-то моменты им невозможно пользоваться». Это проблема очереди, а не полосы.

Под числом три строки: Wi-Fi, Роутер, Провайдер, у каждой статус «OK», «под подозрением» или «виноват». Они считаются по тем же правилам, что вердикт Буферизации, плюс по числам из покоя, которые нагрузка не трогает. Шлюз, отвечающий уже за 30 мс, — вина Wi-Fi. Потери на пути в покое при здоровом шлюзе — вина провайдера.

Ещё один урок первых прогонов. Потери в покое сначала брались по худшей строке, и один отклонённый TCP-handshake из десяти давал этой строке 10% «потерь». Это стоило восемь очков и обвиняло провайдера в одном потерянном SYN. Теперь это среднее по ICMP-строкам, двадцать оценённых сэмплов. Усреднять там, где есть выборка, и брать худшее там, где её нет, — выбор, который стоит проговаривать явно.

Индекс считается на телефоне, без сервера: это чистая функция от чисел одного прогона, и каждая строка раскрывается по нажатию с порогами, так что видно, почему получилось 64, а не 80.

Так выглядят строки подробностей Индекса здоровья, ещё один прогон на том же Wi-Fi:

Показатель

Значение

Оценка буферизации

A

Задержка в покое

14 мс

Джиттер

1 мс

Потери

0%

Загрузка

447,5 Мбит/с

Отдача

68,4 Мбит/с

Шлюз

3 мс

Мелочи в том же релизе

  • Проверка VPN. Поднят ли туннель, каким вас видит интернет, какой NAT видит STUN, и совпадает ли системный резолвер с Cloudflare DoH. На iOS наличие интерфейсов utun ни о чём не говорит: у каждого iPhone их несколько для системных сервисов. VPN видно как ключ utun/ipsec в __SCOPED__ у CFNetworkCopySystemProxySettings() или как туннельный интерфейс в текущем NWPath.

  • HTTP-заголовки со всей цепочкой редиректов. App Transport Security не пускает http:// через URLSession, а хоп http→https как раз тот, который чаще всего хотят увидеть. Поэтому этот один хоп — сырой обмен HTTP/1.1 через NWConnection, а ATS для всего остального остаётся включённым.

  • Обратный DNS через getnameinfo с NI_NAMEREQD: имя хоста сначала разрешается вперёд, а в выдаче есть имя в in-addr.arpa, чтобы ответ можно было повторить через dig -x.

Ограничения

  • Код ответа в вышедшей версии всё ещё не проверяется. Куски по 50 МБ укладываются в сегодняшний лимит Cloudflare, но если его снизят, загрузка снова покажет почти ноль. Это та же ошибка, от которой статья предостерегает, поэтому в следующей версии ответ не 2xx останавливает фазу, и скорость помечается как «не измерена», а не как микроскопическое число.

  • Нагрузку даёт Cloudflare. Если сеть режет или блокирует speed.cloudflare.com, скорость в отчёте будет низкой, и это будет скорость до Cloudflare, а не ваш тариф.

  • Один поток. Куски идут последовательно в одном соединении. Спидтесты обычно открывают несколько потоков параллельно; на линиях с большим RTT один поток может не добить до тарифа, и тогда занижены и скорость, и очередь.

  • На очень быстрых линиях узкое место — телефон. Примерно выше гигабита Wi-Fi и сам аппарат ограничивают скорость раньше линии. Поэтому индекс одинаково награждает всё от 100 Мбит/с.

  • Индекс здоровья — эвристика. Веса — это мнения, записанные кодом. Они открыты в справке приложения, и я лучше буду спорить о таблице, чем прятать её.

  • Старые оценки Буферизации читайте как оценки по отдаче. Если у вас сохранены результаты версий 1.4.5–1.4.8, не опирайтесь на строки загрузки: когда появился лимит, я не знаю, а в последних версиях они точно мерили свободную линию.

Ссылки

Аккаунтов нет, результаты замеров остаются на устройстве, пока вы сами их не отправите. В приложении есть аналитика Firebase, а в бесплатной версии — реклама.

Вопрос к тем, кто работал со speed.cloudflare.com: кто-нибудь знает, когда у __down появился лимит в 100 МБ? И если у вас был генератор нагрузки, который в итоге ничего не нагружал, расскажите в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1088228/