Cloudflare высвободила 100 терабайт памяти в DNS-кеше 1.1.1.1

Cloudflare высвободила 100 терабайт памяти в DNS-кеше 1.1.1.1

Big Pineapple, платформа Cloudflare, на которой построены 1.1.1.1, Gateway DNS, DNS Firewall, AS112 и ряд других DNS-сервисов компании. В любой момент времени она хранит свыше 250 миллиардов записей DNS-кеша, поэтому на таком масштабе один лишний байт, потраченный на одну запись, обходится более чем в 250 гигабайт памяти по всему флоту серверов. Инженеры Cloudflare внесли пять последовательных изменений в то, как записи кеша хранятся в памяти, и суммарно сократили расход памяти на одну запись больше чем вдвое (более чем на 50%). По всему флоту это высвободило около 100 терабайт памяти, столько же оперативной памяти установлено в 130 серверах поколения Gen 13. При этом кеш не потерял в скорости, а стал быстрее: скорость вставки выросла на 43%, а задержка поиска снизилась на 19%, благодаря меньшему числу выделений памяти и лучшей локальности данных экономия памяти не потребовала жертвовать производительностью.

При холодном старте кеш Big Pineapple пуст и заполняется по мере поступления DNS-запросов, пока не достигнет максимального числа записей, после этого более старые и менее популярные записи вытесняются, освобождая место. Точный размер кеша различается по дата-центрам. Когда используется расширение EDNS Client Subnet (ECS), авторитетные серверы возвращают разные ответы в зависимости от сети клиента, поэтому Cloudflare кеширует сразу несколько версий одного и того же запроса. Это увеличивает и число записей, и объём памяти на каждую из них, поэтому описанные оптимизации особенно значимы для дата-центров с интенсивным использованием ECS. Каждая запись кеша, это пара ключ-значение: ключ (структура CacheKey) описывает, что именно запрашивалось (имя, тип записи, флаг аутентификации, тег), а значение (структура CacheEntry) хранит сам DNS-ответ, секции answer, authority и additional, а также метаданные вроде времени создания, счётчика обращений и TTL. Чтобы измерить эффект каждого изменения, инженеры заполняли кеш случайно сгенерированными записями, воспроизводящими распределение трафика в проде: 56% A-записей, 25% AAAA и 19% TXT, от одной до четырёх записей на запись кеша; размер TXT-записей (они использовались как заменитель всех типов записей переменной длины) рандомизировался от 64 до 224 байт, это близко к среднему размеру реальных ответов такого рода. Память отслеживали через собственный аллокатор, обёрнутый вокруг системного аллокатора Rust, который считает число и размер выделений на каждую запись кеша; параллельно измеряли скорость вставки и задержку поиска по всему пути обработки кеша, чтобы убедиться, что экономия памяти не идёт в ущерб производительности. Поскольку синтетический бенчмарк лишь приближённо воспроизводит прод (на реальную занятую память влияют ещё и микс трафика, заполненность кеша и состояние аллокатора), Cloudflare дополнительно замеряла резидентную память на боевых серверах в ходе постепенного развёртывания изменений.

Первое изменение, замена структур Vec и String на Box<[T]> и Box. Vec хранит три поля: указатель на данные в куче, текущую длину и ёмкость (capacity); но раз записи кеша после сохранения никогда не меняются, поле ёмкости не нужно, хотя само оно занимает 8 байт, да ещё куча может быть выделена с запасом под будущий рост, тоже впустую. Box<[T]> и Box не могут расти после создания, поэтому не хранят ни поля ёмкости, ни резерва под рост. В каждой записи кеша таких Vec- и String-полей восемь; замена каждого на Box экономит 8 байт на поле, то есть 64 байта на запись, а с учётом убранного резерва в куче суммарная экономия по флоту при 250+ миллиардах записей превышает 15 терабайт.

