Перейти к содержанию

DNS в Node.js: lookup, resolve и c-ares

Источник: theNodeBook — Node.js DNS Resolution: lookup, resolve & c-ares

Разрешение DNS в Node.js превращает имена хостов в адреса для socket API. Механика охватывает dns.lookup(), dns.resolve*(), поведение резолвера ОС, c-ares, рекурсивный DNS, TTL, кэширование и порядок адресов. dns.lookup() использует путь резолвера операционной системы через libuv. dns.resolve*() использует c-ares для DNS-запросов по протоколу.

DNS в Node.js

Различие важно в продакшене. dns.lookup() совпадает с тем, как большинство socket-соединений резолвят имена, и следует конфигурации хоста. dns.resolve() даёт прямые DNS-запросы записей. Кэширование зависит от состояния резолвера ОС, рекурсивного резолвера и решений в userland.

Имя хоста всё ещё текст, когда код вызывает net.connect(). Путь connect нуждается в числовом адресе сокета. Node должен разрешить имя, выбрать кандидат адреса и только затем попросить ОС начать работу сокета.

1
2
3
4
5
6
7
import net from 'node:net';

const socket = net.connect(443, 'example.com');

socket.on('connect', () => {
  console.log(socket.remoteAddress, socket.remotePort);
});

Вызов называет host и port. В подглаве 9.1 разобран нижний путь сокета после того, как адрес уже числовой. Здесь вход другой. example.com — текст. Путь connect в ядре нуждается в адресе сокета: семейство адресов, IP-адрес и порт.

DNS — система имён, сопоставляющая доменные имена с записями. Для backend на Node на виду в первую очередь адресные записи. Имя вроде example.com может дать IPv4, IPv6, алиасы, MX, TXT, SRV и записи обратного lookup. Для соединения нужна только адресная запись, но в отладке продакшена часто нужны и остальные.

Доменное имя — структурированное DNS-имя из меток. У api.example.com метки api, example, com. DNS рассматривает полное имя как позицию в дереве имён; правые метки ближе к вершине иерархии. Hostname — доменное имя, идентифицирующее хост для подключения. В прикладном коде слова смешивают постоянно. Узкое различие: hostname — имя, которое вы передаёте, когда нужен адрес сетевой конечной точки.

Порт остаётся вне DNS.

1
net.connect({ host: 'api.example.com', port: 5432 });

Резолвер превращает api.example.com в один или несколько кандидатов адресов. Node комбинирует каждый кандидат с 5432 и передаёт этот адрес сокета коду соединения. Порт — от вызывающего. TCP откроется позже. Проверки сертификата HTTPS — позже.

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

1
2
3
socket.on('error', err => {
  console.error(err.code, err.hostname, err.address, err.port);
});

Набор полей зависит от того, где всплыла ошибка. На пути DNS может быть hostname без числового адреса. На пути сокета — числовой адрес и порт. Держите их раздельно. Это экономит время.

Числовые адреса пропускают шаг имени.

1
net.connect({ host: '192.0.2.10', port: 5432 });

С числовым IPv4 Node сразу строит IPv4-адрес сокета. С числовым IPv6 — IPv6-адрес сокета. ОС всё равно делает маршрутизацию, выбор локального адреса и работу TCP после этого. DNS просто вышел из пути, потому что вызывающий уже дал адрес.

Полезное разделение для отладки. Замените hostname одним из возвращённых адресов. Если числовое соединение падает так же — вы ниже DNS. Если числовое работает, а hostname нет — оставайтесь в поведении резолвера. Если один возвращённый адрес работает, другой нет — семейство адресов, маршрут, слушатель, firewall или транспорт.

1
2
const socket = net.connect({ host: '2001:db8::10', port: 443 });
socket.on('error', err => console.error(err.code));

Код пропускает DNS lookup. Может упасть из‑за отсутствия IPv6-маршрутизации, закрытой удалённой конечной точки, firewall или сбоя установки TCP. Ответы DNS — вход для слоя сокетов. Следующий слой всё равно должен их использовать.

Большинство высокоуровневого клиентского кода скрывает эту границу. fetch('https://example.com') разбирает URL, извлекает host, резолвит, открывает соединение, согласует TLS для HTTPS, пишет HTTP и читает ответ. Главы 10 и 11 — за этими частями. Здесь только первая передача: строка host на входе, кандидаты адресов на выходе.

Node позволяет некоторым клиентским API переопределить передачу пользовательской функцией lookup.

1
2
3
4
5
6
7
8
9
import http from 'node:http';

http.get({
  host: 'example.com',
  lookup(name, opts, cb) {
    const found = { address: '104.20.23.154', family: 4 };
    cb(null, opts.all ? [found] : found.address, found.family);
  }
});

Переопределение меняет источник адреса для клиента. Глобальное поведение DNS то же. Современные пути клиента могут просить hook обо всех кандидатах с opts.all, тогда колбэк должен вернуть форму массива. Библиотеки используют hook для тестов, маршрутизации сервисов, метрик, кэширования и особой политики резолвера. Это ещё одно место, где продакшен может расходиться с маленьким скриптом на dns.lookup().

Граница резолвера

Резолвер — путь кода, отвечающий на DNS-вопросы для программы. Иногда этот путь в стеке имён операционной системы. Иногда — в библиотеке, самой шлющей DNS-пакеты. Node использует оба пути; различие объясняет большую часть странного поведения в node:dns.

Резолвер ОС — настроенный на хосте путь разрешения имён. В Unix-подобных системах обычно /etc/hosts, конфигурация резолвера, search domains, правила name-service switch и один или несколько DNS-серверов. В Windows — конфигурация и политика резолвера Windows. Точный путь зависит от хоста.

dns.lookup() использует этот путь ОС через getaddrinfo().

1
2
3
4
5
import dns from 'node:dns';

dns.lookup('localhost', { all: true }, (err, addresses) => {
  console.log(err ?? addresses);
});

