voidlearn
 

3.4Сеть, доступ и отладка

Проследишь запрос через DNS, Service и сетевые правила, а затем проверишь права Pod на Kubernetes API. Разберёшь, как NetworkPolicy может заблокировать DNS и как отлаживать образ без shell.

Что сможешь объяснить после этой главы
  • Какие цепочки kube-proxy пишет ради ClusterIP и откуда у правила вероятность 0,5.
  • Почему внешнее имя из пода стоит восьми DNS-запросов и что меняют точка в конце и dnsConfig.
  • Что отдаёт DNS для headless Service и почему gRPC-клиент не сразу видит новые поды.
  • Чем Gateway API отличается от Ingress и что стало с ingress-nginx.
  • Почему NetworkPolicy принимается, даже если её некому исполнять, и как default deny отрезает DNS.
  • Сколько живёт токен пода и как проверить права через kubectl auth can-i.
  • Что читать в событиях и describe и как отладить distroless-под.

Кластер: kind 0.33.0, Kubernetes 1.37.0, один узел dv-k8s-control-plane, containerd 2.3.4, kindnet, CoreDNS 1.14.6. Его только читали: манифесты проверены --dry-run=server, а то, что без записи не увидеть, описано по документации и исходникам 1.37. Команды с # выполнены на узле.

Service: адрес, которого нет ни на одном интерфейсе

В главе 3.1 Service назван набором правил в ядре узла. Два таких Service есть почти в любом кластере:

$ kubectl get svc,endpointslices -A -o wide
NAMESPACE     NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE     SELECTOR
default       service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP                  4m57s   <none>
kube-system   service/kube-dns     ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   4m55s   k8s-app=kube-dns

NAMESPACE     NAME                                            ADDRESSTYPE   PORTS        ENDPOINTS               AGE
default       endpointslice.discovery.k8s.io/kubernetes       IPv4          6443         172.23.0.2              4m57s
kube-system   endpointslice.discovery.k8s.io/kube-dns-wkh2n   IPv4          53,53,9153   10.244.0.4,10.244.0.2   4m49s
$ kubectl get cm kube-proxy -n kube-system -o jsonpath='{.data.config\.conf}' | grep '^mode:'
mode: iptables

Адрес 10.96.0.10 никто не слушает, его знают только правила kube-proxy на каждом узле. Если режим не задан, kube-proxy 1.37 берёт iptables. Режим nftables стабилен с 1.33, на свежих ядрах советуют его, а ipvs устарел в 1.35: kube-proxy обещает отключить его по умолчанию в 1.40 и удалить в 1.43. Правила для DNS по UDP:

# iptables-save -t nat | grep 'A KUBE-SERVICES.*kube-dns:dns cluster IP'
-A KUBE-SERVICES -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-SVC-TCOU7JCQXEZGVUNU
# iptables-save -t nat | grep '^-A KUBE-SVC-TCOU7JCQXEZGVUNU'
-A KUBE-SVC-TCOU7JCQXEZGVUNU ! -s 10.244.0.0/16 -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-MARK-MASQ
-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.2:53" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-YIL6JZP7A3QYXJU2
-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.4:53" -j KUBE-SEP-WXWGHGKZOCNYRYI7

KUBE-SERVICES подключена к PREROUTING и OUTPUT и по адресу и порту отправляет пакет в цепочку сервиса. Там первое правило метит для MASQUERADE пакеты не из сети подов, например с узла: им подставят адрес узла, и ответ вернётся тем же путём. Дальше лестница: первое правило срабатывает с вероятностью 0,5, второе забирает остальное, а цепочка KUBE-SEP-… делает DNAT на под. Для N эндпоинтов вероятности 1/N, 1/(N−1) и так до 1/2 (writeServiceToEndpointRules в pkg/proxy/iptables/proxier.go).

Пакет на 10.96.0.10:53/udp: цепочки таблицы nat на узле dig на узле src 172.23.0.2 dst 10.96.0.10:53 OUTPUT, из пода PREROUTING KUBE-SERVICES -d 10.96.0.10 --dport 53 KUBE-SVC-TCOU7JCQXEZGVUNU ! -s 10.244.0.0/16: метка для MASQUERADE p = 0,5 остаток KUBE-SEP-YIL6JZP7A3QYXJU2 DNAT 10.244.0.2:53 KUBE-SEP-WXWGHGKZOCNYRYI7 DNAT 10.244.0.4:53 coredns …gx9h8 10.244.0.2:53 coredns …tdsh4 10.244.0.4:53 conntrack первый пакет прошёл цепочки и создал запись, остальные пакеты потока идут по ней мимо таблицы nat udp src=172.23.0.2 dst=10.96.0.10 sport=33098 dport=53 src=10.244.0.4 dst=10.244.0.1 sport=53 dport=54814 1000 запросов: 978 новых потоков, 487 к 10.244.0.2 и 491 к 10.244.0.4; 22 запроса совпали по порту с живой записью
ClusterIP в режиме iptables. Адрес сервиса существует только в правилах: цепочка сервиса выбирает эндпоинт по вероятности, цепочка эндпоинта меняет адрес назначения на адрес пода. Под выбирается один раз на поток, дальше пакеты идут по записи conntrack.