Второе изменение, вместо трёх отдельных списков для секций answer, authority и additional Cloudflare хранит один общий список записей со смещениями до начала каждой секции. Поскольку число DNS-записей в секции всегда умещается в u16, смещение занимает 2 байта вместо 8-байтного указателя и 8-байтной длины на каждый из двух убранных списков, экономия 28 байт на запись. Здесь же сработал побочный эффект выравнивания памяти в Rust: компилятор добавляет выравнивающие байты (padding), чтобы размер структуры был кратен её выравниванию, поэтому удаление даже небольшого поля может убрать заодно и лишние байты выравнивания, например, упаковка нескольких булевых полей в один битовый флаг сократила структуру сильнее, чем сумма размеров самих булевых полей.

Третье изменение, отказ от хранения владельца записи (owner, домен, которому принадлежит DNS-запись), когда он совпадает с запрошенным доменом. В DNS-формате повторяющиеся домены на проводе сжимаются по RFC 1035 через 2-байтные указатели на первое упоминание, но в кеше Cloudflare хранит полное имя владельца при каждой записи, потому что разыменовывать такие указатели на горячем пути поиска слишком дорого, то есть здесь память сознательно тратилась ради скорости. На практике владелец у большинства кешируемых записей совпадает с запрошенным доменом (расходится он, например, у A-записей за CNAME), поэтому теперь для таких записей поле owner в структуре Record, Option, и при значении None Cloudflare просто восстанавливает домен из ключа кеша при построении ответа, не выделяя память в куче; когда владелец отличается, полное имя по-прежнему хранится.

Четвёртое изменение касается перечисления RecordData, в котором каждый тип DNS-записи, это отдельный вариант (A, AAAA, TXT, NAPTR, SVCB и другие). В Rust такое перечисление всегда занимает столько же памяти, сколько его самый большой вариант, а это NAPTR на 136 байт, из-за чего всё перечисление с учётом тега варианта и выравнивания раздувается до 144 байт. При этом A-записи нужно всего 4 байта, а AAAA, 16, и вместе они дают больше 80% трафика: получается, что подавляющее большинство записей впустую тратит больше 120 байт на выравнивание под редкий большой вариант. Решение, вынести крупные варианты (Txt, Naptr, Svcb и другие) в отдельное выделение в куче через Box, оставив A и AAAA храниться прямо внутри уменьшившегося до 24 байт перечисления: это экономит 120 байт на каждую A- или AAAA-запись, а сам NAPTR платит чуть больше (указатель на кучу плюс накладные расходы аллокатора), но поскольку NAPTR-записи в реальном трафике редки, это того стоит. У такого «упаковывания в Box» есть и собственная цена: аллокатор jemalloc, рассчитанный на многопоточную нагрузку с частыми выделениями памяти, группирует выделения по фиксированным классам размеров (bins в терминологии jemalloc), например, запрос на 32 байта под TXT-запись попадает точно в 32-байтный класс без потерь, а запрос на 40 байт под MX-запись округляется до 48-байтного класса, теряя 8 байт впустую; вдобавок вынесенные в Box данные разбросаны по отдельным участкам кучи вместо одного смежного блока, а значит, при чтении процессору чаще приходится подгружать новую строку кеша, то есть локальность памяти ухудшается.

Пятое изменение, отказ от хранения записей как разобранных вариантов перечисления в пользу хранения их в проводном (wire) формате. Хранить целиком весь DNS-ответ в проводном формате Cloudflare не стала: DNSSEC-записи попадают в ответ только когда клиент выставляет флаг DO (DNSSEC OK), а значит пришлось бы либо кешировать сразу два варианта сообщения, с DNSSEC и без, либо каждый раз вырезать их из уже собранного сообщения, да ещё разбирать сообщение целиком при каждом обращении к кешу, чего не требует хранение уже разобранных записей. В качестве компромисса Cloudflare хранит только данные самих записей как сырые байты, единым блоком Box<[u8]>, где перед каждой записью идёт 2-байтная длина, а затем её сырые байты, а остальные поля записи кеша остаются структурированными. Это убирает и накладные расходы на тег перечисления по каждому варианту, и вынесенные в Box выделения из предыдущего изменения, а данные лежат смежно, что улучшает локальность в процессорном кеше. Плата за это, записи больше нельзя читать по произвольному индексу, только последовательным перебором буфера; это усложняет отдельные функции вроде ротации A/AAAA-записей по принципу round-robin, но поскольку записей на кешируемый запрос немного, эту цену Cloudflare считает пренебрежимо малой.