localhost часто резолвится до ухода DNS-пакета с хоста: hosts file или политика ОС владеют именем. Hosts file — локальный статический файл сопоставления имя→адрес. В Unix обычно /etc/hosts. В Windows — под system drivers. Точное расположение меньше важно, чем поведение: резолвер ОС может ответить из локальной конфигурации до обращения к DNS.

Локальный ответ может удивить. dns.lookup('localhost') может вернуть ::1, 127.0.0.1 или оба с all: true — в зависимости от политики хоста и порядка в Node. Сервер только на 127.0.0.1 — IPv4-слушатель. Клиент, выбравший ::1, целится в IPv6 loopback. В подглаве 9.1 уже разобрано, почему это разные адреса сокетов.

В Node есть ещё один путь резолвера. dns.resolve4(), dns.resolve6(), dns.resolveMx() и другие resolve*() используют c-ares. c-ares — C-библиотека асинхронных DNS-запросов по протоколу. В Node она поддерживает DNS-query API, спрашивающие настроенные nameserver'ы о конкретных типах записей.

1
2
3
dns.resolve4('example.com', (err, addresses) => {
  console.log(err ?? addresses);
});

Вызов просит A-записи. Это поведение DNS-протокола. Путь c-ares, а не host-name-service у dns.lookup(). Также нет работы thread pool libuv для getaddrinfo(), потому что c-ares интегрируется с libuv через готовность сокетов. Для JavaScript результат асинхронный, но нижний владелец сменился.

Один модуль. Два пути.

Различие видно и на коротких именах.

1
2
dns.lookup('db', { all: true }, console.log);
dns.resolve4('db', console.log);

В корпоративной сети или кластере резолвер ОС может применять search suffixes. Голое имя db расширяется во что-то из конфигурации хоста. Путь c-ares resolve4() задаёт DNS-вопрос для переданного имени через свою конфигурацию. В зависимости от настроек два вызова могут разойтись без бага в Node.

Различие и по типам имён. lookup() — для выбора адреса. resolve*() — для DNS-запросов записей. Просить у lookup() MX или TXT — неверный инструмент; getaddrinfo() возвращает адресную информацию для setup соединения. resolveMx() перед TCP даёт данные маршрутизации почты, а не список адресов, который использовал бы обычный HTTP-клиент.

Звучит мелочью, пока не отлаживаете библиотеку. Клиент БД может звать dns.lookup(), потому что нужен сокет. Валидатор конфигурации — dns.resolveSrv() для SRV. Health check платформы — resolve4(), чтобы спросить конкретный резолвер. В логах всё это «DNS». В процессе — разные пути резолвера.

Рекурсивный путь DNS

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

Типичный полный путь:

1
2
3
4
5
6
7
Node process
  -> local resolver path
  -> recursive resolver
  -> root nameserver
  -> TLD nameserver
  -> authoritative nameserver
  -> answer records

Рекурсивный резолвер принимает DNS-вопрос от клиента и делает follow-up, чтобы выдать ответ. Ноутбук может использовать роутер, резолвер ISP, корпоративный, VPC или публичный. Контейнер наследует настройки от хоста или runtime. Pod Kubernetes может сначала использовать cluster DNS. Приложение обычно видит настроенный IP сервера и шлёт вопросы туда.

Root nameserver'ы — верхний уровень DNS, знающий, куда слать вопросы для TLD. Nameserver TLD владеет делегированием для суффикса вроде com, org, io. Authoritative nameserver владеет данными зоны и отвечает за имена внутри зоны.

Для api.example.com некэшированный путь примерно такой:

1
2
3
4
5
6
recursive resolver asks root for api.example.com
root answers: ask .com servers
recursive resolver asks .com for api.example.com
.com answers: ask example.com authoritative servers
recursive resolver asks authoritative for api.example.com
authoritative answers: records for api.example.com

Точный обмен включает поля DNS-сообщения, коды ответа, additional records, glue, EDNS, UDP или TCP и retry. Backend-коду Node редко нужно каждое поле. Достаточно ядра: каждый шаг либо даёт ответ, делегирование, либо сообщает о сбое.

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

TTL — Time To Live. В DNS TTL — число секунд, сколько резолвер может кэшировать ответ записи, прежде чем считать его просроченным. Authoritative nameserver'ы прикрепляют TTL к записям. Рекурсивные резолверы отсчитывают вниз. Клиенты могут видеть оставшийся TTL или только данные записи — в зависимости от API.

Слой кэша объясняет, почему две машины могут расходиться после смены DNS. У одного резолвера старый кэш. У другого не было старого ответа и взят новый. Третий может урезать TTL по локальной политике. Зона на authoritative обновлена верно, а клиенты ещё видят старые кэшированные данные.

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

DNSSEC и DNS over HTTPS добавляют другое поведение резолвера. Здесь поверхностно. DNSSEC добавляет криптографическую проверку DNS-данных. DNS over HTTPS шлёт DNS-запросы по HTTPS вместо обычного транспорта резолвера. Обычные dns.lookup() и dns.resolve*() всё равно оставляют вас с поведением настроенного резолвера, видимым из процесса.

DNS-сообщение на рабочем уровне невелико. Запрос имеет имя, тип и class. Class почти всегда Internet class для backend Node. Тип — вид записи: A, AAAA, MX, TXT и т.д. Имя кодируется метками, а не URL. Запрос api.example.com типа A значит «дай IPv4-адресные записи, прикреплённые к этому DNS-имени».

Ответ может нести несколько секций. Answer section — записи, отвечающие на вопрос. Authority section — nameserver'ы, authoritative для следующей части имени. Additional section — дополнительные записи, помогающие резолверу продолжить, например адреса nameserver'ов из делегирования. Backend обычно видит финальный разобранный ответ, но рекурсивный резолвер использует секции при обходе делегирования.

Делегирование позволяет разным nameserver'ам владеть разными частями дерева DNS. Корневая зона делегирует com TLD. Зона com делегирует example.com authoritative серверам домена. Зона example.com отвечает за api.example.com или делегирует поддомен ниже. Резолвер следует делегированиям, пока не получит ответ или сбой.

Authoritative nameserver'ы возвращают полезные формы ответа:

