וואלהרעידת אדמה בעוצמה 6.12 הורגשה בפפואה גינאה החדשהThe Jerusalem PostThe thought that changes everything - opinionESPNAfter Watson's rough opener, Browns to evaluate QB situation before Week 3Punch2027: Over 6,000 NDC members defect to PDP in EnuguInquirerCalabarzon cleanup hauls 35,105 kilos of coastal wasteUOLQuem protege os brasileiros dos protetores da democracia?3DNewsУчёные нашли способ замедлить остывание электронов в солнечных панелях в 1000 раз — это путь за нынешний предел КПД경향신문‘게리맨더링’보다 더 노골적인 공화당의 꼼수 ‘투표 사막화’The GuardianCeltic v Rangers: Scottish Premiership – liveDaily MailChloe Ferry says she will expose a 'friend' who has sent her 'extremely hurtful and threatening messages on social media'OnetPutin knuje przy granicy Rosji z NATO. "Dąży do stworzenia innej Europy"20 MinutenNach 14-Mio.-Sanierung stürzte Decke ein – und nun Pilzbefall
The Daily Newsstand · Free, Always
Sunday, September 20, 2026

[Перевод] Как мы сэкономили 100 ТБ за счёт оптимизации DNS-кэша сервиса 1.1.1.1

Translate

Платформа Big Pineapple — на базе которой работают 1.1.1.1Gateway DNSDNS FirewallAS112 и ряд других DNS-сервисов Cloudflare — в любой отдельный момент времени хранит 250 млрд кэш-записей. При таких масштабах использование даже одного лишнего байта на запись стоит нашему парку машин более 250 ГБ памяти.

Озадачившись этой проблемой, мы последовательно внесли пять изменений в механизм сохранения кэш-записей, сократив объём памяти, необходимый для хранения одной такой записи, более чем вдвое. В общей сложности нам удалось освободить примерно 100 ТБ по всему парку, что равнозначно объёму RAM в 130 из наших серверов 13-го поколения. При этом также ускорилась работа кэша. Пропускная способность при вставке записей выросла на 43%, а задержка поиска упала на 19%. Причём за счёт сокращения числа аллокаций и повышения локальности данных жертвовать скоростью ради экономии памяти не пришлось.

Что мы кэшируем

При холодном старте Big Pineapple запускается с пустым кэшем. По мере поступления DNS-запросов кэш заполняется, пока не будет достигнуто максимальное число записей. Как только это происходит, более старые или менее популярные элементы начинают вытесняться, освобождая место под новые.

Точный размер кэша зависит от конкретного дата-центра. В случае использования технологии EDNS Client Subnet (ECS) ответы авторитативных серверов зависят от сети клиента, поэтому мы кэшируем несколько версий одного и того же запроса. Естественно, это увеличивает как число записей, так и объём занимаемой ими памяти, делая описанные в статье оптимизации особенно актуальными для локаций с высокой долей ECS-трафика.

Каждый элемент в кэше представляет собой пару ключ-значение. Ключ определяет объект запроса:

pub struct CacheKey {
    qname: Name,
    qtype: Rtype,
    authenticated: bool,
    tag: Vec<u8>,
}

Значение же хранит сам DNS-ответ, включающий answers (записи ответов), authority (записи об авторитативных серверах) и additional (дополнительные записи), а также метаданные: hits (количество попаданий в кэш) и ttl (время жизни).

pub struct CacheEntry {
    timestamp: UnixTimeStamp,
    pub inception: Instant,
    pub ttl: Ttl,
    pub hits: u32,
    pub answers: Vec<Record>,
    pub authority: Vec<Record>,
    pub additional: Vec<Record>,
    pub errors: Vec<ExtendedError>,
    ...
}

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

Тестирование производительности памяти

Чтобы измерить влияние каждого отдельного изменения, мы проводили тест, заполняя кэш случайно сгенерированными записями, которые примерно отражают распределение трафика в рабочей среде: 56% записей типа А, 25% — АААА и 19% — TXT. Каждая содержит от одной до четырёх строк данных.