Ключевые факты

  • Big Pineapple, DNS-платформа Cloudflare, стоящая за 1.1.1.1, Gateway DNS, DNS Firewall и AS112, хранит одновременно свыше 250 миллиардов записей кеша; на таком масштабе лишний байт на запись стоит больше 250 гигабайт памяти по всему флоту.
  • Пять последовательных изменений в структурах данных кеша сократили расход памяти на одну запись больше чем вдвое (свыше 50%) и высвободили по флоту около 100 терабайт, это память 130 серверов поколения Gen 13.
  • Кеш не потерял в скорости: вставка ускорилась на 43%, а задержка поиска снизилась на 19% благодаря меньшему числу выделений памяти и лучшей локальности данных.
  • Ключевые технические приёмы: Box<[T]>/Box вместо Vec/String (экономия 64 байта на запись, свыше 15 терабайт по флоту), единый список секций со смещениями вместо трёх списков (28 байт на запись), отказ от хранения совпадающего с запросом владельца записи, упаковка редких крупных вариантов перечисления RecordData в Box (120 байт на A- или AAAA-запись) и, наконец, хранение записей как сырых байт в проводном формате вместо разобранных структур.
  • У оптимизаций есть цена: упаковка в Box добавляет накладные расходы аллокатора jemalloc на округление размеров и ухудшает локальность памяти, а хранение сырых байт лишает записи произвольного доступа, требуя последовательного перебора.

Почему это важно

На масштабе Big Pineapple, свыше 250 миллиардов записей кеша одновременно, лишний байт, потраченный на одну запись, стоит больше 250 гигабайт памяти по всему флоту серверов. Пять изменений в устройстве структур данных дали суммарную экономию памяти больше чем вдвое (свыше 50% на запись) и высвободили около 100 терабайт, величину, сравнимую с памятью 130 серверов поколения Gen 13. При этом экономия не потребовала жертвовать скоростью: вставка ускорилась на 43%, задержка поиска упала на 19%, потому что более компактные структуры означают меньше выделений памяти и лучшую локальность данных в кеше процессора. Это наглядный пример того, как низкоуровневая работа с раскладкой данных в памяти, без смены алгоритмов или архитектуры, при таком масштабе окупается сразу и экономией памяти, и ростом производительности.

Кому это важно

В первую очередь, инженерам, которые строят или эксплуатируют кеши и хранилища с огромным числом однотипных записей: приёмы вроде отказа от Vec/String в пользу невырастающих структур Box, замены нескольких списков одним со смещениями, упаковки редких крупных вариантов перечисления в Box и хранения данных в компактном сыром формате вместо разобранных структур применимы к любому высоконагруженному Rust-сервису, а не только к DNS-кешу. Полезно и разработчикам на Rust в целом, как разбор конкретных, измеренных приёмов экономии памяти и компромиссов между памятью, скоростью аллокатора и локальностью данных. Наконец, это интересно инженерам платформенных и SRE-команд, которые считают стоимость инфраструктуры в терабайтах памяти и серверах: материал показывает, как перевести абстрактную оптимизацию структур данных в конкретные единицы измерения инфраструктуры, терабайты памяти, а не только проценты.

Как это применить