1
2
3
4
5
answer records exist
name exists, requested type has no data
name is an alias through CNAME
name is absent
server failed to answer correctly

Позже эти формы становятся кодами ошибок Node, пустыми результатами или массивами записей. Сопоставление различается: API резолвера ОС и c-ares отдают разные ошибки. Но форма ответа подсказывает, куда смотреть. Нет AAAA — вопрос типа записи. Нет имени — вопрос именования. Сбой сервера — путь резолвера или поведение authoritative сервера.

CNAME стоит держать в поле зрения. Если api.example.com — CNAME на edge.example.net, запрос A для api.example.com может вернуть alias плюс адресные записи целевого имени. Резолверы кэшируют части цепи отдельно по TTL каждой записи. После смены один резолвер держит старый alias, другой уже взял новую цель. Симптом — имя, указывающее на разные наборы адресов с разных сетей.

DNS в основном использует UDP для обычных запросов: один запрос и один ответ обычно помещаются. Большие ответы, truncation, zone transfer и политика резолвера могут использовать TCP. Прикладной код с resolve*() обычно оставляет выбор транспорта на запрос c-ares и резолверу. Узкий факт: у DNS свой транспортный труд до TCP-соединения с сервером приложения.

Рекурсивные резолверы применяют политику. Могут блокировать имена, переписывать внутренние зоны, синтезировать ответы, предпочитать локальные реплики, урезать TTL, форвардить разные домены на разные upstream. Корпоративные сети, VPC и кластеры делают это активно. Из Node всё равно «резолвер ответил». С точки зрения эксплуатации ответ мог пройти через политику до процесса.

Поэтому сравнение только с публичным резолвером вводит в заблуждение. Если продакшен использует внутренний резолвер, знающий db.service.internal, ENOTFOUND у публичного резолвера почти ничего не доказывает о продакшене. Сопоставляйте путь резолвера перед сравнением ответов.

У рекурсивного разрешения есть выходы. Резолвер может остановиться после слишком многих CNAME-hop. Отклонить malformed ответы. Повторить другой authoritative при сбое одного. Перейти на TCP после усечённого UDP-ответа. Вернуть кэшированный сбой, когда upstream недоступны и локальная политика это разрешает. Поведение вне Node, но формирует профиль ошибок и задержек процесса.

Задержки накапливаются по пути. Холодный рекурсивный lookup может потребовать несколько сетевых round trip, прежде чем Node получит один кандидат адреса. Тёплый кэш резолвера — один локальный round trip. Ответ через hosts file у dns.lookup() может завершиться без DNS-сетевого трафика. Когда исходящий запрос медленный только на первом вызове после старта процесса, теплота DNS-кэша — один кандидат. Прогрев пула соединений и TCP/TLS — другие; помечайте тайминги по фазам.

Иерархия DNS объясняет частичные аварии. Если unhealthy набор authoritative для example.com, имена ниже зоны могут падать, пока несвязанные домены работают. Если проблема у пути к TLD nameserver с одной сети резолвера, домены под этим суффиксом ломаются оттуда и в порядке с других. Если сломан только один тип записи, A работает, AAAA нет. Ошибка принадлежит наименьшему падающему вопросу, который вы можете идентифицировать.

Записи, с которыми реально работают разработчики Node

DNS-запись — типизированный кусок DNS-данных, прикреплённый к имени. Тип говорит резолверу, как интерпретировать данные.

Сначала адресные записи.

1
2
3
dns.resolve4('example.com', (err, records) => {
  console.log(records);
});

A-запись сопоставляет имя с IPv4. AAAA — с IPv6. У имени может быть несколько записей одного типа; порядок в ответе может меняться резолвером, authoritative сервером, балансировкой и правилами упорядочивания на клиенте.

1
2
3
dns.resolve6('example.com', (err, records) => {
  console.log(records);
});

Семейство адресов — протокольное семейство адреса. В сетевых API Node чаще 4 и 6 или строки IPv4/IPv6 в os.networkInterfaces(). Семейство — часть адреса сокета. 127.0.0.1:3000 и ::1:3000 — разные семейства.

CNAME говорит, что одно DNS-имя — алиас другого. Резолверы следуют алиасу и возвращают финальные записи, запрошенные клиентом, с учётом правил DNS и кэша.

1
2
3
dns.resolveCname('www.iana.org', (err, names) => {
  console.log(err?.code ?? names);
});

CNAME важны, потому что видимое hostname в конфигурации может быть алиасом. Адресные записи могут жить на целевом имени. Отладка только первого имени может пропустить владельца адресного ответа.

CNAME влияют на границы владения. Команда владеет api.company.test, провайдер платформы — целевым именем и его адресами. Проверка DNS по видимому имени может показать короткий TTL на CNAME и другой TTL на адресах цели. Резолверы кэшируют каждую запись по своему TTL. Тайминг смены следует самому медленному релевантному кэшированному куску на наблюдаемом пути.

MX задают почтовые обменники домена. Backend Node трогает их при проверке маршрутизации почты или инфраструктурных проверках.

1
2
3
dns.resolveMx('example.com', (err, records) => {
  console.log(records);
});

TXT прикрепляет текстовые значения к DNS-имени. Встречаются в верификации владения, SPF, DKIM, DMARC и проверках платформ. DNS видит текстовые куски. Смысл дают приложение или внешний сервис.

1
2
3
dns.resolveTxt('example.com', (err, records) => {
  console.log(records);
});

SRV описывают конечные точки сервиса с priority, weight, port и target name. Некоторые системы используют их для поиска протокольных endpoint без жёсткого host:port в конфиге.

1
2
3
dns.resolveSrv('_sip._udp.sip2sip.info', (err, records) => {
  console.log(err?.code ?? records);
});

Service discovery как архитектура — намного позже. Здесь SRV — типизированная DNS-запись с port и target в данных. Сокет ещё не открыт.

SRV — одна из немногих частых записей, несущих port. Код с семантикой SRV должен читать этот port. net.connect('service.example.com') делает обычный address lookup для полученного host. Если протокол ожидает SRV, клиентская библиотека должна реализовать этот DNS-путь.