Случайность видна по счётчикам. dig в сетевом namespace узла отправил тысячу запросов на ClusterIP:

# iptables-save -c -t nat | grep -o '^\[[0-9:]*\].*kube-dns:dns -> [0-9.:]*'
[1434:153438] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.2:53
[1491:159522] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.4:53
$ docker run --rm --network container:dv-k8s-control-plane --entrypoint sh \
    registry.k8s.io/e2e-test-images/agnhost:2.39 -c \
    'i=0; while [ $i -lt 1000 ]; do echo kube-dns.kube-system.svc.cluster.local; i=$((i+1)); done > /tmp/q; dig @10.96.0.10 +short -f /tmp/q | sort | uniq -c'
   1000 10.96.0.10
# iptables-save -c -t nat | grep -o '^\[[0-9:]*\].*kube-dns:dns -> [0-9.:]*'
[1921:205547] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.2:53
[1982:212059] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.4:53
# conntrack -L -p udp --orig-dst 10.96.0.10 2>/dev/null | grep -o 'src=10.244.0.[0-9]*' | sort | uniq -c
    487 src=10.244.0.2
    491 src=10.244.0.4

Счётчики выросли на 487 и 491, как и число записей conntrack, а запросов было 1000: таблицу nat проходит только первый пакет потока (глава 1.3 статьи «Linux и ОС»), а 22 запроса ушли с порта, для которого запись в этой серии уже была. С TCP под выбирается на SYN. Если у Service нет готовых эндпоинтов, в filter стоит REJECT с комментарием has no endpoints, и клиент сразу получает отказ. В режиме nftables сервис ищется в словаре service-ips, без перебора всех сервисов подряд, а эндпоинт выбирает numgen random mod N.

EndpointSlice и sessionAffinity

Адреса kube-proxy берёт из EndpointSlice: до 100 адресов в срезе (--max-endpoints-per-slice), у каждого свои условия.

$ kubectl get endpointslices -n kube-system -l kubernetes.io/service-name=kube-dns \
    -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} {.conditions} {.targetRef.name}{"\n"}{end}'
10.244.0.4 {"ready":true,"serving":true,"terminating":false} coredns-559f6c778d-tdsh4
10.244.0.2 {"ready":true,"serving":true,"terminating":false} coredns-559f6c778d-gx9h8

ready значит «готов и не удаляется», serving повторяет готовность пода, terminating говорит об удалении; как они меняются при остановке, разобрано в главе 3.2. Endpoints в 1.33 объявлен устаревшим, и kubectl get endpoints из главы 3.1 печатает об этом Warning.

# orders-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: orders
spec:
  selector:
    app: orders
  ports:
    - name: http
      port: 80
      targetPort: 8080
  sessionAffinity: ClientIP
$ kubectl apply --dry-run=server -f orders-svc.yaml \
    -o jsonpath='{.spec.clusterIP} {.spec.sessionAffinity} {.spec.sessionAffinityConfig}{"\n"}'
10.96.0.0 ClientIP {"clientIP":{"timeoutSeconds":10800}}

sessionAffinity: ClientIP закрепляет клиента за подом на 10800 секунд (допустимо до 86400). Адрес 10.96.0.0 подставил dry-run, настоящий ClusterIP не выделен. В iptables закрепление держит модуль recent: KUBE-SEP-… запоминает источник, а в KUBE-SVC-… перед лестницей встают правила -m recent --rcheck --seconds 10800 --reap. Список у каждого узла свой, клиентом считается IP, и весь трафик из-за одного NAT или от ingress-контроллера выглядит одним клиентом.

CoreDNS и записи Service

$ kubectl get cm coredns -n kube-system -o jsonpath='{.data.Corefile}'
.:53 {
    errors
    health {
       lameduck 5s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
       pods insecure
       fallthrough in-addr.arpa ip6.arpa
       ttl 30
    }
    prometheus :9153
    forward . /etc/resolv.conf {
       max_concurrent 1000
    }
    cache 30 {
       disable success cluster.local
       disable denial cluster.local
    }
    loop
    reload
    loadbalance
}

kubernetes отвечает за cluster.local по данным из API, pods insecure добавляет записи вида 10-244-0-2.kube-system.pod.cluster.local без проверки пода. forward отправляет остальное серверам из resolv.conf пода CoreDNS, cache зону кластера не кэширует, loadbalance перемешивает A-записи. Ниже dig из образа agnhost в сетевом namespace узла:

# dig +noall +answer @10.96.0.10 kube-dns.kube-system.svc.cluster.local
kube-dns.kube-system.svc.cluster.local.	30 IN A	10.96.0.10
# dig +noall +answer @10.96.0.10 SRV _dns._udp.kube-dns.kube-system.svc.cluster.local
_dns._udp.kube-dns.kube-system.svc.cluster.local. 30 IN	SRV 0 100 53 kube-dns.kube-system.svc.cluster.local.
# dig @10.96.0.10 api.example.com.default.svc.cluster.local | grep -E "status|flags"
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 25361
;; flags: qr aa rd; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
; EDNS: version: 0, flags:; udp: 4096

На несуществующее имя зоны CoreDNS отвечает NXDOMAIN сам, с флагом aa. У headless Service нет ни адреса, ни правил kube-proxy, а записи по спецификации DNS для Kubernetes 1.1.0 такие:

# orders-headless.yaml
apiVersion: v1
kind: Service
metadata:
  name: orders-headless
spec:
  clusterIP: None
  selector:
    app: orders
  ports:
    - name: grpc
      port: 9000
$ kubectl apply --dry-run=server -f orders-headless.yaml \
    -o jsonpath='{.spec.clusterIP} {.spec.clusterIPs}{"\n"}'
None ["None"]
ЗапросОбычный ServiceHeadless Service
A orders.shop.svc.cluster.localClusterIPадреса готовых эндпоинтов; нет готовых - NXDOMAIN
SRV _grpc._tcp.orders.shop.svc…запись на именованный портзапись на пару «готовый эндпоинт, порт»
A orders-0.orders.shop.svc…в спецификации нет, но CoreDNS отвечает так жеадрес пода, если у эндпоинта есть hostname

Hostname эндпоинт получает, когда у пода есть hostname и subdomain с именем Service, StatefulSet ставит их сам. gRPC за обычным Service держит одно соединение с одним подом (статья «Сети», глава 9.4). Headless Service отдаёт готовые адреса на момент запроса, а grpc-go 1.83 перечитывает DNS при обрыве соединения, не чаще раза в 30 секунд и без оглядки на TTL. Новые поды клиент увидит после обрыва, поэтому на сервере ставят MaxConnectionAge.

ndots:5: восемь запросов на одно имя

Под с dnsPolicy: ClusterFirst, это значение по умолчанию, получает resolv.conf от kubelet:

# cat /var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/$(crictl pods -q --name local-path-provisioner)/resolv.conf
search local-path-storage.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

kubelet (GetPodDNS в pkg/kubelet/network/dns/dns.go) ставит домены <namespace>.svc.<домен>, svc.<домен>, <домен> и домены узла, сервер из clusterDNS и ndots:5, а опции узла отбрасывает. Поды CoreDNS с dnsPolicy: Default получили файл узла как есть: сервер 192.168.65.254 (DNAT на DNS Docker) и ndots:0. Под с hostNetwork и ClusterFirst kubelet тоже переводит на Default, нужен ClusterFirstWithHostNet.

С ndots:5 имя, где точек меньше пяти, резолвер сначала пробует с каждым доменом поиска и потом как есть. В api.example.com две точки, в orders.default.svc.cluster.local четыре. Стенд в Docker: CoreDNS 1.14.6 с плагином log, у клиента resolv.conf пода из default, где вместо 10.96.0.10 этот CoreDNS, а resolve на Go 1.27.1 (CGO_ENABLED=0) вызывает net.DefaultResolver.LookupHost:

$ docker run --rm --network dv-c4-dns -v "$PWD/resolv.conf:/etc/resolv.conf:ro" \
    -v "$PWD/resolve:/resolve:ro" debian:13 /resolve api.example.com
api.example.com [203.0.113.10] <nil> 1ms
$ docker logs dv-c4-coredns 2>&1 | awk '/INFO/ {print $5, $7, $12}' | tr -d '"'
AAAA api.example.com.default.svc.cluster.local. NXDOMAIN
A api.example.com.default.svc.cluster.local. NXDOMAIN
AAAA api.example.com.svc.cluster.local. NXDOMAIN
A api.example.com.svc.cluster.local. NXDOMAIN
A api.example.com.cluster.local. NXDOMAIN
AAAA api.example.com.cluster.local. NXDOMAIN
A api.example.com. NOERROR
AAAA api.example.com. NOERROR
LookupHost("api.example.com") в поде при ndots:5 resolv.conf пода, namespace default search default.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5 в api.example.com две точки, меньше ndots: сначала имя с каждым доменом search, потом как есть; на каждое имя сразу A и AAAA имя в запросе A AAAA 1 api.example.com.default.svc.cluster.local. NXDOMAIN NXDOMAIN 2 api.example.com.svc.cluster.local. NXDOMAIN NXDOMAIN 3 api.example.com.cluster.local. NXDOMAIN NXDOMAIN 4 api.example.com. 203.0.113.10 нет записей 8 запросов, 6 ответов NXDOMAIN; адрес пришёл только на последнее имя api.example.com. с точкой в конце полное имя, домены поиска не нужны: одно имя, 2 запроса dnsConfig: ndots:2 две точки не меньше двух: имя как есть первым, 2 запроса несуществующее имя Go и glibc всё равно проведут через домены поиска (у Go 8 NXDOMAIN), musl остановится на 2
ndots и домены поиска. К имени, где точек меньше ndots, резолвер по очереди дописывает домены поиска и потом спрашивает его как есть; на каждое имя уходят A и AAAA. Точка в конце или меньший ndots убирают лишние шаги для существующих внешних имён.

