Диапазонные запросы: отдача больших файлов без настройки Range-запросов

Видеоплеер, перематывающий фильм на середину, запрашивает всего несколько мегабайт. При нашей прежней схеме кэширования диапазонов один такой запрос мог заставить Cloudflare вытянуть 128 МиБ с источника и переместить их между дата-центрами — порой уже после того, как зритель закрыл плеер. Range-запросы — это способ доставки больших файлов, и наивное кэширование таких запросов легко приводит к лишнему расходу сетевой пропускной способности и дисковых операций при работе CDN в масштабе.

Поэтому мы перестроили то, как наш кэш обрабатывает их. Для R2, объектного хранилища Cloudflare, p99 времени до первого байта сократилось примерно вдвое, а последующее улучшение снизило трафик tiered cache для R2 и аналогичных нагрузок примерно на 6%.

Сегодня мы делаем эту новую реализацию доступной как Origin Range Requests — функция самообслуживания на всех тарифах. Не нужно подбирать размер диапазона и обращаться в поддержку. Включите её с помощью Cache Rule, а Cloudflare сделает остальное.

Запрос части файла

HTTP range-запрос запрашивает часть ресурса вместо всего ресурса. Клиент отправляет обычный GET с заголовком Range:

GET /movie.mp4 HTTP/1.1 Host: example.com Range: bytes=1048576-2097151

Если сервер может обработать такой запрос, он отвечает кодом 206 Partial Content и указывает переданный диапазон байтов в заголовке Content-Range:

HTTP/1.1 206 Partial Content Content-Range: bytes 1048576-2097151/524288000 Content-Length: 1048576

Запросы диапазонов — обычное дело для больших объектов. Видеоплеер может запросить байты вокруг позиции перемотки, а не весь файл. Менеджер загрузок может возобновить работу после прерывания. Пакетный менеджер может получить часть артефакта. Клиент может просмотреть конец файла, не скачивая всё, что перед ним.

Клиент решает, запрашивать ли диапазон, и Cloudflare не изменяет этот запрос. Вместо этого Origin Range Requests меняет то, как Cloudflare его выполняет.

Проблема размера диапазона

Cloudflare поддерживает кэширование запросов диапазонов уже много лет через статическую конфигурацию зоны.

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

Размер каждого диапазона создавал компромисс: малые диапазоны приводили к множеству мелких запросов к источнику и давлению на память Cloudflare из-за объединения всех запросов в памяти, тогда как большие диапазоны заставляли Cloudflare скачивать гораздо больше данных, чем запрашивал клиент. Изменения самого размера диапазона инвалидировали записи кэша, поскольку размер был частью ключа кэша.

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

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

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

К счастью, наш переход с nginx на кэширующий сервер на базе Pingora дал возможность исправить проблемы реализации, из-за которых клиентам приходилось пользоваться неудобной настройкой.

Кэш-блоки фиксированного размера

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

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

Например, запрос байтов 150-250 может привести к тому, что Cloudflare загрузит выровненный блок, содержащий эти байты. Ответ клиенту при этом всё равно содержит только 150-250.

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

Координация кэшированных и загружаемых данных

Разбор заголовка Range — дело несложное. Более трудная задача — сформировать один корректный ответ, когда запрошенные байты распределены между кэшированными данными, ответом источника и уже идущими загрузками.

На высоком уровне edge:

  1. Ищет заголовки ресурса и метаданные покрытия диапазонов.
  2. Считывает ту часть ресурса, которую может, с диска или из потока в памяти.
  3. Загружает недостающее покрытие из источника с использованием выровненных диапазонов.
  4. Записывает новые байты и фиксирует завершённый диапазон в метаданных.
  5. Объединяет части и устанавливает Content-Range и Content-Length для ответа клиенту.

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

Когда уже идущая загрузка покрывает байты другого запроса, второй запрос может подключиться к этой загрузке, а не инициировать новое обращение к источнику. Так работа распределяется при всплесках спроса на популярные крупные файлы.

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

Проверка перед сборкой

Составные ответы по диапазонам корректны только тогда, когда все байты относятся к одному и тому же представлению. Координатор проверяет, что каждый ответ источника соответствует запрошенному диапазону, что общий размер ресурса остаётся неизменным и что entity tag согласован между подзапросами.

Заголовок If-Range позволяет клиенту запросить диапазон только при условии, что представление по-прежнему соответствует известному валидатору. Если совпадения нет, Cloudflare возвращает полное представление вместо объединения байтов из разных версий.

Cloudflare также не собирает диапазоны для представления, которое собирается распаковать, поскольку диапазон по набору сжатых байтов отличался бы от диапазона, выбранного в распакованном ответе. В таких случаях Cloudflare отдаёт преобразованный полный ответ, а не заявляет диапазон, который не может корректно обеспечить.

Итоговое поведение соответствует стандарту HTTP:

  • Выполнимый диапазон получает 206 Partial Content с точным Content-Range.
  • Невыполнимый диапазон получает 416 Range Not Satisfiable.
  • Ответ, который нельзя безопасно разбить на диапазоны, отдаётся как полное представление.

Что изменилось в продакшене

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

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

R2 был самым крупным внутренним пользователем. Настроенный размер диапазона составлял 64 МиБ, поэтому небольшое чтение, пересекающее границу диапазона, могло обойтись нам в 128 МиБ трафика между дата-центрами. После перевода R2 на новую реализацию мы увидели:

  • Более быстрые ответы. Во всех регионах хранения R2 показатель p99 времени до первого байта снизился примерно вдвое как при попаданиях в кэш, так и при промахах.
  • Меньше байтов в нашей сети. В последнем изменении мы научили координатор объединять байты, уже находящиеся на диске, с байтами, полученными из upstream, в одном ответе. Раньше запрос обслуживался либо полностью из кэша, либо полностью из upstream. После включения этой функции для R2 трафик многоуровневого кэша для R2 и аналогичных нагрузок снизился примерно на 6% неделя к неделе, что означает на петабайты меньше данных, ежедневно перемещающихся между нашими дата-центрами.

Начало работы

Как только кэш перестал зависеть от выбранного клиентом размера диапазона, конфигурация продукта стала максимально простой. Клиенты выбирают подходящий трафик через Cache Rules. Cloudflare берёт на себя разбиение на чанки и координацию кэша.

Origin Range Requests предназначен для трафика, где клиенты естественным образом запрашивают части большого кэшируемого объекта:

  • Прогрессивное и перематываемое видео, например MP4
  • Возобновляемые большие загрузки
  • Распространение программного обеспечения и репозитории пакетов
  • Образы дисков, архивы и другие объекты размером в несколько гигабайт
  • Нагрузки обучения и инференса ИИ, которые передают большие файлы моделей или запрашивают отдельные области артефактов моделей
  • Клиенты, которые запрашивают метаданные или небольшую область более крупного файла

Мы намерены сделать этот способ способом по умолчанию для обслуживания определённых типов файлов в нашей сети с возможностью изменения через правила кэширования.

Эта функция не меняет обычные условия кэшируемости. Её включение не делает ответ, который иначе не кэшируется, кэшируемым. Origin также должен поддерживать байтовые диапазоны, чтобы частичные запросы к origin были полезны.

Чтобы включить её, создайте Cache Rule, соответствующее вашему трафику больших файлов, и установите Origin Range Requests в положение On. Это можно сделать в панели управления в разделе Caching > Cache Rules или через API с помощью origin_range_requests: { "mode": "on" }. Клиенты выбирают трафик; edge обеспечивает координацию кэша и требования корректности.