PTR поддерживают обратный lookup: адрес-производное DNS-имя обратно в доменное имя. Node отдаёт это через dns.reverse().

1
2
3
dns.reverse('8.8.8.8', (err, hostnames) => {
  console.log(err ?? hostnames);
});

Обратный DNS — эксплуатационные данные. Помогает в логах, почте и диагностике. Не доказательство идентичности. Валидация сертификата, HTTP host routing, proxy headers и идентичность приложения — выше.

NS и SOA полезны при отладке, хотя прикладной код редко их ест. NS называют authoritative nameserver'ы зоны или делегирования. SOA несут метаданные зоны, влияющие на negative caching. Node отдаёт resolveNs() и resolveSoa(). Используйте, когда вопрос «кто владеет этим ответом» или «почему отсутствие кэшируется так долго».

dns.resolve() может запросить тип строкой:

1
2
3
dns.resolve('example.com', 'MX', (err, records) => {
  console.log(records);
});

Чаще яснее типизированные хелперы: форма возврата различается по типу. resolve4() по умолчанию — строки адресов. resolveMx() — объекты с exchange и priority. resolveTxt() — массивы строковых кусков, потому что TXT может быть разбит на несколько character strings в одной записи.

Тип записи важен для ошибок. Имя может отвечать на A и не отвечать на MX. Может отвечать на TXT и не иметь адресных записей. Может быть корректно делегировано и всё равно не иметь типа, который спросил код. Логи только с именем выбрасывают половину DNS-вопроса. Логируйте и тип.

1
2
3
4
5
6
7
8
async function readTxt(name) {
  try {
    return await dns.resolveTxt(name);
  } catch (err) {
    console.error({ name, type: 'TXT', code: err.code });
    throw err;
  }
}

Маленькая обёртка фиксирует реальный DNS-вопрос. При отладке лучше, чем «lookup failed»: видны путь резолвера, имя и тип.


TTL, кэши и устаревшие ответы

Ошибки DNS часто выглядят непоследовательно, потому что кэши стоят на нескольких уровнях.

У authoritative nameserver актуальные данные зоны. У рекурсивного резолвера — закэшированные ответы. ОС может кэшировать имена. Рантайм, библиотека или пул соединений могут держать разрешённые адреса или открытые сокеты. Облачный балансировщик может обновить DNS раньше, чем все клиенты отбросят старые соединения. Это разные куски состояния.

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

dns.resolve4() может попросить Node включить значения TTL:

1
2
3
dns.resolve4('example.com', { ttl: true }, (err, records) => {
  console.log(records);
});

Записи приходят объектами с address и ttl. Этот TTL — оставшееся время жизни ответа по отчёту резолвера. Другой клиент с другим рекурсивным резолвером может видеть другое число или другой ответ.

У dns.lookup() другая форма. Он спрашивает у ОС адресную информацию через getaddrinfo(). Резолвер ОС может использовать DNS, hosts, mDNS, корпоративные name services или локальную политику кэша. Результат несёт адресную информацию, а не сырые DNS-ответы, поэтому TTL DNS не попадают в возвращаемую JavaScript-форму.

1
2
3
dns.lookup('api.example.com', { all: true }, (err, addresses) => {
  console.log(addresses);
});

Когда деплой обновляет api.example.com, повторные вызовы какое-то время могут давать смешанные ответы. Один процесс использует закэшированный старый адрес. Другой спросил резолвер, который уже забрал новый. Долгоживущий HTTP-клиент может держать существующее TCP-соединение на старый адрес и не делать lookup для следующего запроса. DNS изменился; пул соединений продолжал сокет, который уже был.

Переопределения через hosts делают это ещё локальнее.

1
127.0.0.1 api.example.test

После такой записи в пути host-резолвера dns.lookup('api.example.test') может вернуть 127.0.0.1. dns.resolve4('api.example.test') спрашивает DNS-серверы про A-записи и может упасть, потому что hosts относится к пути резолвера ОС. Это один из самых быстрых способов доказать, какой DNS API использует зависимость.

Negative caching имеет ту же форму. Если new-api.example.com в 10:00 не дал ответа, а запись создана в 10:01, некоторые резолверы ещё какое-то время отдают прежний сбой, пока не истечёт запись negative cache. Retry-цикл внутри Node не заставит резолвер перезапросить раньше через обычные DNS API.

Контейнеры и VM добавляют ещё один слой кэша через сгенерированный конфиг резолвера. Контейнер может указывать на внутренний DNS forwarder. Тот — на host или cluster resolver. Рекурсивный резолвер процесса может быть в нескольких прыжках от authoritative nameserver. API Node всё равно отдаёт один асинхронный результат.

Практический ход при отладке — зафиксировать вопрос.

1
2
3
4
5
6
name: api.example.com
type: A or AAAA
resolver path: OS lookup or DNS query
resolver server: which IP, if known
observed answer: addresses and TTL, if available
time: when the answer was observed

Без этих полей «DNS неправ» значит слишком многое. Имя может быть неверным. Тип записи — отсутствовать. Рекурсивный резолвер — устареть. Резолвер ОС — использовать hosts. Приложение — переиспользовать старое соединение и не делать lookup вовсе.

Собственный DNS-модуль Node — API над нижним поведением резолвера. Кэш ответов на уровне приложения — ваша зона или зона библиотеки. Обычные dns.resolve*() идут через c-ares и настроенный путь резолвера. Обычные dns.lookup() — через путь резолвера ОС. Кэширование может быть ниже Node. Если сервису нужен процессный кэш со своим сроком жизни, его нужно осознанно согласовать с TTL.

Долгоживущие клиенты создают отдельный «кэш», избегая lookup. HTTP agent держит сокет открытым. Пул БД — соединения. gRPC channel — subchannel'ы. Пока соединения живы, клиент может говорить со старыми адресами после смены DNS. Это переиспользование соединений, а не DNS-кэш. Снаружи всё равно похоже на устаревший DNS, потому что новый lookup выбрал бы другие адреса.

Rolling-изменения требуют обеих шкал времени: время жизни ответа и время жизни соединения.

