Синхронный DNS в dns.lookup: скрытая причина падения производительности Node.js под нагрузкой
При разработке высоконагруженных сервисов на Node.js исходящие запросы к внешним API (интерфейсам прикладного программирования) могут внезапно приводить к деградации производительности всего приложения. Причина часто кроется в механизме разрешения доменных имен (DNS-резолвинг).
По умолчанию http.request использует метод dns.lookup(), который неявно вызывает синхронную системную функцию, блокирующую пул потоков. Под нагрузкой это приводит к исчерпанию ресурсов и росту задержек обработки событий (Event Loop).
Почему dns.lookup блокирует работу приложения
Чтобы понять природу этой проблемы, необходимо рассмотреть внутреннее устройство Node.js. Выполнение JavaScript-кода в Node.js происходит в одном потоке с помощью механизма Event Loop — главного цикла обработки событий, который последовательно выполняет зарегистрированные задачи. Для выполнения ресурсоемких или блокирующих системных операций (таких как работа с файловой системой или шифрование) используется библиотека libuv — вспомогательный компонент на языке C, предоставляющий пул фоновых потоков (thread pool). По умолчанию этот пул содержит всего 4 потока.
Когда Node.js совершает сетевой запрос к хосту (например, https://api.example.com), ему необходимо преобразовать текстовое имя хоста в IP-адрес. Для этого стандартный модуль http.request вызывает встроенный метод dns.lookup(). Особенность dns.lookup() заключается в том, что на системном уровне он вызывает стандартную функцию операционной системы getaddrinfo(3).
Эта функция является строго синхронной и блокирующей: поток, вызвавший ее, замирает до тех пор, пока операционная система не получит ответ от DNS-сервера. Чтобы этот вызов не заблокировал основной поток выполнения JavaScript (Event Loop), Node.js вынужден отправлять задачу разрешения имени в пул фоновых потоков libuv.
Под высокой нагрузкой (более 1000 запросов в секунду к множеству уникальных доменов) возникает следующая цепочка событий:
- Все 4 фоновых потока libuv оказываются заняты ожиданием ответов от системного резолвера DNS.
- Новые запросы на разрешение имен выстраиваются в очередь.
- Любые другие операции приложения, использующие пул libuv (например, чтение файлов с диска с помощью
fs.readFile, хэширование паролей черезbcryptили операции шифрования TLS), встают в ту же очередь и замирают. - В результате увеличивается задержка Event Loop, приложение перестает своевременно обрабатывать сетевые события, а p99 (время отклика для 99% пользователей) критически возрастает.
Симптомы и методы диагностики
Для выявления проблемы с блокировкой пула потоков из-за DNS-запросов можно использовать несколько методов диагностики:
1. Анализ сопутствующих симптомов
Если замедление работы сервиса сопровождается ростом времени выполнения дисковых операций (хотя дисковая подсистема не перегружена) или задержками при установлении TLS-соединений, это указывает на дефицит свободных потоков в пуле libuv.
2. Замеры времени разрешения имен в коде
Для точечной проверки времени работы DNS-резолвера можно обернуть вызовы в точный таймер с использованием метода process.hrtime.bigint() (системный таймер высокого разрешения в Node.js):
const dns = require('dns');
const start = process.hrtime.bigint();
dns.lookup('api.example.com', (err, address, family) => {
const end = process.hrtime.bigint();
const durationMs = Number(end - start) / 1e6;
console.log(`DNS Lookup took ${durationMs} ms`);
});
Если в периоды высокой нагрузки время разрешения имен превышает 10–20 мс или колеблется до сотен миллисекунд, проблема локализована.
3. Профилирование с помощью Clinic.js
Для комплексного анализа задержек рекомендуется использовать инструментальный стек Clinic.js (программа для профилирования производительности приложений Node.js). Запуск инструмента Clinic Doctor под нагрузочным тестом наглядно покажет графики задержки Event Loop и загрузки потоков ввода-вывода. Наличие пиков задержек, совпадающих с сетевой активностью, подтвердит проблему с dns.lookup.
Пошаговое руководство по исправлению проблемы
Для устранения DNS-блокировок в Node.js существует три основных подхода: включение постоянного соединения (keep-alive), использование асинхронного резолвера без пула libuv или внедрение кэширования DNS-запросов.
Шаг 1. Настройка постоянных соединений (Keep-Alive)
Первым шагом необходимо убедиться, что HTTP-клиент не создает новое TCP-соединение для каждого запроса. Включение параметра keepAlive: true в настройках HTTP-агента позволяет повторно использовать существующие соединения, что снижает частоту обращений к DNS. Однако это не решит проблему, если сервис обращается ко множеству уникальных внешних адресов.
Шаг 2. Выбор альтернативного метода разрешения имен
В Node.js встроен альтернативный набор функций для работы с DNS — семейство dns.resolve (например, dns.resolve4(), dns.resolve6()). В отличие от dns.lookup(), эти методы выполняют сетевые DNS-запросы асинхронно через встроенную библиотеку c-ares, не используя пул потоков libuv.
Важное предупреждение: Вызовы dns.resolve* работают напрямую с сетевыми DNS-серверами. Они игнорируют локальный файл конфигурации /etc/hosts операционной системы, внутренние правила разрешения имен (mDNS) и специфические настройки корпоративных сетей. Применение dns.resolve напрямую может нарушить работу сервиса во внутренней сети или контейнерах Kubernetes.
Шаг 3. Внедрение кэширующего DNS-резолвера
Оптимальным решением, сохраняющим системное поведение разрешения имен и устраняющим нагрузку на libuv, является использование специализированной библиотеки кэширования, такой как cacheable-lookup (популярный npm-пакет с открытым исходным кодом для кэширования DNS-запросов с учетом TTL — времени жизни записи).
Для интеграции библиотеки в проект выполните установку пакета:
npm install cacheable-lookup
После установки настройте интеграцию в коде приложения.
Вариант А. Настройка для конкретного HTTP-клиента Вы можете передать метод разрешения имен в параметры HTTP-запроса:
const http = require('http');
const CacheableLookup = require('cacheable-lookup');
const cacheable = new CacheableLookup();
const options = {
hostname: 'api.example.com',
port: 80,
path: '/',
method: 'GET',
lookup: cacheable.lookup // Переопределяем стандартный резолвер
};
const req = http.request(options, (res) => {
// Обработка ответа
});
req.end();
Вариант Б. Глобальное переопределение для стандартного агента Если вы хотите применить кэширование ко всем исходящим запросам, использующим глобальный агент:
const http = require('http');
const CacheableLookup = require('cacheable-lookup');
const cacheable = new CacheableLookup();
cacheable.install(http.globalAgent); // Внедряем кэш в глобальный HTTP-агент
Шаг 4. Верификация изменений
После внедрения кэширования запустите повторный нагрузочный тест. Успешность оптимизации подтверждается следующими метриками:
- Снижение задержки Event Loop в моменты пиковой нагрузки.
- Стабильно низкое p95/p99 время отклика для исходящих запросов.
- Отсутствие задержек при выполнении дисковых операций и шифрования во время сетевой активности.
- Успешное разрешение локальных имен и сервисов Kubernetes.
Важные ограничения и предупреждения безопасности
При изменении стандартного механизма DNS-резолвинга необходимо учитывать следующие аспекты безопасности и администрирования:
- Управление временем жизни кэша (TTL): Кэш должен строго уважать параметры TTL, возвращаемые DNS-сервером. Слишком длительное кэширование может привести к отправке запросов на устаревшие IP-адреса при переключении серверов (failover) на стороне внешнего API.
- Влияние на внутренние сети: При использовании облачных сред и контейнеров Kubernetes имена сервисов (например,
database.local) часто разрешаются через внутренние DNS-серверы с коротким TTL. Убедитесь, что кэширующий резолвер корректно обрабатывает такие имена и не кэширует их надолго. - Безопасность TLS/SNI: Если вы решаете вручную резолвить адреса перед установкой соединения (передавая IP-адрес в
net.createConnection), обязательно сохраняйте оригинальное доменное имя в параметреservernameдля корректной проверки SSL/TLS-сертификатов. В противном случае клиент отклонит соединение из-за несовпадения имен в сертификате. - Нестабильность временного увеличения UV_THREADPOOL_SIZE: Увеличение размера пула потоков libuv через переменную окружения
process.env.UV_THREADPOOL_SIZE = 64может временно снизить остроту проблемы, но это является устранением симптома, а не причины. Оно расходует больше системных ресурсов и не гарантирует стабильную производительность при дальнейшем росте трафика.

![Node.JS [ru]](/api/digests/it_development/daily/20260706/assets/sources/we-use-js.jpg)