Шесть запросов из восьми ушли впустую, и каждый прошёл DNAT и conntrack. Отказы CoreDNS даёт сам, наружу уходят последние два. Число запросов зависит от библиотеки:

Резолвер, тот же стендapi.example.comapi.example.com.
Go 1.27.1, чистый резолвер8: A и AAAA на четыре имени2
glibc 2.41, getent ahosts (Debian 13)4: только A (AI_ADDRCONFIG, IPv6-адреса нет); с --no-addrconfig 81
musl 1.2.6, wget из busybox (Alpine 3.24)82 (getent hosts)
  • Точка в конце делает имя полным. HTTP-клиент Go 1.27.1 шлёт SNI и сверяет сертификат без точки, а Host уходит как api.example.com., и принять его должен сервер.
  • dnsConfig с ndots: "2" пускает имя с двумя точками как есть первым. Несуществующее имя Go и glibc всё равно проведут по доменам, а musl при точках не меньше ndots поиск не делает (name_from_dns_search), и orders.default.svc у него не нашёлся.
  • NodeLocal DNSCache (стабилен с 1.18) ставит кэш на узел: запросов не меньше, но они минуют DNAT и conntrack, а отказы кэшируются рядом с подом.
# orders-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: orders-dns
spec:
  containers:
    - name: app
      image: registry.local/orders:1.4.2
  dnsConfig:
    options:
      - name: ndots
        value: "2"
$ kubectl apply --dry-run=server -f orders-pod.yaml \
    -o jsonpath='{.spec.dnsPolicy} {.spec.dnsConfig}{"\n"}'
ClusterFirst {"options":[{"name":"ndots","value":"2"}]}

Ingress, Gateway API и конец ingress-nginx

Таймауты, переписывание путей и canary в Ingress каждый контроллер задаёт своими аннотациями, и манифест под один контроллер другому не подходит. Документация называет API Ingress замороженным (не удалят, но и развивать не будут) и рекомендует Gateway API. Его CRD от SIG Network ставят отдельно, в kind их нет:

$ kubectl api-resources | grep -i -E 'gateway|ingress'
ingressclasses                                   networking.k8s.io/v1              false        IngressClass
ingresses                           ing          networking.k8s.io/v1              true         Ingress
$ kubectl apply --dry-run=server -f orders-route.yaml
error: resource mapping not found for name: "orders" namespace: "default" from "orders-route.yaml": no matches for kind "HTTPRoute" in version "gateway.networking.k8s.io/v1"
ensure CRDs are installed first
# orders-route.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: orders
  namespace: default
spec:
  parentRefs:
    - name: public
      namespace: infra
  hostnames: ["api.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /orders }
      backendRefs:
        - name: orders
          port: 80

Вход поделён между владельцами:

  • GatewayClass задаёт контроллер: Envoy Gateway, Istio, NGINX Gateway Fabric, Traefik и другие.
  • Gateway с портами, сертификатами и allowedRoutes отдан платформе.
  • HTTPRoute, GRPCRoute лежат в namespace приложения и цепляются через parentRefs.
  • ReferenceGrant разрешает ссылку в чужой namespace.

В v1.6.2 (3 сентября 2026) стандартный канал держит в v1 GatewayClass, Gateway, HTTPRoute, GRPCRoute, ReferenceGrant, BackendTLSPolicy, ListenerSet, TLSRoute, TCPRoute и UDPRoute, последние два с v1.6.0. GRPCRoute сопоставляет запросы по сервису и методу gRPC, то есть шлюз видит отдельные вызовы, а не соединение.

Что стало с ingress-nginx

11 ноября 2025 года SIG Network и Security Response Committee объявили о закрытии kubernetes/ingress-nginx: поддержка по мере сил шла до марта 2026, дальше ни выпусков, ни исправлений уязвимостей. Последние выпуски, v1.15.1, v1.14.5 и v1.13.9, вышли 19 марта 2026, репозиторий ушёл в архив. Установленные контроллеры работают. Переезжать советуют на Gateway API с утилитой ingress2gateway (1.0 от 20 марта 2026) или на другой Ingress-контроллер.

NetworkPolicy: кто её исполняет

NetworkPolicy описывает разрешённые соединения, а исполняет её CNI. API-сервер примет политику в любом кластере, поля «применена» у неё нет, а по документации без контроллера, который её реализует, политика не действует.

# orders-netpol.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: default}
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: default}
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: {kubernetes.io/metadata.name: kube-system}
          podSelector:
            matchLabels: {k8s-app: kube-dns}
      ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