Из статьи можно вынести пять переносимых приёмов экономии памяти. Первый: если данные, попав в структуру, больше никогда не меняются, использовать Box<[T]> и Box вместо Vec и String, пропадает ненужное поле ёмкости и лишний резерв в куче под будущий рост. Второй: если несколько параллельных списков заведомо укладываются в компактный размер (как число DNS-записей в секции, в u16), заменить их одним списком с индексами-смещениями вместо отдельных указателя и длины для каждого. Третий: не хранить то, что можно однозначно восстановить из уже доступного контекста в момент чтения, Cloudflare не хранит владельца записи, когда он совпадает с ключом кеша, и восстанавливает его при сборке ответа. Четвёртый: если перечисление занимает память по своему самому большому варианту, а этот вариант редкий, вынести крупные варианты в Box, тогда частые и мелкие варианты (в данном случае A- и AAAA-записи) не платят за размер редкого NAPTR; но это стоит проверять на своём аллокаторе, округление до классов размеров и худшая локальность данных из-за разбросанных по куче Box-выделений способны частично съесть выигрыш. Пятый, самый радикальный: для данных, которые не нужно менять и не нужно читать по произвольному индексу, хранить их как сырые байты с длиной-префиксом вместо разобранной структуры, это убирает и накладные расходы перечисления, и Box-выделения, ценой перехода на последовательный перебор вместо произвольного доступа. И главное методологически: любое из этих изменений стоит сначала проверять на бенчмарке с трафиком, близким к реальному (в данном случае, 56% A-записей, 25% AAAA и 19% TXT), через кастомный аллокатор, считающий выделения на запись, а затем подтверждать на резидентной памяти боевых серверов при постепенном развёртывании, а не полагаться только на синтетические цифры.

Можно ли доверять

Это первичный источник, инженерный блог самой Cloudflare, с фрагментами реального Rust-кода структур CacheKey, CacheEntry и Record, подробным описанием методологии бенчмарка (доля типов записей, диапазон размеров TXT, кастомный аллокатор для замера памяти) и оговоркой, что синтетический тест лишь приближённо воспроизводит прод, из-за чего компания дополнительно проверяла резидентную память на боевых серверах при развёртывании. Формулировки честно квалифицированы («более 50%», «примерно 100 терабайт», «более 120 байт») там, где цифра, оценка, а не точный подсчёт, а не поданы как круглые маркетинговые числа. Независимой проверки цифр у стороннего источника нет, это самоотчёт Cloudflare о собственной инфраструктуре, и точные проценты экономии памяти и роста производительности снаружи не перепроверить. У поста указан один именованный автор, Sebastiaan Neuteboom, хотя сам текст написан от лица команды («we»), поэтому здесь уместно оценивать репутацию не только Cloudflare как инженерной организации, но и автора, подписавшего материал. На Hacker News публикация вызвала заметный отклик, 634 балла и 196 комментариев на момент подготовки этого пересказа, что говорит о технической аудитории, готовой оспорить сомнительные цифры, если бы они в них нашлись.

Риски и подводные камни

У каждой из пяти оптимизаций есть своя цена, и источник проговаривает её прямо. Упаковка крупных вариантов перечисления RecordData в Box снижает память, но добавляет накладные расходы аллокатора jemalloc на округление размеров до ближайшего класса (например, запрос на 40 байт под MX-запись округляется до 48-байтного класса, теряя 8 байт впустую) и ухудшает локальность данных: вынесенные в кучу фрагменты записи лежат не смежно, а разбросанно, и при чтении процессору чаще приходится подгружать новую строку кеша. Отказ от хранения владельца записи, когда он совпадает с запрошенным доменом, делает запись не самодостаточной, чтобы её прочитать, нужен ещё и ключ кеша, а не только сама запись; для Big Pineapple это безопасно, поскольку ключ и так доступен при каждом обращении к кешу, но это не универсальное решение для произвольного хранилища. Хранение записей как сырых байт в проводном формате устраняет накладные расходы перечисления и Box-выделений, но лишает записи произвольного доступа: читать их можно только последовательным перебором, что усложняет функции вроде ротации A/AAAA-записей по round-robin, Cloudflare считает эту цену незначительной именно потому, что записей на один кешируемый запрос немного, а в кеше с длинными списками записей на ключ это условие может не выполняться. Для третьего изменения, отказа от хранения владельца, отдельной цифры экономии в байтах источник не приводит, в отличие от остальных четырёх, поэтому вклад именно этого шага в итоговые 100 терабайт из текста не выводится. И все приведённые проценты и терабайты измерены на собственном трафике Cloudflare (микс 56/25/19% по типам записей) и её серверах поколения Gen 13, перенос этих цифр на кеш с другим распределением типов записей или другим железом источник не гарантирует.