1
2
3
DNS TTL controls cached answers
connection pooling controls old sockets
retry policy controls when new addresses are tried

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

Обход через hosts — ещё один класс устаревшего ответа. Разработчик добавляет локальное переопределение при тесте и забывает. dns.lookup() следует пути ОС и продолжает отдавать override. dns.resolve4() может вернуть реальный DNS-ответ. Приложение «ломается» только на этой машине. Правильный шаг — сравнить lookup path с DNS-query path и осмотреть локальный конфиг резолвера.

Search domains создают тихие суффиксы. Короткое имя api может резолвиться в api.corp.example по политике ОС. Другая машина с другим search suffix сообщит сбой. Код на голых именах опирается на конфигурацию резолвера. Внутри контролируемой сети это может быть нормально, но зависимость должна быть в deployment config, а не в размытом предположении про DNS.

TTL ноль заслуживает точной формулировки. Некоторые записи с TTL 0 просят резолверы не кэшировать или кэшировать кратко. Реализации и промежуточные системы всё равно могут применять локальную политику. Считайте TTL 0 просьбой в DNS-данных и проверяйте резолверы, которые реально использует процесс.

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

Есть и проблема time-of-check. Процесс резолвит имя, ждёт, потом подключается. Ответ может истечь в DNS за это время, но процесс всё ещё хранит строку адреса, которую уже получил. TTL DNS управляют свежестью кэша у резолверов. Как только код сохранил адрес, эта переменная владеет своей устарелостью.

1
2
const addresses = await dns.resolve4('api.example.com');
setTimeout(() => connectTo(addresses[0]), 60_000);

Код держит адрес минуту независимо от DNS TTL. Может быть нормально. Может быть багом. Резолвер не может забрать значение из вашей переменной.


Два DNS-пути в Node

Модуль node:dns даёт одно пространство имён над двумя нижними механизмами.

dns.lookup() — API address lookup, который используют многие сетевые вызовы Node. net.connect(), http.request(), https.request() и клиенты выше обычно используют семантику lookup перед открытием сокета, если не передана custom lookup или уже есть числовой адрес.

1
2
3
dns.lookup('example.com', (err, address, family) => {
  console.log(address, family);
});

Под капотом Node вызывает getaddrinfo() через libuv. getaddrinfo() — системный API, превращающий имя узла и подсказки сервиса в адресные структуры. Для Node здесь на выходе кандидаты IP и данные семейства адресов. Политикой владеет резолвер ОС: hosts, search suffixes, доступность семейств, локальный кэш и платформенные name services.

getaddrinfo() на уровне C API может блокироваться, пока резолвер ОС работает. Node держит поток JavaScript свободным, выполняя работу в thread pool libuv. Цикл событий уже разбирал thread pool; локальный вывод мал: много одновременных dns.lookup() занимают worker threads, которые нужны и другой работе пула.

Грубый путь вызова:

1
2
3
4
5
6
JavaScript dns.lookup()
  -> Node dns binding
  -> libuv uv_getaddrinfo request
  -> libuv thread pool
  -> OS getaddrinfo()
  -> callback on the event loop

uv_getaddrinfo — обёртка libuv вокруг платформенного API address lookup. Node создаёт запрос, прикрепляет состояние JavaScript-колбэка и просит libuv выполнить lookup вне главного потока JavaScript. Когда worker закончил, libuv постит завершение в цикл событий. Node превращает нативные адресные структуры в JavaScript-строки и номера family.

Этот путь завершения уводит медленную работу резолвера от выполнения JavaScript. Таймеры срабатывают. Существующие сокеты читают. Promises завершаются. Колбэк lookup приходит позже. Вместе с тем ёмкость thread pool конечна. Всплеск медленных OS lookup может соседствовать с файловой системой и crypto в том же пуле.

1
2
3
4
5
for (const host of hosts) {
  dns.lookup(host, err => {
    if (err) console.error(host, err.code);
  });
}

Цикл позволяет JavaScript продолжаться, пока колбэки в ожидании. Ниже всё равно может быть давление. Если резолвер ОС медленный, задачи lookup сидят в пуле. Файловая система, crypto и другие пользователи пула могут почувствовать contention — в зависимости от процесса и UV_THREADPOOL_SIZE.

Взаимодействие с пулом — одна из причин, почему высоконагруженные клиенты иногда добавляют lookup cache или свою стратегию резолвера. У решения есть цена. Как только приложение кэширует DNS-ответы, оно владеет сроком жизни, negative caching, порядком семейств и политикой повторов при сбоях. Плохой кэш может держать мёртвые адреса дольше, чем рекурсивный резолвер. Кэш, игнорирующий сбои, может долбить резолвер во время outage.

Сетевые API Node часто отдают lookup hook, потому что рантайм не знает политику каждого деплоя. Форма hook следует семантике dns.lookup(): hostname, options, callback с address и family.

1
2
3
function lookup(host, options, callback) {
  dns.lookup(host, { ...options, all: false }, callback);
}

Реальные custom lookup обычно больше, чем обёртка dns.lookup(). Они могут писать timing, кэшировать ответы, предпочитать приватные адреса или читать из service registry. Контракт узкий. Вызывающий ждёт пригодный address и family для попытки сокета. Адрес без верного family даёт нижние сбои сокета, не похожие на DNS.

С all: true контракт custom lookup меняет форму для клиентов, запрашивающих все адреса. net.connect() и http.request() могут выставить эту опцию при autoselection семейства.

1
2
3
function lookup(host, options, callback) {
  callback(null, [{ address: '127.0.0.1', family: 4 }]);
}

Реализация hook должна уважать полученный options. Если клиент просит family: 6, возврат IPv4 ломает политику. Если клиент просит все адреса, один адрес отключает fallback. Hook мал, но стоит на чувствительной границе.

dns.resolve*() использует c-ares для DNS-запросов по протоколу. c-ares создаёт сокеты, шлёт DNS-сообщения на настроенные nameserver'ы и интегрирует завершение с циклом событий. Спрашивает записи по типу. Обходит большую часть политики OS name service, включая ответы из hosts.

