Kubernetes для самых маленьких. Часть 16: Как работает DNS в кластере?

Все части серии «Kubernetes для самых маленьких»

В прошлой части у сервиса hello появился постоянный адрес из сети сервисов, а клиенты в кластере обращались к нему по имени — hello, hello.demo, hello.demo.svc.cluster.local. Имя в адрес превращает DNS кластера — CoreDNS. Сегодня разберём, как он устроен: Deployment и Service kube-dns, Corefile и его плагины, какие записи CoreDNS отдаёт для сервисов, подов, headless-сервисов и StatefulSet, что kubelet пишет в /etc/resolv.conf каждого пода и почему из-за ndots:5 один запрос к внешнему имени превращается в несколько. Протокол DNS — запросы и ответы, типы записей, рекурсия и кэширование — разобран в части 12 сетевой серии; здесь только то, что в нём делает Kubernetes.

Неймспейс demo с Deployment hello из трёх подов собран заново по части 11, сервис hello — по прошлой части, поэтому ClusterIP у него другой: 10.102.84.146. У вас он тоже будет свой — берите его из kubectl -n demo get svc hello.


CoreDNS в кластере

CoreDNS — DNS-сервер на Go, проект CNCF, собранный из плагинов: каждый плагин отвечает за одну функцию (ответы из API Kubernetes, пересылка, кэш, журнал), а их цепочку для каждой зоны описывает конфигурационный файл Corefile. kubeadm ставит его при init как дополнение (addon):

$ kubectl -n kube-system get deployment,svc -l k8s-app=kube-dns
NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/coredns   2/2     2            2           4d10h

NAME               TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE
service/kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   4d10h
$ kubectl -n kube-system get pods -l k8s-app=kube-dns -o wide
NAME                       READY   STATUS    RESTARTS   AGE     IP              NODE           NOMINATED NODE   READINESS GATES
coredns-589f44dc88-bzxpn   1/1     Running   0          4d10h   172.20.70.194   k8s-master-1   <none>           <none>
coredns-589f44dc88-lv5bl   1/1     Running   0          4d10h   172.20.70.197   k8s-master-1   <none>           <none>
  • Deployment coredns — две реплики образа registry.k8s.io/coredns/coredns:v1.14.2 (версия, которую закрепляет kubeadm 1.36). У подов класс приоритета system-cluster-critical, запрос 100m CPU и 70Mi памяти, предел памяти 170Mi, dnsPolicy: Default (о нём ниже), tolerations на taint control plane и CriticalAddonsOnly, мягкая anti-affinity по узлам (preferredDuringScheduling… с весом 100). Контейнер работает с файловой системой только для чтения и без capabilities, кроме NET_BIND_SERVICE — права слушать порт 53. Liveness-проба ходит на /health порта 8080, readiness — на /ready порта 8181.
  • Service kube-dns с фиксированным адресом 10.96.0.10 — десятый адрес сети сервисов. kubeadm задаёт его явно, из нижней полосы диапазона, которую автоматическое выделение использует в последнюю очередь (часть 15). Имя kube-dns историческое: так назывался DNS-сервер кластера до CoreDNS, и сервис сохранил имя для совместимости. Порт 9153 — метрики.

Обе реплики на мастере: kubeadm создаёт Deployment при init, поды встают, как только появляется сеть подов, — а воркеров к этому моменту ещё нет (на стенде CoreDNS был готов за две минуты до присоединения k8s-node-1). Планировщик уже работающие поды не переносит (часть 13), а anti-affinity здесь мягкая, так что сама она ничего не исправит: если мастер недоступен, DNS кластера пропадает вместе с ним. После присоединения воркеров поды CoreDNS стоит пересоздать командой kubectl -n kube-system rollout restart deployment coredns: новый ReplicaSet создаст поды заново, и мягкая anti-affinity разведёт их по разным узлам. На стенде статьи мы этого не делали, поэтому дальше в выводе обе реплики живут на мастере.

Для такого критичного компонента разумно добавить и PodDisruptionBudget с maxUnavailable: 1, чтобы drain не выселил обе реплики сразу. kubeadm его не создаёт:

$ kubectl -n kube-system get pdb
No resources found in kube-system namespace.

CoreDNS не хранит записей кластера в файлах: плагин kubernetes держит watch на Service и EndpointSlice (а в режиме pods verified — и на поды) и строит ответы из этого кэша в памяти. Новый сервис отвечает по имени сразу после создания: на стенде первый же dig, выполненный следом за kubectl create service, получил адрес, а после удаления сервиса имя тут же перестало разрешаться.


Corefile

Конфигурация — ConfigMap coredns в kube-system. Так выглядит Corefile, который пишет kubeadm 1.36 (блок плагина kubernetes в выводе сокращён до «…», его разберём ниже):

$ kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}'
.:53 {
    errors
    health {
       lameduck 5s
    }
    ready
    …
    prometheus :9153
    forward . /etc/resolv.conf {
       max_concurrent 1000
    }
    cache 30 {
       disable success cluster.local
       disable denial cluster.local
    }
    loop
    reload
    loadbalance
}

.:53 — серверный блок: зона . (корень, то есть все имена) на порту 53. Внутри — плагины. Порядок их выполнения задан не порядком строк в файле, а при сборке CoreDNS (файл plugin.cfg в исходниках); в Corefile важно только, какие плагины включены и с какими параметрами.

