Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Пиксельная шестеренка Event Loop, заблокированная DNS-барьером

Синхронный DNS в dns.lookup: скрытая причина падения производительности Node.js под нагрузкой

Анализ влияния стандартного синхронного вызова системного getaddrinfo в модуле Node.js dns.lookup на задержки Event Loop при высокой сетевой нагрузке. Карточка рассматривает методы диагностики проблемы, блокировку пула потоков libuv, а также варианты перехода на асинхронное разрешение адресов.

Синхронный 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 может временно снизить остроту проблемы, но это является устранением симптома, а не причины. Оно расходует больше системных ресурсов и не гарантирует стабильную производительность при дальнейшем росте трафика.