В этом тесте TXT-записи служат заменой для всех остальных типов за исключением А и АААА. Их размер выбирается случайно в диапазоне от 64 до 224 байт, что близко к среднему размеру ответов, который мы наблюдаем для записей переменной длины.

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

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

Плата за ёмкость

Vec<T> хранит три поля: указатель на данные в куче, текущую длину и общую ёмкость. Когда мы пушим в него элемент, он оценивает, хватит ли его ёмкости, и при необходимости реаллоцирует нужный объём памяти. Если места хватает, он просто добавляет элемент и инкрементирует значение длины.

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

Обе проблемы решаются с помощью Box<[T]>. Такой массив не может расти после создания, а значит, ему не нужно поле ёмкости или резервное пространство под добавление будущих элементов. Аналогичная история со String, которая тоже содержит поле ёмкости. В Box<str> оно отсутствует.

В общей сложности в каждой записи кэша хранится восемь полей Vec и String. Заменяя их на Box<[T]> и Box<str>, мы экономим по 8 байтов на поле или 64 байта на запись. Это также избавляет от выделения лишней памяти в куче, которая резервируется под будущий рост Vec . В итоге при наличии более 250 млрд записей кэша общая экономия составила более 15 ТБ.

Меньше списков — меньше указателей

Вместо того чтобы сохранять ответ, авторитетные записи и дополнительные данные в отдельных списках, можно поместить их в один с указанием смещения до начала каждого раздела. Поскольку количество DNS-записей в каждом разделе легко укладывается в тип u16, будет вполне достаточно выделить на каждое смещение по 2 байта (u16). Для сравнения, каждый отдельный Box<[T]> требует 8 байт под указатель и ещё 8 байт под длину.

Это позволяет заменить два списка, каждый из которых занимал по 8 байт под сохранение указателя и длины, двумя 2-байтовыми смещениями, сэкономив таким образом по 28 байт на одну запись кэша.

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

Убираем владельца записи

У каждой DNS-записи есть владелец — домен, которому она принадлежит. Зачастую он совпадает с доменом, к которому обращаются. К примеру, запрос типа А к example.com возвращает две записи с одним и тем же владельцем:

Однако в случае CNAME владелец записи может отличаться от запрашиваемого домена:

В сетевом формате DNS повторяющиеся владельцы обрабатываются с помощью сжатия имён по стандарту RFC 1035. Вместо того чтобы дважды кодировать один и тот же домен, при его последующих упоминаниях сохраняется 2-байтовый указатель на изначальное. Для домена вроде www.example.com можно закодировать только часть www, за которой следует указатель на место в сообщении, где example.com уже встречался.

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

Однако в большинстве записей владелец совпадает с запрашиваемым доменом. В таких случаях можно полностью убрать это поле и восстанавливать уже во время чтения. Если же владелец отличается (как в случае с А-записями, стоящими за CNAME), мы сохраняем имя целиком.

pub struct Record {
    owner: Option<Box<Name>>,
    class: Class,
    ttl: Ttl,
    rtype: Rtype,
    data: RecordData,
}

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

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

Размер Enum

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

pub enum Option<T> {
    Some(T),
    None,
}

Тип Option — это либо Some со значением, либо пустой None. При этом оба варианта занимают одинаковый объём памяти. В перечислении сохраняется тег, указывающий на активный вариант, а за ним следует пространство, достаточное для вмещения данных самого большого варианта. В случае None это пространство не используется.

Для хранения DNS-данных кажется логичным представлять каждый тип записи в виде варианта enum:

pub enum RecordData {
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    Txt(Txt),
    Naptr(Naptr),
    Svcb(Svcb),
    // ...
}