1
2
3
dns.resolve6('example.com', (err, addresses) => {
  console.log(err ?? addresses);
});

Вызов спрашивает настроенные DNS-серверы про AAAA. Если в hosts есть IPv6-маппинг для example.com, resolve6() всё равно спрашивает DNS. Если в DNS нет AAAA, вызов отражает состояние ответа для этого типа записи.

Путь c-ares другой:

1
2
3
4
5
6
JavaScript dns.resolve4()
  -> Node dns binding
  -> c-ares channel
  -> DNS query socket
  -> configured nameserver
  -> callback on the event loop

c-ares владеет состоянием DNS-запроса: outstanding queries, выбор сервера, retries, timeouts, разбор ответа. libuv следит за сокетами, которые c-ares хочет читать или писать. Когда приходит DNS-ответ, c-ares парсит, Node превращает данные записей в JavaScript-формы возврата.

В этом пути нет getaddrinfo(). Нет lookup в hosts ОС. IP nameserver'ов часто всё же берутся из конфигурации резолвера ОС при старте или reinit, но поведение запроса — поведение c-ares, когда Node выполняет вызов.

В этом предложении живёт много багов. Путь c-ares может использовать те же IP nameserver'ов, что и резолвер ОС, и всё равно вести себя иначе: пропущены NSS, различия search, локальные кэши, enterprise plugins. Тот же адрес резолвера. Другое поведение клиента.

c-ares значит, что нагрузка DNS-запросов видна как UDP или TCP от процесса к серверам резолвера. В закрытых средах egress может разрешать путь OS resolver и блокировать прямой DNS из контейнеров приложений. Тогда dns.lookup() успешен, а dns.resolve4() таймаутится или получает refused. Сбой относится к сетевой политике вокруг DNS query path, а не целевого сервиса.

Обратное тоже бывает. В образе контейнера сломан hosts или политика резолвера для OS lookup, а прямые c-ares-запросы на известный сервер работают. Сравнение обоих путей показывает, смотреть ли local name-service config или поведение DNS-сервера.

dns.setServers() относится к пути c-ares.

1
2
3
4
5
dns.setServers(['1.1.1.1', '8.8.8.8']);

dns.resolve4('example.com', (err, addresses) => {
  console.log(addresses);
});

Это меняет серверы для resolve*() в текущем контексте процесса. Резолвер ОС для dns.lookup() сохраняет свою конфигурацию. /etc/resolv.conf, настройки Windows и прочая host policy вне состояния c-ares Node остаются как были.

Для изолированных настроек резолвера используйте экземпляр Resolver.

1
2
3
4
5
6
const resolver = new dns.Resolver();
resolver.setServers(['8.8.8.8']);

resolver.resolve4('example.com', (err, addresses) => {
  console.log(addresses);
});

Экземпляр держит свой список серверов для c-ares-запросов. Он отделён от dns.lookup() и от глобального списка серверов.

Per-instance резолверы удобны для диагностики: сервер резолвера явный, без смены остального процесса.

1
2
3
4
5
const corp = new dns.Resolver();
const pub = new dns.Resolver();

corp.setServers(['10.0.0.53']);
pub.setServers(['1.1.1.1']);

Спросите оба про один тип записи. Если отвечает только корпоративный — имя внутреннее. Если оба отвечают, но расходятся — сравните TTL и authority. Если оба падают, а lookup() успешен — hosts, search domains и OS name services.

Resolver полезен и для изоляции тестов. Тест может направить один резолвер на локальный DNS-сервер, не меняя глобальные c-ares servers для других тестов в том же процессе. Это всё ещё реальное DNS по протоколу. Для чистых unit-тестов обычно проще custom lookup или прямая инъекция зависимости, чем живой DNS.

Promise API зеркалит callback API.

1
2
3
4
import { promises as dns } from 'node:dns';

const records = await dns.resolve4('example.com');
console.log(records);

Promises меняют только стиль потребления в JavaScript. dns.promises.lookup() всё ещё lookup semantics. dns.promises.resolve4() — c-ares query semantics.

Выбирайте по задаче.

Используйте dns.lookup(), когда нужно то же поведение выбора адреса, что Node использовал бы для соединения на этом хосте. Уважает локальную политику хоста. Видит hosts. Даёт данные семейства, готовые для сокета.

Используйте dns.resolve*(), когда нужны DNS-записи как записи. Проверки MX, верификация TXT, чтение SRV, инспекция TTL и «что говорит резолвер про AAAA» — туда.

Ловушка — тестировать connection path не тем API. Продакшен-клиент может использовать dns.lookup() через http.request(). Отладочный скрипт — dns.resolve4() и получить другой ответ. Оба могут быть верны: разные системы, разные вопросы.

Вторая ловушка: dns.lookup() с all: false скрывает альтернативных кандидатов.

1
2
3
dns.lookup('example.com', (err, address, family) => {
  console.log(address, family);
});

Колбэк получает один выбранный адрес. Если у имени десять записей в двух семействах, в колбэке всё равно один. Для отладки — all: true и весь список кандидатов. Для setup соединения одного адреса может хватить, если у клиента есть fallback elsewhere.

lookupService() идёт в обратную сторону. Сопоставляет числовой адрес и порт с hostname и именем сервиса через путь ОС.

1
2
3
dns.lookupService('127.0.0.1', 80, (err, host, service) => {
  console.log(host, service);
});

Это reverse lookup плюс mapping имени сервиса через системные средства. Полезно для диагностики. Валидация пира и аутентификация приложения — в других местах.

lookup() также принимает опции семейства и порядка.

1
2
3
dns.lookup('example.com', { family: 6 }, (err, address) => {
  console.log(address);
});

Запрос IPv6-результата. С { all: true } возвращает все адреса, выбранные путём OS lookup и правилами порядка Node:

1
2
3
dns.lookup('example.com', { all: true }, (err, addresses) => {
  console.log(addresses);
});

Объекты вида { address: '...', family: 4 } или { address: '...', family: 6 }. Форма близка к тому, что нужно следующей попытке соединения.

resolve4() и resolve6() делят семейство по типу записи:

1
2
3
4
const [v4, v6] = await Promise.all([
  dns.resolve4('example.com'),
  dns.resolve6('example.com')
]);

Код задаёт два DNS-вопроса. Порядок попыток сокета — решение политики соединения позже. Fallback, локальная маршрутизация и Happy Eyeballs — позже. В этой главе остановитесь на кандидатах адресов.


Семейство адресов и порядок lookup

Порядок lookup решает, какой кандидат адреса идёт первым, когда у имени несколько пригодных ответов.

В Node v24 dns.getDefaultResultOrder() сообщает порядок по умолчанию для процесса. На «чистой» сборке v24 часто verbatim: Node сохраняет порядок, возвращённый путём резолвера, а не принудительно IPv4 первым.

1
console.log(dns.getDefaultResultOrder());

Порядок процесса можно сменить:

1
dns.setDefaultResultOrder('ipv4first');

Допустимые значения включают verbatim, ipv4first и ipv6first в текущем Node. Настройка влияет на порядок результатов dns.lookup(). Для конкретного lookup можно передать order:

1
2
3
dns.lookup('localhost', { all: true, order: 'ipv4first' }, (err, out) => {
  console.log(out);
});

Порядок наблюдаем, потому что клиенты часто пробуют первый адрес раньше остальных. Если localhost отдаёт ::1 первым, а сервер слушает только 127.0.0.1, первая попытка целится в IPv6 loopback. Некоторые клиенты потом пробуют IPv4. Некоторые показывают первый сбой. У клиентов выше — своё поведение.

dns.lookup() принимает и family.

1
2
3
dns.lookup('localhost', { family: 4 }, (err, address) => {
  console.log(address);
});

Ограничение ответа IPv4. family: 6 — IPv6. family: 0 — любое семейство. Фильтрация семейства до попытки соединения. Нижний путь сокета получает выбранное семейство.

Есть и подсказки резолвера: dns.ADDRCONFIG и dns.V4MAPPED. Они мапятся на hint behavior getaddrinfo(), где платформа поддерживает. ADDRCONFIG просит ОС учитывать настроенные локальные семейства. V4MAPPED может запросить IPv4-mapped IPv6 там, где это уместно. Поведение платформы различается — используйте только с конкретной причиной и тестами на целевых хостах.

Не используйте порядок lookup как скрытый слой совместимости для плохих bind. Если сервер должен поддерживать оба loopback-семейства — bind и тестируйте оба под целевой политикой платформы. Если клиенту нужно предпочитать IPv4 или IPv6 — сделайте политику явной на lookup или connection layer и зафиксируйте в конфигурации.

all: true — друг отладки.

1
2
3
dns.lookup('api.example.com', { all: true }, (err, addresses) => {
  console.table(addresses);
});

Видеть каждого кандидата упрощает классификацию следующего сбоя. Нет IPv6 — проблема записи или политики резолвера. IPv6 есть, connect падает — маршрут, listener, firewall или транспорт. IPv4 есть, клиент его игнорирует — порядок или fallback.

Порядок — состояние процесса; границы worker'ов могут иметь значение. Код, меняющий default result order, должен делать это при bootstrap, рядом с другой конфигурацией рантайма. Библиотеки обычно предпочитают per-call options вместо смены глобального DNS для всего процесса. Пакет, вызывающий dns.setDefaultResultOrder() при import, может изменить несвязанных клиентов.

Используйте самый узкий контроль:

1
dns.lookup(host, { all: true, order: 'ipv6first' }, cb);

Вызов заявляет свою политику. Остальной процесс сохраняет default. В тестах проще рассуждать: зависимость порядка рядом с assertion.

Happy Eyeballs — стратегия соединения с гонкой или staggering попыток по семействам — в подглаве про полный request path. DNS-часть заканчивается на кандидатах и порядке. Сокет решает, что и когда пробовать.


Коды сбоев и отладка

Ошибки DNS проще читать, когда к коду прикреплён путь API.

ENOTFOUND — выбранный путь резолвера не смог разрешить запрошенное имя. Для dns.lookup() обычно путь резолвера ОС не дал адрес для hostname. Для dns.resolve*() обычно name error DNS или результат «нет такого имени» на пути DNS-запроса.

1
2
3
dns.lookup('missing.example.invalid', err => {
  console.error(err.code);
});

EAI_AGAIN — временный сбой резолвера. Имя может заработать позже. Частые причины: таймаут DNS-сервера, транзиентный сбой резолвера, потеря сети между хостом и резолвером, локальные проблемы резолвера. Относитесь иначе, чем к стабильному «имени нет».

ENODATA — имя существует на DNS-пути, но запрошенный тип записи не имеет данных в наблюдаемом ответе. У имени могут быть A без MX. A без AAAA. Это не то же самое, что полное отсутствие имени.

1
2
3
dns.resolveMx('example.com', err => {
  if (err) console.error(err.code);
});

Точный код может отличаться по API, платформе, резолверу и времени. Держите логи конкретными:

1
2
3
function logDnsError(name, type, err) {
  console.error({ name, type, code: err.code, errno: err.errno });
}

Для кода, тяжёлого на соединения, логируйте результат lookup и ошибку connect отдельно.

1
2
3
4
dns.lookup(host, { all: true }, (err, addresses) => {
  if (err) return console.error('lookup', err.code);
  console.log('lookup', addresses);
});

Затем проверьте числовой путь адреса. TCP/IP в Node.js дал границу сокета; используйте её. Если числовой connect на возвращённый адрес падает так же — DNS сделал своё, провалился транспорт. Если lookup падает до появления адреса — путь сокета ещё не начался.

ENOTFOUND от HTTP-клиента может скрыть нижнюю DNS-операцию. Достаньте исходную ошибку, когда возможно. Ошибки Node часто несут code, errno, syscall, hostname. Некоторые библиотеки оборачивают поля; хорошие сохраняют их. Потеря hostname и code превращает сбой резолвера в общий сбой запроса.