$ kubectl apply --dry-run=server -f orders-netpol.yaml
networkpolicy.networking.k8s.io/default-deny created (server dry run)
networkpolicy.networking.k8s.io/allow-dns created (server dry run)
$ kubectl logs -n kube-system ds/kindnet \
    | grep -E 'kube-network-policies|Policy engine'
I0917 07:22:14.174492       1 controller.go:173] "Starting controller" name="kube-network-policies"
I0917 07:22:14.675365       1 controller.go:185] "Policy engine is ready."

Под изолирован в направлении, если его выбирает хоть одна политика с этим типом в policyTypes; разрешения складываются, запретов в API нет. Ответ на разрешённое соединение проходит сам, трафик с узла пода разрешён всегда, и пробы kubelet политика не режет. Service по имени не указать: поды выбирают метками, namespace - меткой kubernetes.io/metadata.name.

В kind 0.33 политики исполняет kindnet: с kind 0.24 в него встроен kube-network-policies. У его ClusterRole есть watch на networkpolicies, а на узле таблица inet kindnet-network-policies с набором podips-v4 и правилами ip saddr @podips-v4 queue flags bypass to 101: пакеты подов под политикой уходят через NFQUEUE в kindnetd, разрешённое соединение помечается меткой conntrack.

Политика принята, а трафик идёт

created одинаково там, где политики исполняются, и там, где их молча игнорируют. Проверяй делом: после default deny соединение без разрешения должно перестать устанавливаться.

ServiceAccount, токен пода и RBAC

Под работает от имени ServiceAccount, по умолчанию default, и admission-плагин монтирует ему том kube-api-access-…, это видно и в dry-run:

$ kubectl apply --dry-run=server -f orders-pod.yaml -o jsonpath='{.spec.serviceAccountName} {.spec.volumes[*].name}{"\n"}'
default kube-api-access-h8g6x
$ kubectl describe pod -n kube-system coredns-559f6c778d-gx9h8 | sed -n '/kube-api-access-b6wbz:/,/DownwardAPI/p'
  kube-api-access-b6wbz:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    Optional:                false
    DownwardAPI:             true

Projected-том кладёт в /var/run/secrets/kubernetes.io/serviceaccount токен, CA кластера и имя namespace. Токен выдаёт TokenRequest, это JWT с привязкой к поду: в нём имя и UID пода, узла и ServiceAccount. Когда объект пода с этим UID исчезает из API, токен перестаёт действовать. Срок 3607 секунд, час и семь секунд, API-сервер узнаёт по константе WarnOnlyBoundTokenExpirationSeconds: с --service-account-extend-token-expiration (включён по умолчанию) выписывает токен на год, а настоящий срок пишет в warnafter. Дальше токен работает, но в аудите каждый запрос с ним помечен authentication.k8s.io/stale-token. kubelet обновляет файл после 80% срока, около 48 минут. Сервису без API том не нужен: automountServiceAccountToken: false.

Права даёт RBAC: Role действует в namespace, ClusterRole во всём кластере или как шаблон, RoleBinding и ClusterRoleBinding связывают роль с субъектом. Правила только разрешают и складываются. CoreDNS:

$ kubectl get clusterrolebinding system:coredns -o jsonpath='{.roleRef.name} {.subjects}{"\n"}'
system:coredns [{"kind":"ServiceAccount","name":"coredns","namespace":"kube-system"}]
$ kubectl auth can-i --list --as=system:serviceaccount:kube-system:coredns \
    | grep -E '^(Resources|endpoints|namespaces|pods|services|endpointslices)'
Resources                                       Non-Resource URLs                      Resource Names   Verbs
endpoints                                       []                                     []               [list watch]
namespaces                                      []                                     []               [list watch]
pods                                            []                                     []               [list watch]
services                                        []                                     []               [list watch]
endpointslices.discovery.k8s.io                 []                                     []               [list watch]
$ kubectl auth can-i get secrets -n kube-system --as=system:serviceaccount:kube-system:coredns
no

--as работает через impersonation, а встроенная роль edit в 1.37 разрешает impersonate на ServiceAccount: разработчик с edit может действовать от имени любого ServiceAccount своего namespace.

События, describe и логи упавшего контейнера

Event живёт час (--event-ttl API-сервера), и ночное падение утром в событиях не найти. kubectl get events выводит их в порядке имён, а имя события начинается с имени объекта, так что строки сгруппированы по объектам. kubectl events сортирует по времени, умеет --for, --types=Warning и -w:

$ kubectl events -n kube-system --for pod/coredns-559f6c778d-gx9h8
LAST SEEN   TYPE      REASON             OBJECT                         MESSAGE
27m         Warning   FailedScheduling   Pod/coredns-559f6c778d-gx9h8   0/1 nodes are available: 1 node(s) had untolerated taint(s). preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.
26m         Normal    Scheduled          Pod/coredns-559f6c778d-gx9h8   Successfully assigned kube-system/coredns-559f6c778d-gx9h8 to dv-k8s-control-plane
26m         Normal    Pulled             Pod/coredns-559f6c778d-gx9h8   Container image "registry.k8s.io/coredns/coredns:v1.14.6" already present on machine and can be accessed by the pod
26m         Normal    Created            Pod/coredns-559f6c778d-gx9h8   Container created
26m         Normal    Started            Pod/coredns-559f6c778d-gx9h8   Container started
26m         Warning   Unhealthy          Pod/coredns-559f6c778d-gx9h8   Readiness probe failed: Get "http://10.244.0.2:8181/ready": dial tcp 10.244.0.2:8181: connect: connection refused
26m         Warning   Unhealthy          Pod/coredns-559f6c778d-gx9h8   Readiness probe failed: HTTP probe failed with statuscode: 503

Здесь вся история старта: планировщик ждал готовности узла, потом readiness-проба не достучалась до порта, а затем получала 503, пока плагин kubernetes не синхронизировался (в логе пода plugin/ready: Plugins not ready: "kubernetes"). На старте это обычный шум. describe pod показывает события внизу, а выше то, чего в них нет:

Поле describeЧто из него следует
Last State, Reason, Exit Codeкак умер прошлый экземпляр: OOMKilled, Error с кодом, Completed
Restart Countрастёт - причина в логе прошлого экземпляра
Ready, Conditionsпускают ли трафик; PodScheduled False - под не размещён
Liveness, Readinessфактические порт, путь, таймаут и пороги

logs --previous отдаёт лог прошлого экземпляра, а kubelet хранит один мёртвый экземпляр на контейнер (--maximum-dead-containers-per-container, по умолчанию 1): лог позапрошлого падения уже не достать. Без перезапусков ответ такой:

$ kubectl logs -n kube-system coredns-559f6c778d-gx9h8 --previous
Error from server (BadRequest): previous terminated container "coredns" in pod "coredns-559f6c778d-gx9h8" not found
$ kubectl logs -n kube-system -l k8s-app=kube-proxy | wc -l
10

С селектором kubectl logs отдаёт по 10 последних строк с пода (справка к --tail): начало лога так не увидеть.

kubectl debug и port-forward

В distroless нет shell (для Docker это разобрано в главе 2.2 статьи «Docker»). kubectl debug добавляет в работающий под эфемерный контейнер через подресурс pods/ephemeralcontainers, не перезапуская его. Команды раздела не запускались (эфемерный контейнер меняет под), поведение взято из kubectl explain и исходников kubectl 1.37.

$ kubectl debug -it orders-7d9f-xk2 --image=busybox:1.37 --target=app -- sh

Эфемерный контейнер нельзя изменить или удалить, у него нет resources, портов и проб, после выхода он остаётся в поде завершённым. Сеть у контейнеров пода общая, а --target=app добавляет пространство процессов app: ps покажет сервис, его файлы видны в /proc/<pid>/root, если хватает прав на ptrace. Остальное решает профиль:

--profileЭфемерный контейнерdebug node/…
general, по умолчанию с 1.36SYS_PTRACEсеть, PID и IPC узла, корень узла в /host
baseline, restrictedничего; у restricted не root и drop: ALLобычный под на узле
netadminNET_ADMIN, NET_RAWnamespace узла без /host
sysadminprivilegednamespace узла, /host, privileged

legacy был умолчанием до 1.36 и уйдёт в 1.39. Под node-debugger-… от debug node/ терпит все taint и после выхода остаётся, удаляют его руками. С general он не привилегирован, но chroot /host вопреки документации пройдёт: право на chroot есть по умолчанию.

kubectl port-forward открывает поток через API-сервер (pods/portforward, с 1.31 по умолчанию поверх WebSocket) до kubelet, а containerd в сетевом namespace пода подключается к localhost:<порт> (sandbox_portforward_linux.go в containerd 2.3.4). Service, kube-proxy и политики в этом пути не участвуют, и port-forward service/orders лишь выбирает один под по селектору.

Через port-forward отвечает, через Service нет

Сервис на 127.0.0.1:8080 через port-forward доступен: containerd подключается к localhost внутри пода. Соседи и Service ходят на IP пода и получают отказ (адрес слушателя разобран в главе 1.3 статьи «Linux и ОС»).

Вопросы

4
Суть: иди по пути запроса: имя, эндпоинты, правила узла, политика, процесс. Звено подскажет текст ошибки: no such host указывает на DNS, мгновенный отказ на эндпоинты или порт, таймаут на политику или сеть.