Но, как уже говорилось, размер перечисления всегда равен размеру самого крупного из его вариантов. В нашем случае это запись NAPTR, которая занимает 136 байт. Она хранит три текстовых поля переменной длины, имя домена и два целых числа. В итоге полный размер enum, включая тег варианта и паддинг, увеличивается до 144 байт.

Записям типа А требуется всего 4 байта, а АААА — 16. При этом на форматы А и АААА приходится более 80% нашего трафика. Получается, что в большинстве записей на паддинг впустую тратится больше 120 байт. А поскольку одна запись кэша может содержать множество таких элементов, общие потери растут лавинообразно.

Упаковка вариантов Box

Для решения этой проблемы можно обернуть более крупные варианты enum в тип Box, переместив их в отдельную область кучи. В таком случае enum будет хранить только 8-байтовый указатель на кучу, где сами данные займут столько места, сколько им необходимо.

pub enum RecordData {
    // Небольшие и часто используемые варианты сохраняются внутри enum.
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    // Крупные варианты сохраняются в куче.
    Txt(Box<Txt>),
    Naptr(Box<Naptr>),
    Svcb(Box<Svcb>),
    // ...
}

В случае записей типа A и AAAA это позволяет сэкономить по 120 байт на запись. При этом менее крупные варианты TXT и CNAME тоже от такого подхода выигрывают: они всё так же размещаются внутри 24-байтового enum, но выделенная под них область кучи уже соответствует размеру их фактических данных, а не раздувается до 144 байт. Единственный минус в том, что самый крупный вариант — NAPTR — в этом случае обходится чуть дороже. Теперь сюда добавляются затраты на указатель и выделение памяти в куче. Но записи NAPTR встречаются редко, так что компромисс того стоит.

Однако упаковка крупных вариантов в Box несёт и свои издержки.

Затраты на упаковку

Обёртывание в Box несёт в себе два недостатка. Первый — это издержки аллокации. Каждый упакованный вариант оказывается в отдельной области кучи, а аллокаторы округляют размер этих областей до ближайшего размерного класса. В Big Pineapple используется jemalloc, ориентированный на высоконагруженные среды с частым выделением памяти. Он группирует операции аллокации схожих объёмов в бакеты фиксированного размера. К примеру, для записи TXT запрашивается 32 байта, и она идеально укладывается в 32-байтовый бакет без лишних затрат. Но вот под запись MX запрашивается 40 байт, и в этом случае аллокация округляется до 48, впустую расходуя 8 байт.

Второй недостаток — это ухудшение локальности данных. Без Box значения enum для всех записей в рамках одного элемента кэша лежат в непрерывной области памяти. Если же мы их упаковываем, данные каждого упакованного варианта разбрасываются по разным областям кучи. Теперь, чтобы их прочитать, процессору нужно перейти по указателю. И если этот указатель ведёт в область, удалённую от остальной части кэш-записи, процессору приходится загружать новую строку кэша. Когда в кэше хранятся миллионы записей, упакованные данные оказываются разбросаны по всей куче, а не собраны в одном месте.

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

Хранение записей в сетевом формате

Очевидным следующим шагом стало бы сохранение всего DNS-ответа в сетевом формате и при каждом поиске обновление только поля на уровне отдельных клиентов — например, ID сообщения. Однако у такого подхода есть свои недостатки. Записи DNSSEC включаются в ответ, только если клиент устанавливает флаг DO (DNSSEC OK). Хранение сообщения полностью в сетевом формате означает, что нам пришлось бы либо кэшировать два варианта ответа (с DNSSEC и без), либо отфильтровывать эти записи из уже собранного сообщения. Кроме того, парсинг всего сообщения при каждом поиске создаёт дополнительную нагрузку, которой мы как раз избегаем в описанном выше подходе с перечислениями за счёт сохранения записей в уже распарсенном виде.

В качестве компромиссного решения мы решили хранить в виде сырых байтов только данные записи, а остальную часть кэш-записи оставлять в структурированных полях. Вместо списка распарсенных вариантов enum мы сохраняем записи в виде единого блока Box<[u8]>, где каждая из них кодируется следующим образом: сначала идёт двухбайтовый префикс длины, а за ним — её сырые байты.

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