EAI_AGAIN заслуживает осторожности с повторами. Мгновенные повторы с тысяч запросов усиливают проблемы резолвера. Используйте ограниченные повторы и jitter на уровне вызывающего, если повтор разрешения имени — часть политики клиента. Держите бюджет повторов отдельно от TCP retry и HTTP retry — они падают на разных слоях.

ENODATA заслуживает проверки типа записи. Если resolve6() даёт ENODATA, а resolve4() — адреса, сервис может быть только IPv4. Если resolveMx() даёт ENODATA, домен может не принимать почту напрямую. Имя всё ещё может существовать. Запрошенных данных нет.

Появляются и другие DNS-коды. ESERVFAIL — ответ server failure. ETIMEOUT — таймаут запроса на пути DNS-query. ECONNREFUSED — когда настроенный DNS-сервер отклоняет транспорт запроса. К ошибке прикрепляйте путь резолвера, имя, тип и путь к серверу.

Путь c-ares изолируется через Resolver.

1
2
3
4
5
const r = new dns.Resolver();
r.setServers(['8.8.8.8']);
r.resolve4('api.example.com', (err, records) => {
  console.log(err?.code ?? records);
});

Сравните с обычным lookup процесса:

1
2
3
dns.lookup('api.example.com', { all: true }, (err, out) => {
  console.log(err?.code ?? out);
});

Разные ответы показывают раскол. Override в hosts виден в lookup() и исчезает в resolve4(). Custom c-ares server влияет на экземпляр резолвера, lookup() не трогает. Корпоративный резолвер отвечает на внутренние имена, публичный — нет. Search suffix может сделать короткое hostname рабочим через OS path, а DNS-запрос для голого имени — нет.

Search domains — ещё одна возможность резолвера ОС. Хост может быть настроен так, что короткое имя db пробуется с настроенными суффиксами. dns.lookup('db') может резолвиться через эту политику. dns.resolve4('db') спрашивает DNS для имени как переданного через путь c-ares. Внутренняя инфраструктура опирается на это достаточно часто, чтобы различие заслуживало строку в логе при отладке.

Kubernetes делает это видимым. У pod часто search domains для namespace и кластера. Короткое имя сервиса резолвится через OS lookup path, потому что конфиг резолвера его расширяет. Прямой c-ares-запрос для короткого имени может вести себя иначе в зависимости от обработки конфига c-ares и опций. В переносимых диагностических скриптах запрашивайте полностью квалифицированное имя сервиса и фиксируйте, какой API использовали.

Облачный private DNS ведёт себя так же на более высокой границе. Имя резолвится внутри VPC и падает с ноутбука. Публичный резолвер не отвечает, облачный — отдаёт приватные адреса. Это область резолвера. Сопоставьте исходную сеть и резолвер перед сравнением результатов.

Таймауты требуют осторожности. Таймаут HTTP-клиента может включать время DNS, TCP connect, TLS handshake, запись запроса, обработку на сервере и чтение ответа. Код таймаута DNS из node:dns уже. Он говорит, что операция резолвера не успела дать ответ вовремя. Держите метки таймаутов точными.

Для локальной отладки начните с имён без внешнего DNS.

1
dns.lookup('localhost', { all: true }, console.log);

Это тестирует OS lookup path и локальную политику хоста. Затем реальный DNS-запрос:

1
dns.resolve4('example.com', console.log);

Первый может успеть без DNS-сервера. Второму нужно DNS query behavior. Если первый работает, второй таймаутится — процесс использует локальную политику резолвера, но не завершает c-ares DNS-запросы к настроенным серверам. Если второй работает, а зависимость на lookup() падает — hosts, search domains, конфиг OS resolver и давление thread pool.

Последняя проверка — семейство адресов. Логируйте каждый ответ, включая тот, что попробовал клиент.

1
2
3
dns.lookup(host, { all: true, order: 'verbatim' }, (err, all) => {
  console.log(all);
});

Имя может резолвиться верно и всё равно отдать следующему слою адрес, который хост не может использовать. IPv6 в DNS есть, маршрутизация IPv6 сломана. IPv4 работает, путь IPv6-first падает рано. DNS выдал кандидатов. Путь сокета всё ещё должен доказать каждого.

Компактный DNS debug script должен печатать оба пути:

1
2
3
4
5
6
7
8
9
const host = process.argv[2];

dns.lookup(host, { all: true }, (err, out) => {
  console.log('lookup', err?.code ?? out);
});

dns.resolve4(host, (err, out) => {
  console.log('A', err?.code ?? out);
});

Сниппет укладывается в лимит, проверяя только A. Добавьте resolve6(), когда в баге участвует семейство адресов. Экземпляр Resolver, когда важен IP сервера. Числовой net.connect() — только после кандидатов адресов.

Самый быстрый путь через DNS-инцидент обычно такой:

1
2
3
4
5
6
capture failing hostname and record type
check lookup path used by the code
print all address candidates
compare OS lookup with c-ares query
test numeric socket address
inspect caches and TTLs

Последовательность сохраняет границу. TCP-сбой не помечается как DNS. Override hosts не принимается за authoritative DNS. Проблема маршрута IPv6 не прячется под «hostname failed».

Когда сбой внутри крупного клиента, добавьте timing вокруг границы lookup. Custom lookup wrapper может писать start, end, hostname, options и число результатов. Держите временно, если у библиотеки уже есть tracing hooks.

1
2
3
4
5
6
7
const timedLookup = (host, opts, cb) => {
  const started = performance.now();
  dns.lookup(host, opts, (err, address, family) => {
    console.log(host, performance.now() - started);
    cb(err, address, family);
  });
};

Обёртка измеряет только OS lookup path. Время TCP connect ниже. Если lookup быстрый, а запрос всё ещё тормозит — спускайтесь к пути сокета. Если lookup съедает большую часть бюджета запроса — оставайтесь с конфигом резолвера, состоянием кэша и давлением thread pool.

Имена заканчиваются на адресах. Дальше глава возвращает управление примитивам из TCP/IP: семейство адресов, адрес сокета, lookup маршрута, состояние сокета в ядре и жизненный цикл соединения, которым владеет TCP.

Связанное чтение

Комментарии