DNS внутри кластера отвечал 5 секунд: виноват был параметр ndots
Приложение в кластере иногда «зависает» на ровно 5 секунд перед обращением к внешнему API — не к базе, не к соседнему сервису, а именно к чему-то за пределами кластера. Задержка не постоянная, воспроизвести её по требованию не получается, а метрики самого внешнего сервиса чистые. Если это похоже на вашу ситуацию — вы почти наверняка встретили классическую проблему ndots в резолвере пода. Разбираем по шагам, как её диагностировали и что в итоге поправили.
Содержание
Что сломалось: симптомы
Сервис на бэкенде дергал внешний платёжный API по HTTPS на каждый заказ. В обычном режиме запрос занимал десятки миллисекунд. Но примерно один запрос из полусотни-сотни зависал ровно на 5 секунд, а затем либо проходил, либо падал по таймауту на стороне клиента приложения.
Характерные черты, которые сразу натолкнули на след:
- Задержка была не «плавающей» — не 200 мс, не 800 мс, а стабильно около 5000 мс. Такая ровная цифра почти всегда означает, что где-то сработал таймаут ожидания ответа, а не реальная медленная обработка.
- Проблема проявлялась только на запросах к доменам вне кластера (внешние API, S3-совместимое хранилище стороннего провайдера, SMTP-релей). Запросы к соседним сервисам по
service-name.namespace.svc.cluster.localне задерживались никогда. - Частота зависаний росла с нагрузкой: на малом трафике ошибка почти не проявлялась, на пиках количество зависших запросов заметно увеличивалось.
- Проблема не зависела от конкретного узла — воспроизводилась на разных нодах кластера, что сразу исключало «убитое железо» или проблему одного конкретного воркера.
- Рестарт пода временно снимал симптом на несколько минут, но потом всё возвращалось.
Первая реакция команды — заподозрить сам внешний API или сетевого провайдера. Но мониторинг у стороннего сервиса показывал ровную картину, а ping и mtr до его IP с той же ноды не показывали никаких потерь пакетов или скачков RTT.
Что видели в логах и метриках
Дальше стали смотреть глубже, чем логи приложения. Внутри пода зашли через kubectl exec и посмотрели, что резолвер пода вообще пытается сделать:
kubectl exec -it my-app-7f9c9d8f6-xk2lp -- cat /etc/resolv.conf
Файл выглядел стандартно для пода Kubernetes с dnsPolicy: ClusterFirst:
nameserver 10.96.0.10
search my-namespace.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Само по себе это ничего не доказывало — так выглядит resolv.conf в подавляющем большинстве кластеров по умолчанию. Дальше сняли трафик прямо из сетевого пространства пода:
kubectl debug -it my-app-7f9c9d8f6-xk2lp --image=nicolaka/netshoot -- bash
tcpdump -i eth0 -n udp port 53 -w /tmp/dns.pcap
И параллельно инициировали единственный HTTP-запрос к внешнему API из приложения. В дампе на один вызов api.paymentprovider.com обнаружилось не одно DNS-обращение, а несколько подряд — с разными доменными суффиксами:
api.paymentprovider.com.my-namespace.svc.cluster.local→ NXDOMAINapi.paymentprovider.com.svc.cluster.local→ NXDOMAINapi.paymentprovider.com.cluster.local→ NXDOMAINapi.paymentprovider.com→ корректный ответ с адресом
Итого — четыре последовательных DNS-запроса вместо одного, причём три из них заведомо обречены на NXDOMAIN. При этом метрики CoreDNS (плагин prometheus в Corefile, дашборд по coredns_dns_request_count_total и coredns_dns_response_rcode_count_total) показывали, что доля NXDOMAIN-ответов у CoreDNS в пересчёте на количество внешних доменов кластера была высокой — заметно выше, чем можно было бы объяснить опечатками в конфигурации приложений.
Отдельно включили в Corefile плагин log, чтобы видеть каждый запрос с исходным подом:
.:53 {
errors
log
health
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
}
prometheus :9153
forward . /etc/resolv.conf
cache 30
loop
reload
loadbalance
}
Логи подтвердили: на каждый реальный внешний домен резолвер пода действительно генерирует до четырёх DNS-запросов, а не один. Само по себе это не объясняло пятисекундные зависания — три лишних запроса в норме укладываются в единицы миллисекунд. Но именно это натолкнуло на мысль, что при определённых условиях один из этих запросов не долетает и не долетает ответ, и клиент внутри пода ждёт истечения таймаута резолвера.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые не подтвердились
Прежде чем добраться до реальной причины, отработали и отбросили несколько версий.
Версия 1: CoreDNS не справляется с нагрузкой. Проверили CPU и память подов CoreDNS через kubectl top pod -n kube-system -l k8s-app=kube-dns — утилизация была далека от лимитов, троттлинга по CPU не наблюдалось. Версию отбросили.
Версия 2: DNS-запросы теряются на NetworkPolicy. Порт 53 к kube-dns ClusterIP был явно разрешён для всех namespace, а iptables -t nat -L -n на нодах не показывал ничего подозрительного вокруг DNAT для сервиса kube-dns. Версию отбросили.
Версия 3: Проблема на стороне приложения — неправильный DNS-кеш в рантайме. strace -f -e trace=network показывал, что каждый раз действительно уходит новый UDP-пакет к резолверу, а не берётся что-то из кеша уровня приложения. Версию отбросили.
Версия 4: Проблема во внешнем DNS-провайдере (upstream у CoreDNS). Попробовали резолвить тот же домен напрямую с ноды, минуя CoreDNS, через dig @8.8.8.8 api.paymentprovider.com в цикле несколько сотен раз — ни одной задержки. Проблема была между подом и CoreDNS, а не дальше. Версию отбросили.
Версия 5: Перегруженный узел или «шумный сосед». Проблема воспроизводилась на разных нодах и в разное время суток, включая периоды низкой общей загрузки. Версию отбросили.
После исключения этих версий стало ясно, что нужно разбираться не с тем, «почему медленно», а с тем, «почему один конкретный UDP-пакет DNS-запроса иногда не долетает или не долетает ответ на него» — именно это давало ожидание таймаута резолвера.
В чём была реальная причина
Разгадка сложилась из двух факторов, каждый из которых по отдельности безобиден, а вместе — стабильно давали редкие, но заметные пятисекундные паузы.
Фактор первый — ndots:5. Это стандартная опция резолвера, которую kubelet прописывает в resolv.conf пода при dnsPolicy: ClusterFirst. Она означает: если в искомом имени меньше пяти точек, резолвер (glibc) считает его «неполным» и сначала пробует дописать к нему каждый суффикс из списка search по очереди, и только когда все они дали NXDOMAIN, пробует имя как есть, в виде полностью квалифицированного доменного имени. У домена api.paymentprovider.com всего две точки — значит, он гарантированно попадает под это правило и переберёт все суффиксы search из resolv.conf, прежде чем дойти до правильного варианта.
При трёх суффиксах в search (namespace.svc.cluster.local, svc.cluster.local, cluster.local) на один вызов внешнего домена приходится не 1, а 4 DNS-запроса — ровно то, что видели в tcpdump. Для внутренних имён вида redis.my-namespace.svc.cluster.local (пять точек и больше) это правило, наоборот, не мешает: резолвер сразу пробует имя как абсолютное.
Фактор второй — гонка при параллельных A/AAAA-запросах через один и тот же UDP-сокет. Резолвер glibc может отправлять запросы на IPv4-адрес (A) и IPv6-адрес (AAAA) для одного и того же имени почти одновременно, через один и тот же исходящий UDP-сокет с одним портом источника. В связке с conntrack на стороне ядра и iptables-правилами CNI-плагина при определённом совпадении по времени один из двух параллельных запросов иногда не долетает до CoreDNS или ответ не долетает обратно. Для одиночного запроса такая гонка — редкое событие. Но когда на один вызов внешнего домена нужно последовательно выполнить не один запрос, а четыре (из-за ndots), а каждый ещё и параллелит A/AAAA — суммарное число UDP-обменов вырастает в несколько раз, и вероятность попасть в эту гонку заметно растёт.
Когда пакет теряется, резолвер не получает ответа и ждёт истечения собственного таймаута, заданного в resolv.conf (по умолчанию 5 секунд), прежде чем повторить попытку или перейти к следующему серверу. Отсюда и характерная ровная цифра — не «сеть медленная», а «резолвер прождал полный таймаут один раз в цепочке из нескольких запросов».
Рост частоты симптома под нагрузкой тоже находит объяснение: чем больше подов одновременно резолвят внешние имена, тем выше суммарная интенсивность UDP-обменов с CoreDNS и tem выше шанс задеть гонку по conntrack хотя бы у части запросов.
Как подтвердили гипотезу
Гипотезу проверили без изменений в проде, на копии окружения.
Во-первых, сравнили поведение dig с явным именем и с суффиксом поиска:
dig api.paymentprovider.com
dig +search api.paymentprovider.com
Второй вариант (эмулирующий поведение резолвера с ndots) действительно показывал несколько последовательных запросов в трассировке, тогда как первый — только один.
Во-вторых, посчитали соотношение количества DNS-запросов в CoreDNS к количеству реальных исходящих HTTP-вызовов приложений за один и тот же период. Соотношение оказалось заметно выше единицы — там, где логика приложения предполагала один вызов внешнего домена, DNS-подсистема получала несколько запросов, что совпадало с картиной из tcpdump.
В-третьих, намеренно нагрузили тестовый под параллельными обращениями к внешнему домену и одновременно писали tcpdump на этом же поде. При достаточном числе параллельных запросов в дампе стали видны отдельные случаи, где на один из под-запросов A/AAAA не приходило ответа вовсе, и резолвер после паузы либо получал ответ по повторной отправке, либо получал общий таймаут.
Совпадение по времени, по характеру ровно пятисекундных пауз и по корреляции с нагрузкой дало достаточную уверенность, что причина найдена: не сеть, не CoreDNS как сервис, а комбинация ndots и редкой потери UDP-пакета в цепочке из нескольких последовательных DNS-запросов.
Что изменили
Правки внесли в двух направлениях — сократили количество лишних DNS-запросов и убрали саму гонку по UDP-сокету там, где это было возможно.
1. Понизили ndots для рабочей нагрузки через dnsConfig пода. Полностью убирать ndots для всего кластера рискованно — это сломает короткие имена внутренних сервисов вида redis без домена. Поэтому ndots понизили точечно, для конкретных Deployment'ов, которые в основном ходят во внешний мир и почти не обращаются к другим сервисам по короткому имени:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "2"
containers:
- name: my-app
image: registry.example.com/my-app:latest
Значение 2 было выбрано так, чтобы внутренние обращения по полному служебному имени (svc.name.namespace.svc.cluster.local, где точек достаточно) по-прежнему уходили в кластерный поиск, а внешние домены вида api.paymentprovider.com сразу резолвились как абсолютные, без перебора суффиксов search.
2. Там, где сервис обращается к внутренним сервисам по короткому имени, эти обращения переписали на использование полного FQDN (redis.my-namespace.svc.cluster.local. с завершающей точкой в конфигурации подключения) — это тоже заставляет резолвер трактовать имя как абсолютное и не идти по search-суффиксам, независимо от значения ndots.
3. Добавили опцию single-request-reopen в dnsConfig. Она заставляет резолвер использовать отдельные сокеты для последовательных A- и AAAA-запросов вместо разделения одного и того же сокета, что снижает вероятность попасть именно в ту гонку с conntrack, которую увидели в дампах:
dnsConfig:
options:
- name: ndots
value: "2"
- name: single-request-reopen
4. Развернули NodeLocal DNSCache. Это официальный компонент экосистемы Kubernetes, который поднимает кеширующий DNS-сервер на каждой ноде и отвечает подам локально, снижая число сетевых переходов и нагрузку на кластерный CoreDNS. Это не устраняет саму механику ndots, но снижает суммарную стоимость лишних запросов.
5. Настроили мониторинг. В дашборд добавили метрику по доле NXDOMAIN-ответов CoreDNS в разрезе namespace — устойчиво высокая доля таких ответов теперь воспринимается как сигнал «кто-то резолвит внешние домены с избыточным ndots» ещё до тикета от пользователей.
После этих изменений частота ровно пятисекундных зависаний заметно снизилась — команда наблюдала это по логам приложения и по количеству повторных попыток запросов к внешнему API. Точную цифру улучшения в процентах приводить не будем: она сильно зависит от исходной топологии search-доменов и интенсивности трафика конкретного кластера, поэтому у вас цифры почти наверняка будут другими.
Как не наступить на эти грабли заранее
Несколько практик, которые стоит завести в команде ещё до того, как похожая проблема появится в проде:
- Перед выкаткой сервиса, который активно ходит во внешние API, проверяйте
resolv.confвнутри пода и решайте, нужен ли емуndots:5по умолчанию. - Держите в голове разницу между окружениями: в маленьком тестовом кластере с коротким списком
searchэта проблема почти не проявляется — она заметна именно там, где вsearchнесколько вложенных namespace или корпоративных доменов. - Заведите алерт по доле
NXDOMAINв CoreDNS в разрезе namespace — это самый дешёвый способ поймать проблему до жалоб пользователей. - Если мигрируете с Docker Compose на Kubernetes — заранее почитайте про особенности DNS в новой среде, это одна из типичных неожиданностей при переходе, разобранная в статье из Docker Compose в Kubernetes: когда оправдано.
- Для понимания того, что вообще происходит с DNS-запросом на пути от клиента до ответа, полезно освежить базовую механику резолвинга — она разобрана в материале как DNS-запрос обходит полмира.
- Если проблемы с DNS у вас проявились сразу после переезда инфраструктуры на новые серверы или в новый регион — возможно, это смежная, но другая история; она разобрана в статье проблемы с DNS после переезда.
- Каскадные эффекты от одной точки отказа в инфраструктуре — отдельная большая тема, во многом похожая по логике расследования; пример разбора такого случая — в статье отвалился один микросервис и утянул пять.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему проблема проявлялась не у всех подов одинаково?
Частота зависела от того, как часто конкретный под резолвит именно внешние домены с малым числом точек. Поды, которые в основном общаются с другими сервисами внутри кластера по полным служебным именам, эту гонку почти не задевают.
Можно ли просто выставить ndots:1 для всего кластера через kubelet?
Технически можно, но это глобальная и рискованная правка: она затронет вообще все поды, включая те, что полагаются на короткие внутрикластерные имена без явного домена. Безопаснее менять ndots точечно, через dnsConfig на уровне конкретного Deployment или Pod.
NodeLocal DNSCache полностью решает эту проблему сам по себе?
Нет, он снижает число сетевых переходов и нагрузку на центральный CoreDNS, но не отменяет саму логику перебора search-суффиксов при ndots:5. Для полного решения нужна и правка ndots, и по возможности снижение вероятности потери UDP-пакета.
Как быстро проверить, есть ли у меня эта проблема, без глубокого дампа трафика?
Сравните вывод dig имя_домена и dig +search имя_домена из пода — если во втором случае видно несколько промежуточных NXDOMAIN-ответов перед финальным успешным, у вас работает та же механика ndots.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →