The Daily Newsstand · Free, Always
Monday, October 12, 2026

Почему DNS в Kubernetes тормозит и что с этим делать

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

  • Второй — когда что‑то начинает падать и в логах видно i/o timeout на резолве.

Между этими двумя моментами может пройти года три, и всё это время механизм работает, никого не беспокоит и настройки свои не показывает.

А настройки там интересные.

Они были выбраны под очень узкий сценарий: приложение в кластере ходит к соседнему сервису по короткому имени вроде payment-service, и хочется, чтобы это просто работало без всяких доменов и суффиксов. Задачу решили, короткие имена действительно работают из коробки.

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

Он ходит в платёжный шлюз, в объектное хранилище, в чужой API за курсами валют, в почтовый сервис.

И вот на этих вызовах умолчания разворачиваются к вам неудобной стороной — сначала лишним трафиком, а потом и задержками.

Что лежит в resolv.conf

Начнём с того, что видно.

kubectl exec -it deploy/api -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

Этот файл пишет kubelet при создании пода, приложение к нему отношения не имеет.

  • nameserver указывает на ClusterIP сервиса kube‑dns, за которым стоят поды CoreDNS.

  • search перечисляет суффиксы, которые резолвер будет подставлять к неполным именам.

  • А ndots задаёт границу, по которой имя считается полным или неполным.

Логика такая:

  • Если точек в имени меньше, чем указано в ndots, резолвер считает имя коротким и сначала пробует дописать к нему суффиксы из списка по очереди.

  • Если точек столько же или больше, имя уходит в сеть как есть.

Пятёрка выбрана не случайно.

Полное имя сервиса в кластере выглядит как payment-service.billing.svc.cluster.local, в нём четыре точки, и чтобы такое имя тоже проходило через search‑список при обращении из другого неймспейса, порог подняли до пяти.

Решение аккуратное и для внутренних имён работает отлично: короткое payment-service находится с первой же попытки, потому что первый суффикс в списке как раз тот, что нужен.

Для всего остального оно работает ровно наоборот.

Во что обходится внешний вызов

Возьмём обычное обращение к внешнему API, скажем к api.stripe.com.

Две точки, до пяти далеко, значит имя неполное и его нужно достраивать. Резолвер честно идёт по списку:

api.stripe.com.default.svc.cluster.local   → NXDOMAIN
api.stripe.com.svc.cluster.local           → NXDOMAIN
api.stripe.com.cluster.local               → NXDOMAIN
api.stripe.com                             → наконец-то ответ

Четыре имени вместо одного. Три из них заведомо мусорные.

Теперь умножаем на два.

Начиная с версии 2.9 glibc спрашивает адреса A и AAAA параллельно, двумя отдельными запросами, и делает это независимо от того, включён у вас IPv6 или выключен.

Итого восемь UDP‑пакетов, чтобы узнать адрес одного внешнего хоста.

Представим, что он на каждый входящий запрос ходит в три внешних API, а HTTP‑клиент создаётся заново и соединения не переиспользует.

Это двадцать четыре DNS‑пакета на один запрос пользователя.

При тысяче запросов в секунду получается двадцать четыре тысячи пакетов, из которых восемнадцать тысяч гарантированно вернут NXDOMAIN.

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

Неприятности начинаются, когда к этому объёму добавляется вторая деталь.

Откуда берутся пять секунд

Когда glibc отправляет A и AAAA одновременно, оба пакета уходят из одного сокета, с одинаковым исходным портом, и различаются только содержимым.

По дороге к CoreDNS они проходят через DNAT, потому что обращение идёт к ClusterIP, а доставить надо в конкретный под. Каждый пакет регистрируется в таблице conntrack.

Вставка в эту таблицу устроена с гонкой.

Два пакета, пришедшие практически одновременно и претендующие на один и тот же набор адресов и портов, могут столкнуться при регистрации, и тогда второй получает NF_DROP.

Просто исчезает.

Резолвер про это ничего не знает. Он отправил запрос и ждёт ответа.

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

Здесь две части истории и складываются.

Гонка сама по себе — событие редкое, на десятки тысяч запросов. Но ndots:5 умножает количество попыток на восемь, и то, что должно было случаться раз в час, начинает происходить каждую минуту.

