DNS: иерархия, типы записей, резолвинг, кэширование, DoH/DoT
Каждая статья этого трека до сих пор начиналась с готового IP-адреса. Канальный уровень доставлял кадр до соседа, IP — пакет до сети, TCP и QUIC строили поверх этого надёжный поток. Откуда берётся сам адрес, мы аккуратно не спрашивали.
Отвечает на это DNS — и отвечает так, что за 40 лет система пережила рост на семь порядков без единой точки отказа и без единой глобальной транзакции. Цена, которую мы за это платим, — согласованность в конечном счёте. В DNS нет момента «запись изменена». Есть момент «авторитетный сервер начал отдавать новое значение», и есть долгий хвост чужих кэшей, которые про это ещё не знают и знать не обязаны. Почти все страшные истории про DNS — это истории про то, как инженер перепутал первое со вторым.
Вторая половина страшных историй — про то, что DNS проектировали в 1983 году, когда сеть состояла из знакомых друг с другом организаций. Аутентичности данных в протоколе не было вообще, приватности — тем более. Ответ приходил открытым текстом в одной UDP-датаграмме, и единственным доказательством подлинности были 16 бит случайного ID. Обе дыры закрывали задним числом: DNSSEC для подлинности, DoT/DoH/DoQ для приватности. Обе заплатки заметно сложнее того, что латают, и обе с собственной ценой.
Аналогия на всю статью: DNS — не телефонная книга, а система справочных бюро с делегированием полномочий. Корневое бюро не знает ни одного телефона, но точно знает, кто ведёт справочник по .com. Тот не знает ваш телефон, но знает, кто ведёт справочник по example.com. А ещё каждое бюро по дороге запоминает ответ на некоторое время — и именно поэтому справка бывает устаревшей.
Зачем понадобился DNS
До 1983 года имена хостов ARPANET жили в одном файле — HOSTS.TXT. Его вручную вёл Стэнфордский исследовательский институт (SRI-NIC), а администраторы периодически скачивали копию по FTP. Схема работала, пока хостов были сотни, и начала разваливаться на тысячах: файл рос, трафик на его раздачу рос квадратично, конфликты имён приходилось разруливать по телефону, а изменение применялось днями.
Пол Мокапетрис предложил заменить файл распределённой иерархической базой — RFC 882/883 (1983), которые через четыре года переписали в канонические RFC 1034 (концепции) и RFC 1035 (формат сообщений). Три инженерных решения из этих документов работают до сих пор:
- Иерархия имён — пространство имён делится на дерево, право именования внутри поддерева делегируется вниз. Конфликтов не бывает по построению.
- Делегирование администрирования — тот, кому делегировали зону, отвечает за неё сам. Центральной точки записи нет, и её невозможно перегрузить.
- Кэширование с явным TTL — автор данных сам объявляет, сколько его ответ разрешено считать актуальным. Это уводит подавляющее большинство запросов из сети вообще.
Пространство имён: дерево, зоны, делегирование
Имя читается справа налево. www.api.example.com. — это метка www внутри api внутри example внутри com внутри корня. Финальная точка — это и есть корень; имя с ней называется FQDN (fully qualified domain name), без неё имя относительное и его будут достраивать по search-доменам. Точка в конце — не педантизм: в Kubernetes она экономит по шесть лишних запросов на каждый резолв.
Жёсткие ограничения формата (RFC 1035 §2.3.4), которые стоит помнить наизусть:
- метка — от 1 до 63 байт (в длине метки два старших бита зарезервированы под сжатие, отсюда 63, а не 255);
- полное имя на проводе — до 255 байт вместе с байтами длин и завершающим нулём;
- сравнение имён регистронезависимое, но регистр сохраняется при передаче — на этом построен трюк 0x20 из раздела про безопасность;
- не-ASCII имена кодируются в punycode:
почта.рфедет по сети какxn--80a1acny.xn--p1ai(IDNA, RFC 5890).
Ключевое различие, которое путают чаще всего: домен — это поддерево имён, зона — это административная единица. Зона example.com содержит все имена поддерева, кроме тех, что делегированы дальше. Делегирование выглядит как набор записей NS в родительской зоне: .com не хранит адрес www.example.com, он хранит «спрашивайте a.iana-servers.net».
13 имён a…m.root-servers.net
≈1900 anycast-инстансов"] COM["Зона com.
Verisign"] RU["Зона ru.
Координационный центр"] ARPA["Зона arpa.
обратные зоны"] EX["Зона example.com.
ваш DNS-провайдер"] SUB["Делегированная зона
k8s.example.com."] REC["Записи внутри зоны
www, api, mx, _dmarc"] INADDR["in-addr.arpa
PTR для IPv4"] ROOT -->|"NS + glue"| COM ROOT -->|"NS + glue"| RU ROOT --> ARPA COM -->|"NS a.iana-servers.net"| EX ARPA --> INADDR EX --> REC EX -->|"отдельный NS — граница зоны"| SUB classDef zone fill:#4b83d4,fill-opacity:0.12,stroke:#4b83d4; classDef leaf fill:#3f9d6b,fill-opacity:0.12,stroke:#3f9d6b; class ROOT,COM,RU,ARPA,EX,SUB zone; class REC,INADDR leaf;
Glue-записи — тонкое место. Если серверы зоны example.com называются ns1.example.com, возникает круг: чтобы узнать адрес ns1.example.com, нужно спросить ns1.example.com. Разрывается он тем, что родительская зона .com вместе с делегированием отдаёт адреса этих серверов в секции additional. Забыли обновить glue при смене IP — домен пропадает у всех, у кого истёк кэш, а dig с вашего ноутбука ещё несколько часов показывает, что всё в порядке.
Отдельный класс инцидентов — lame delegation: родитель говорит «спрашивайте ns2», а ns2 про эту зону ничего не знает и отвечает REFUSED. Резолвер честно перебирает серверы, но половина запросов тратит лишний RTT, а часть падает в SERVFAIL. Проверяется одной командой:
# Спрашиваем каждый NS напрямую, без рекурсии, и смотрим на флаг aa
$ for ns in $(dig +short NS example.com); do
printf '%-28s ' "$ns"
dig @"$ns" example.com SOA +norecurse +noall +comments | grep -o 'status: [A-Z]*, .*flags: [a-z ]*'
done
a.iana-servers.net. status: NOERROR, flags: qr aa
b.iana-servers.net. status: NOERROR, flags: qr aa
aa (authoritative answer) обязан быть у всех перечисленных серверов. Нет aa — делегирование битое.
Типы записей: что реально бывает в зоне
Единица хранения в DNS — не запись, а RRset: все записи с одинаковыми (имя, тип, класс). Резолвер кэширует RRset целиком и целиком же его подписывает DNSSEC. Отсюда правило: у всех записей одного RRset обязан быть одинаковый TTL (RFC 2181 §5.2).
| Тип | Что содержит | Зачем и где подвох |
|---|---|---|
A |
IPv4-адрес | базовая привязка имени к адресу |
AAAA |
IPv6-адрес | отдельный RRset с отдельным TTL — про это ниже |
CNAME |
каноническое имя | псевдоним; не может сосуществовать с другими записями того же имени |
MX |
приоритет + хост | почта; хост в MX обязан быть именем с A/AAAA, не CNAME |
TXT |
произвольный текст | SPF, DKIM, DMARC, подтверждение владения доменом |
NS |
имя сервера зоны | делегирование; живёт и в родителе, и в самой зоне |
SOA |
параметры зоны | serial, refresh, retry, expire, minimum = TTL негативного кэша |
PTR |
имя по адресу | обратные зоны in-addr.arpa и ip6.arpa; почтовики его проверяют |
SRV |
приоритет, вес, порт, хост | сервис + порт; SIP, XMPP, Kerberos, LDAP |
CAA |
какому УЦ можно выпускать сертификат | единственная запись, которую обязаны проверять все публичные УЦ |
HTTPS / SVCB |
параметры подключения | ALPN, порт, ECH-ключ, адреса — до первого пакета |
DNAME |
подстановка поддерева | переименование целой ветки |
DS, DNSKEY, RRSIG, NSEC3 |
криптография | DNSSEC |
Проблема CNAME на apex ловит всех. Стандарт запрещает CNAME рядом с другими записями, а на apex домена (example.com, без поддомена) обязаны быть SOA и NS. Значит, example.com. CNAME cdn.provider.net. — невалидная конфигурация, и строгий сервер её не примет. Провайдеры обходят это нестандартными типами ALIAS/ANAME/CNAME flattening: авторитетный сервер сам резолвит цель и отдаёт наружу обычные A. Работает, но резолв цели происходит с точки зрения вашего DNS-провайдера, а не пользователя, — geo-балансировка CDN при этом промахивается. Правильное решение с 2023 года — запись HTTPS из RFC 9460, у которой есть легальный механизм AliasMode на apex.
Как это выглядит вживую:
$ dig +noall +answer cloudflare.com HTTPS
cloudflare.com. 300 IN HTTPS 1 . alpn="h3,h2" ipv4hint=104.16.132.229 ipv6hint=2606:4700::6810:84e5
$ dig +noall +answer _dmarc.example.com TXT
_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; pct=100"
$ dig +noall +answer example.com MX
example.com. 3600 IN MX 0 .
Три примера — три урока. Первый: запись HTTPS сообщает браузеру, что сервер умеет HTTP/3, до установки соединения, — раньше для этого требовался круг по HTTP/1.1 с заголовком Alt-Svc (см. HTTP), а ipv4hint позволяет не ждать отдельный запрос A. Второй: политика DMARC — обычный TXT, и опечатка в нём молча выключает защиту почты. Третий: MX 0 . — это «null MX» из RFC 7505, явное заявление «домен почту не принимает»; отсутствие MX означало бы fallback на A-запись и доставку на веб-сервер.
Зона в текстовом виде (формат master file, RFC 1035 §5) — то, что реально лежит на авторитетном сервере:
$ORIGIN example.com.
$TTL 3600 ; TTL по умолчанию для записей ниже
@ IN SOA ns1.example.com. hostmaster.example.com. (
2026071601 ; serial — монотонно растёт, иначе вторичные не заберут зону
7200 ; refresh — как часто вторичный опрашивает первичный
3600 ; retry — пауза после неудачной попытки
1209600 ; expire — когда вторичный признает данные мёртвыми
3600 ) ; minimum — TTL негативного кэша (NXDOMAIN), НЕ «минимальный TTL»
@ IN NS ns1.example.com.
@ IN NS ns2.example.com.
@ IN A 203.0.113.10
@ IN AAAA 2001:db8::10
@ IN MX 10 mx1.example.com.
@ IN CAA 0 issue "letsencrypt.org"
www IN CNAME @
api 60 IN A 203.0.113.20 ; собственный TTL 60 — готовим быструю перекладку
ns1 IN A 203.0.113.53 ; это и есть glue, отдаваемый родителем
_dmarc IN TXT "v=DMARC1; p=reject"
Поле minimum в SOA — самое неудачно названное поле во всём DNS. С 1998 года (RFC 2308) оно означает не «минимальный TTL записей», а срок кэширования отрицательных ответов. Поставили 86400 — и опечатка в имени будет отдавать NXDOMAIN сутки после того, как вы её исправите.
Как выглядит резолв: от getaddrinfo до корня
Резолв делится на две принципиально разные роли, и путаница между ними — источник половины непониманий.
Stub-резолвер живёт внутри вашего процесса (это getaddrinfo() из libc или встроенный резолвер Go/JVM). Он умеет ровно одно: задать вопрос настроенному серверу и поверить ответу. Настройки берёт из /etc/resolv.conf и /etc/nsswitch.conf.
Рекурсивный резолвер — сервер, который делает всю работу: обходит иерархию сверху вниз итеративными запросами и кэширует всё, что видит. Это 1.1.1.1, 8.8.8.8, резолвер провайдера, CoreDNS в кластере.
Ключевая деталь: рекурсивным запрос делает флаг RD, а иерархию резолвер обходит итеративно — каждый сервер отвечает не «вот адрес», а «вот кто знает больше».
потом search-домены по правилу ndots Stub->>R: QNAME example.com, QTYPE A, RD=1 Note over R: кэш пуст — идём сверху R->>Root: QNAME com (QNAME minimisation), RD=0 Root-->>R: не знаю, вот NS зоны com + glue-адреса R->>TLD: QNAME example.com, RD=0 TLD-->>R: не знаю, вот NS зоны example.com + glue R->>Auth: QNAME example.com, QTYPE A, RD=0 Auth-->>R: AA=1, A 93.184.216.34, TTL 86400 Note over R: кладём в кэш все три RRset
NS com, NS example.com, A R-->>Stub: A 93.184.216.34, TTL 86400 Stub-->>App: struct addrinfo Note over App,R: следующий такой запрос от любого клиента
этого резолвера уйдёт из кэша за доли миллисекунды
Обратите внимание на шаг 4: резолвер спрашивает у корня не полное имя, а только com. Это QNAME minimisation (RFC 9156): корневому серверу незачем знать, что вы ищете secret-staging.example.com. До 2016 года полное имя уходило на каждый сервер по пути, включая корневые.
Посмотреть весь обход целиком можно dig +trace — он игнорирует ваш резолвер и идёт по цепочке сам:
$ dig +trace +nodnssec example.com A
. 518400 IN NS a.root-servers.net.
. 518400 IN NS m.root-servers.net.
;; Received 239 bytes from 127.0.0.53#53(127.0.0.53) in 1 ms
com. 172800 IN NS a.gtld-servers.net.
com. 172800 IN NS m.gtld-servers.net.
;; Received 1170 bytes from 192.5.5.241#53(f.root-servers.net) in 12 ms
example.com. 172800 IN NS a.iana-servers.net.
example.com. 172800 IN NS b.iana-servers.net.
;; Received 275 bytes from 192.55.83.30#53(m.gtld-servers.net) in 24 ms
example.com. 86400 IN A 93.184.216.34
;; Received 56 bytes from 199.43.135.53#53(a.iana-servers.net) in 8 ms
Три сетевых обращения, ~45 мс — и это холодный путь, который случается один раз на тысячи запросов. Стоимость резолва в общем случае — O(глубина имени) обращений по RTT и O(1) по памяти клиента; вся экономика DNS построена на том, что верхние уровни дерева меняются раз в сутки и живут в кэше вечно.
Первая строка вывода важна отдельно: даже +trace начинает с вашего резолвера, чтобы узнать список корневых серверов. Настоящий рекурсивный сервер берёт его из вшитого файла root hints и при старте делает priming query — спрашивает NS . у первого попавшегося корневого сервера, чтобы получить актуальный список.
Между приложением и рекурсивным резолвером есть ещё слой, про который забывают. Вот что на самом деле делает getaddrinfo:
$ cat /etc/nsswitch.conf | grep hosts
hosts: files mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns
$ cat /etc/resolv.conf
nameserver 127.0.0.53 # это stub systemd-resolved, а не настоящий резолвер
options edns0 trust-ad
search prod.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Порядок из nsswitch.conf объясняет классику «dig работает, приложение — нет»: dig не читает ни /etc/hosts, ни nsswitch.conf. Он говорит с сервером напрямую. Приложение идёт через libc и может получить ответ из files (то есть /etc/hosts) или от mDNS. Инструмент, который повторяет путь приложения, — getent hosts или resolvectl query:
$ getent hosts api.example.com
203.0.113.20 api.example.com
$ resolvectl query api.example.com
api.example.com: 203.0.113.20 -- link: eth0
-- Information acquired via protocol DNS in 1.9ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: no
-- Data from: cache network
Строка Data from: cache — прямой ответ на вопрос «почему я поменял запись, а ничего не изменилось».
Сообщение на проводе: заголовок, EDNS, TCP
Формат сообщения не менялся с 1987 года и одинаков для запроса и ответа: 12-байтовый заголовок, затем четыре секции подряд без разделителей — их длины определяются только счётчиками из заголовка.
Что важно на практике:
- ID (16 бит) плюс случайный исходный UDP-порт (ещё ~16 бит) — вся защита ответа от подделки до появления DNSSEC. Итого около 32 бит энтропии, которые атака Камински научилась обходить.
- RCODE (4 бита) — тот самый
status:в выводе dig.NOERROR(0),SERVFAIL(2),NXDOMAIN(3),REFUSED(5). Расширенные коды ошибок (RFC 8914) едут в EDNS и объясняют, почему SERVFAIL. - TC (truncated) — «ответ не влез». Клиент обязан повторить вопрос по TCP. DNS работает поверх TCP не как аварийный режим, а как штатный транспорт (RFC 7766); фаервол, режущий 53/tcp, ломает всё большое: DNSSEC, длинные TXT, зонные передачи.
- AA — ответ от авторитетного сервера; RA — резолвер готов рекурсировать; AD — данные прошли DNSSEC-валидацию; CD — «не проверяй DNSSEC, отдай как есть» (бесценно при отладке).
- EDNS(0) (RFC 6891) — псевдозапись
OPTв additional. Она несёт размер UDP-буфера, флаг DO (хочу DNSSEC), DNS-cookies, NSID и ECS. Именно её видно как строку;; OPT PSEUDOSECTION:.
Соберём и разберём сообщение руками — это лучший способ убедиться, что формат простой:
"""Минимальный DNS-клиент: собрать запрос, отправить по UDP, разобрать ответ.
Ровно то, что делает stub-резолвер внутри libc, без сторонних библиотек."""
import random
import socket
import struct
QTYPE = {"A": 1, "NS": 2, "CNAME": 5, "SOA": 6, "PTR": 12,
"MX": 15, "TXT": 16, "AAAA": 28, "SRV": 33, "HTTPS": 65}
RCODE = {0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL",
3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"}
def encode_name(name: str) -> bytes:
# "example.com" превращается в 07 'example' 03 'com' 00
out = bytearray()
for label in name.rstrip(".").split("."):
raw = label.encode() if label.isascii() else label.encode("idna")
if not 1 <= len(raw) <= 63:
raise ValueError(f"метка {label!r} нарушает лимит 1..63 байта")
out.append(len(raw))
out += raw
out.append(0) # корневая метка — та самая точка в конце
if len(out) > 255:
raise ValueError("имя длиннее 255 байт")
return bytes(out)
def build_query(name: str, qtype: str = "A", udp_payload: int = 1232) -> tuple[int, bytes]:
tid = random.SystemRandom().randrange(0x10000) # 16 бит энтропии
header = struct.pack("!HHHHHH", tid, 0x0100, 1, 0, 0, 1) # flags: RD=1; 1 вопрос, 1 additional
question = encode_name(name) + struct.pack("!HH", QTYPE[qtype], 1) # QCLASS=IN
# OPT: имя = корень, тип 41, «класс» = размер буфера, TTL = расширенные флаги, RDLEN = 0
opt = b"\x00" + struct.pack("!HHIH", 41, udp_payload, 0, 0)
return tid, header + question + opt
def read_name(msg: bytes, off: int, budget: int = 128) -> tuple[str, int]:
"""Читает имя с учётом сжатия. budget защищает от зацикленных указателей —
это реальный класс уязвимостей (DNS decompression loop)."""
labels, after_pointer = [], None
while budget > 0:
budget -= 1
length = msg[off]
if length & 0xC0 == 0xC0: # два старших бита 11 — указатель
pointer = struct.unpack("!H", msg[off:off + 2])[0] & 0x3FFF
if after_pointer is None:
after_pointer = off + 2 # вернёмся сюда после прыжка
off = pointer
continue
off += 1
if length == 0:
break
labels.append(msg[off:off + length].decode("ascii", "replace"))
off += length
else:
raise ValueError("подозрительное сообщение: слишком много прыжков по указателям")
return ".".join(labels), (after_pointer if after_pointer is not None else off)
def parse(msg: bytes, expect_id: int):
tid, flags, qd, an, _ns, _ar = struct.unpack("!HHHHHH", msg[:12])
if tid != expect_id:
raise ValueError("ID не совпал — так выглядит чужой или поддельный ответ")
rcode = flags & 0x000F
if rcode:
raise ValueError(f"сервер ответил {RCODE.get(rcode, rcode)}")
off = 12
for _ in range(qd): # секцию вопроса пропускаем
_, off = read_name(msg, off)
off += 4
records = []
for _ in range(an):
name, off = read_name(msg, off)
rtype, _cls, ttl, rdlen = struct.unpack("!HHIH", msg[off:off + 10])
off += 10
records.append((name, rtype, ttl, msg[off:off + rdlen]))
off += rdlen
return bool(flags & 0x0200), records # TC-бит и записи
def resolve(name: str, server: str = "1.1.1.1", qtype: str = "A"):
tid, query = build_query(name, qtype)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(2.0)
sock.sendto(query, (server, 53)) # исходный порт ядро выберет случайно
reply, _ = sock.recvfrom(4096)
truncated, records = parse(reply, tid)
if truncated:
raise RuntimeError("TC=1: ответ обрезан, надо повторить запрос по TCP")
return records
if __name__ == "__main__":
for name, rtype, ttl, rdata in resolve("example.com"):
value = socket.inet_ntoa(rdata) if rtype == 1 else rdata.hex()
print(f"{name:24} TTL={ttl:<6} {value}")
$ python3 mini_dns.py
example.com TTL=86400 93.184.216.34
Разбор сообщения — O(n) по его длине и O(1) по памяти сверх самого буфера; единственное место, где наивная реализация ломается, — сжатие имён, поэтому в коде и стоит budget. Указатель, ссылающийся сам на себя, укладывает парсер без счётчика прыжков; на этом граблях спотыкались и коммерческие продукты.
Кэширование: TTL, негативный кэш и почему миграция «не применяется»
TTL — это не «через столько запись обновится». Это «столько мне разрешено считать ответ годным». Кто именно и когда возьмёт запись в кэш — вам неизвестно, поэтому реальный срок жизни старого значения складывается из TTL всех слоёв по пути.
Убедиться, что вы смотрите в чужой кэш, а не в источник, можно за две команды: TTL в ответе резолвера убывает, у авторитетного сервера — постоянен.
$ dig @1.1.1.1 +noall +answer example.com A
example.com. 86171 IN A 93.184.216.34
$ sleep 5 && dig @1.1.1.1 +noall +answer example.com A
example.com. 86166 IN A 93.184.216.34
$ dig @a.iana-servers.net +noall +answer example.com A
example.com. 86400 IN A 93.184.216.34
Убывающий TTL — прямая улика: «ответ из кэша, ему уже 229 секунд». Постоянный — «это первоисточник».
Практические следствия, каждое из которых стоило кому-то ночи:
AиAAAA— независимые RRset с независимыми TTL. Клиент с двойным стеком может получить новый IPv4 и старый IPv6, и половина запросов пойдёт не туда. Меняйте оба одновременно и с одинаковым TTL.- Негативный ответ кэшируется по SOA. Создали запись и не видите её три часа — смотрите поле
minimumв SOA. Для активно меняющихся зон разумно 300–900 секунд. - Верхняя граница кэширования есть у резолвера. BIND по умолчанию режет TTL до
max-cache-ttl(неделя), Unbound — доcache-max-ttl(86400). TTL в 30 дней не даст того, на что вы рассчитывали. - Приложения кэшируют мимо TTL. У JVM исторически
networkaddress.cache.ttlмог означать «кэшировать навсегда»; Go кэширует ответы только внутриnet.Resolverбез учёта TTL; HTTP-клиенты держат keep-alive соединения к уже разрешённому IP и не перерезолвят его, пока соединение живо. Смена DNS не разрывает установленные соединения — никогда. - Понижать TTL нужно заранее. Понижение — тоже запись с TTL, и её увидят только после того, как истечёт старый TTL.
Главное на этой диаграмме — полоса «старый сервер держим включённым». Клиенты с кривыми кэшами, забытые cron-задачи и корпоративные форвардеры с TTL-полом будут идти на старый адрес ещё сутки после переключения. Выключать его сразу — гарантированный инцидент; правильный признак готовности — не время, а нулевой трафик на старом адресе.
Заглянуть в кэш резолвера полезно и локально:
$ resolvectl statistics
Transactions
Current Transactions: 0
Total Transactions: 14238
Cache
Current Cache Size: 137
Cache Hits: 11402
Cache Misses: 2836
# Сбросить локальный кэш при отладке (не влияет на кэш провайдера!)
$ sudo resolvectl flush-caches
# У BIND — выгрузить кэш в файл и посмотреть, что и с каким TTL там лежит
$ sudo rndc dumpdb -cache && grep -A2 'example.com' /var/cache/bind/named_dump.db
Что видно в tcpdump и Wireshark
DNS — идеальный протокол для наблюдения: один запрос, один ответ, всё в открытом виде (пока вы не включили DoT/DoH).
$ sudo tcpdump -n -i any -s0 'port 53'
10:02:11.312445 IP 192.168.1.42.51267 > 1.1.1.1.53: 51873+ [1au] A? example.com. (40)
10:02:11.344812 IP 1.1.1.1.53 > 192.168.1.42.51267: 51873 1/0/1 A 93.184.216.34 (56)
Читаем по частям: 51873 — тот самый transaction ID; + — установлен флаг RD; [1au] — одна additional-запись, то есть EDNS OPT; A? — вопрос типа A; (40) — размер DNS-сообщения. В ответе 1/0/1 — счётчики ANCOUNT/NSCOUNT/ARCOUNT. 32 миллисекунды между строками — это RTT до резолвера плюс его работа.
Как выглядит обрезанный ответ и переход на TCP:
$ sudo tcpdump -n -i any 'port 53'
10:05:03.101234 IP 192.168.1.42.39955 > 198.51.100.53.53: 4711+ [1au] ANY? example.com. (40)
10:05:03.130445 IP 198.51.100.53.53 > 192.168.1.42.39955: 4711| 0/0/0 (12)
10:05:03.131002 IP 192.168.1.42.44120 > 198.51.100.53.53: Flags [S], seq 2938471, length 0
10:05:03.160338 IP 192.168.1.42.44120 > 198.51.100.53.53: 4711+ [1au] ANY? example.com. (42)
Символ | в третьей строке — флаг TC. Клиент немедленно открывает TCP-соединение и повторяет вопрос; обратите внимание, что по TCP сообщение на два байта длиннее — там оно предваряется 16-битным полем длины. Если 53/tcp закрыт фаерволом, вы увидите SYN без ответа и SERVFAIL через 5 секунд — типичный симптом «большие ответы не работают, маленькие работают».
Как выглядит потеря пакета. У DNS нет ретрансмиссии на уровне транспорта, поэтому повтор делает сам stub-резолвер — по таймауту из resolv.conf (options timeout:5 attempts:2):
10:07:20.001122 IP 192.168.1.42.55010 > 10.0.0.53.53: 30412+ A? api.example.com. (33)
10:07:25.004531 IP 192.168.1.42.55010 > 10.0.0.53.53: 30412+ A? api.example.com. (33)
10:07:30.008874 IP 192.168.1.42.55011 > 10.0.0.53.53: 41200+ A? api.example.com. (33)
Три признака сразу: ровно 5 секунд между попытками (это не сеть тормозит, это таймер), тот же самый ID и порт при первом повторе, и смена сервера на третьей попытке. Отсюда знаменитые «ровно пять секунд» и «ровно двадцать секунд» в логах приложений — это всегда таймауты резолвера, а не медленный бэкенд.
В Wireshark добавьте столбец dns.time и включите фильтр dns:
- Нормальный резолв — две строки,
dns.flags.response == 0и== 1, связанныеdns.id. Wireshark сам считаетdns.timeи подсвечивает ответ ссылкой на запрос. - Потеря — запрос без ответа; фильтр
dns.flags.response == 0 && !dns.response_inнаходит все неотвеченные запросы в дампе за секунду. - Обрезание —
dns.flags.truncated == 1, следом TCP-поток на порт 53. - Подозрение на подделку — два ответа с одним
dns.idот разных источников; фильтрdns.id == 0x...покажет обоих. Легитимный дубль тоже бывает (анкаст с разной задержкой), но выигрывает всегда первый пришедший, и в этом суть атаки. - Медленный резолв в приложении — сравните
dns.timeс временем, которое приложение показывает как «DNS lookup». Расхождение означает, что время съел не сервер, а перебор search-доменов или ожидание AAAA.
Полезная деталь: Wireshark умеет расшифровывать DoT и DoH, если вы подложите SSLKEYLOGFILE (как это работает — в статье про TLS). Для DoH ищите HTTP/2 POST на /dns-query с content-type: application/dns-message — внутри лежит ровно тот же бинарный формат, что мы разбирали выше.
Надёжность зоны: NS, зонные передачи, anycast
Зона обслуживается несколькими серверами: один первичный (там правится зона) и несколько вторичных, которые копируют её. Синхронизация — по SOA serial: вторичный раз в refresh секунд спрашивает SOA, и если serial вырос, забирает изменения. Плюс NOTIFY от первичного, чтобы не ждать refresh.
# Инкрементальная передача зоны — только изменения с указанного serial (RFC 1995)
$ dig @ns1.example.com example.com IXFR=2026071600 +noall +answer
# Полная передача — должна быть закрыта ACL или TSIG-ключом
$ dig @ns1.example.com example.com AXFR
; Transfer failed.
Transfer failed — правильный ответ публичного сервера. Открытый AXFR отдаёт всю внутреннюю структуру компании: имена стендов, тестовых баз, VPN-шлюзов. Это до сих пор одна из первых вещей, которую проверяют при пентесте.
Корневая зона обслуживается 13 именами (a … m.root-servers.net) — ограничение из-за старого лимита 512 байт на UDP-ответ с priming query. Но за этими 13 именами стоят около 1900 физических площадок, разбросанных по миру: работает anycast, один и тот же IP анонсируется из множества точек, и BGP приводит вас к ближайшей (механика — в статье про IP и маршрутизацию). DNS для anycast идеален именно потому, что транзакция умещается в одну датаграмму: смена маршрута между запросом и ответом ничего не ломает, в отличие от TCP-сессии.
Проверить, на какую именно площадку вы попали, помогает NSID:
$ dig @1.1.1.1 +nsid example.com A | grep NSID
; NSID: 6c 68 72 30 31 ("lhr01")
DNS как балансировщик: что он умеет и чем платит
Несколько A-записей на одно имя — самый дешёвый способ размазать нагрузку, и самый обманчивый.
$ dig +short example-api.net A
203.0.113.10
203.0.113.11
203.0.113.12
Что здесь не так, как кажется:
- Порядок не гарантирует распределения. Сервер ротирует записи, но клиент часто берёт первую, а libc ещё и пересортирует по RFC 6724 (предпочтение «своей» подсети). Плюс Happy Eyeballs в браузере пробует несколько адресов параллельно.
- Гранулярность — кэш, а не запрос. Один корпоративный резолвер закэширует один порядок на весь офис на весь TTL. Пятитысячный офис уедет на один адрес.
- Отказ не убирается TTL. Упал
203.0.113.11— DNS об этом не знает. Даже сняв запись, вы будете ждать хвост кэшей. Поэтому DNS не является механизмом отказоустойчивости; им является health-check на балансировщике (прокси и балансировка) или anycast. - Geo-маршрутизация видит резолвер, а не клиента. Авторитетный сервер знает IP резолвера. Если сотрудник в Новосибирске ходит через корпоративный резолвер во Франкфурте, CDN отдаст ему франкфуртский узел.
Последнее чинится костылём EDNS Client Subnet (RFC 7871): резолвер добавляет в запрос усечённую подсеть клиента.
$ dig @8.8.8.8 +subnet=203.0.113.0/24 +noall +answer +comments cdn.example.com A | grep -A1 CLIENT-SUBNET
; CLIENT-SUBNET: 203.0.113.0/24/24
cdn.example.com. 60 IN A 198.51.100.7
Платим приватностью (подсеть клиента уезжает всем авторитетным серверам по пути) и эффективностью кэша: ключ кэширования теперь включает подсеть, и вместо одной записи резолвер хранит тысячи. Cloudflare намеренно не поддерживает ECS на 1.1.1.1 именно из-за приватности; подробнее о географической маршрутизации — в статье про CDN и edge.
Безопасность: подмена, Камински, DNSSEC
Ответ по UDP принимается, если совпали пять вещей: адрес и порт источника, адрес и порт назначения, transaction ID. Атакующему, который может слать пакеты, но не видит трафик, нужно угадать ID (16 бит) и порт (~16 бит). В 2008 году Дэн Камински показал, что перебирать можно бесконечно: вместо атаки на популярное имя (у которого ответ уже закэширован и надо ждать TTL) атакуют несуществующие имена random123.bank.com, а в подделанном ответе кладут в секцию authority NS-запись, отравляющую всю зону bank.com. Каждая попытка занимает миллисекунды, кэш ждать не нужно.
Экстренные меры того года живы до сих пор:
- рандомизация исходного порта — до 2008 многие резолверы использовали фиксированный порт, что оставляло 16 бит;
- 0x20 encoding — резолвер случайно меняет регистр букв в QNAME (
ExAmPle.CoM), а ответ обязан вернуть тот же регистр: ещё несколько бит энтропии бесплатно; - DNS cookies (RFC 7873) — лёгкий обмен маркерами, отсеивающий слепого атакующего.
Всё это — статистические заплатки. Криптографическое решение — DNSSEC (RFC 4033–4035): каждый RRset подписывается, подпись едет в записи RRSIG, ключ — в DNSKEY, а хэш ключа дочерней зоны (DS) публикуется в родительской зоне и подписан ею. Так строится цепочка от корня, чей ключ вшит в резолверы.
вшит в резолвер, KSK-2017"] RK["DNSKEY корня"] RDS["DS для com
подписан ключом корня"] CK["DNSKEY зоны com"] CDS["DS для example.com
подписан ключом com"] EK["DNSKEY зоны example.com
KSK подписывает ZSK"] RR["RRSIG на RRset A
подписан ZSK зоны"] DATA["A 93.184.216.34"] OK["Резолвер ставит флаг AD"] TA -->|"совпал хэш"| RK RK -->|"подписал"| RDS RDS -->|"совпал хэш"| CK CK -->|"подписал"| CDS CDS -->|"совпал хэш"| EK EK -->|"подписал"| RR RR -->|"проверяет"| DATA DATA --> OK BRK["Любое звено не сошлось
→ SERVFAIL, а не «непроверенный ответ»"] RR -.->|"подпись истекла"| BRK CDS -.->|"DS не обновили при смене ключа"| BRK classDef good fill:#3f9d6b,fill-opacity:0.12,stroke:#3f9d6b; classDef bad fill:#c2413f,fill-opacity:0.12,stroke:#c2413f; class TA,RK,RDS,CK,CDS,EK,RR,DATA,OK good; class BRK bad;
Проверяется одной командой — delv делает валидацию сам, не доверяя чужому флагу AD:
$ delv @1.1.1.1 cloudflare.com A
; fully validated
cloudflare.com. 300 IN A 104.16.132.229
cloudflare.com. 300 IN RRSIG A 13 2 300 20260801000000 20260730000000 34505 cloudflare.com. Xk8...
$ dig +dnssec +noall +comments cloudflare.com A @1.1.1.1 | head -2
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 33871
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
Флаг ad — «резолвер проверил подписи». Его отсутствие само по себе не ошибка: зона может быть просто не подписана.
Чем платим за DNSSEC — честно и без агитации:
- Операционный риск выше, чем криптографический выигрыш. Истёкшие подписи или несинхронизированный DS кладут домен целиком и в
SERVFAIL— именно так в 2020 году на несколько часов пропадалslack.com, а в разные годы — целые национальные домены. Ошибка в DNSSEC не деградирует, она отключает. - Ответы растут в разы. RRSIG и DNSKEY толстые, растёт доля TCP-фолбэков и потенциал amplification-атак.
- Перечисление зоны. Отрицание существования в DNSSEC требует подписать «между именем X и именем Y ничего нет» — запись
NSEC, по которой зона обходится целиком. Ответ —NSEC3с хэшами (RFC 5155), но хэши перебираются офлайн; современный ответ — NSEC3 white lies или NSEC-записи, генерируемые на лету. - Не защищает «последнюю милю». Между валидирующим резолвером и вашим приложением данные едут по обычному UDP. Флагу AD от чужого резолвера можно верить, только если канал до него защищён, — то есть DNSSEC и DoT решают разные задачи и не заменяют друг друга.
При отладке главный инструмент — флаг +cd (checking disabled):
$ dig broken-dnssec.example SOA @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 4110
$ dig +cd broken-dnssec.example SOA @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 4111
Работает с +cd и не работает без него — виноват DNSSEC, а не сеть и не зона. Визуальный разбор всей цепочки даёт dnsviz.net, проверка зоны целиком — zonemaster.net.
Отдельно — ANY. Раньше dig ANY example.com отдавал всё содержимое имени; этим пользовались для amplification-атак (маленький запрос, огромный ответ на подставленный адрес жертвы). С RFC 8482 сервер имеет право ответить одной синтетической записью, а авторитетные серверы дополнительно включают RRL (response rate limiting) — ограничение числа одинаковых ответов на подсеть.
Приватность: DoT, DoH, DoQ
Классический DNS (Do53) едет открытым текстом. Провайдер, владелец Wi-Fi в кафе и любой на пути видят каждое имя, которое вы резолвите, — это полная история посещений, даже если весь трафик потом идёт по HTTPS. Шифрование HTTP закрыло содержимое, но не оставило и следа сомнений в том, куда вы ходите.
| Транспорт | Порт | Как выглядит для наблюдателя | Практика |
|---|---|---|---|
| Do53 | 53/udp, 53/tcp | всё видно | по умолчанию везде |
| DoT (RFC 7858) | 853/tcp | видно, что это DNS, содержимое скрыто | системный резолвер, Android Private DNS |
| DoH (RFC 8484) | 443/tcp | неотличимо от обычного HTTPS | браузеры, обход блокировок |
| DoQ (RFC 9250) | 853/udp | QUIC-трафик | мобильные сети, нет head-of-line blocking |
| ODoH | 443/tcp | прокси не видит запрос, резолвер не видит клиента | эксперимент Cloudflare/Apple |
Включение и проверка на практике:
# Системный DoT через systemd-resolved
$ sudo resolvectl dns eth0 1.1.1.1#cloudflare-dns.com
$ sudo resolvectl dnsovertls eth0 yes
$ resolvectl query example.com
example.com: 93.184.216.34
-- Data was acquired via local or encrypted transport: yes
# Ручная проверка DoT: kdig умеет TLS и показывает сертификат
$ kdig -d @1.1.1.1 +tls-ca +tls-host=cloudflare-dns.com example.com A
;; DEBUG: TLS, imported 152 system certificates
;; DEBUG: TLS, received certificate hierarchy:
;; DEBUG: #1, CN=cloudflare-dns.com
;; DEBUG: TLS, The certificate is trusted.
;; ANSWER SECTION:
example.com. 86400 IN A 93.184.216.34
# Ручная проверка DoH: обычный GET, ответ — тот же бинарный формат RFC 1035
$ curl -sS -H 'accept: application/dns-message' \
'https://cloudflare-dns.com/dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' \
| xxd | head -3
00000000: abcd 8180 0001 0001 0000 0000 0377 7777 .............www
00000010: 0765 7861 6d70 6c65 0363 6f6d 0000 0100 .example.com....
00000020: 01c0 0c00 0100 0100 0151 8000 045d b8d8 .........Q...]..
# curl умеет резолвить через DoH сам, минуя системный резолвер
$ curl -sS --doh-url https://1.1.1.1/dns-query -o /dev/null \
-w 'ip=%{remote_ip} dns=%{time_namelookup}s\n' https://example.com
ip=93.184.216.34 dns=0.041s
Обратите внимание на строку 01c0 0c в hexdump — это начало записи в секции ответа, и c0 0c здесь тот самый указатель сжатия на смещение 12, который мы разбирали выше. Формат сообщения внутри DoH не изменился ни на байт; поменялся только конверт.
Чем платим за шифрование DNS:
- Централизация. DoH в браузере по умолчанию отправляет весь ваш DNS одной компании. Приватность от провайдера обменяли на приватность от вендора браузера.
- Сломанный split-horizon. Браузер с DoH игнорирует системный резолвер, и внутренние имена перестают резолвиться — в браузере, продолжая работать в терминале. Классический симптом из статьи про NAT, фаерволы и VPN. Механизм отказа — canary-домен
use-application-dns.net: если он не резолвится, Firefox выключает DoH. - Потеря наблюдаемости. Корпоративный DNS-лог — дешёвый и очень эффективный источник сигналов безопасности (обращения к C2-доменам, DGA). DoH его обнуляет.
- Латентность и стоимость соединения. DoT/DoH требуют TCP + TLS-рукопожатие; на холодном соединении это лишние 1–2 RTT. Спасают долгоживущие соединения и TLS-возобновление, а DoQ снимает и head-of-line blocking.
И связка на будущее: ECH (Encrypted Client Hello) невозможен без защищённого DNS. Ключ ECH публикуется в записи HTTPS, и если её отдают открытым текстом, шифровать SNI бессмысленно. Подробности — в статье про TLS.
Отладка: чек-лист и типичные инциденты
Порядок действий, который экономит больше всего времени, — идти сверху вниз по слоям кэша, а не гадать.
# 1. Что видит приложение (libc, hosts, nsswitch, search-домены)
$ getent hosts api.example.com
# 2. Что видит системный резолвер и откуда взял ответ
$ resolvectl query api.example.com
# 3. Что отвечает публичный резолвер — и с каким остатком TTL
$ dig @1.1.1.1 api.example.com A +noall +answer +stats
# 4. Что реально настроено в зоне — минуя все кэши
$ dig +trace api.example.com A | tail -5
# 5. Что говорит каждый авторитетный сервер по отдельности
$ for ns in $(dig +short NS example.com); do
echo "== $ns"; dig @"$ns" api.example.com A +norecurse +short
done
# 6. Если SERVFAIL — это DNSSEC?
$ dig +cd api.example.com A @1.1.1.1
Расхождение между соседними шагами локализует слой за один проход: разошлись 1 и 2 — дело в /etc/hosts или nsswitch; 2 и 3 — местный кэш; 3 и 4 — чужой кэш или частично применённая миграция; расхождение внутри 5 — рассинхрон между авторитетными серверами.
Инциденты, которые встречаются чаще всего:
«Задеплоили, полёт нормальный, а через 40 минут 5xx у части клиентов». Меняли A, забыли AAAA. Клиенты с IPv6 идут на выключенный сервер, Happy Eyeballs даёт им таймаут в 300 мс и фолбэк на IPv4 — отсюда «медленно, но работает» у одних и «ошибка» у других. Проверка: dig +short AAAA рядом с dig +short A, всегда обе.
«Ровно 5 секунд на каждый запрос». Таймаут stub-резолвера. Причина почти всегда одна: первый сервер из resolv.conf молчит (упал, закрыт фаерволом, отвечает только на 53/udp), а libc ждёт timeout и идёт ко второму. Лечится options timeout:1 attempts:2 как обезболивающим и починкой первого сервера как лечением.
«CoreDNS в кластере на 100% CPU при обычной нагрузке». ndots:5 и абсолютные имена без точки в конце: на каждый резолв внешнего домена уходит 4 попытки × 2 семейства адресов = 8 запросов. Лечится точкой в конце имени или dnsConfig с ndots:2 (подробно — в диагностике сети).
«Создали поддомен, он не работает три часа». Негативный кэш: до создания кто-то спросил имя, получил NXDOMAIN, и резолвер запомнил его на SOA minimum. Единственное лекарство — заранее держать minimum небольшим; задним числом чужой кэш не сбросить.
«Работает через VPN, не работает без VPN» (или наоборот). Split-horizon: внутренняя зона отдаёт приватные адреса, внешняя — публичные. Смотрите resolvectl status — какой резолвер привязан к какому интерфейсу и есть ли routing-домен ~corp.internal.
«Домен пропал у всех, кроме нас». Мы смотрим в свой кэш, весь мир — в реальность. Признак: dig без указания сервера работает, dig +trace — нет. Смотрите на срок действия делегирования у регистратора, glue-записи и подписи DNSSEC.
Антипаттерны, которые стоит вычеркнуть из привычек:
- Проверять изменения через
ping.pingкэширует, не показывает TTL, не различает A и AAAA и молчит про источник ответа. Всегдаdig. - Ставить TTL 86400 «чтобы меньше нагрузка». Экономия на запросах измеряется копейками, а стоимость невозможности быстро переключиться — часами простоя. Разумная база: 300–3600 для сервисных имён, сутки — для
NSиMX. - Считать DNS средством отказоустойчивости. Он им не является ни в каком виде.
- Полагаться на порядок записей. Клиент имеет полное право его переупорядочить.
- Резолвить в горячем пути без кэша. Библиотека, дергающая
getaddrinfoна каждый HTTP-запрос, добавляет к p99 всю нестабильность DNS. Держите пул соединений и локальный кэш.
Мини-итог
- DNS — распределённая база с делегированием и кэшированием. Согласованность в ней конечная, и все практики работы с ней — следствие этого факта.
- Домен — поддерево имён, зона — административная единица. Границу между ними задают
NS-записи; забытый glue и lame delegation ломают домен незаметно и не сразу. - Единица хранения — RRset, а не запись. TTL общий на RRset,
AиAAAA— разные RRset с разными сроками жизни. - TTL — это разрешение кэшировать, а не расписание обновления. Реальное время устаревания складывается из TTL всех слоёв; понижайте TTL заранее, минимум за старый TTL до переключения.
minimumв SOA — TTL негативного кэша. Именно из-за него «созданная запись не появляется».- Формат сообщения не менялся с 1987 года: 12 байт заголовка, четыре секции, имена метками с длиной, сжатие указателями. EDNS(0) добавил размер буфера, DNSSEC-флаги, cookies и ECS — не меняя базовый формат.
- Флаг TC означает «повтори по TCP». DNS поверх TCP — штатный режим; закрытый 53/tcp ломает всё большое.
digне читает/etc/hostsиnsswitch.conf— путь приложения повторяютgetent hostsиresolvectl query. Половина загадок объясняется этим.- DNSSEC даёт подлинность данных, но не приватность и не «последнюю милю»; его главный риск — операционный: истёкшая подпись означает
SERVFAIL, а не деградацию. - DoT/DoH/DoQ дают приватность запроса, платя централизацией, сломанным split-horizon и потерей корпоративной наблюдаемости.
- Балансировать DNS можно грубо и по регионам; отказоустойчивость он не обеспечивает никогда.
Источники
- RFC 1034 и RFC 1035 — концепции и формат. Читаются за вечер и до сих пор актуальны.
- RFC 2181 — Clarifications to the DNS Specification — про RRset и TTL; ответы на 80% споров «а можно ли так».
- RFC 2308 — Negative Caching, RFC 6891 — EDNS(0), RFC 7766 — DNS over TCP, RFC 9156 — QNAME minimisation, RFC 8767 — Serve Stale.
- RFC 9460 — SVCB и HTTPS RR — как параметры подключения приезжают вместе с адресом.
- RFC 4033, 4034, 4035 — DNSSEC; RFC 5155 — NSEC3.
- RFC 7858 — DoT, RFC 8484 — DoH, RFC 9250 — DoQ, RFC 7871 — EDNS Client Subnet.
- Cricket Liu, Paul Albitz. DNS and BIND (O’Reilly) — классический справочник по эксплуатации зон.
- Mess with DNS — интерактивная песочница Джулии Эванс: своя зона, любые записи, реальный резолв.
- DNSViz — визуализация цепочки DNSSEC; Zonemaster — полная проверка зоны.
- Root Servers — карта площадок и операторов корневой зоны.
- DNS Flag Day — что и зачем операторы выключали в 2019 и 2020 годах.
Смежные темы на портале: сетевой стек ядра и путь пакета до сокета — в «Операционные системы», обзорная картина протоколов — в «Компьютерные науки», а что делает браузер до первого байта — в «Frontend».
Что дальше
Имя превратилось в адрес, транспорт до этого адреса мы уже умеем строить. Осталось разобраться с тем, ради чего всё затевалось, — с прикладным протоколом, который эти адреса использует. И это протокол, спроектированный вокруг посредников: кэшей, прокси и CDN, каждый из которых имеет право прочитать и переинтерпретировать ваше сообщение.
HTTP: методы, коды, заголовки, кэширование, эволюция 1.1 → 2 → 3 — про анатомию сообщения до байта, семантику методов и кодов, модель кэширования с ETag и Vary, и честное сравнение трёх версий протокола, в котором запись HTTPS из этой статьи наконец пригодится по назначению.