Плагин Что делает
errors пишет ошибки обработки запросов в журнал пода
health HTTP :8080/health для liveness-пробы; lameduck 5s — при остановке ещё 5 секунд продолжать работу, и /health всё это время отвечает 200
ready HTTP :8181/ready для readiness-пробы: 200, когда все плагины готовы (в том числе kubernetes синхронизировался с API); в режиме lameduck перестаёт отвечать OK, и под уходит из EndpointSlice, пока ещё обслуживает запросы
kubernetes отвечает на имена из cluster.local и на обратные запросы по данным API
prometheus метрики в формате Prometheus — системы мониторинга, которая периодически забирает их по HTTP (часть 32) — на :9153
forward всё, что не обработали предыдущие, пересылает вышестоящим серверам из /etc/resolv.conf пода CoreDNS
cache кэширует ответы не дольше 30 секунд, кроме ответов о cluster.local
loop при старте обнаруживает петлю пересылки и останавливает CoreDNS
reload перечитывает Corefile при изменении ConfigMap (без перезапуска пода)
loadbalance перемешивает порядок записей в ответе, чтобы клиенты не ходили всегда на первый адрес

Подробнее о главных.

kubernetes — в заголовке его блока перечислены зоны, за которые плагин отвечает: домен кластера cluster.local и зоны обратных запросов, в том числе in-addr.arpa, где ищут имена по адресам подов и сервисов. Внутри блока три строки. pods insecure включает записи подов по адресу (о них ниже) без проверки, что под с таким адресом существует; это режим совместимости со старым kube-dns. Строже pods verified — отвечать, только если под с таким адресом есть в этом неймспейсе, — но он требует от CoreDNS следить за всеми подами кластера и заметно больше памяти; pods disabled (значение по умолчанию самого плагина) выключает такие записи совсем. fallthrough с теми же обратными зонами — если обратную запись не нашли в кластере, передать запрос дальше по цепочке, то есть в forward. ttl 30 — время жизни записей в ответах (у плагина по умолчанию 5 секунд).

forward . /etc/resolv.conf — пересылка всего остального (. — любые имена) на серверы из /etc/resolv.conf пода CoreDNS. У подов CoreDNS dnsPolicy: Default, поэтому это файл, который kubelet взял с узла. Сервер для каждого запроса выбирается случайно, а недоступный сервер forward после первой ошибки проверяет каждые полсекунды, пока тот не ответит, и запросы на него не шлёт. max_concurrent 1000 ограничивает число одновременных пересылаемых запросов: каждый занимает около 2 КБ памяти, а сверх предела CoreDNS сразу отвечает REFUSED, вместо того чтобы копить очередь. Вместо файла можно указать адреса прямо: forward . 192.168.10.2 192.168.10.3.

cache 30 с disable success cluster.local и disable denial cluster.local — кэш внешних имён не дольше 30 секунд (число ограничивает TTL сверху и не продлевает короткий), но не имён кластера: плагин kubernetes отвечает на них из памяти мгновенно, а без кэша новый сервис виден сразу и удалённый пропадает сразу. Кэш у каждого пода CoreDNS свой.

loop — защита от самой частой поломки CoreDNS на Ubuntu. Если forward отправит запрос на адрес, который ведёт обратно в этот же CoreDNS, запрос будет бегать по кругу. При старте и после каждой перезагрузки конфигурации loop посылает сам себе запрос типа HINFO (старый тип записи с описанием хоста, который никто не использует) со случайным именем и, если увидит его больше двух раз, завершает процесс с ошибкой Loop (…) detected. Как такая петля получается — в разделе о вышестоящих серверах.

Изменения Corefile применяются правкой ConfigMap (kubectl -n kube-system edit configmap coredns). kubelet обновляет смонтированный ConfigMap с задержкой до минуты, а reload сверяет SHA512 файла раз в 30 секунд с разбросом ±15 секунд, и каждый под делает это сам по себе. Документация советует ждать до двух минут; на стенде один под перечитал конфигурацию через 14 секунд после правки, второй — через 81, и всё это время поды отвечали по разным конфигурациям. Если новый Corefile с ошибкой, reload оставит старый и напишет ошибку в журнал (это мы увидим в «Смотрим руками»). Перед правкой Corefile сохраните его копию. kubeadm upgrade сохраняет существующую конфигурацию CoreDNS при обновлении, но после обновления кластера (часть 35) правки стоит проверить.


Какие записи есть

Имя Тип Ответ
hello.demo.svc.cluster.local A ClusterIP сервиса: 10.102.84.146
hello-headless.demo.svc.cluster.local A адреса всех готовых подов headless-сервиса
_http._tcp.hello.demo.svc.cluster.local SRV порт 80 и имя сервиса
_http._tcp.hello-headless.demo.svc.cluster.local SRV по записи на под: порт 8080 и имя пода
web-0.web.demo.svc.cluster.local A адрес пода web-0 StatefulSet'а web
172-20-172-24.hello.demo.svc.cluster.local A 172.20.172.24 (под за сервисом, имя из адреса)
172-20-172-24.demo.pod.cluster.local A 172.20.172.24 (запись пода по адресу)
registry.demo.svc.cluster.local CNAME ghcr.io (сервис ExternalName)
146.84.102.10.in-addr.arpa PTR hello.demo.svc.cluster.local
24.172.20.172.in-addr.arpa PTR по имени на каждый сервис, выбирающий под

Сервисы. Имя <сервис>.<неймспейс>.svc.<домен кластера>. Для обычного сервиса — A-запись с ClusterIP. Это единственный адрес, сколько бы подов ни было: балансирует kube-proxy, а не DNS.

SRV для именованных портов. SRV — тип записи, который сообщает не только адрес, но и порт службы: _<имя порта>._<протокол>.<сервис>.<неймспейс>.svc.cluster.local отдаёт приоритет, вес, порт и имя цели. Записи создаются только для портов с именем — ещё одна причина давать портам имена. Клиентов, которые сами ищут SRV, немного (например, некоторые драйверы баз данных и брокеры сообщений), но так можно узнать порт сервиса, не зная его заранее.

Headless-сервисы. У сервиса с clusterIP: None нет своего адреса, и A-запись на его имя возвращает адреса всех готовых подов из EndpointSlice. Клиент получает список и выбирает сам; loadbalance каждый раз перемешивает порядок. SRV-записи headless-сервиса указывают на каждый под отдельно, с его собственным портом и равными весами.

