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 уже возвращён.
Источники
- DNS for Services and Pods — записи сервисов и подов,
hostnameиsubdomain,dnsPolicy,dnsConfigи его пределы. - Customizing DNS Service — Corefile по умолчанию, плагины, stub-зоны, задержка применения ConfigMap.
- Debugging DNS Resolution — проверка DNS из пода, журнал
log, известные проблемы сresolvConfи systemd-resolved. - Using CoreDNS for Service Discovery — CoreDNS в kubeadm и сохранение конфигурации при обновлении.
- Using NodeLocal DNSCache in Kubernetes Clusters — архитектура кэша, варианты для iptables и IPVS, память.
- KEP-1024: NodeLocal DNS Cache — зачем кэш на узле, правила
NOTRACK, TCP до CoreDNS. - Kubernetes DNS-Based Service Discovery — спецификация записей A, SRV и PTR для сервисов.
- kubernetes/kubernetes#56903 — гонка conntrack при DNAT и пятисекундные задержки DNS.
- Плагины CoreDNS: kubernetes (режимы
pods, PTR для подов за сервисами), forward (max_concurrent, проверки здоровья, метрикиcoredns_proxy_*), cache, loop (проба HINFO, systemd-resolved), reload, log (формат строки), hosts, autopath, health (lameduck), metrics. - plugin.cfg — порядок выполнения плагинов CoreDNS.
- RFC 2782 — записи SRV: приоритет, вес, порт, цель.
- resolv.conf(5) —
search,ndots, предел в три сервера. - nicolaka/netshoot — образ с сетевыми утилитами для отладки.
Дальше — LoadBalancer без облака: почему на bare metal сервис типа LoadBalancer висит в Pending, как MetalLB выдаёт ему адрес из пула 192.168.10.200–220 и объявляет его в сети по ARP или BGP, кто из узлов отвечает за адрес и что происходит при отказе этого узла.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.