17. DNS под капотом. Record Types, TTL, Recursive Resolver, кеширование
Двоичный формат DNS-пакета и UDP-транспорт
"Сложные протоколы рушатся под собственной тяжестью; простые протоколы переживают эпохи."
— Принцип архитектуры Unix
В предыдущей статье мы познакомились с общими контурами службы доменных имен. Теперь снимем защитный кожух и заглянем непосредственно в сетевой провод. Что представляет собой DNS-запрос на уровне байтов?
Исторически DNS построен поверх транспортного протокола UDP, использующего порт 53. Выбор UDP в 1983 году был гениальным инженерным расчетом: отсутствие фазы установления соединения (Handshake) означает нулевую задержку (0 RTT) перед передачей данных. Клиент формирует один компактный пакет, отправляет его на резолвер и мгновенно получает один пакет с ответом. Для глобальной системы, обрабатывающей триллионы запросов в сутки, это единственный способ не утонуть в накладных расходах.
Любой DNS-пакет (как запрос, так и ответ) упаковывается в строгую бинарную структуру, состоящую из фиксированного заголовка и четырех переменных секций:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ID (16 бит) |QR| Opcode |AA|TC|RD|RA| Z |RCODE|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| QDCOUNT (Вопросы) | ANCOUNT (Ответы) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| NSCOUNT (Авторитеты) | ARCOUNT (Дополнительно) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Question Section (Вопрос) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Answer Section (Ответы) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Authority Section (Авторитеты) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Additional Section (Дополнительно) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Анатомия заголовка: Header (12 байт)
Заголовок всегда занимает ровно 12 байт:
ID(16 бит): Случайный идентификатор транзакции. Поскольку UDP не имеет состояния, клиент использует это число, чтобы сопоставить входящий асинхронный ответ со своим ранее отправленным запросом.- Битовые флаги (16 бит):
QR(1 бит):0— запрос (Query),1— ответ (Response).Opcode(4 бита): Тип операции (0— стандартный запрос QUERY,4— NOTIFY,5— UPDATE).AA(Authoritative Answer, 1 бит): Взводится сервером, если он является авторитетным владельцем запрашиваемой зоны.TC(Truncated, 1 бит): Сигнал обрезки пакета из-за превышения допустимого размера буфера.RD(Recursion Desired, 1 бит): Клиент просит резолвер выполнить рекурсивный поиск.RA(Recursion Available, 1 бит): Сервер сообщает, что поддерживает рекурсивные запросы.RCODE(Response Code, 4 бита): Статус ответа:0—NOERROR,2—SERVFAIL(сбой сервера),3—NXDOMAIN(домен не существует).
- Счетчики записей (по 16 бит каждый):
QDCOUNT— количество записей в секцииQuestion.ANCOUNT— количество записей в секцииAnswer.NSCOUNT— количество записей в секцииAuthority.ARCOUNT— количество записей в секцииAdditional.
После заголовка следуют секции данных:
Question: Запрашиваемое доменное имя (закодированное как последовательность меток с префиксами длины), класс сети (IN— Internet) и тип записи (A,AAAA,MXи т.д.).Answer, Authority, Additional: Массивы ресурсных записей (Resource Records, RR). Каждая запись содержит: имя узла, тип, класс,TTL(32 бита), длину поля данныхRDLENGTH(16 бит) и сами сырые байты данныхRDATA(например, 4 байта для IPv4 или 16 байт для IPv6).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph PKT["Структура DNS-пакета (RFC 1035)"]
H["Header (12 байт): ID, QR, Opcode, AA, TC, RD, RA, RCODE, Счетчики"]
Q["Question: Имя (QNAME) + Тип (QTYPE) + Класс (QCLASS)"]
A["Answer: Найденные ресурсные записи (A, AAAA, CNAME) + TTL"]
NS["Authority: Серверы имен (NS-записи), управляющие зоной"]
AR["Additional: Дополнительные A-записи для NS, параметры EDNS0"]:::process
H --> Q --> A --> NS --> AR
end
Для бэкенд-разработчика важно понимать: DNS-запрос — это не атомарная операция. Это сетевой round-trip. Если UDP-пакет теряется (помехи, перегрузка роутеров, строгие правила NAT), клиент должен реализовать собственную логику ретраев и таймаутов. В Go эту логику инкапсулирует рантайм, но вы должны явно управлять контекстом запроса.
Иерархия резолверов: от Stub до Authoritative
DNS — распределённая иерархическая база данных. Запрос никогда не идёт напрямую к владельцу домена.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Stub Resolver<br>Клиент / ОС (Go netgo)"]:::entry -->|"UDP 53<br>Рекурсивный запрос (RD=1)"| B["Recursive Resolver<br>ISP / Cloud DNS (8.8.8.8, 1.1.1.1)"]
B -->|"UDP 53<br>Итеративный запрос"| C["Root Server<br>13 anycast-узлов (.)"]
C -->|"REFERRAL<br>NS для зоны .com"| D["TLD Server (.com)"]
D -->|"REFERRAL<br>NS для example.com"| E["Authoritative Server (example.com)"]
E -->|"ANSWER<br>A / AAAA / CNAME"| B
B -->|"ANSWER<br>Кэшированный результат"| A
Разрешение имени опирается на четкое разделение ролей:
Stub Resolver: Локальная библиотека в адресном пространстве вашей программы или ОС. В Go это встроенный резолверnet.Resolver. Он не занимается обходом интернета, а просто формирует запрос с флагомRD=1(Recursion Desired) и пересылает его апстрим-серверу.Recursive Resolver: Высокопроизводительный кэширующий DNS-сервер (публичные8.8.8.8,1.1.1.1или внутренний CoreDNS в Kubernetes). Получив рекурсивный запрос, он берет на себя выполнение серии итеративных запросов:- Получает ответ
REFERRALот Root-сервера со ссылкой на TLD. - Получает
REFERRALот TLD-сервера со ссылкой на авторитетный сервер домена. - Получает финальный ответ
ANSWERс IP-адресами, кэширует его и возвращает клиенту.
- Получает ответ
Authoritative Server: Сервер, хранящий оригинальную мастер-зону домена. Отвечает только за свои домены и выставляет флагAA=1.
Типы записей (Record Types), на которые стоит обратить внимание
Каждая запись в зоне DNS имеет определенный тип, определяющий формат поля данных RDATA:
| Тип записи | Полное имя | Назначение и структура данных |
|---|---|---|
A |
IPv4 Address | Сопоставляет доменное имя с 32-битным IPv4-адресом (например, 93.184.216.34). |
AAAA |
IPv6 Address | Сопоставляет доменное имя со 128-битным IPv6-адресом (2606:2800:220:1:248:1893:25c8:1946). |
CNAME |
Canonical Name | Канонический алиас. Указывает, что запрашиваемое имя является синонимом другого домена. |
MX |
Mail Exchange | Почтовый шлюз домена. Содержит поле приоритета (Preference) и имя почтового сервера. Резолвер сортирует по приоритету (меньше число = выше приоритет). |
TXT |
Text | Произвольная текстовая строка. Используется для SPF, DKIM, верификации доменов, а в микросервисах — для передачи конфигурации (Service Discovery через DNS). |
SRV |
Service Locator | Стандартизированная запись (RFC 2782) для поиска сервисов: priority, weight, port, target. Критичен для LDAP, Consul и Kubernetes (CoreDNS). |
NS |
Name Server | Указывает авторитетные DNS-серверы для зоны или делегированного поддомена. |
PTR |
Pointer | Обратная запись: сопоставляет IP-адрес с доменным именем (зоны in-addr.arpa и ip6.arpa). |
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph CNAME_CHAIN["Каскадная цепочка CNAME"]
direction LR
REQ["api.example.com"]:::network -->|"CNAME"| C1["lb-prod.cloudprovider.net"]
C1 -->|"CNAME"| C2["ingress-12.eu-west-1.elb.amazonaws.com"]
C2 -->|"A Запись"| IP["52.214.80.12"]
end
TTL и кэширование: почему DNS не мгновенный
TTL (Time To Live) — это 32-битное целое число в секундах, определяющее время жизни ресурсной записи. Значение задается администратором на авторитетном сервере. Клиент не может кэшировать запись дольше указанного TTL, даже если сетевое соединение с хостом остается активным.
Механика кэширования:
- Клиент делает запрос к
api.example.com, получает ответ со значениемTTL: 300(5 минут). - Записывает в локальный кэш (OS resolver cache или кэширующий DNS-прокси) время получения и TTL.
- При повторном запросе того же имени в течение 5 минут проверяет условие
now < received_time + TTL:
$$\text{now} < \text{received\_time} + \text{TTL}$$ - Если время не истекло — IP-адрес берется из памяти за наносекунды (Cache Hit).
- Если TTL истёк — кэш инвалидируется, и резолвер снова отправляет пакет в сеть (Cache Miss).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
REQ["Запрос LookupIP('api.example.com')"]:::entry --> CHECK{"Запись есть в кэше ОС/резолвера<br>и now < expire_at?"}
CHECK -->|"Да: Cache Hit"| RET["Мгновенный возврат IP (0 мс)"]
CHECK -->|"Нет: Cache Miss / TTL истек"| NET["Сетевой запрос к DNS-серверу (UDP 53)"]
NET --> SAVE["Сохранить IP в кэш ОС на время TTL"]
SAVE --> RET
Инженерное управление TTL при релизах
TTL напрямую влияет на деплои и миграции. Если у вашего домена выставлен TTL = 3600 (1 час), вы не сможете мгновенно переключить трафик на новый IP при аварии или миграции датацентра. Мировые провайдеры будут отдавать старый IP еще целый час!
Золотое правило: Перед проведением масштабных технических работ или миграций за 24–48 часов снижайте TTL до 60–300 секунд. После успешного завершения переключения возвращайте TTL к стандартным 3600–86400 с для снижения нагрузки на DNS-инфраструктуру.
Как Go резолвит имена под капотом (netgo vs glibc)
В Go процесс разрешения имен управляется пакетом net. Ключевые компоненты:
net.LookupIP(host)— простая глобальная функция, использующая дефолтныйnet.Resolver.net.Resolver— кастомизируемый резолвер, позволяющий настраивать собственные DNS-серверы, поисковые домены и таймауты.(*net.Dialer).DialContext— при подключении к строковому адресу хоста прозрачно вызывает резолвинг внутри себя.
По умолчанию Go компилируется с встроенным чистым резолвером netgo. Он парсит конфигурационный файл /etc/resolv.conf вручную и поддерживает следующие директивы:
nameserver— список IP-адресов рекурсивных резолверов.search— домены поиска для разрешения неполных (коротких) имен.ndots— порог количества точек в имени, определяющий приоритет поиска.timeoutиattempts— время ожидания ответа и число повторных попыток перед возвратом ошибки.options:rotate— балансировка запросов Round-Robin между указанными серверамиnameserver.single-request-reopen— отправлять A и AAAA запросы последовательно, а не параллельно (обходит баг некоторых домашних NAT-роутеров, теряющих второй ответ); в реализации Gosingle-requestиsingle-request-reopenразбираются одним и тем жеcaseи ведут себя одинаково.use-vc— принудительно использовать виртуальный канал (TCP) вместо UDP для всех запросов.edns0— включение расширения EDNS0.
Если программа скомпилирована с использованием cgo, Go делегирует резолвинг системной библиотеке glibc (getaddrinfo). Поведение программы становится зависимым от окружения хоста, что делает скомпилированные бинарники непереносимыми между разными дистрибутивами Linux (например, между Alpine на musl и Ubuntu на glibc) без жесткой фиксации CGO_ENABLED=0.
Практика: управление DNS в Go-приложениях
В высоконагруженных бэкенд-сервисах DNS-запросы нередко становятся скрытым узким местом системы.
Основные архитектурные паттерны:
- Кэширование на уровне приложения: Использование потокобезопасного кэша на базе
sync.Mapили библиотек вродеttlcacheдля исключения повторных сетевых вызовов. - Кастомный резолвер с явным сервером: Прямая отправка запросов на CoreDNS или VPC DNS-эндпоинт в обход медленного системного резолвера.
- Асинхронный резолвинг с контролем контекста: Предотвращение зависания HTTP-хендлеров при деградации внешних резолверов.
package main
import (
"context"
"fmt"
"net"
"time"
)
// lookupWithTimeout предотвращает блокировку горутины при зависшем DNS
func lookupWithTimeout(ctx context.Context, host string) ([]net.IP, error) {
type result struct {
ips []net.IP
err error
}
ch := make(chan result, 1)
go func() {
// netgo парсит /etc/resolv.conf или использует переданные nameservers
ips, err := net.LookupIP(host)
ch <- result{ips, err}
}()
select {
case res := <-ch:
return res.ips, res.err
case <-ctx.Done():
return nil, fmt.Errorf("dns lookup timeout for %s: %w", host, ctx.Err())
}
}
func main() {
// Создаем изолированный резолвер с явным транспортом и таймаутами
resolver := &net.Resolver{
PreferGo: true, // Принудительно использовать netgo вместо cgo
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: 2 * time.Second,
}
// Подключаемся к конкретному DNS-серверу (например, локальный CoreDNS)
return d.DialContext(ctx, "udp", "10.0.0.53:53")
},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ips, err := resolver.LookupIP(ctx, "ip4", "api.internal.service")
if err != nil {
fmt.Printf("DNS failed: %v\n", err)
return
}
for _, ip := range ips {
fmt.Printf("Resolved: %s\n", ip)
}
}
Итог
- DNS-пакет: Компактная бинарная структура с 12-байтным заголовком (
Header (12 байт)), полямиID,QR,Opcode,AA,TC,RD,RA,RCODEи счетчиками секцийQuestion,Answer,Authority,Additional. - Транспорт: Быстрый UDP 53 по умолчанию. При превышении лимита сервер выставляет флаг
TC, и клиент повторяет запрос по надежному TCP 53. РасширениеEDNS0снимает старый лимит 512 байт: клиент объявляет больший размер буфера в записиOPT(RFC 6891 рекомендует 4096 байт; Go объявляет 1232). - Иерархия: Иерархия
Stub -> Recursive -> Root -> TLD -> Authобеспечивает масштабируемость, но добавляет сетевые задержки. Запрос идет от клиентскогоStub Resolverчерез кэширующийRecursive Resolver(8.8.8.8, 1.1.1.1) к корневым серверам (Root), зонам TLD и авторитетным серверам (Authoritative). - Типы записей: Фундаментальные
A(IPv4) иAAAA(IPv6), канонические алиасыCNAME, почтовыеMX, текстовыеTXT(SPF/DKIM) и конфигурационныеSRV(Service Discovery). - TTL и кэширование: Определяет актуальность записей и скорость переключения трафика при деплоях. стандартный
net.Resolverв Go не кэширует DNS-ответы между последовательными запросами, полагаясь на системный кэш ОС или постоянные TCP-пулыhttp.Transport. - Go-резолвер: Чистый
netgoпарсит/etc/resolv.conf, поддерживает опцииrotate,single-request-reopen,use-vc, не блокирует треды ОС и при необходимости включается черезPreferGo: true, а работать должен использоваться с контекстными таймаутами.
Мы досконально изучили, как приложения находят IP-адрес нужного сервера. Но передача данных в открытом виде через кабель опасна: любой промежуточный маршрутизатор может подслушать или подменить пакеты. В следующей статье мы перейдем к фундаментальным механизмам безопасности и шифрования: 18. TLS и HTTPS. Шифрование поверх TCP.