Звено за звеном

  1. Имя. Короткое orders находится только из своего namespace, из другого нужно orders.shop. Проверяй тем же резолвером, что у сервиса (GODEBUG=netdns=2, getent hosts), а не dig (глава 1.3 статьи «Linux и ОС»).
  2. Эндпоинты. kubectl get endpointslices -n shop -l kubernetes.io/service-name=orders: если пусто, селектор не совпал с метками, если ready ложно, под не проходит readiness. Сверь и targetPort.
  3. Узел. iptables-save -t nat | grep 'shop/orders' покажет цепочку сервиса.
  4. Политика. kubectl get networkpolicy в обоих namespace: Egress клиента, Ingress сервера.
  5. Процесс. Эфемерный контейнер с --target и netstat -tln покажет адрес, который слушает сервис.
Ошибка Go-клиентаКуда смотреть
lookup orders on 10.96.0.10:53: no such hostимя и namespace
lookup …: i/o timeoutдо CoreDNS не доходят пакеты: default deny без allow-dns, сам CoreDNS
connect: connection refused сразунет готовых эндпоинтов; процесс слушает не тот адрес или порт
i/o timeout при соединенииNetworkPolicy, сеть между узлами
Таймаут DNS длиннее, чем кажется

Когда пакеты до сервера имён не доходят, Go 1.27.1 при ndots:5 вернул i/o timeout через 40 секунд (опыт в Docker): четыре имени × attempts:2 × timeout:5. Если в ошибке есть lookup, виноват DNS, а не сервис.

Суть: живой под отлаживают эфемерным контейнером с --target, падающий копией через --copy-to без проб и меток, узел через debug node/. Нужны права на подресурсы и разрешение Pod Security.

Под жив

Эфемерный контейнер с --target=app видит порты, файлы процесса в /proc/<pid>/fd и /debug/pprof/goroutine?debug=2 через wget localhost:…. Если на уровне пода задан пользователь не root, capabilities профиля могут не подействовать, предупреждает справка kubectl debug.

Под падает

$ kubectl debug orders-7d9f-xk2 -it --copy-to=orders-debug --image=busybox:1.37 -- sh

Копия повторяет спецификацию пода и добавляет отладочный контейнер. Профили снимают с неё метки, аннотации и пробы (init-контейнеры остаются: --keep-init-containers=true) и делают процессы общими. На копию не пойдёт трафик Service, её не посчитает ReplicaSet, и liveness не убьёт зависший на старте процесс. --set-image=app=… подменит образ. Удаляют копию руками. Для беды на узле есть kubectl debug node/<узел> --profile=sysadmin и chroot /host.

Что может не пустить

  • RBAC. kubectl шлёт PATCH на pods/ephemeralcontainers, а во встроенных edit и admin 1.37 этого подресурса нет, хотя pods/exec и pods/portforward есть (grep по всем ClusterRole кластера). Для копии и узла нужно право создавать поды.
  • Pod Security Admission проверяет и ephemeralcontainers (его нет в ignoredPodSubresources), так что sysadmin в namespace уровня baseline отклонят, как и профиль по умолчанию.
Суть: Egress без правил закрыл и DNS, и любое соединение по имени падает на первом шаге, причём долго. Нужны явные разрешения для DNS, своих сервисов, API-сервера и внешних адресов, а селекторы стоит проверить на И и ИЛИ.

Почему упало всё

Default deny на Egress блокирует и DNS: пакет к 10.96.0.10:53 после DNAT идёт на под CoreDNS, которого никто не разрешил, и Go-резолвер ждёт до 40 секунд (вопрос 1). Первым добавляют allow-dns, по UDP и TCP. У API-сервера hostNetwork, его эндпоинт на адресе узла, и podSelector его не выберет, нужен ipBlock:

# orders-egress-api.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: orders-to-apiserver, namespace: default}
spec:
  podSelector:
    matchLabels: {app: orders}
  policyTypes: [Egress]
  egress:
    - to: [{ipBlock: {cidr: 172.23.0.2/32}}]
      ports: [{protocol: TCP, port: 6443}]
$ kubectl get endpointslices kubernetes \
    -o jsonpath='{.endpoints[0].addresses[0]} {.ports[0].port}{"\n"}'
172.23.0.2 6443
$ kubectl apply --dry-run=server -f orders-egress-api.yaml
networkpolicy.networking.k8s.io/orders-to-apiserver created (server dry run)

Сверяет CNI ipBlock до трансляции адреса или после, документация не определяет, и судьбу открытых соединений при новой политике тоже оставляет CNI: такое проверяют на своём.

И или ИЛИ

Два манифеста отличаются одним дефисом перед podSelector, и API понимает их по-разному:

$ kubectl apply --dry-run=server -f orders-from-api.yaml -o jsonpath='{.spec.policyTypes} {.spec.ingress[0].from}{"\n"}'
["Ingress"] [{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"shop"}},"podSelector":{"matchLabels":{"app":"api"}}}]
$ kubectl apply --dry-run=server -f orders-from-api-or.yaml -o jsonpath='{.spec.policyTypes} {.spec.ingress[0].from}{"\n"}'
["Ingress"] [{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"shop"}}},{"podSelector":{"matchLabels":{"app":"api"}}}]

