Что важнее в DNS: CNAME или A-запись? Разбор критического сбоя

Переведи этот HTML-контент с английского на русский. Сохрани все HTML-теги:

8 января 2026 года плановое обновление 1.1.1.1, направленное на снижение потребления памяти, случайно спровоцировало волну сбоев DNS-разрешения у пользователей по всему Интернету. Первопричиной стала не атака и не сбой, а тонкое изменение порядка записей в наших DNS-ответах.

Хотя большинство современного программного обеспечения считает порядок записей в DNS-ответах несущественным, мы обнаружили, что некоторые реализации ожидают, что CNAME-записи будут идти перед всеми остальными. Когда этот порядок изменился, разрешение стало давать сбои. В этой статье исследуется изменение кода, вызвавшее сдвиг, почему оно сломало определённые DNS-клиенты и 40-летняя неоднозначность протокола, из-за которой «правильный» порядок DNS-ответа трудно определить.

Хронология

Все указанные отметки времени приводятся по всемирному координированному времени (UTC).

Время

Описание

2025-12-02

Изменение порядка записей внедрено в кодовую базу 1.1.1.1

2025-12-10

Изменение выпущено в нашу тестовую среду

2026-01-07 23:48

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

2026-01-08 17:40

Выпуск достигает 90% серверов

2026-01-08 18:19

Объявлен инцидент

2026-01-08 18:27

Выпуск откатывается

2026-01-08 19:55

Откат завершён. Воздействие прекращено

Что произошло?

В ходе некоторых улучшений, направленных на снижение потребления памяти нашей реализацией кэша, мы внесли тонкое изменение в порядок CNAME-записей. Изменение было внедрено 2 декабря 2025 года, выпущено в нашу тестовую среду 10 декабря, а развёртывание началось 7 января 2026 года.

Как работают цепочки DNS CNAME

Когда вы запрашиваете домен, например www.example.com, вы можете получить CNAME (каноническое имя) запись, которая указывает, что одно имя является псевдонимом для другого. Задача публичных резолверов, таких как 1.1.1.1, — следовать по этой цепочке псевдонимов, пока не будет достигнут окончательный ответ:

www.example.com → cdn.example.com → server.cdn-provider.com → 198.51.100.1

Когда 1.1.1.1 проходит по этой цепочке, он кэширует каждую промежуточную запись. Каждая запись в цепочке имеет свой собственный TTL (время жизни), указывающий, как долго мы можем её кэшировать. Не все TTL в цепочке CNAME должны быть одинаковыми:

www.example.com → cdn.example.com (TTL: 3600 секунд) # Всё ещё в кэше cdn.example.com → 198.51.100.1    (TTL: 300 секунд)  # Истёк

Когда одна или несколько записей в цепочке CNAME истекают, она считается частично устаревшей. К счастью, поскольку части цепочки всё ещё находятся в нашем кэше, нам не нужно повторно разрешать всю цепочку CNAME — только ту часть, которая устарела. В нашем примере выше мы возьмём всё ещё действительную цепочку www.example.com → cdn.example.com и разрешим только устаревшую A-запись cdn.example.com. После этого мы объединяем существующую цепочку CNAME и вновь разрешённые записи в единый ответ.

Изменение логики

Изменение произошло в коде, который объединяет эти две цепочки. Ранее код создавал новый список, вставлял существующую цепочку CNAME, а затем добавлял новые записи:

impl PartialChain {
    /// Объединяет записи с записью кэша, чтобы сделать кэшированные записи полными.
    pub fn fill_cache(&self, entry: &mut CacheEntry) {
        let mut answer_rrs = Vec::with_capacity(entry.answer.len() + self.records.len());
        answer_rrs.extend_from_slice(&self.records); // Сначала CNAME
        answer_rrs.extend_from_slice(&entry.answer); // Затем A/AAAA записи
        entry.answer = answer_rrs;
    }
}

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

impl PartialChain {
    /// Объединяет записи с записью кэша, чтобы сделать кэшированные записи полными.
    pub fn fill_cache(&self, entry: &mut CacheEntry) {
        entry.answer.extend(self.records); // CNAME в конце
    }
}

В результате в ответах, которые возвращал 1.1.1.1, CNAME-записи теперь иногда оказывались внизу, после окончательного разрешённого ответа.

Почему это вызвало воздействие

Когда DNS-клиенты получают ответ с цепочкой CNAME в секции ответа, они также должны следовать по этой цепочке, чтобы узнать, что www.example.com указывает на 198.51.100.1. Некоторые реализации DNS-клиентов обрабатывают это, отслеживая ожидаемое имя для записей по мере их последовательного перебора. При обнаружении CNAME ожидаемое имя обновляется:

;; СЕКЦИЯ ВОПРОСА:
;; www.example.com.        IN    A

;; СЕКЦИЯ ОТВЕТА:
www.example.com.    3600   IN    CNAME  cdn.example.com.
cdn.example.com.    300    IN    A      198.51.100.1

  1. Найти записи для www.example.com

  2. Обнаружить www.example.com. CNAME cdn.example.com

  3. Найти записи для cdn.example.com

  4. Обнаружить cdn.example.com. A 198.51.100.1

Когда CNAME внезапно оказывается внизу, это перестаёт работать:

;; СЕКЦИЯ ВОПРОСА:
;; www.example.com.	       IN    A

;; СЕКЦИЯ ОТВЕТА:
cdn.example.com.    300    IN    A      198.51.100.1
www.example.com.    3600   IN    CNAME  cdn.example.com.

  1. Найти записи для www.example.com

  2. Проигнорировать cdn.example.com. A 198.51.100.1, так как оно не соответствует ожидаемому имени

  3. Обнаружить www.example.com. CNAME cdn.example.com

  4. Найти записи для cdn.example.com

  5. Больше записей нет, поэтому ответ считается пустым

Одной из таких сломавшихся реализаций является функция getaddrinfo в glibc, которая обычно используется в Linux для DNS-разрешения. Если посмотреть на её реализацию getanswer_r, действительно видно, что она ожидает найти CNAME-записи перед любыми ответами:

for (; ancount > 0; --ancount)
  {
    // ... парсинг DNS-записей ...
    
    if (rr.rtype == T_CNAME)
      {
        /* Записать цель CNAME как новое ожидаемое имя. */
        int n = __ns_name_unpack (c.begin, c.end, rr.rdata,
                                  name_buffer, sizeof (name_buffer));
        expected_name = name_buffer;  // Обновить то, что мы ищем
      }
    else if (rr.rtype == qtype
             && __ns_samebinaryname (rr.rname, expected_name)  // Должно совпадать!
             && rr.rdlength == rrtype_to_rdata_length (type:qtype))
      {
        /* Запись адреса совпадает - сохранить её */
        ptrlist_add (list:addresses, item:(char *) alloc_buffer_next (abuf, uint32_t));
        alloc_buffer_copy_bytes (buf:abuf, src:rr.rdata, size:rr.rdlength);
      }
  }

Другой заметной затронутой реализацией был процесс DNSC в трёх моделях коммутаторов Ethernet Cisco. В случаях, когда коммутаторы были настроены на использование 1.1.1.1, они испытывали спонтанные циклы перезагрузки при получении ответа, содержащего переупорядоченные CNAME. Cisco опубликовал сервисный документ с описанием проблемы.

Не все реализации ломаются

У большинства DNS-клиентов нет этой проблемы. Например, systemd-resolved сначала разбирает записи в упорядоченный набор:

typedef struct DnsAnswerItem {
        DnsResourceRecord *rr; // Сама запись
        DnsAnswerFlags flags;  // Из какой секции она пришла
        // ... другие метаданные
} DnsAnswerItem;


typedef struct DnsAnswer {
        unsigned n_ref;
        OrderedSet *items;
} DnsAnswer;

При следовании по цепочке CNAME он может затем искать во всём наборе ответов, даже если CNAME-записи находятся не в начале.

Что говорится в RFC

RFC 1034, опубликованный в 1987 году, определяет большую часть поведения протокола DNS и должен дать нам ответ на вопрос, важен ли порядок CNAME-записей. Раздел 4.3.1 содержит следующий текст:

Если запрашивается рекурсивная служба и она доступна, рекурсивный ответ на запрос будет одним из следующих:

- Ответ на запрос, возможно, предваряемый одной или несколькими записями CNAME, указывающими псевдонимы, встреченные на пути к ответу.

Хотя фразу «possibly preface» можно интерпретировать как требование, чтобы записи CNAME появлялись перед всем остальным, в ней не используются нормативные ключевые слова, такие как MUST и SHOULD, которые современные RFC используют для выражения требований. Это не недостаток RFC 1034, а просто следствие его возраста. RFC 2119, который стандартизировал эти ключевые слова, был опубликован в 1997 году, через 10 лет после RFC 1034.

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

Тонкое различие: наборы записей (RRsets) vs отдельные записи (RRs) в разделах сообщения

Чтобы понять, почему существует эта неоднозначность, нужно уяснить тонкое, но важное различие в терминологии DNS.

RFC 1034, раздел 3.6, определяет наборы ресурсных записей (RRsets) как коллекции записей с одинаковым именем, типом и классом. Для наборов записей спецификация ясна относительно порядка:

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

Однако RFC 1034 не чётко определяет, как разделы сообщения соотносятся с наборами записей. Хотя современные спецификации DNS показывают, что разделы сообщения действительно могут содержать несколько наборов записей (например, ответы DNSSEC с подписями), RFC 1034 не описывает разделы сообщения в этих терминах. Вместо этого он рассматривает разделы сообщения как содержащие отдельные ресурсные записи (RRs).

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

RFC 1034, раздел 6.2.1, включает пример, который демонстрирует эту неоднозначность ещё больше. В нём говорится, что порядок ресурсных записей (RRs) также не является значимым:

Разница в порядке следования RR в секции ответа не является значимой.

Однако этот пример показывает только две A-записи для одного имени в пределах одного набора записей. Он не затрагивает вопрос о том, применимо ли это к разным типам записей, таким как CNAME и A.

Упорядочивание цепочки CNAME

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

;; QUESTION SECTION:
;; www.example.com.              IN    A

;; ANSWER SECTION:
cdn.example.com.           3600  IN    CNAME  server.cdn-provider.com.
www.example.com.           3600  IN    CNAME  cdn.example.com.
server.cdn-provider.com.   300   IN    A      198.51.100.1

Каждая запись CNAME принадлежит разному набору записей (RRset), так как у них разные владельцы (имена), поэтому утверждение о незначимости порядка наборов записей здесь не применяется.

Однако RFC 1034 не специфицирует, что цепочки CNAME должны появляться в каком-либо определённом порядке. Нет требования, чтобы www.example.com. CNAME cdn.example.com. обязательно шло перед cdn.example.com. CNAME server.cdn-provider.com.. При последовательном парсинге возникает та же проблема:

  1. Найти записи для www.example.com

  2. Проигнорировать cdn.example.com. CNAME server.cdn-provider.com, так как оно не соответствует ожидаемому имени

  3. Обнаружить www.example.com. CNAME cdn.example.com

  4. Найти записи для cdn.example.com

  5. Проигнорировать server.cdn-provider.com. A 198.51.100.1, так как оно не соответствует ожидаемому имени

Что должны делать резолверы?

RFC 1034, раздел 5, описывает поведение резолверов. Раздел 5.2.2 конкретно рассматривает, как резолверы должны обрабатывать псевдонимы (CNAME):

В большинстве случаев резолвер просто перезапускает запрос с новым именем, когда встречает CNAME.

Это предполагает, что резолверы должны перезапускать запрос при обнаружении CNAME, независимо от того, где он появляется в ответе. Однако важно различать разные типы резолверов:

  • Рекурсивные резолверы, такие как 1.1.1.1, — это полные DNS-резолверы, которые выполняют рекурсивное разрешение, запрашивая авторитетные серверы имён.

  • Заглушки-резолверы (stub resolvers), такие как getaddrinfo в glibc, — это упрощённые локальные интерфейсы, которые перенаправляют запросы рекурсивным резолверам и обрабатывают ответы.

Разделы RFC о поведении резолверов были написаны в основном с учётом полных резолверов, а не упрощённых заглушек-резолверов, которые фактически используют большинство приложений. Некоторые заглушки-резолверы, очевидно, не реализуют определённые части спецификации, например, логику перезапуска при CNAME, описанную в RFC.

Спецификации DNSSEC дают контрастный пример

Более поздние спецификации DNS демонстрируют иной подход к определению порядка записей. RFC 4035, который определяет модификации протокола для DNSSEC, использует более явный язык:

При размещении подписанного набора записей (RRset) в разделе Answer сервер имён ДОЛЖЕН также разместить свои записи RRSIG в разделе Answer. Записи RRSIG имеют более высокий приоритет для включения, чем любые другие наборы записей, которые, возможно, придётся включить.

Спецификация использует «MUST» и явно определяет «более высокий приоритет» для RRSIG записей. Однако «более высокий приоритет для включения» относится к тому, должны ли RRSIG быть включены в ответ, а не к тому, где они должны появиться. Это даёт недвусмысленные указания разработчикам о включении записей в контексте DNSSEC, но не предписывает какого-либо конкретного поведения относительно порядка записей.

Для неподписанных зон, однако, неоднозначность из RFC 1034 сохраняется. Слово «preface» (предваряться) направляло поведение реализаций почти четыре десятилетия, но оно никогда не было формально определено как требование.

Должны ли записи CNAME идти первыми?

Хотя, на наш взгляд, RFC не требуют, чтобы CNAME появлялись в каком-либо определённом порядке, ясно, что по крайней мере некоторые широко распространённые DNS-клиенты полагаются на это. Поскольку некоторые системы, использующие эти клиенты, могут обновляться редко или никогда, мы считаем, что лучше всего требовать, чтобы записи CNAME появлялись по порядку перед любыми другими записями.

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