Есть ещё похожий по симптомам, но совершенно другой случай: CoreDNS просто не справляется.

Путать их дорого, потому что исправляются они разными вещами.

Что смотрим

Гонка в conntrack

Не хватает CoreDNS

Тайминги неудачных запросов

кучкуются на 5,00 и 10,00 с

разбросаны произвольно

Что видит приложение

i/o timeout

SERVFAIL

Логи CoreDNS

чисто

отказы, всплески времени ответа

conntrack -S на узле

растёт insert_failed

без изменений

Добавление реплик CoreDNS от гонки не спасает вообще никак, и это первое, что обычно пробуют.

Самое дешёвое: точка в конце

Прежде чем трогать кластер, имеет смысл посмотреть на то, что можно исправить в конфигурации приложения.

Имя с точкой на конце считается абсолютным, и search‑список к нему не применяется:

api.stripe.com.

Один запрос вместо четырёх.

Когда внешние адреса лежат в переменных окружения или в конфиге сервиса, правка занимает десять минут и не требует согласований с теми, кто отвечает за кластер.

Нюанс: не все библиотеки это переваривают.

Понизить ndots

Более общее решение — сказать резолверу, что пять точек это слишком высокий порог.

Делается это через dnsConfig в спецификации пода:

spec:
  dnsPolicy: ClusterFirst
  dnsConfig:
    options:
      - name: ndots
        value: "2"
      - name: single-request-reopen

С ndots:2 имя api.stripe.com уходит в сеть сразу, одним запросом: две точки, порог не меньше двух.

Короткое payment-service с нулём точек по‑прежнему идёт через search‑список и находится с первой попытки.

Внутренние обращения не страдают.

Страдает одна форма — обращение к сервису в чужом неймспейсе по имени вида payment.billing.

Там одна точка, до порога не хватает. Резолвер снова пойдёт по списку, и первый суффикс default.svc.cluster.local выдаст NXDOMAIN.

Работать это будет, просто с лишними запросами, как раньше.

Короче, понижать до двух безопасно, если внутри кластера сервисы ходят друг к другу либо по коротким именам, либо по полным FQDN.

Ставить единицу разумно только там, где все внутренние адреса записаны полностью, в форме payment-service.billing.svc.cluster.local.

Вторая опция в том же примере, single-request-reopen, разводит A и AAAA по разным сокетам и убирает гонку.

Но у неё есть ограничение, из‑за которого половина попыток заканчивается ничем:

это опция glibc. Musl её не поддерживает и игнорирует молча.

Значит, на Alpine‑образах строчка не делает ничего.

Конфиг применился, опция в файле лежит, поведение не изменилось, пятисекундные задержки остались на месте.

NodeLocal DNSCache

Этот способ работает независимо от того, какая в образе libc, и вообще независимо от приложения.

На каждый узел ставится DaemonSet с кэширующим агентом, а поды получают в nameserver не ClusterIP сервиса, а локальный адрес:

# /var/lib/kubelet/config.yaml
clusterDNS:
  - 169.254.20.10

Адрес link‑local, трафик до него не покидает узел и не проходит через DNAT.

Нет DNAT, нет вставки в conntrack, нет гонки. Пятисекундные ступеньки исчезают целиком.

Дальше идут приятные побочные эффекты.

Агент держит соединение с CoreDNS по TCP, а записи conntrack для TCP закрываются вместе с соединением, вместо того чтобы висеть тридцать секунд, как у UDP.

Таблица перестаёт заполняться на пустом месте.

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

Минус в том, что агент на узле станет единой точкой отказа для резолва: если он упал, DNS на этом узле не работает ни у кого.

Обновление демонсета даёт несколько секунд таймаутов на каждом узле по очереди.

И если у вас настроены сетевые политики, их придётся дополнить, потому что агенту нужен свой доступ к CoreDNS.

Autopath: починить на стороне сервера

А еще есть подход, при котором манифесты приложений не трогаются совсем.

Плагин autopath в CoreDNS определяет, из какого неймспейса пришёл запрос, сам проходит по search‑списку за клиента и возвращает готовый ответ на первое же обращение.

.:53 {
    errors
    health { lameduck 5s }
    ready

    autopath @kubernetes

    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods verified
        fallthrough in-addr.arpa ip6.arpa
    }
    forward . /etc/resolv.conf
    cache 30
    loop
    reload
}