При сборке DNS-ответа из кэшированных записей большинство их типов можно копировать в исходящее сообщение напрямую из буфера. Ранее каждую распарсенную запись приходилось сериализовывать обратно в сетевой формат по полям. Новый же формат позволяет пропустить эту работу для записей А, АААА, TXT и всех DNSSEC, просто копируя их закодированные байты напрямую. Парсинг остаётся необходимым только для записей, содержащих имена доменов — таких как CNAME, NS, MX и SOA — поэтому здесь можно применить сжатие DNS-имён. Поскольку записи, поддерживающие прямое копирование, составляют основную часть нашего трафика, это изменение снизило нагрузку на пути поиска. По результатам бенчмарков, такой подход, в сочетании с улучшенной локальностью памяти, сократил задержку при поиске в кэше на 5%.

Для сборки буфера данных мы производим запись в переиспользуемый буфер временного пространства, который сохраняется между операциями добавления в кэш. Поскольку записи имеют разный размер, точный размер буфера становится известен только после их сериализации. Когда все записи оказываются в буфере, мы выделяем память под Box<[u8]> и копируем туда данные через memcpy. Это позволяет заменить отдельные аллокации под каждую упакованную запись одной единственной для всех данных сразу. Это также исключает потери при сжатии Vec<u8>, когда аллокатор может не иметь возможности вернуть системе неиспользованный хвост изначально выделенной памяти. В бенчмарках одно только это изменение повысило пропускную способность кэша при операциях вставки на 13%.

Результаты

Измерения в продакшене показывают, что зафиксированная бенчмарками экономия памяти на уровне одной записи масштабировалась на резидентную память всего процесса. На графике ниже отражено использование памяти на уровнях p90, p98 и p99 по всем инстансам Big Pineapple. Первой пунктирной линией отмечено начало роллаута 18 мая 2026 года, а второй — его завершение на всех сервисах 6 июля. Каждый релиз включал одну или несколько из описанных оптимизаций, поэтому потребление памяти снижалось ступенчато, а не разом.

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

Потребление памяти в расчёте на один инстанс снизилось по всем перцентилям. На уровне p99 оно опустилось с 9,3 ГБ до 5,3 ГБ, что означает сокращение объёма занятой резидентной памяти на 43%. На уровне p90 потребление снизилось с 6,5 ГБ до 3,8 ГБ — то есть на 42%. Наиболее же выраженная экономия возникла на инстансах с максимально заполненным кэшем.

По результатам бенчмарков эти пять оптимизаций позволили уменьшить объём памяти, занимаемый одной записью, с 953 до 420 байт — на 56%. Объём аллокаций на каждую запись снизился с 1,1 КБ до 461 байта. При этом показатели в продакшене оказались ниже, так как резидентная память включает в себя не только сам кэш, но и все остальные данные процесса. Когда результаты роллаута стабилизировались, совокупный объём рабочей памяти по всему парку серверов сократился примерно на 100 ТБ.

Увеличилось и быстродействие. Конкретно пропускная способность кэша при операциях вставки выросла на 43%, а задержка при поиске снизилась на 19%.

Метрика

До

После

Изменение

Чистый объём памяти на запись

953 Б

420 Б

-56%

Объём выделяемой памяти на запись

1,1 КБ

461 Б

-58%

Пропускная способность кэша при вставке

625,000 записей/с

893,000 записей/с

+43%

Задержка при поиске в кэше

828 нс

670 нс

-19%

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

Подробнее о сервисе Big Pineapple можете почитать в статье «How Rust and Wasm power Cloudflare's 1.1.1.1». А если вы сами работаете с DNS или другими масштабными системами, поделитесь своими примерами оптимизации c сообществом Cloudflare или в нашем Discord-канале разработчиков.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.