В первом один элемент from: поды app: api из shop. Во втором два: все поды shop и поды app: api из namespace политики. policyTypes в файлах нет, и API записал ["Ingress"].

Суть: свой ServiceAccount, Role на один ConfigMap через resourceNames и RoleBinding, проверка через kubectl auth can-i --as. Токен обновляет kubelet, client-go перечитывает файл сам, ломается клиент, прочитавший токен один раз.
# orders-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata: {name: orders, namespace: default}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: orders-config-reader, namespace: default}
rules:
  - apiGroups: [""]
    resources: [configmaps]
    resourceNames: [orders-flags]
    verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: orders-config-reader, namespace: default}
subjects:
  - {kind: ServiceAccount, name: orders, namespace: default}
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: orders-config-reader}
$ kubectl apply --dry-run=server -f orders-rbac.yaml
serviceaccount/orders created (server dry run)
role.rbac.authorization.k8s.io/orders-config-reader created (server dry run)
rolebinding.rbac.authorization.k8s.io/orders-config-reader created (server dry run)

resourceNames сужает права до одного объекта, но list и watch по документации RBAC пройдут, только если в запросе есть селектор полей metadata.name с этим именем: в client-go это fields.OneTermEqualSelector("metadata.name", "orders-flags").String() в ListOptions.FieldSelector.

Проверка и токен

Dry-run не покажет права роли, а auth can-i видит только существующие привязки. После применения kubectl auth can-i watch configmaps/orders-flags -n default --as=system:serviceaccount:default:orders должен ответить yes, а get secrets - no. rest.InClusterConfig() в client-go 1.37 задаёт BearerTokenFile, и транспорт перечитывает файл раз в минуту, так что обновление токена kubelet клиент не замечает. Самописный клиент, прочитавший токен при старте, не позже чем через час и семь секунд попадёт в stale-token, а с --service-account-extend-token-expiration=false получит 401.

can-i отвечает yes, а информер получает 403

can-i watch configmaps/orders-flags спрашивает про объект по имени и проходит по resourceNames. Информер без селектора полей просит все ConfigMap namespace, и роль его не пускает. Расширять роль не нужно, нужно добавить селектор metadata.name.

Что стоит запомнить

  • Service направляет трафик к выбранным Pod через сетевой dataplane кластера.
  • NetworkPolicy действует только при поддержке сетевого плагина.
  • ServiceAccount и RBAC определяют доступ Pod к API, а не доступ к обычной сети.

Частые ошибки

  • Default deny egress может заблокировать DNS, даже если разрешение для приложения добавлено.
  • Успешный DNS lookup не доказывает, что endpoint Service доступен.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай проверку RBAC

Проверка ServiceAccount reporter в namespace payments дала результаты:

get secrets: no
get pods: yes

Какие два вывода можно сделать и какой нельзя?

Ответ и разбор

Этой учётке разрешено читать Pod в namespace payments и запрещено читать Secret в нём. Нельзя заключить, что Pod не может читать Secret, уже смонтированный в filesystem, или что доступа нет в других namespace; результат относится только к проверенному действию и области.

Код-ревью

2. Добавь DNS к egress allowlist

После включения default deny egress сервис не разрешает имена. Политика пропускает DNS только по TCP.

egress:
  - to:
      - namespaceSelector: {}
    ports:
      - {protocol: TCP, port: 53}
Ответ и разбор

Обычные DNS-запросы используют UDP/53; TCP нужен для крупных ответов и fallback. Добавь отдельные разрешения UDP и TCP/53 к DNS-pod или namespace с учётом labels кластера. Сверь labels CoreDNS и проверь поддержку NetworkPolicy сетевым плагином.

Применение

3. Раздели DNS и доступность Service

Имя orders.payments.svc.cluster.local разрешается в ClusterIP, но TCP connect к порту 8080 завершается timeout. Назови проверки по пути запроса, не меняя DNS.

Ответ и разбор

Проверь готовые EndpointSlices Service и соответствие selector labels, затем совпадение targetPort с listener контейнера. Проверь NetworkPolicy и правила dataplane, после чего проверь listener с самого Pod и клиента. DNS уже разрешил адрес; повторная правка DNS не объясняет timeout к ClusterIP.

Применение

4. Отладь образ без shell

Pod не содержит shell, но нужно проверить его filesystem и DNS. Какие варианты отладки есть и чего нельзя делать с рабочим Pod наугад?

Ответ и разбор

Сначала изучи Events и логи. Ephemeral debug container может разделять сетевое пространство Pod, но его filesystem не совпадает с root filesystem приложения. Для чтения файлов можно адресовать процесс целевого контейнера и посмотреть /proc//root, если runtime поддерживает target process и хватает прав; иначе используй отдельный диагностический образ или штатную выгрузку файла. Не меняй рабочий image и не запускай privileged debug container без согласования.