StatefulSet. Если у пода заданы spec.hostname и spec.subdomain, а в неймспейсе есть headless-сервис с именем, равным subdomain, CoreDNS создаёт имя <hostname>.<subdomain>.<неймспейс>.svc.cluster.local. StatefulSet заполняет эти поля сам (hostname = имя пода, subdomain = serviceName), отсюда стабильные имена web-0.web.demo.svc.cluster.local (часть 8). Обычный под может сделать то же самое явно. Запись появляется, только когда под готов (если у сервиса не стоит publishNotReadyAddresses: true).

Имена подов за сервисом. Под без hostname получает имя из адреса через дефисы под каждым сервисом, который его выбирает: 172-20-172-24.hello-headless.demo.svc.cluster.local. CoreDNS отвечает на такие имена и для обычных сервисов с ClusterIP (172-20-172-24.hello.demo.svc.cluster.local), хотя документация описывает их как особенность CoreDNS, а не обязательную часть спецификации.

Записи подов по адресу. <адрес через дефисы>.<неймспейс>.pod.cluster.local. С pods insecure CoreDNS ответит на любое такое имя адресом из него же, не проверяя, есть ли под: 10-1-2-3.demo.pod.cluster.local разрешится в 10.1.2.3. Полезны они в основном для сертификатов TLS — протокола шифрования соединений (часть 23 сетевой серии), — где нужно имя, а у пода есть только адрес.

ExternalName. Сервис типа ExternalName не имеет ни адреса, ни подов: CoreDNS отвечает на его имя записью CNAME — псевдонимом, который говорит «это имя — другое имя», и резолвер продолжает поиск уже по нему (часть 15).

Обратные записи (PTR). PTR — запись «адрес → имя», которую ищут по адресу, записанному задом наперёд в зоне in-addr.arpa. Для ClusterIP сервиса CoreDNS отвечает именем сервиса, а для адреса пода — именами из предыдущего пункта, по одной записи на каждый сервис, который выбирает под: у пода hello их две, от hello и от hello-headless. Для адресов, которых нет ни в одном сервисе, — fallthrough передаёт запрос вышестоящему серверу, и тот обычно отвечает NXDOMAIN.

Чего нет. Нет записей по имени пода (hello-6d5c6db6b4-gn5n4) — только по адресу или по hostname; нет записей для Deployment; нет имён узлов: k8s-node-1 CoreDNS разрешит только через forward, если его знает вышестоящий DNS.


/etc/resolv.conf пода

Какой DNS-сервер и какие суффиксы видит под, решает kubelet при создании пода. Он берёт параметры из своей конфигурации:

user@k8s-node-1:~$ sudo grep -A1 -E 'clusterDNS|clusterDomain|resolvConf' /var/lib/kubelet/config.yaml
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
containerRuntimeEndpoint: unix:///var/run/containerd/containerd.sock
--
resolvConf: /run/systemd/resolve/resolv.conf
rotateCertificates: true

и пишет файл, который монтируется в каждый контейнер пода:

$ kubectl -n demo exec net -- cat /etc/resolv.conf
search demo.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
  • nameserver 10.96.0.10 — ClusterIP сервиса kube-dns. Запросы проходят через DNAT kube-proxy и попадают в один из подов CoreDNS.
  • search — суффиксы, которые резолвер дописывает к неполным именам: сначала неймспейс пода (demo.svc.cluster.local), потом все сервисы (svc.cluster.local), потом домен кластера. После них kubelet добавляет суффиксы узла из файла resolvConf; на стенде у узлов своего домена нет (search .), поэтому список кончается на cluster.local. Из demo работает просто hello, а из другого неймспейса — hello.demo.
  • options ndots:5 — если в имени меньше пяти точек, резолвер сначала пробует его со всеми суффиксами из search и только потом как есть.

Почему пять: имя web-0.web.demo.svc (три точки) должно разрешаться через суффикс cluster.local, а самые длинные внутрикластерные имена вида _http._tcp.hello.demo.svc содержат четыре точки. Порог 5 гарантирует, что любое сокращённое имя кластера сначала попробуют с суффиксами.


Лишние запросы к внешним именам

Обратная сторона: имя api.github.com — две точки, меньше пяти. Резолвер glibc (стандартная библиотека C в образах на Debian и Ubuntu и в образе busybox) отправит по очереди:

api.github.com.demo.svc.cluster.local.   → NXDOMAIN (CoreDNS, из памяти)
api.github.com.svc.cluster.local.        → NXDOMAIN (CoreDNS, из памяти)
api.github.com.cluster.local.            → NXDOMAIN (CoreDNS, из памяти)
api.github.com.                          → ответ (CoreDNS → forward → DNS-сервер сети)

Четыре имени — минимум четыре запроса. Запросы по трём первым CoreDNS отклоняет за десятые доли миллисекунды, но они всё равно проходят через DNAT, conntrack и сеть до пода CoreDNS; запрос по последнему уходит наружу и ждёт ответа вышестоящего сервера. Если у узла есть свой домен поиска (например, search example.com от DHCP), он встанет в список пятым суффиксом, и перед настоящим ответом появится ещё одно имя, api.github.com.example.com., запрос по которому тоже уйдёт наружу. Для приложения, которое делает сотни исходящих запросов в секунду к внешним API, это заметная задержка и нагрузка на CoreDNS. Резолвер musl (библиотека C в образах Alpine) в деталях устроен иначе — например, опрашивает все серверы из nameserver параллельно, — но суффиксы из search перебирает так же: на стенде он перебрал те же четыре имени в том же порядке.