Четыре запроса схлопываются в один.

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

Цена у плагина в том, что во‑первых, ему нужен режим pods verified, при котором CoreDNS держит в памяти список подов и следит за его изменениями.

Во‑вторых: autopath определяет неймспейс по исходному адресу запроса.

Если перед CoreDNS стоит нодовый кэш, который не сохраняет IP пода‑клиента, плагин увидит адрес агента вместо адреса приложения и начнёт достраивать имена не тем суффиксом.

Пару проблем

Первая касается подов с hostNetwork: true.

Без явно указанной политики такой под получает dnsPolicy: Default, и слово «Default» здесь означает не «как принято в кластере», а «взять resolv.conf узла».

Кластерных суффиксов там нет, CoreDNS в nameserver тоже нет.

Дальше агент мониторинга или ingress‑контроллер пытается сходить в сервис по имени и получает NXDOMAIN.

В логе появляется что‑то про недоступность, и разбирательство уходит в сторону сети.

Фиксится одной строкой:

spec:
  hostNetwork: true
  dnsPolicy: ClusterFirstWithHostNet

А найти все такие места в кластере можно так:

kubectl get pods -A -o json | jq -r '
  .items[]
  | select(.spec.hostNetwork == true)
  | select(.spec.dnsPolicy != "ClusterFirstWithHostNet")
  | "\(.metadata.namespace)/\(.metadata.name) → \(.spec.dnsPolicy)"'

Вторая проблема скорее про возможность.

Сервису кластерные имена могут быть не нужны в принципе — шлюз, выгрузчик, что‑то, что смотрит только наружу, search‑список можно выключить целиком, и тогда значение ndots перестаёт иметь значение:

spec:
  dnsPolicy: None
  dnsConfig:
    nameservers:
      - 10.96.0.10
    searches: []
    options:
      - name: ndots
        value: "1"

dnsPolicy: None отменяет всё, что kubelet подставил бы сам.

Nameserver приходится указывать руками.

Обычному сервису так делать не надо: соседей по кластеру он после этого не найдёт.

метрика coredns_dns_requests_total и время ответа покажут это точнее любых рекомендаций.

Под конец

DNS в кластере устроен так, что о нём невозможно вспомнить вовремя.

Он не отдаёт ошибок, не занимает заметных ресурсов, не попадает в дашборды.

Пока запросы резолвятся, весь этот слой существует для команды примерно как электропроводка в стене: она есть, она работает, заглядывать туда незачем.

Заглянуть всё‑таки приходится, и обычно в неудачный момент.

Причём выясняется, что настройки в порядке — они просто выбраны под сценарий, в котором приложение общается с соседями по кластеру и почти не ходит наружу.

Такие приложения были нормой, когда ndots:5 попал в умолчания, и почти исчезли с тех пор, как каждый второй сервис дёргает чужие API.

Отсюда и общая логика.

Дешевле всего разбираться не с кластером, а со своими клиентами: общий HTTP‑клиент с живым пулом соединений убирает изрядную часть DNS‑трафика вообще без согласований.

Следом идёт ndots на самых разговорчивых сервисах — правка в один манифест, которая откатывается мгновенно.

И только если после этого остались ровные пятисекундные ступеньки, стоит поднимать нодовый кэш: он единственный лечит гонку в conntrack независимо от того, что за libc внутри образа.

DNS‑таймауты, лишние запросы и пятисекундные задержки могут возникать даже в исправно работающем кластере. Чтобы быстрее находить причины таких проблем и не тратить время на случайные изменения конфигурации, важно понимать, как устроено сетевое взаимодействие и чем диагностировать сбои.

Разобраться в этом на практике помогут бесплатные открытые уроки OTUS:

  • 15 октября, 20:00. «Поднимаем кластер Kubernetes с помощью Terraform и Ansible». Записаться

  • 5 ноября, 20:00. «Стек сетевых протоколов и с чем его едят. На примере TCP/IP». Записаться

  • 17 ноября, 20:00. «Почему тормозит приложение: диагностика в Wireshark». Записаться

Ещё больше тем для развития технических навыков — в октябрьском дайджесте OTUS.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.