Способы это лечить:

  • Полное имя с точкой в конце. api.github.com. — абсолютное имя, search не применяется: одно имя вместо четырёх. Самый надёжный способ для имён внешних служб в конфигурации приложения; большинство HTTP-клиентов и библиотек точку в конце понимают. Минус — точка может попасть в заголовок Host и в SNI (имя сервера, которое клиент TLS называет в начале соединения), и некоторые серверы на неё реагируют плохо, поэтому проверять надо на конкретном клиенте.

  • dnsConfig в поде — уменьшить ndots:

    spec:
      dnsConfig:
        options:
          - name: ndots
            value: "2"
    

    kubelet заменит ndots:5 на ndots:2, остальное оставит как было. Теперь api.github.com (две точки) сразу идёт как есть, а hello и hello.demo (меньше двух точек) — по-прежнему с суффиксами. Цена: имя вроде web-0.web.demo (две точки) больше не дополнится суффиксами первым делом, его лучше писать полностью. Для подов, которые в основном ходят наружу, — хороший обмен.

  • Кэш на узле — NodeLocal DNSCache (ниже) не уменьшает число запросов, но отвечает на них из кэша на том же узле, без DNAT и сети.

Существует и плагин CoreDNS autopath, который по адресу клиента находит его под и неймспейс и на первый же запрос из цепочки отвечает итоговым ответом с CNAME на найденное имя. Он требует pods verified, то есть watch на все поды кластера, и в стандартную конфигурацию не входит.


dnsPolicy и dnsConfig

Как формировать /etc/resolv.conf, задаёт поле spec.dnsPolicy пода:

dnsPolicy /etc/resolv.conf пода
ClusterFirst (по умолчанию) CoreDNS, search кластера и узла, ndots:5 — как выше
Default DNS-серверы и суффиксы из файла resolvConf узла, имена кластера не разрешаются
ClusterFirstWithHostNet как ClusterFirst, но для подов с hostNetwork: true
None ничего автоматически; всё задаётся в dnsConfig

Название Default обманчиво: это не значение по умолчанию, а «как на узле». Его используют поды самого CoreDNS — иначе он отправлял бы запросы сам себе.

ClusterFirstWithHostNet нужен потому, что под в сети узла (hostNetwork: true) с политикой ClusterFirst молча получает настройки Default, то есть DNS узла, а не кластера. Если такому поду (агенту мониторинга, ingress-контроллеру в сети узла) нужны имена сервисов, ему ставят ClusterFirstWithHostNet. На Windows-узлах эта политика не поддерживается.

dnsConfig дополняет то, что сгенерировала политика, или, при None, задаёт всё:

spec:
  dnsPolicy: None
  dnsConfig:
    nameservers:
      - 10.96.0.10
    searches:
      - demo.svc.cluster.local
      - svc.cluster.local
    options:
      - name: ndots
        value: "2"
      - name: timeout
        value: "2"

Ограничения проверяет API-сервер: не больше трёх nameservers, не больше 32 суффиксов в searches и не больше 2048 символов в них вместе; при None нужен хотя бы один сервер. Три сервера — предел самих резолверов glibc и musl, которые читают только первые три строки nameserver.


Вышестоящие серверы и stub-зоны

Всё, что не относится к cluster.local, CoreDNS пересылает по forward . /etc/resolv.conf. А /etc/resolv.conf пода CoreDNS kubelet собирает из файла, указанного в параметре resolvConf. Здесь на Ubuntu и случается петля.

На Ubuntu Server имена разрешает systemd-resolved — локальная служба-кэш, которая слушает на 127.0.0.53 и пересылает запросы настоящим серверам. Файл /etc/resolv.conf узла — ссылка на её «заглушку»:

user@k8s-node-1:~$ ls -l /etc/resolv.conf
lrwxrwxrwx 1 root root 39 Aug 26 07:46 /etc/resolv.conf -> ../run/systemd/resolve/stub-resolv.conf
user@k8s-node-1:~$ grep -v '^#' /etc/resolv.conf

nameserver 127.0.0.53
options edns0 trust-ad
search .

Если бы kubelet передал этот файл поду CoreDNS, CoreDNS пересылал бы запросы на 127.0.0.53 — но в сетевом пространстве имён пода это его собственный loopback, где никакого systemd-resolved нет (а в похожих конфигурациях с 127.0.0.1 слушает сам CoreDNS). Поэтому kubeadm при init и join проверяет, работает ли systemd-resolved, и если да — пишет в /var/lib/kubelet/config.yaml узла resolvConf: /run/systemd/resolve/resolv.conf (в общий ConfigMap kubelet-config эта строка не попадает). Это файл с настоящими вышестоящими серверами, который systemd-resolved ведёт как раз для таких программ:

user@k8s-node-1:~$ grep -v '^#' /run/systemd/resolve/resolv.conf

nameserver 192.168.10.2
nameserver 192.168.10.3
…
search .

Строк nameserver в этом файле может быть больше трёх. Тогда kubelet оставляет поду первые три, а о лишних предупреждает событием DNSConfigForming (Nameserver limits were exceeded, some nameservers have been omitted) на каждом поде с dnsPolicy: Default или в сети узла, включая CoreDNS. Если на узлах больше трёх серверов или среди них есть недостижимые из подов, надёжнее указать вышестоящие серверы в Corefile явно: forward . 192.168.10.2 192.168.10.3.

Если вы видите поды CoreDNS в CrashLoopBackOff с ошибкой plugin/loop: Loop (127.0.0.1:<порт> -> :53) detected for zone "." в журнале — проверьте именно resolvConf kubelet: петля почти всегда из-за loopback-адреса в файле, который kubelet отдал поду CoreDNS.

Stub-зоны. Если в сети есть отдельный DNS-сервер для внутренней зоны (например, corp.example.com обслуживает сервер 192.168.10.53), а обычные серверы о ней не знают, в Corefile добавляют отдельный серверный блок:

corp.example.com:53 {
    errors
    cache 30
    forward . 192.168.10.53
}

CoreDNS выбирает блок по самой длинной совпадающей зоне: запросы к *.corp.example.com пойдут на 192.168.10.53, всё остальное — в блок .:53. У блока свой набор плагинов: без prometheus в нём его запросы не попадут в метрики coredns_dns_requests_total, без log — в журнал. Статические записи без отдельного сервера — например, имя своего реестра из раздела «Свой реестр» части 5 — можно добавить плагином hosts:

    hosts {
       192.168.10.32 registry.example.com
       fallthrough
    }

в блоке .:53. Без явного имени файла hosts читает ещё и /etc/hosts самого пода CoreDNS, а fallthrough передаёт дальше по цепочке всё, чего в этих записях нет. Но лучше, чтобы такие имена были в настоящем DNS, а не в конфигурации кластера: иначе узлы и поды разрешают одно и то же имя по-разному.


NodeLocal DNSCache

На больших кластерах DNS упирается не в CoreDNS, а в путь до него. Каждый запрос — UDP-пакет через DNAT kube-proxy, и для каждого создаётся запись conntrack, которая живёт 30 секунд (nf_conntrack_udp_timeout, на узлах стенда — 30). Хуже того, glibc может отправить несколько запросов с одного сокета почти одновременно, и при DNAT таких пакетов возможна гонка при вставке записей conntrack: один из пакетов теряется, и резолвер ждёт таймаута — по умолчанию 5 секунд. Такие «случайные пятисекундные задержки DNS» — известная проблема кластеров с большим числом запросов.

NodeLocal DNSCache решает это кэширующим агентом на каждом узле:

  • DaemonSet node-local-dns в kube-system — сборка CoreDNS в режиме кэша (образ k8s-dns-node-cache), в сети узла;
  • агент поднимает на узле фиктивный интерфейс с link-local адресом (в документации пример — 169.254.20.10) и слушает на нём;
  • для пакетов к этому адресу и от него он добавляет правила NOTRACK, чтобы conntrack их не отслеживал, — и гонки при DNAT больше нет;
  • промахи по cluster.local и по обратным зонам агент пересылает в CoreDNS по TCP (force_tcp в его Corefile), где потерянный пакет TCP переотправит сам за доли секунды, а не резолвер после пятисекундного таймаута; внешние имена агент отправляет прямо вышестоящим серверам узла.

Ставится манифестом nodelocaldns.yaml из репозитория Kubernetes, в котором sed подставляет адрес кэша, домен кластера и адрес kube-dns. Документация описывает два варианта: для kube-proxy в режиме iptables агент слушает и на своём адресе, и на 10.96.0.10, перехватывая запросы без изменения kubelet; для IPVS — только на своём адресе, а kubelet'у меняют clusterDNS на этот адрес. Для режима nftables, как у нас, документация отдельного варианта не приводит; надёжнее второй: агент на своём адресе и явная правка clusterDNS в конфигурации kubelet на всех узлах, после которой новые поды получают nameserver с адресом кэша. Ещё одно предупреждение документации: если под агента убьёт OOM, его правила останутся на узле, и до перезапуска DNS на этом узле не будет работать, поэтому предел памяти ему выбирают по измеренному потреблению.

На трёх узлах стенда кэш не нужен, и мы его не ставили. Он оправдан, когда в кластере сотни узлов или приложения с интенсивными запросами к внешним именам и в журналах видны таймауты DNS.


Смотрим руками

Под с инструментами — nicolaka/netshoot, в нём есть dig (BIND 9.20):

$ kubectl -n demo run net --image=nicolaka/netshoot:v0.16 -- sleep 3600
pod/net created
$ kubectl -n demo exec net -- dig +noall +answer +stats hello.demo.svc.cluster.local
hello.demo.svc.cluster.local. 30 IN     A       10.102.84.146
;; Query time: 0 msec
;; SERVER: 10.96.0.10#53(10.96.0.10) (UDP)
;; WHEN: Wed Oct 28 21:02:09 UTC 2026
;; MSG SIZE  rcvd: 113

TTL 30 — из ttl 30 в Corefile. dig сам не применяет search из /etc/resolv.conf (в отличие от резолвера приложений): dig hello спросит имя hello. у внешних серверов и получит SERVFAIL. Короткое имя спрашиваем с +search:

$ kubectl -n demo exec net -- dig +search +noall +answer hello
hello.demo.svc.cluster.local. 30 IN     A       10.102.84.146

SRV по именованному порту:

$ kubectl -n demo exec net -- dig +noall +answer SRV _http._tcp.hello.demo.svc.cluster.local
_http._tcp.hello.demo.svc.cluster.local. 30 IN SRV 0 100 80 hello.demo.svc.cluster.local.

Приоритет 0, вес 100, порт 80 и имя цели. Теперь headless-сервис над теми же подами:

apiVersion: v1
kind: Service
metadata:
  name: hello-headless
  namespace: demo
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: hello
  ports:
    - name: http
      port: 8080
      targetPort: http
$ kubectl apply -f hello-headless.yaml
service/hello-headless created
$ kubectl -n demo exec net -- dig +noall +answer hello-headless.demo.svc.cluster.local
hello-headless.demo.svc.cluster.local. 30 IN A  172.20.211.50
hello-headless.demo.svc.cluster.local. 30 IN A  172.20.172.24
hello-headless.demo.svc.cluster.local. 30 IN A  172.20.172.22
$ kubectl -n demo exec net -- dig +noall +answer hello-headless.demo.svc.cluster.local
hello-headless.demo.svc.cluster.local. 30 IN A  172.20.172.22
hello-headless.demo.svc.cluster.local. 30 IN A  172.20.211.50
hello-headless.demo.svc.cluster.local. 30 IN A  172.20.172.24
$ kubectl -n demo exec net -- dig +noall +answer SRV _http._tcp.hello-headless.demo.svc.cluster.local
_http._tcp.hello-headless.demo.svc.cluster.local. 30 IN SRV 0 33 8080 172-20-172-24.hello-headless.demo.svc.cluster.local.
_http._tcp.hello-headless.demo.svc.cluster.local. 30 IN SRV 0 33 8080 172-20-211-50.hello-headless.demo.svc.cluster.local.
_http._tcp.hello-headless.demo.svc.cluster.local. 30 IN SRV 0 33 8080 172-20-172-22.hello-headless.demo.svc.cluster.local.

Три адреса подов, и порядок от запроса к запросу разный — это loadbalance. В SRV у подов без hostname имя цели составлено из адреса через дефисы, вес делится поровну. Обратные записи ClusterIP и адреса пода:

$ kubectl -n demo exec net -- dig +noall +answer -x 10.102.84.146
146.84.102.10.in-addr.arpa. 30  IN      PTR     hello.demo.svc.cluster.local.
$ kubectl -n demo exec net -- dig +noall +answer -x 172.20.172.24
24.172.20.172.in-addr.arpa. 30  IN      PTR     172-20-172-24.hello.demo.svc.cluster.local.
24.172.20.172.in-addr.arpa. 30  IN      PTR     172-20-172-24.hello-headless.demo.svc.cluster.local.

У пода два имени — по одному от каждого сервиса, который его выбирает. На адрес самого net, который ни в один сервис не входит, dig -x 172.20.172.21 получает NXDOMAIN с флагом ra и без aa — запрос ушёл через fallthrough к серверам сети. Записи подов по адресу:

$ kubectl -n demo exec net -- dig +noall +answer 172-20-172-24.demo.pod.cluster.local
172-20-172-24.demo.pod.cluster.local. 30 IN A   172.20.172.24
$ kubectl -n demo exec net -- dig +noall +answer 172-20-172-24.hello-headless.demo.svc.cluster.local
172-20-172-24.hello-headless.demo.svc.cluster.local. 30 IN A 172.20.172.24
$ kubectl -n demo exec net -- dig +noall +answer 10-1-2-3.demo.pod.cluster.local
10-1-2-3.demo.pod.cluster.local. 30 IN  A       10.1.2.3

Последний ответ — то самое pods insecure: пода с адресом 10.1.2.3 нет, а запись есть. Теперь StatefulSet с headless-сервисом:

apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: demo
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: web
  ports:
    - name: http
      port: 8080
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
  namespace: demo
spec:
  serviceName: web
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: web
  template:
    metadata:
      labels:
        app.kubernetes.io/name: web
    spec:
      containers:
      - name: web
        image: ghcr.io/example/hello:1.0
        ports:
        - name: http
          containerPort: 8080
$ kubectl apply -f web.yaml
service/web created
statefulset.apps/web created
$ kubectl -n demo get pod web-0 -o jsonpath='{.spec.hostname} {.spec.subdomain}{"\n"}'
web-0 web
$ kubectl -n demo exec net -- dig +noall +answer web-0.web.demo.svc.cluster.local
web-0.web.demo.svc.cluster.local. 30 IN A       172.20.211.63
$ kubectl -n demo exec net -- dig +noall +answer SRV _http._tcp.web.demo.svc.cluster.local
_http._tcp.web.demo.svc.cluster.local. 30 IN SRV 0 50 8080 web-0.web.demo.svc.cluster.local.
_http._tcp.web.demo.svc.cluster.local. 30 IN SRV 0 50 8080 web-1.web.demo.svc.cluster.local.

Поля hostname и subdomain StatefulSet проставил сам, и SRV теперь указывает на имена подов, а не на адреса. И сервис ExternalName:

$ kubectl -n demo create service externalname registry --external-name=ghcr.io
service/registry created
$ kubectl -n demo exec net -- dig +noall +answer registry.demo.svc.cluster.local
registry.demo.svc.cluster.local. 30 IN  CNAME   ghcr.io.
ghcr.io.                30      IN      A       140.82.121.34

CoreDNS отдал CNAME и сам же разрешил ghcr.io через forward, положив обе записи в один ответ.

nslookup из busybox, которым часто проверяют DNS в кластере, устроен по-своему: он не пользуется резолвером libc, а сам отправляет запросы со всеми суффиксами сразу и печатает каждый отказ. Под bb с busybox:1.37 и политикой по умолчанию; ключ -type=a оставляет только запросы A-записей:

$ kubectl -n demo run bb --image=busybox:1.37 -- sleep 3600
$ kubectl -n demo exec bb -- nslookup -type=a hello
Server:		10.96.0.10
Address:	10.96.0.10:53

** server can't find hello.cluster.local: NXDOMAIN

Name:	hello.demo.svc.cluster.local
Address: 10.102.84.146

** server can't find hello.svc.cluster.local: NXDOMAIN

command terminated with exit code 1

Адрес найден, а код возврата 1 — из-за отказов по остальным суффиксам. В скриптах проверки busybox nslookup лучше давать полное имя; nslookup из netshoot (BIND) короткие имена разрешает как обычный резолвер, но печатает предупреждение ;; Got recursion not available from 10.96.0.10: ответы о cluster.local CoreDNS даёт сам, без флага рекурсии ra.

Политики DNS. Четыре пода busybox с разными настройками — dnsPolicy: Default, hostNetwork: true без политики, hostNetwork: true с ClusterFirstWithHostNet и None с dnsConfig из примера выше:

apiVersion: v1
kind: Pod
metadata:
  name: dns-hostnet-cf
  namespace: demo
spec:
  hostNetwork: true
  dnsPolicy: ClusterFirstWithHostNet
  containers:
  - name: sh
    image: busybox:1.37
    command: ["sleep", "3600"]
$ for p in dns-default dns-hostnet dns-hostnet-cf dns-none; do echo "== $p"; kubectl -n demo exec $p -- cat /etc/resolv.conf; done
== dns-default
nameserver 192.168.10.2
nameserver 192.168.10.3
…
== dns-hostnet
nameserver 192.168.10.2
nameserver 192.168.10.3
…
== dns-hostnet-cf
search demo.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
== dns-none
search demo.svc.cluster.local svc.cluster.local
nameserver 10.96.0.10
options ndots:2 timeout:2
$ kubectl -n demo get pod dns-hostnet -o jsonpath='{.spec.dnsPolicy}{"\n"}'
ClusterFirst

У dns-hostnet в спецификации стоит ClusterFirst, а файл — как у Default: под в сети узла молча получает DNS узла. Строки search . с узла kubelet в под не переносит. Пятый под с одним dnsConfig.options ndots: "2" и политикой по умолчанию получил кластерный файл с options ndots:2. Ограничения dnsConfig API-сервер проверяет сразу (здесь — через kubectl apply --dry-run=server):

The Pod "dns-bad" is invalid: spec.dnsConfig.nameservers: Invalid value: ["10.96.0.10","192.0.2.1","192.0.2.2","192.0.2.3"]: must not have more than 3 nameservers
The Pod "dns-bad" is invalid: spec.dnsConfig.nameservers: Required value: must provide at least one DNS nameserver when `dnsPolicy` is None
The Pod "dns-bad" is invalid: spec.dnsConfig: Required value: must provide `dnsConfig` when `dnsPolicy` is None

а на 33 суффикса — must not have more than 32 search paths со всем списком в тексте ошибки.

Лишние запросы к внешнему имени. Сохраним Corefile, включим журнал запросов — строка log после errors в блоке .:53 — и дождёмся, пока reload применит конфигурацию. Вместо интерактивного kubectl edit правку удобно сделать патчем из копии:

$ kubectl -n kube-system get configmap coredns -o json | jq '{data:{Corefile:.data.Corefile}}' > coredns-orig.json
$ jq '.data.Corefile |= sub("    errors\n"; "    errors\n    log\n")' coredns-orig.json > coredns-log.json
$ kubectl -n kube-system patch configmap coredns --type merge --patch-file coredns-log.json
configmap/coredns patched
$ kubectl -n kube-system logs -l k8s-app=kube-dns --since=3m --prefix
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] Reloading
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] plugin/kubernetes: waiting for Kubernetes API before starting server
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] plugin/reload: Running configuration SHA512 = 2dd49c56b94cd5672aafc99f92d4b5ff824b6ae87878b18330ea3f5199d47baffba03feab068065d91ea11f9ff9de9eb155741708e79bfb0228360463ef6dbde
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] Reloading complete
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] 127.0.0.1:52498 - 19161 "HINFO IN 3633825480454799534.3516841135501091470. udp 57 false 512" NXDOMAIN qr,rd,ra 132 0.029861796s

Последняя строка — проба плагина loop после перезагрузки: запрос HINFO со случайным именем ушёл к серверам сети и вернулся NXDOMAIN, петли нет. Второй под перечитал конфигурацию на минуту позже — журнал смотрим по обоим. Теперь разрешим внешнее имя через резолвер glibc — ping в busybox; ключ -4 оставляет только запросы A-записей:

$ kubectl -n demo exec bb -- ping -4 -c1 -W2 api.github.com
PING api.github.com (140.82.121.5): 56 data bytes
64 bytes from 140.82.121.5: seq=0 ttl=54 time=27.561 ms

--- api.github.com ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 27.561/27.561/27.561 ms
$ kubectl -n kube-system logs -l k8s-app=kube-dns --since=30s --prefix | grep api.github.com
[pod/coredns-589f44dc88-lv5bl/coredns] [INFO] 172.20.211.8:52478 - 29223 "A IN api.github.com.demo.svc.cluster.local. udp 55 false 512" NXDOMAIN qr,aa,rd 148 0.000131424s
[pod/coredns-589f44dc88-lv5bl/coredns] [INFO] 172.20.211.8:33691 - 45563 "A IN api.github.com.svc.cluster.local. udp 50 false 512" NXDOMAIN qr,aa,rd 143 0.000112023s
[pod/coredns-589f44dc88-lv5bl/coredns] [INFO] 172.20.211.8:49009 - 24720 "A IN api.github.com.cluster.local. udp 46 false 512" NXDOMAIN qr,aa,rd 139 0.000057053s
[pod/coredns-589f44dc88-lv5bl/coredns] [INFO] 172.20.211.8:51928 - 16629 "A IN api.github.com. udp 32 false 512" NOERROR qr,rd,ra 62 0.039123878s

Четыре запроса на одно имя. Формат строки: адрес и порт клиента (под bb), идентификатор запроса, тип, имя, протокол, размер запроса, флаг DO (готовность принять подписи DNSSEC — расширения DNS, где записи подписаны криптографически), размер буфера UDP, который клиент объявил через EDNS (расширение DNS для ответов больше 512 байт); затем код ответа, флаги (aa — авторитетный ответ из зоны кластера, ra — рекурсия доступна, то есть ответ пришёл через forward), размер ответа и время. Первые три запроса CoreDNS отклонил из памяти за 0,06–0,13 мс, последний ушёл к серверам сети и стоил 39 мс. Тот же опыт из netshoot (getent ahostsv4 api.github.com, резолвер musl) дал те же четыре имени в том же порядке. С точкой на конце остаётся один запрос:

$ kubectl -n demo exec bb -- ping -4 -c1 -W2 api.github.com. | head -1
PING api.github.com. (140.82.121.6): 56 data bytes
$ kubectl -n kube-system logs -l k8s-app=kube-dns --since=10s --prefix | grep api.github.com
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] 172.20.211.8:42586 - 45704 "A IN api.github.com. udp 32 false 512" NOERROR qr,aa,rd,ra 62 0.000387958s

Этот запрос попал во второй под CoreDNS, у которого имя уже было в кэше после опыта с netshoot, — ответ за 0,4 мс вместо 39. Ответ из кэша CoreDNS помечает ещё и флагом aa, поэтому от ответа из зоны кластера (qr,aa,rd) его отличает флаг ra, а от ответа, пересланного наружу (qr,rd,ra), — флаг aa и время. TTL в кэше убывает, а кэш у каждого пода CoreDNS свой:

$ kubectl -n demo exec net -- dig +noall +answer api.github.com
api.github.com.         30      IN      A       140.82.121.5
$ kubectl -n demo exec net -- dig +noall +answer api.github.com
api.github.com.         25      IN      A       140.82.121.5
$ kubectl -n demo exec net -- dig +noall +answer +stats api.github.com | grep -E 'IN|Query'
api.github.com.         30      IN      A       140.82.121.5
;; Query time: 3 msec

Второй запрос через пять секунд — из кэша того же пода (TTL 25), третий попал в другой под и пошёл наружу заново. TTL не больше 30 даже у первого ответа: cache 30 срезает TTL внешней записи, если он больше.

Stub-зона и hosts. Добавим в Corefile блок для corp.example.com, который пересылает запросы только первому серверу сети (зону знают оба, так что смысл опыта — проверить механику выбора блока), и hosts в блок .:53:

    hosts {
       192.168.10.32 registry.example.com
       fallthrough
    }
    loop
    reload
    loadbalance
}
corp.example.com:53 {
    errors
    log
    cache 30
    forward . 192.168.10.2
}
$ kubectl -n demo exec net -- dig +noall +answer registry.example.com
registry.example.com.   30      IN      A       192.168.10.32
$ kubectl -n demo exec net -- dig +noall +answer www.corp.example.com
www.corp.example.com.   30      IN      CNAME   web.corp.example.com.
web.corp.example.com.   30      IN      A       192.168.10.80
$ kubectl -n kube-system logs -l k8s-app=kube-dns --since=30s --prefix | grep registry
[pod/coredns-589f44dc88-bzxpn/coredns] [INFO] 172.20.172.21:43345 - 64115 "A IN registry.example.com. udp 61 false 1232" NOERROR qr,aa,rd 74 0.000309068s
$ kubectl -n demo exec net -- curl -s http://172.20.70.194:9153/metrics | grep 'zones="corp'
coredns_cache_misses_total{server="dns://:53",view="",zones="corp.example.com."} 1
coredns_cache_requests_total{server="dns://:53",view="",zones="corp.example.com."} 1

Запись из hosts — авторитетная (aa), TTL обрезал до 30 кэш блока .:53 (у hosts по умолчанию час). Запрос к corp.example.com обработал отдельный блок: это видно по метрикам его кэша с zones="corp.example.com.", а в coredns_dns_requests_total его нет — prometheus в блоке мы не включали.

А если ошибиться? Допишем в блок .:53 несуществующий плагин nosuchplugin:

$ kubectl -n kube-system logs coredns-589f44dc88-bzxpn --since=2m
[INFO] Reloading
[ERROR] Restart failed: /etc/coredns/Corefile:28 - Error during parsing: Unknown directive 'nosuchplugin'
[ERROR] plugin/reload: Corefile changed but reload failed: starting with listener file descriptors: /etc/coredns/Corefile:28 - Error during parsing: Unknown directive 'nosuchplugin'
$ kubectl -n kube-system get pods -l k8s-app=kube-dns
NAME                       READY   STATUS    RESTARTS   AGE
coredns-589f44dc88-bzxpn   1/1     Running   0          4d10h
coredns-589f44dc88-lv5bl   1/1     Running   0          4d10h
$ kubectl -n demo exec net -- dig +short hello.demo.svc.cluster.local
10.102.84.146

CoreDNS продолжает работать со старой конфигурацией и отвечать. Опасность в другом: под, который перезапустится с таким ConfigMap, уже не стартует. Поэтому после правки Corefile всегда смотрите журнал обоих подов. Журнал запросов на продуктивном кластере держать включённым тоже не стоит — это строка на каждый запрос. Вернём исходный Corefile из копии:

$ kubectl -n kube-system patch configmap coredns --type merge --patch-file coredns-orig.json
configmap/coredns patched

Через минуту-полторы оба пода пишут Running configuration SHA512 = 1b226df7… — ту же сумму, что при первом старте.

Метрики CoreDNS — на порту 9153 любого его пода:

$ kubectl -n demo exec net -- curl -s http://172.20.70.194:9153/metrics | grep -E '^coredns_(dns_request_duration_seconds_count|cache_hits_total|proxy_request_duration_seconds_count)'
coredns_cache_hits_total{server="dns://:53",type="denial",view="",zones="."} 3
coredns_cache_hits_total{server="dns://:53",type="success",view="",zones="."} 2
coredns_dns_request_duration_seconds_count{server="dns://:53",view="",zone="."} 240
coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="NOERROR",to="192.168.10.2:53"} 12
coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="NOERROR",to="192.168.10.3:53"} 15
coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="NXDOMAIN",to="192.168.10.2:53"} 4
coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="NXDOMAIN",to="192.168.10.3:53"} 8
coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="SERVFAIL",to="192.168.10.2:53"} 1
coredns_proxy_request_duration_seconds_count{proxy_name="forward",rcode="SERVFAIL",to="192.168.10.3:53"} 2

coredns_dns_request_duration_seconds_count — сколько запросов под обработал с запуска, coredns_cache_hits_total — сколько из них отдал из кэша (denial — отрицательные ответы вроде NXDOMAIN). Пересылку показывают метрики coredns_proxy_* с proxy_name="forward" (прежние coredns_forward_requests_total и соседние в CoreDNS 1.14 уже не выдаются): запросы делятся между двумя серверами случайно.

Убрать за собой: kubectl -n demo delete pod net bb dns-default dns-hostnet dns-hostnet-cf dns-none, kubectl -n demo delete svc hello-headless registry и kubectl delete -f web.yaml; Corefile уже возвращён.


Источники


Дальше — LoadBalancer без облака: почему на bare metal сервис типа LoadBalancer висит в Pending, как MetalLB выдаёт ему адрес из пула 192.168.10.200–220 и объявляет его в сети по ARP или BGP, кто из узлов отвечает за адрес и что происходит при отказе этого узла.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.