Kubernetes для самых маленьких. Часть 15: Как работают Service и kube-proxy?

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

В прошлой части мы проследили пакет от пода к поду: у каждого пода свой адрес, маршрутизация между узлами без NAT. Но обращаться к подам по их адресам неудобно и опасно: адрес меняется при каждом пересоздании пода, подов несколько, а часть из них может быть не готова. Для этого есть Service — объект, который даёт группе подов один стабильный адрес и имя, и kube-proxy, который на каждом узле превращает этот адрес в правила netfilter. Сегодня разберём, откуда берётся виртуальный ClusterIP, как контроллер EndpointSlice следит за готовыми подами по readiness-пробам из части 6, что именно kube-proxy пишет в nftables на нашем стенде, чем отличаются режимы iptables и IPVS, и все виды сервисов: NodePort, LoadBalancer, headless, ExternalName и сервис без селектора. Подбор подов идёт по меткам и селекторам из части 11.


Зачем Service

У Deployment hello в неймспейсе demo из части 11 три пода. Клиенту внутри кластера нужно:

  • один адрес, который не меняется, сколько бы раз поды ни пересоздавались;
  • распределение соединений по всем подам;
  • только готовые поды: под, который ещё прогревается или перестал проходить readiness-пробу, трафик получать не должен.

Service описывает это декларативно:

apiVersion: v1
kind: Service
metadata:
  name: hello
  namespace: demo
  labels:
    app.kubernetes.io/name: hello
    app.kubernetes.io/instance: hello
spec:
  selector:
    app.kubernetes.io/name: hello
    app.kubernetes.io/instance: hello
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP
  • metadata.labels — метки самого объекта Service, те же рекомендуемые app.kubernetes.io/* из части 11: по ним сервис находят другие объекты, которым нужно выбрать именно сервисы, а не поды.
  • selector — какие поды обслуживает сервис. Обычно совпадает с селектором Deployment — здесь app.kubernetes.io/name и app.kubernetes.io/instance, как в части 11.
  • port — порт сервиса, на который приходят клиенты.
  • targetPort — порт пода, куда пересылать. Здесь он задан именем http, которое объявлено в containerPort шаблона пода. Это лучшая практика: если приложение переедет на другой порт, достаточно изменить Deployment, сервис менять не придётся. Числом его задают, когда у подов имени порта нет.
  • name порта обязателен, если портов несколько; по нему порт находят Ingress и записи DNS (часть 16).
$ kubectl apply -f hello-service.yaml
service/hello created
$ kubectl -n demo get svc hello
NAME    TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
hello   ClusterIP   10.105.101.118   <none>        80/TCP    5s

10.105.101.118 — ClusterIP: адрес сервиса из сети сервисов 10.96.0.0/12. Он не меняется, пока существует объект Service. Клиенты внутри кластера обращаются к http://10.105.101.118/ или, что правильнее, к имени hello.demo.svc.cluster.local, которое DNS кластера превращает в этот адрес (часть 16).


ClusterIP: адрес, которого нет

Адрес сервису выдаёт kube-apiserver при создании объекта. Диапазон задан флагом --service-cluster-ip-range (kubeadm берёт его из serviceSubnet) и виден в API как объект ServiceCIDR с именем kubernetes; каждый выданный адрес — объект IPAddress (оба API стабильны с 1.33), что исключает гонки при выдаче и позволяет добавлять диапазоны без перезапуска kube-apiserver. Адрес можно попросить конкретный (spec.clusterIP), и для этого диапазон делится на две полосы: нижняя — первые min(max(16, размер/16), 256) адресов, у нашей /12 это 256 адресов 10.96.0.1–10.96.1.0, — достаётся автоматически, только когда закончится верхняя. Поэтому явно запрошенный адрес из нижней полосы почти никогда не окажется занят, и 10.96.0.10 для DNS живёт именно там. Первый адрес диапазона, 10.96.0.1, всегда у сервиса kubernetes в неймспейсе default — это kube-apiserver. Адрес вне диапазона API-сервер не выдаст:

$ kubectl -n demo create service clusterip bad --tcp=80:8080 --clusterip=10.200.0.10
error: failed to create ClusterIP service: Service "bad" is invalid: spec.clusterIPs: Invalid value: ["10.200.0.10"]: failed to allocate IP 10.200.0.10: the provided network does not match the current range

Главное про ClusterIP: этого адреса нет ни на одном интерфейсе ни одного узла. На ARP за него никто не отвечает, слушающего сокета нет, ping 10.105.101.118 ответа не получит. Адрес существует только как ключ в правилах netfilter на каждом узле: пакет, отправленный на 10.105.101.118:80, ещё до маршрутизации получает новый адрес назначения — адрес одного из подов — и дальше идёт по обычной сети подов из прошлой части. Это делает kube-proxy.

 под client 172.20.211.52 на k8s-node-1           k8s-node-1 (netfilter)
   │ connect 10.105.101.118:80
   └──────────────────────────────────────► prerouting, nat:
                                            10.105.101.118:80 есть в service-ips →
                                            DNAT на 172.20.172.17:8080 (выбран случайно)
                                            conntrack запомнил соединение
                                                  │
                                                  ▼ маршрут 172.20.172.0/26 via k8s-node-2
                                            под hello 172.20.172.17:8080
   ◄────────────── ответ: conntrack возвращает источником 10.105.101.118:80

Ответ пода идёт с адреса 172.20.172.17:8080, и на обратном пути conntrack — таблица отслеживания соединений ядра — подставляет исходным адресом снова 10.105.101.118:80: клиент видит, что ему ответил тот, к кому он обращался. Решение о поде принимается один раз на соединение — для TCP при первом пакете, — дальше все пакеты этого соединения идут к тому же поду. Поэтому балансировка в Service — по соединениям, а не по запросам: клиент с долгоживущим соединением (HTTP/2 — версия HTTP, в которой множество запросов идёт по одному соединению; gRPC — протокол удалённых вызовов поверх HTTP/2; пул соединений к базе) всё время говорит с одним подом.


EndpointSlice: кто сейчас за сервисом

Сам Service знает только селектор. Список адресов подов ведёт контроллер EndpointSlice в kube-controller-manager. Он следит за сервисами и подами и для каждого сервиса с селектором создаёт объекты EndpointSlice:

$ kubectl -n demo get endpointslices -l kubernetes.io/service-name=hello
NAME          ADDRESSTYPE   PORTS   ENDPOINTS                                   AGE
hello-g6zpm   IPv4          8080    172.20.172.17,172.20.211.48,172.20.172.15   5s
$ kubectl -n demo get endpointslices -l kubernetes.io/service-name=hello -o yaml
apiVersion: v1
items:
- addressType: IPv4
  apiVersion: discovery.k8s.io/v1
  endpoints:
  - addresses:
    - 172.20.172.17
    conditions:
      ready: true
      serving: true
      terminating: false
    nodeName: k8s-node-2
    targetRef:
      kind: Pod
      name: hello-6d5c6db6b4-7k5d9
      namespace: demo
      uid: bde33654-1539-4c88-b0bf-3bf1a30c3d07
  - addresses:
    - 172.20.211.48
    conditions:
      ready: true
      serving: true
      terminating: false
    nodeName: k8s-node-1
    targetRef:
      kind: Pod
      name: hello-6d5c6db6b4-d7bpf
      namespace: demo
      uid: 3599259d-3f09-4d90-b841-6701367420ce
  # … третья запись: 172.20.172.15, hello-6d5c6db6b4-tbvhn на k8s-node-2
  kind: EndpointSlice
  metadata:
    annotations:
      endpoints.kubernetes.io/last-change-trigger-time: "2026-10-28T20:27:44Z"
    creationTimestamp: "2026-10-28T20:27:44Z"
    generateName: hello-
    generation: 1
    labels:
      app.kubernetes.io/instance: hello
      app.kubernetes.io/name: hello
      endpointslice.kubernetes.io/managed-by: endpointslice-controller.k8s.io
      kubernetes.io/service-name: hello
    name: hello-g6zpm
    namespace: demo
    ownerReferences:
    - apiVersion: v1
      blockOwnerDeletion: true
      controller: true
      kind: Service
      name: hello
      uid: 72008210-7b21-4ef0-b6e6-f85eae221dad
    resourceVersion: "819731"
    uid: 49ec3ae8-4404-46ca-8224-86a21e184814
  ports:
  - name: http
    port: 8080
    protocol: TCP
kind: List
metadata:
  resourceVersion: ""

Имя среза — имя сервиса плюс случайный суффикс (generateName), владелец — сам Service, а метки сервиса контроллер копирует в срез и добавляет две свои: kubernetes.io/service-name связывает срез с сервисом, endpointslice.kubernetes.io/managed-by говорит, кто срез ведёт. Как контроллер наполняет срез:

  • подбор — все поды неймспейса, подходящие под селектор сервиса и имеющие адрес;
  • порт — именованный targetPort: http контроллер разрешает в число по каждому поду отдельно (у разных подов одно имя может означать разные порты);
  • условия — serving повторяет условие Ready пода, то есть результат readiness-проб всех его контейнеров; terminating — под получил команду остановиться (у него появился deletionTimestamp); ready — короткая запись для «serving и не terminating». kube-proxy шлёт новые соединения на ready, а на поды с serving: true, terminating: true — только когда готовых не осталось совсем: так соединения не теряются, пока последние поды сервиса завершаются (например, у внешнего балансировщика при externalTrafficPolicy: Local, который ещё не заметил, что подов на узле больше нет). Документация описывает это для политики Local, на стенде kube-proxy так же поступает и с Cluster;
  • nodeName и зона узла — для политик трафика и trafficDistribution (ниже);
  • размер — до 100 адресов в одном срезе (флаг --max-endpoints-per-slice у kube-controller-manager). Сервис с тысячей подов получит десяток срезов, и изменение одного пода переписывает один срез, а не огромный общий объект. В колонке ENDPOINTS kubectl показывает первые три адреса и дописывает + N more....

Под, переставший проходить readiness-пробу, через секунды получает ready: false, и kube-proxy на всех узлах перестаёт слать ему новые соединения. Под, который никогда не был готов, в балансировку не попадает вовсе. Поэтому readiness-проба — не формальность: без неё под считается готовым, как только запустился процесс, и первые запросы после деплоя уходят в ещё не прогретое приложение. У нашего hello в demo проб нет (манифест части 11 их не задаёт) — в «Смотрим руками» мы добавим пробу и посмотрим, что станет со срезом.

Если клиенту нужны и неготовые поды (например, участники кластерной системы ищут друг друга до готовности), в сервисе ставят publishNotReadyAddresses: true: тогда ready у всех адресов — true.

Старый объект Endpoints — один на сервис, со всеми адресами в одном списке — контроллер тоже пока создаёт для совместимости, но с 1.33 этот API объявлен устаревшим:

$ kubectl -n demo get endpoints hello
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME    ENDPOINTS                                                  AGE
hello   172.20.172.15:8080,172.20.172.17:8080,172.20.211.48:8080   5s

Новый код и собственные инструменты должны читать EndpointSlice.


kube-proxy

kube-proxy — DaemonSet в kube-system, по поду на каждом узле, в сети узла. Он следит через watch за Service и EndpointSlice и превращает их в правила ядра. Сам он трафик не проксирует (название осталось от самой первой реализации, где он действительно принимал соединения в своём процессе): пакеты переписывает ядро по его правилам, и если kube-proxy остановить, существующие правила продолжат работать — просто перестанут обновляться.

Режим задан в его конфигурации:

$ kubectl -n kube-system get configmap kube-proxy -o jsonpath='{.data.config\.conf}' | grep -E '^(mode|clusterCIDR|nodePortAddresses):'
clusterCIDR: 172.20.0.0/16
mode: nftables
nodePortAddresses: null

На стенде — nftables: kubeadm передал ему KubeProxyConfiguration при kubeadm init (часть 4). Режим стабилен с 1.33 и требует ядра 5.13 и новее; кроме того, проект советует не включать его на узлах, где утилита nft старше 1.0.0 (на стенде — ядро 7.0 и nft 1.1.6). По умолчанию на Linux до сих пор iptables — ради совместимости (kube-proxy --help так и пишет: 'iptables' (default)), — поэтому режим стоит всегда указывать в конфигурации явно, а для новых кластеров документация рекомендует nftables.

Таблица ip kube-proxy

kube-proxy создаёт собственную таблицу ip kube-proxy и ничего не трогает за её пределами. Это одно из преимуществ перед iptables: другие компоненты (Calico, файрвол узла) живут в своих таблицах, у каждой своя транзакция, и никто не перезаписывает чужие правила и не ждёт общей блокировки. Внутри:

Объект Тип Что делает
nat-prerouting цепочка, hook prerouting, приоритет dstnat пакеты, пришедшие на узел (от подов и снаружи), — в services
nat-output цепочка, hook output, приоритет dstnat пакеты процессов самого узла — в services
services цепочка пометить для маскарадинга то, что идёт на ClusterIP не из сети подов; найти сервис в service-ips и service-nodeports
service-ips verdict map ключ «адрес . протокол . порт» → goto в цепочку сервиса
service-nodeports verdict map ключ «протокол . порт» → цепочка для NodePort
nodeport-ips множество адреса узла, на которых принимается NodePort
service-<хеш>-<ns>/<имя>/<proto>/<порт> цепочка на каждый порт сервиса DNAT на случайный адрес из списка подов
external-…, local-… цепочки вход для трафика снаружи кластера (NodePort, LoadBalancer): маскарадинг или только локальные поды при Local
endpoint-…, affinity-… цепочки и множества только у сервисов с sessionAffinity (ниже)
cluster-ips множество все действующие ClusterIP
nat-postrouting цепочка, hook postrouting, приоритет srcnat в masquerading
masquerading цепочка помеченные пакеты получают адрес узла источником
hairpin-connections множество поды этого узла, которые могут обратиться к своему же сервису (ниже)
filter-*, *-check, no-endpoint-*, firewall-ips цепочки и карты фильтрации отказ на сервисы без подов, на несуществующие порты ClusterIP и на невыданные адреса из 10.96.0.0/12; loadBalancerSourceRanges (список адресов, которым разрешён доступ к LoadBalancer)

Главная разница с iptables — verdict map service-ips. Это хеш-таблица в ядре: одно правило ip daddr . meta l4proto . th dport vmap @service-ips находит нужный сервис за одно обращение, сколько бы сервисов ни было. Обновление тоже точечное: появился сервис — в карту добавляется элемент и создаётся одна цепочка, а не перезаписываются все правила.

Выбор пода. В цепочке сервиса одно правило:

meta l4proto tcp dnat ip to numgen random mod 3 map { 0 : 172.20.172.15 . 8080, 1 : 172.20.172.17 . 8080, 2 : 172.20.211.48 . 8080 }

numgen random mod 3 — случайное число от 0 до 2 для каждого нового соединения; анонимная карта переводит его в «адрес . порт», а dnat переписывает адрес и порт назначения. Распределение равномерное и без состояния. Если в сервисе включена привязка клиента (sessionAffinity, ниже), правила другие: цепочка на каждый под (endpoint-…) и множество адресов клиентов, которые недавно к нему ходили.

Маскарадинг. Пакеты от подов к ClusterIP проходят DNAT, но адрес источника не меняют: под-получатель видит настоящий адрес клиента, и это хорошо. Но если соединение начал не под, а сам узел (процесс в сети узла) или клиент снаружи кластера, без подмены источника ответ мог бы пойти другим путём, мимо conntrack, который помнит DNAT. Поэтому:

  1. в services правило помечает пакеты к ClusterIP, у которых источник не из 172.20.0.0/16 (clusterCIDR в конфигурации kube-proxy): meta mark set meta mark | 0x00004000; трафик снаружи на NodePort помечается так же в цепочке external-…;
  2. в masquerading (postrouting) помеченные пакеты получают маскарадинг с fully-random — случайным выбором порта источника, который снижает вероятность коллизий в conntrack при множестве одновременных соединений;
  3. отдельно — hairpin: если под обратился к своему же сервису и DNAT выбрал его самого, пакет вернулся бы к нему с его же адресом источника, и ответ ушёл бы мимо conntrack. Для этого kube-proxy держит множество hairpin-connections — для каждого пода сервиса на этом узле четвёрку «адрес пода . адрес пода . протокол . порт» — и маскирует соединения, у которых после DNAT источник и назначение совпали.
 nat-prerouting / nat-output (приоритет dstnat)
        │ jump services
        ▼
 services ── ClusterIP и источник не из 172.20.0.0/16? ──► пометить 0x4000
        │
        ├─ vmap @service-ips:       10.105.101.118 . tcp . 80 ──► service-VSI2YVQZ-demo/hello/tcp/http
        └─ vmap @service-nodeports: tcp . 30080 ──► external-BJ5L76QF-demo/hello-np/tcp/http
                                                    (пометить 0x4000, goto service-BJ5L76QF-…)

 service-…-<сервис>
        dnat ip to numgen random mod N map { … }   выбрать под, переписать адрес назначения
        │
        ▼ маршрутизация к поду
 nat-postrouting (приоритет srcnat)
        │ jump masquerading
        ▼
 masquerading: помечен 0x4000 или hairpin → masquerade fully-random

Режим iptables

В режиме iptables kube-proxy пишет то же самое в таблицу nat iptables. На стенде этого режима нет, поэтому ниже не вывод, а схема правил для сервиса hello с тремя подами в том виде, в каком их пишет kube-proxy (хеши в именах цепочек заменены на <хеш>, комментарии -m comment сокращены):

-A KUBE-SERVICES -d 10.105.101.118/32 -p tcp -m comment --comment "demo/hello:http cluster IP" -m tcp --dport 80 -j KUBE-SVC-<хеш>
-A KUBE-SVC-<хеш> ! -s 172.20.0.0/16 -d 10.105.101.118/32 -p tcp -m tcp --dport 80 -j KUBE-MARK-MASQ
-A KUBE-SVC-<хеш> -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-<хеш1>
-A KUBE-SVC-<хеш> -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-<хеш2>
-A KUBE-SVC-<хеш> -j KUBE-SEP-<хеш3>
-A KUBE-SEP-<хеш1> -s 172.20.172.15/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-<хеш1> -p tcp -m tcp -j DNAT --to-destination 172.20.172.15:8080
-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000
-A KUBE-POSTROUTING -m mark ! --mark 0x4000/0x4000 -j RETURN
-A KUBE-POSTROUTING -j MARK --set-xmark 0x4000/0x0
-A KUBE-POSTROUTING -j MASQUERADE --random-fully

Те же три идеи — пометка 0x4000 в KUBE-MARK-MASQ, DNAT на под и маскарадинг помеченного в KUBE-POSTROUTING, — но устроены иначе. Цепочка KUBE-SERVICES — список, по правилу на каждый порт каждого сервиса, и первый пакет соединения проходит его сверху вниз, пока не найдёт свой адрес. Выбор пода — каскад вероятностей: первое правило срабатывает с вероятностью 1/3, иначе второе с 1/2 от оставшегося, иначе третье, что даёт равные доли. Правило -s <адрес пода> -j KUBE-MARK-MASQ в каждой цепочке пода — это hairpin. Это работает и было стандартом много лет, но на тысячах сервисов первый пакет каждого соединения проходит тысячи правил, а каждое обновление передаётся через iptables-restore объёмом, пропорциональным числу всех сервисов (с 1.26 kube-proxy пропускает неизменившиеся правила, но не все), под общей блокировкой xtables, которую делят все программы на узле. Отсюда задержки в секунды при синхронизации на больших кластерах. Для небольших кластеров, вроде нашего, разница незаметна, и режим остаётся полностью рабочим.

На узлах стенда в iptables всё-таки кое-что есть — служебные цепочки самого kubelet:

user@k8s-node-1:~$ sudo iptables-save | grep -- '-A KUBE'
-A KUBE-FIREWALL ! -s 127.0.0.0/8 -d 127.0.0.0/8 -m comment --comment "block incoming localnet connections" -m conntrack ! --ctstate RELATED,ESTABLISHED,DNAT -j DROP

Цепочек KUBE-SERVICES и KUBE-SVC-… нет: в режиме nftables kube-proxy правил iptables не пишет, а при переключении режима сам удаляет правила прежнего — перезагружать узлы не нужно.

Режим IPVS

IPVS (IP Virtual Server) — балансировщик четвёртого уровня в ядре Linux, исторически применявшийся для балансировки без Kubernetes. kube-proxy в этом режиме создаёт на каждом узле фиктивный интерфейс kube-ipvs0, назначает на него все ClusterIP и описывает каждый сервис виртуальным сервером IPVS с алгоритмом балансировки (по кругу, по наименьшему числу соединений и т. д.). Поиск — хеш-таблица, как в nftables. Но модель IPVS плохо легла на Service API, часть крайних случаев так и не была решена, и с 1.35 режим ipvs объявлен устаревшим: документация рекомендует переходить на nftables, а на старых ядрах — на iptables. Документация следующей версии уже называет сроки: с 1.40 режим будет выключен по умолчанию (его можно будет вернуть feature gate KubeProxyIPVS), в 1.43 — удалён. Переход, как и между остальными режимами, не требует перезагрузки узлов.

eBPF вместо kube-proxy

Есть и третий путь — вообще без kube-proxy. eBPF — механизм ядра Linux, позволяющий загружать в ядро проверенные программы, которые обрабатывают пакеты прямо на сетевом интерфейсе. eBPF-датаплейн Calico сам следит за сервисами и выбирает под ещё до того, как пакет попадёт в netfilter; kube-proxy в таком кластере отключают. Это разобрано в серии про Kubernetes на BGP.


Типы сервисов

Тип Что добавляет Кто обрабатывает
ClusterIP (по умолчанию) виртуальный адрес внутри кластера kube-proxy
NodePort ClusterIP + порт 30000–32767 на адресах всех узлов kube-proxy
LoadBalancer NodePort + внешний адрес от балансировщика внешний контроллер + kube-proxy
ExternalName только DNS-псевдоним CoreDNS

NodePort

apiVersion: v1
kind: Service
metadata:
  name: hello-np
  namespace: demo
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: hello
    app.kubernetes.io/instance: hello
  ports:
    - name: http
      port: 80
      targetPort: http
      nodePort: 30080
$ kubectl apply -f hello-np.yaml
service/hello-np created
$ kubectl -n demo get svc hello-np
NAME       TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
hello-np   NodePort   10.105.48.156   <none>        80:30080/TCP   1s

kube-apiserver выдаёт сервису порт из диапазона --service-node-port-range (по умолчанию 30000–32767), можно попросить конкретный — если он свободен и попадает в диапазон. Копии этого манифеста под именами hello-np2 с тем же nodePort: 30080 и hello-np3 с nodePort: 20080 API-сервер не примет:

The Service "hello-np2" is invalid: spec.ports[0].nodePort: Invalid value: 30080: provided port is already allocated
The Service "hello-np3" is invalid: spec.ports[0].nodePort: Invalid value: 20080: provided port is not in the valid range. The range of valid ports is 30000-32767

Теперь http://192.168.10.20:30080/ с ноутбука администратора попадёт в hello, причём через любой узел — даже через мастер k8s-master-1, где подов hello нет: kube-proxy каждого узла знает все сервисы.

В режиме nftables NodePort принимается только на основных адресах узла — тех, что записаны в объекте Node, — а не на всех (nodePortAddresses: null в конфигурации означает здесь именно это, документация называет такое поведение primary), и не отвечает на 127.0.0.1: curl http://127.0.0.1:30080/ на узле получит отказ в соединении. В режиме iptables без nodePortAddresses NodePort открыт на всех адресах узла, включая loopback. Ещё одно отличие: kube-proxy в nftables не добавляет разрешающих правил для NodePort в файрвол узла; если на узлах настроен собственный файрвол на nftables, порты 30000–32767 он должен пропускать сам.

NodePort — инструмент для отладки и для интеграции с внешним балансировщиком, который знает адреса узлов. Выставлять сервис пользователям через нестандартные порты на адресах узлов — плохая практика: клиент привязан к конкретному узлу, и при его выключении сервис для этого клиента пропадает.

externalTrafficPolicy: Cluster и Local

Клиент снаружи пришёл на 192.168.10.21:30080. По умолчанию (externalTrafficPolicy: Cluster) узел выбирает любой под сервиса, в том числе на другом узле. Чтобы ответ вернулся через этот же узел (где conntrack помнит DNAT), пакет маскируется адресом узла:

 клиент 192.168.10.50 ──► k8s-node-2:30080 ── DNAT + SNAT ──► под на k8s-node-1
                          источник становится 192.168.10.21

Распределение равномерное, но под видит не адрес клиента, а адрес узла. hello пишет в журнал адрес, с которого пришёл запрос, и это видно сразу:

laptop:~$ for i in 1 2 3 4 5 6; do curl -s http://192.168.10.21:30080/; done
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-tbvhn
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-tbvhn
hello from hello-6d5c6db6b4-tbvhn
$ kubectl -n demo logs hello-6d5c6db6b4-d7bpf --since=1m
2026/10/28 20:29:11 192.168.10.21:8906 GET /
2026/10/28 20:29:11 192.168.10.21:35142 GET /
2026/10/28 20:29:11 192.168.10.21:10153 GET /

Под d7bpf живёт на k8s-node-1, а запросы пришли к нему от 192.168.10.21 — узла, через который вошёл клиент; маскируется и трафик к подам на самом k8s-node-2. Для журналов доступа, ограничения частоты запросов по адресу и списков разрешённых адресов это неприемлемо.

externalTrafficPolicy: Local меняет правило: узел отправляет внешний трафик только на поды на себе и без SNAT, поэтому под видит настоящий адрес клиента. Если своих подов на узле нет, пакет отбрасывается. Чтобы внешний балансировщик не слал трафик на такие узлы, у сервиса type: LoadBalancer с Local kube-apiserver выделяет healthCheckNodePort, и kube-proxy на этом порту отвечает 200, если на узле есть готовые поды сервиса, и 503, если нет. У type: NodePort такого поля нет: балансировщик, который ходит на NodePort, проверяет узлы сам. Цена Local — неравномерность: узел с одним подом получает столько же трафика, сколько узел с тремя.

В nftables это видно в цепочке external-… сервиса с Local: пакеты из сети подов и с самого узла отправляются в обычную цепочку сервиса (им можно ко всем подам), остальные — в цепочку local-…, где в карте только поды этого узла. Всё это покажем в «Смотрим руками».

Как это используется с реальным балансировщиком, разберём в части 17 на MetalLB; Traefik с externalTrafficPolicy: Local разобран в серии про BGP.

internalTrafficPolicy

То же для трафика изнутри кластера: internalTrafficPolicy: Local (стабильно с 1.26) — поды-клиенты и процессы узла ходят только на поды сервиса на своём узле, а если их нет, соединение не устанавливается. Применяется для узловых агентов: например, приложение отправляет метрики или журналы в сервис DaemonSet-агента и должно попасть в агент своего узла. Для обычных сервисов — Cluster (по умолчанию).

trafficDistribution

Мягкий вариант предпочтения «близких» подов — поле spec.trafficDistribution (стабильно с 1.33; значения ниже стабильны с 1.35):

  • PreferSameZone — предпочитать поды в той же зоне (topology.kubernetes.io/zone узла), а если их нет — любые. Экономит межзонный трафик в облаках, где он платный;
  • PreferSameNode — предпочитать поды на том же узле, затем в той же зоне, затем любые;
  • PreferClose — прежнее название PreferSameZone, оставлено как устаревший синоним.

Контроллер EndpointSlice записывает для каждого адреса подсказку hints.forZones (или forNodes), kube-proxy строит на каждом узле карту только из подходящих адресов. В отличие от internalTrafficPolicy: Local, при отсутствии близких подов трафик не теряется. Риск — перекос: если в зоне один под, а клиентов много, он получит весь их трафик. Зон на стенде нет, и PreferSameZone ни на что не влияет, а PreferSameNode работает — это тоже покажем ниже.

sessionAffinity

sessionAffinity: ClientIP — соединения от одного адреса клиента в течение sessionAffinityConfig.clientIP.timeoutSeconds (по умолчанию 10800, три часа) идут на тот же под. Это привязка по адресу, а не по сессии приложения: все клиенты за одним NAT окажутся на одном поде, а при externalTrafficPolicy: Cluster вместо адреса клиента будет адрес узла. Для приложений, которым нужны «липкие» сессии, правильнее хранить сессию вне пода (в Redis — хранилище «ключ — значение» в памяти, — или в базе) или привязывать по cookie на уровне HTTP-прокси (часть 18).

LoadBalancer

type: LoadBalancer — это NodePort плюс просьба к внешнему балансировщику выдать адрес и направить его трафик на узлы. Kubernetes сам ничего не выдаёт: в облаке это делает облачный контроллер (cloud controller manager), который создаёт балансировщик провайдера и записывает его адрес в status.loadBalancer.ingress. В нашем кластере такого контроллера нет, и сервис будет ждать вечно — без событий и ошибок:

$ kubectl apply -f hello-lb.yaml
service/hello-lb created
$ kubectl -n demo get svc hello-lb
NAME       TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
hello-lb   LoadBalancer   10.97.232.108   <pending>     80:31950/TCP   60s
$ kubectl -n demo get events --field-selector involvedObject.kind=Service
No resources found in demo namespace.

NodePort 31950 при этом уже работает — kube-proxy сделал свою часть. Поле loadBalancerClass позволяет выбрать конкретный контроллер, если их несколько, а allocateLoadBalancerNodePorts: false — не выделять NodePort, если балансировщик умеет слать трафик прямо на поды. Как получить адрес без облака — в части 17.


Headless, ExternalName и сервис без селектора

Headless-сервис — clusterIP: None. Адреса у сервиса нет, kube-proxy его игнорирует, но EndpointSlice заполняются как обычно, и DNS на имя сервиса отвечает списком адресов подов. Клиент сам выбирает, к какому поду идти, — так работают StatefulSet (часть 8), кластерные базы данных и клиенты с собственной балансировкой (например, gRPC, который держит соединения со всеми подами сразу).

ExternalName — псевдоним внешнего имени:

apiVersion: v1
kind: Service
metadata:
  name: registry
  namespace: demo
spec:
  type: ExternalName
  externalName: ghcr.io

На запрос registry.demo.svc.cluster.local DNS кластера ответит записью CNAME на ghcr.io. Ни адреса, ни правил kube-proxy, ни портов. Удобно, чтобы в конфигурации приложений было внутреннее имя, которое можно переключить, не трогая приложения. Подводный камень: клиент обращается к registry.demo.svc…, а сервер получает это имя в заголовке Host и в SNI — расширении TLS (протокола шифрования соединений, на котором работает HTTPS; см. часть 23 сетевой серии), где клиент называет имя сервера, — и сертификат, выписанный на ghcr.io, не совпадёт. Для HTTP и HTTPS ExternalName поэтому подходит плохо, о чём предупреждает и документация.

Сервис без селектора — когда бэкенды не поды кластера. Создаём сервис без selector — контроллер EndpointSlice его не трогает, — и EndpointSlice руками:

apiVersion: v1
kind: Service
metadata:
  name: legacy-api
  namespace: demo
spec:
  ports:
    - name: http
      port: 80
      targetPort: 8080
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: legacy-api-1
  namespace: demo
  labels:
    kubernetes.io/service-name: legacy-api
    endpointslice.kubernetes.io/managed-by: admins.example.com
addressType: IPv4
ports:
  - name: http
    port: 8080
    protocol: TCP
endpoints:
  - addresses: ["192.168.10.40"]
    conditions:
      ready: true
  - addresses: ["192.168.10.41"]
    conditions:
      ready: true

Связь с сервисом — метка kubernetes.io/service-name. Метка managed-by со своим значением говорит контроллеру EndpointSlice, что срез чужой и трогать его не надо. Поды обращаются к legacy-api.demo и получают балансировку по двум внешним серверам со всеми правилами kube-proxy. Пока среза нет, сервис без адресов отвечает отказом в соединении. Ограничения: адреса не могут быть loopback и link-local — такой срез API-сервер не примет, — и по документации не должны быть ClusterIP других сервисов: kube-proxy не поддерживает виртуальные адреса как назначение, а API-сервер такой срез не отвергает, так что следить за этим приходится самому. И проверок здоровья нет — если 192.168.10.41 упадёт, его нужно убрать из среза самому или внешним скриптом.


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

Сначала — какие таблицы nftables есть на узле:

user@k8s-node-1:~$ sudo nft list tables | grep -E 'table (ip|arp) '
table ip mangle
table ip filter
table ip nat
table ip kube-proxy
table ip calico
table arp calico-arp

mangle, filter и nat — таблицы iptables-nft: в них kubelet держит свои служебные цепочки (KUBE-FIREWALL, KUBE-KUBELET-CANARY), правил сервисов там нет. Рядом с таблицами kube-proxy — таблицы Calico из прошлой части: Felix тоже работает в режиме nftables и держит политики и natOutgoing в своей таблице ip calico. Друг друга они не трогают, а порядок обработки пакета задают хуки и приоритеты базовых цепочек. DNAT kube-proxy происходит на хуке prerouting (nat-prerouting, приоритет dstnat), а политики Calico для трафика между подами проверяются позже, на хуке forward, — поэтому они видят пакет к сервису уже с адресом пода назначения, и правило «разрешить к подам hello» работает и для обращений через ClusterIP. На postrouting NAT-цепочка Calico nat-POSTROUTING (приоритет srcnat + 10) идёт сразу после nat-postrouting kube-proxy (srcnat).

Таблица kube-proxy на k8s-node-1, когда в demo есть hello, hello-np и hello-lb, — с сокращениями (кроме наших, на стенде есть сервисы kubernetes, kube-dns и два сервиса Calico — calico-api и calico-typha):

user@k8s-node-1:~$ sudo nft list table ip kube-proxy
table ip kube-proxy {
	comment "rules for kube-proxy"
	set hairpin-connections {
		type ipv4_addr . ipv4_addr . inet_proto . inet_service
		comment "service hairpin connections"
		elements = { 192.168.10.20 . 192.168.10.20 . tcp . 5473,
			     172.20.211.48 . 172.20.211.48 . tcp . 8080 }
	}

	set cluster-ips {
		type ipv4_addr
		comment "Active ClusterIPs"
		elements = { 10.96.0.1, 10.96.0.10,
			     10.97.64.183, 10.97.232.108,
			     10.98.231.113, 10.105.48.156,
			     10.105.101.118 }
	}

	set nodeport-ips {
		type ipv4_addr
		comment "IPs that accept NodePort traffic"
		elements = { 192.168.10.20 }
	}
	…
	map service-ips {
		type ipv4_addr . inet_proto . inet_service : verdict
		comment "ClusterIP, ExternalIP and LoadBalancer IP traffic"
		elements = { 10.96.0.10 . tcp . 53 : goto service-NWBZK7IH-kube-system/kube-dns/tcp/dns-tcp,
			     10.96.0.10 . udp . 53 : goto service-FY5PMXPG-kube-system/kube-dns/udp/dns,
			     10.97.232.108 . tcp . 80 : goto service-WRGIWGFD-demo/hello-lb/tcp/http,
			     10.105.101.118 . tcp . 80 : goto service-VSI2YVQZ-demo/hello/tcp/http,
			     10.105.48.156 . tcp . 80 : goto service-BJ5L76QF-demo/hello-np/tcp/http,
			     10.98.231.113 . tcp . 5473 : goto service-EZ4UY2LR-calico-system/calico-typha/tcp/calico-typha,
			     10.96.0.1 . tcp . 443 : goto service-2QRHZV4L-default/kubernetes/tcp/https,
			     10.97.64.183 . tcp . 443 : goto service-W56QCMHD-calico-system/calico-api/tcp/apiserver,
			     10.96.0.10 . tcp . 9153 : goto service-AS2KJYAD-kube-system/kube-dns/tcp/metrics }
	}

	map service-nodeports {
		type inet_proto . inet_service : verdict
		comment "NodePort traffic"
		elements = { tcp . 30080 : goto external-BJ5L76QF-demo/hello-np/tcp/http,
			     tcp . 31950 : goto external-WRGIWGFD-demo/hello-lb/tcp/http }
	}
	…
	chain filter-forward {
		type filter hook forward priority filter; policy accept;
		ct state new jump service-endpoints-check
		ct state new jump cluster-ips-check
	}
	…
	chain nat-prerouting {
		type nat hook prerouting priority dstnat; policy accept;
		jump services
	}

	chain nat-output {
		type nat hook output priority dstnat; policy accept;
		jump services
	}

	chain nat-postrouting {
		type nat hook postrouting priority srcnat; policy accept;
		jump masquerading
	}
	…
	chain services {
		ip daddr @cluster-ips ip saddr != 172.20.0.0/16 meta mark set meta mark | 0x00004000 comment "masquerade clusterIP traffic from outside cluster"
		ip daddr . meta l4proto . th dport vmap @service-ips
		ip daddr @nodeport-ips meta l4proto . th dport vmap @service-nodeports
	}

	chain masquerading {
		meta mark & 0x00004000 != 0x00000000 meta mark set meta mark ^ 0x00004000 masquerade fully-random
		ct status dnat ip saddr . ip daddr . meta l4proto . th dport @hairpin-connections masquerade fully-random
	}

	chain cluster-ips-check {
		ip daddr @cluster-ips reject comment "Reject traffic to invalid ports of ClusterIPs"
		ip daddr 10.96.0.0/12 drop comment "Drop traffic to unallocated ClusterIPs"
	}
	…
	chain service-2QRHZV4L-default/kubernetes/tcp/https {
		meta l4proto tcp dnat ip to numgen random mod 1 map { 0 : 192.168.10.17 . 6443 }
	}

	chain service-VSI2YVQZ-demo/hello/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 3 map { 0 : 172.20.172.15 . 8080, 1 : 172.20.172.17 . 8080, 2 : 172.20.211.48 . 8080 }
	}
	…
	chain service-BJ5L76QF-demo/hello-np/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 3 map { 0 : 172.20.172.15 . 8080, 1 : 172.20.172.17 . 8080, 2 : 172.20.211.48 . 8080 }
	}

	chain external-BJ5L76QF-demo/hello-np/tcp/http {
		meta mark set meta mark | 0x00004000 comment "masquerade"
		goto service-BJ5L76QF-demo/hello-np/tcp/http
	}
}

Читаем сверху вниз. cluster-ips — все семь ClusterIP; пакет на адрес из 10.96.0.0/12, которого здесь нет, отбрасывается в цепочке cluster-ips-check, а на действующий ClusterIP, но на порт, которого у сервиса нет, — отвергается с ответом. nodeport-ips — только 192.168.10.20, основной адрес узла. В service-ips — по элементу на каждый порт каждого сервиса, у DNS их три (UDP и TCP на 53 и метрики на 9153). Цепочка сервиса hello — одно правило с картой на три пода; у kubernetes в карте один адрес — kube-apiserver на мастере. NodePort hello-np идёт через external-…: трафик снаружи помечается для маскарадинга и уходит в обычную цепочку сервиса. В hairpin-connections — под hello этого узла (172.20.211.48) и под Typha, который работает в сети узла.

Хеш в имени цепочки — первые восемь символов SHA-256 от «неймспейс/имя/протокол/имя порта» в base32, чтобы имена были уникальными и укладывались в ограничение длины. Его можно посчитать самому:

$ printf 'demo/hello/tcp/http' | sha256sum | cut -d' ' -f1 | xxd -r -p | base32 | cut -c1-8
VSI2YVQZ

Как карта меняется вслед за подами. Уменьшим hello до двух реплик и посмотрим на цепочку:

$ kubectl -n demo scale deployment hello --replicas=2
deployment.apps/hello scaled
user@k8s-node-1:~$ sudo nft list chain ip kube-proxy 'service-VSI2YVQZ-demo/hello/tcp/http'
table ip kube-proxy {
	chain service-VSI2YVQZ-demo/hello/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 2 map { 0 : 172.20.172.17 . 8080, 1 : 172.20.211.48 . 8080 }
	}
}
$ kubectl -n demo scale deployment hello --replicas=3
deployment.apps/hello scaled

Правило переписано целиком, но только оно: остальная таблица не тронута. Третья реплика вернулась новым подом hello-6d5c6db6b4-dj4mf с адресом 172.20.172.18.

Распределение по подам. Для опытов запустим под-клиент на k8s-node-1 — busybox, в котором есть wget, nslookup и ping:

$ kubectl -n demo run client --image=busybox:1.37 --overrides='{"spec":{"nodeName":"k8s-node-1"}}' -- sleep 3600
pod/client created
$ kubectl -n demo get pod client -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP              NODE         NOMINATED NODE   READINESS GATES
client   1/1     Running   0          3s    172.20.211.52   k8s-node-1   <none>           <none>
$ kubectl -n demo exec client -- sh -c 'for i in 1 2 3 4 5 6 7 8; do wget -qO- http://hello/; done'
hello from hello-6d5c6db6b4-dj4mf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-dj4mf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-dj4mf
hello from hello-6d5c6db6b4-dj4mf
hello from hello-6d5c6db6b4-7k5d9

Каждый wget — новое соединение, и случайный выбор раскидывает их по трём подам — случайно, а не по кругу, поэтому на восьми запросах доли и не обязаны быть равными. Короткое имя hello сработало, потому что клиент в том же неймспейсе; как это устроено — в следующей части.

Неготовый под. Добавим в шаблон hello readiness-пробу на несуществующий путь и посмотрим, что станет со срезом и правилами:

$ kubectl -n demo patch deployment hello --type=json -p='[{"op":"add","path":"/spec/template/spec/containers/0/readinessProbe","value":{"httpGet":{"path":"/nope","port":"http"},"periodSeconds":5}}]'
deployment.apps/hello patched
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello -o wide
NAME                     READY   STATUS    RESTARTS   AGE     IP              NODE         NOMINATED NODE   READINESS GATES
hello-696bd4ff7-gf7wm    0/1     Running   0          30s     172.20.172.20   k8s-node-2   <none>           <none>
hello-6d5c6db6b4-7k5d9   1/1     Running   0          2m51s   172.20.172.17   k8s-node-2   <none>           <none>
hello-6d5c6db6b4-d7bpf   1/1     Running   0          2m51s   172.20.211.48   k8s-node-1   <none>           <none>
hello-6d5c6db6b4-dj4mf   1/1     Running   0          55s     172.20.172.18   k8s-node-2   <none>           <none>
$ kubectl -n demo get endpointslice -l kubernetes.io/service-name=hello -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} ready={.conditions.ready} serving={.conditions.serving} terminating={.conditions.terminating}{"\n"}{end}'
172.20.172.17 ready=true serving=true terminating=false
172.20.211.48 ready=true serving=true terminating=false
172.20.172.18 ready=true serving=true terminating=false
172.20.172.20 ready=false serving=false terminating=false
$ kubectl -n demo get events --field-selector reason=Unhealthy
LAST SEEN   TYPE      REASON      OBJECT                      MESSAGE
0s          Warning   Unhealthy   pod/hello-696bd4ff7-gf7wm   Readiness probe failed: HTTP probe failed with statuscode: 404
user@k8s-node-1:~$ sudo nft list chain ip kube-proxy 'service-VSI2YVQZ-demo/hello/tcp/http'
table ip kube-proxy {
	chain service-VSI2YVQZ-demo/hello/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 3 map { 0 : 172.20.172.17 . 8080, 1 : 172.20.172.18 . 8080, 2 : 172.20.211.48 . 8080 }
	}
}

При трёх репликах умолчания стратегии из части 7 — maxSurge 25 %, то есть один под с округлением вверх, и maxUnavailable 25 %, то есть ноль с округлением вниз, — поэтому Deployment создал один новый под сверх трёх и ни одного старого не тронул. Новый под не готов: в срезе он есть, но с ready=false, а в карту kube-proxy не попал. Rolling update стоит, сервис продолжает работать на трёх старых подах. Вернём:

$ kubectl -n demo rollout undo deployment hello
Warning: resource deployments/hello was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/hello rolled back

Завершающийся под. Удалим один под hello и сразу посмотрим на срез:

$ kubectl -n demo delete pod hello-6d5c6db6b4-dj4mf --wait=false
pod "hello-6d5c6db6b4-dj4mf" deleted from demo namespace
$ kubectl -n demo get endpointslice -l kubernetes.io/service-name=hello -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} ready={.conditions.ready} serving={.conditions.serving} terminating={.conditions.terminating}{"\n"}{end}'
172.20.172.17 ready=true serving=true terminating=false
172.20.211.48 ready=true serving=true terminating=false
172.20.172.18 ready=false serving=true terminating=true

Под ещё отвечает (serving=true), но уже завершается, поэтому ready=false, и новые соединения на него не идут. Через секунды он исчез из среза, а ReplicaSet создал замену — hello-6d5c6db6b4-m6pbj с адресом 172.20.172.19.

Conntrack на узле: DNAT в действии. Утилиты conntrack в Ubuntu Server по умолчанию нет, а ставить пакеты на узлы кластера ради отладки незачем: её удобнее запустить в отладочном поде kubectl debug node, который работает в сети и пространстве PID узла, а с профилем sysadmin — привилегированным. Образ — nicolaka/netshoot, набор сетевых утилит для отладки:

$ kubectl debug node/k8s-node-1 -n demo --profile=sysadmin --image=nicolaka/netshoot:v0.16 -- sleep 3600
Creating debugging pod node-debugger-k8s-node-1-2srlg with container debugger on node k8s-node-1.

Обратимся к ClusterIP с самого узла и из пода client, затем прочитаем таблицу conntrack узла:

user@k8s-node-1:~$ curl -s http://10.105.101.118/
hello from hello-6d5c6db6b4-d7bpf
$ kubectl -n demo exec client -- wget -qO- http://hello/
hello from hello-6d5c6db6b4-d7bpf
$ kubectl -n demo exec node-debugger-k8s-node-1-2srlg -- conntrack -L -d 10.105.101.118
conntrack v1.4.9 (conntrack-tools): 2 flow entries have been shown.
tcp      6 56 TIME_WAIT src=172.20.211.52 dst=10.105.101.118 sport=37466 dport=80 packets=5 bytes=336 src=172.20.211.48 dst=172.20.211.52 sport=8080 dport=37466 packets=5 bytes=458 [ASSURED] mark=0 use=1
tcp      6 55 TIME_WAIT src=192.168.10.20 dst=10.105.101.118 sport=52108 dport=80 packets=6 bytes=398 src=172.20.211.48 dst=192.168.10.20 sport=8080 dport=18188 packets=4 bytes=387 [ASSURED] mark=0 use=1

Запись conntrack читается двумя половинами: исходное направление (кто → 10.105.101.118:80) и ожидаемый ответ (от 172.20.211.48:8080). У соединения из пода ответ ждут на тот же порт 37466, с которого под и пришёл: источник не менялся, под hello видит настоящий адрес 172.20.211.52. У соединения с узла порт в ответе — 18188, а не исходный 52108: источник был не из сети подов, поэтому сработала метка, и маскарадинг с fully-random выбрал новый порт. Число после tcp 6 — сколько секунд запись ещё проживёт: закрытое соединение держится в TIME_WAIT две минуты.

Отказы. Создадим сервис legacy-api из раздела выше — пока только Service, без среза. Сервис без адресов, неверный порт ClusterIP и невыданный адрес сети сервисов ведут себя по-разному:

$ kubectl -n demo exec client -- wget -qO- -T 3 http://legacy-api/
wget: can't connect to remote host (10.110.229.142): Connection refused
command terminated with exit code 1
user@k8s-node-1:~$ sudo nft list map ip kube-proxy no-endpoint-services
table ip kube-proxy {
	map no-endpoint-services {
		type ipv4_addr . inet_proto . inet_service : verdict
		comment "vmap to drop or reject packets to services with no endpoints"
		elements = { 10.110.229.142 . tcp . 80 comment "demo/legacy-api:http" : goto reject-chain }
	}
}
$ kubectl -n demo exec client -- wget -qO- -T 3 http://10.105.101.118:8080/
wget: can't connect to remote host (10.105.101.118): Connection refused
command terminated with exit code 1
$ kubectl -n demo exec client -- wget -qO- -T 3 http://10.100.0.99/
wget: download timed out
command terminated with exit code 1

У legacy-api нет ни селектора, ни среза: kube-proxy заносит его в карту no-endpoint-services с переходом в reject-chain, и клиент сразу получает отказ, а не ждёт таймаута. На порт 8080 ClusterIP сервиса hello (у сервиса только порт 80) отвечает отказом cluster-ips-check, а адрес 10.100.0.99, который никому не выдан, молча отбрасывается. Ограничения на адреса в срезе проверяет API-сервер — так он отвечает на срез legacy-bad с адресом 0.0.0.0:

The EndpointSlice "legacy-bad" is invalid: endpoints[0].addresses[0]: Invalid value: "0.0.0.0": may not be unspecified (0.0.0.0)

externalTrafficPolicy: Local. Переключим hello-np и обратимся с ноутбука к узлам по очереди:

$ kubectl -n demo patch svc hello-np -p '{"spec":{"externalTrafficPolicy":"Local"}}'
service/hello-np patched
laptop:~$ for i in 1 2 3; do curl -s http://192.168.10.20:30080/; done
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
laptop:~$ curl -s -m 3 http://192.168.10.17:30080/; echo "rc=$?"
rc=28
$ kubectl -n demo logs hello-6d5c6db6b4-d7bpf --since=20s
2026/10/28 20:31:18 192.168.10.50:56260 GET /
2026/10/28 20:31:18 192.168.10.50:56261 GET /
2026/10/28 20:31:18 192.168.10.50:56262 GET /

На k8s-node-1 все запросы достались его единственному поду d7bpf, и тот видит настоящий адрес ноутбука 192.168.10.50. На мастере подов нет, и соединение не устанавливается (curl вышел по таймауту, код 28). В правилах это выглядит так:

user@k8s-node-1:~$ sudo nft list chain ip kube-proxy 'external-BJ5L76QF-demo/hello-np/tcp/http'
table ip kube-proxy {
	chain external-BJ5L76QF-demo/hello-np/tcp/http {
		ip saddr 172.20.0.0/16 goto service-BJ5L76QF-demo/hello-np/tcp/http comment "short-circuit pod traffic"
		fib saddr type local meta mark set meta mark | 0x00004000 comment "masquerade local traffic"
		fib saddr type local goto service-BJ5L76QF-demo/hello-np/tcp/http comment "short-circuit local traffic"
		goto local-BJ5L76QF-demo/hello-np/tcp/http
	}
}
user@k8s-node-1:~$ sudo nft list chain ip kube-proxy 'local-BJ5L76QF-demo/hello-np/tcp/http'
table ip kube-proxy {
	chain local-BJ5L76QF-demo/hello-np/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 1 map { 0 : 172.20.211.48 . 8080 }
	}
}
user@k8s-master-1:~$ sudo nft list map ip kube-proxy no-endpoint-nodeports
table ip kube-proxy {
	map no-endpoint-nodeports {
		type inet_proto . inet_service : verdict
		comment "vmap to drop or reject packets to service nodeports with no endpoints"
		elements = { tcp . 30080 comment "demo/hello-np:http" : drop }
	}
}

Трафик из сети подов и с самого узла (fib saddr type local — источник является адресом этого узла) идёт в общую цепочку сервиса, остальное — в local-… с единственным подом узла. На мастере своих подов нет, и NodePort попадает в no-endpoint-nodeports с drop. У hello-np поля healthCheckNodePort нет — оно бывает только у LoadBalancer. Переключим hello-lb и спросим его порт проверки у узлов:

$ kubectl -n demo patch svc hello-lb -p '{"spec":{"externalTrafficPolicy":"Local"}}'
service/hello-lb patched
$ kubectl -n demo get svc hello-lb -o jsonpath='{.spec.healthCheckNodePort}{"\n"}'
31692
laptop:~$ curl -s -i http://192.168.10.21:31692/
HTTP/1.1 200 OK
Content-Type: application/json
X-Content-Type-Options: nosniff
X-Load-Balancing-Endpoint-Weight: 2
Date: Wed, 28 Oct 2026 20:31:36 GMT
Content-Length: 116

{
	"service": {
		"namespace": "demo",
		"name": "hello-lb"
	},
	"localEndpoints": 2,
	"serviceProxyHealthy": true
}
laptop:~$ curl -s -i http://192.168.10.17:31692/
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
X-Content-Type-Options: nosniff
X-Load-Balancing-Endpoint-Weight: 0
Date: Wed, 28 Oct 2026 20:31:36 GMT
Content-Length: 116

{
	"service": {
		"namespace": "demo",
		"name": "hello-lb"
	},
	"localEndpoints": 0,
	"serviceProxyHealthy": true
}

kube-proxy отвечает на этом порту сам: на k8s-node-2 два локальных пода — 200, на мастере ни одного — 503. Документация обращается к пути /healthz, kube-proxy отвечает так же и на /. Заголовок X-Load-Balancing-Endpoint-Weight с числом локальных подов — подсказка балансировщикам, которые умеют взвешивать узлы и так сглаживают неравномерность Local.

sessionAffinity и internalTrafficPolicy. Создадим ещё два сервиса для тех же подов — hello-sticky с sessionAffinity: ClientIP и hello-local с internalTrafficPolicy: Local (манифесты — как у hello, с одним добавленным полем):

$ kubectl -n demo get svc hello-local hello-sticky
NAME           TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
hello-local    ClusterIP   10.102.119.174   <none>        80/TCP    3s
hello-sticky   ClusterIP   10.103.243.53    <none>        80/TCP    3s
$ kubectl -n demo get svc hello-sticky -o jsonpath='{.spec.sessionAffinityConfig}{"\n"}'
{"clientIP":{"timeoutSeconds":10800}}
$ kubectl -n demo exec client -- sh -c 'for i in 1 2 3 4 5 6; do wget -qO- http://hello-sticky/; done; echo ---; for i in 1 2 3 4 5 6; do wget -qO- http://hello-local/; done'
hello from hello-6d5c6db6b4-7k5d9
hello from hello-6d5c6db6b4-7k5d9
hello from hello-6d5c6db6b4-7k5d9
hello from hello-6d5c6db6b4-7k5d9
hello from hello-6d5c6db6b4-7k5d9
hello from hello-6d5c6db6b4-7k5d9
---
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf
hello from hello-6d5c6db6b4-d7bpf

hello-sticky выбрал под случайно один раз и дальше держится его; hello-local отправляет клиента только на под его узла k8s-node-1. Правила у них такие (с сокращениями):

user@k8s-node-1:~$ sudo nft list table ip kube-proxy
…
			     10.103.243.53 . tcp . 80 : goto service-4MXLGZVK-demo/hello-sticky/tcp/http,
…
			     10.102.119.174 . tcp . 80 : goto local-4H2GLPZP-demo/hello-local/tcp/http,
…
	set affinity-GWRCMAKN-demo/hello-sticky/tcp/http__172.20.172.17/8080 {
		type ipv4_addr
		size 65535	# count 1
		flags dynamic,timeout
		timeout 3h
		elements = { 172.20.211.52 expires 2h59m59s157ms }
	}
…
	chain endpoint-GWRCMAKN-demo/hello-sticky/tcp/http__172.20.172.17/8080 {
		update @affinity-GWRCMAKN-demo/hello-sticky/tcp/http__172.20.172.17/8080 { ip saddr }
		meta l4proto tcp dnat to 172.20.172.17:8080
	}
…
	chain service-4MXLGZVK-demo/hello-sticky/tcp/http {
		ip saddr @affinity-GWRCMAKN-demo/hello-sticky/tcp/http__172.20.172.17/8080 goto endpoint-GWRCMAKN-demo/hello-sticky/tcp/http__172.20.172.17/8080
		ip saddr @affinity-D7LANPZH-demo/hello-sticky/tcp/http__172.20.172.19/8080 goto endpoint-D7LANPZH-demo/hello-sticky/tcp/http__172.20.172.19/8080
		ip saddr @affinity-EXLX3BV3-demo/hello-sticky/tcp/http__172.20.211.48/8080 goto endpoint-EXLX3BV3-demo/hello-sticky/tcp/http__172.20.211.48/8080
		numgen random mod 3 vmap { 0 : goto endpoint-GWRCMAKN-demo/hello-sticky/tcp/http__172.20.172.17/8080, 1 : goto endpoint-D7LANPZH-demo/hello-sticky/tcp/http__172.20.172.19/8080, 2 : goto endpoint-EXLX3BV3-demo/hello-sticky/tcp/http__172.20.211.48/8080 }
	}

	chain local-4H2GLPZP-demo/hello-local/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 1 map { 0 : 172.20.211.48 . 8080 }
	}
}

Для sessionAffinity у каждого пода своё множество affinity-… с таймаутом три часа и своя цепочка endpoint-…: она записывает адрес клиента в множество (update … { ip saddr }) и делает DNAT. Цепочка сервиса сначала ищет клиента в множествах и, если находит, отправляет его к «его» поду, и только иначе выбирает случайно. Клиент 172.20.211.52 уже записан к поду 172.20.172.17, запись истечёт через три часа после последнего соединения. У hello-local элемент service-ips ведёт сразу в local-… с подом узла. На мастере, где подов нет, этот сервис попадает в no-endpoint-services с drop, и curl http://10.102.119.174/ с мастера заканчивается таймаутом.

trafficDistribution: PreferSameNode на стенде проверяется так же — сервис hello-near с этим полем:

$ kubectl -n demo get endpointslice -l kubernetes.io/service-name=hello-near -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} {.nodeName} hints={.hints}{"\n"}{end}'
172.20.172.17 k8s-node-2 hints={"forNodes":[{"name":"k8s-node-2"}]}
172.20.211.48 k8s-node-1 hints={"forNodes":[{"name":"k8s-node-1"}]}
172.20.172.19 k8s-node-2 hints={"forNodes":[{"name":"k8s-node-2"}]}
user@k8s-node-1:~$ sudo nft list table ip kube-proxy | grep -A2 'hello-near/tcp/http {'
	chain service-AKGLRN4Y-demo/hello-near/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 1 map { 0 : 172.20.211.48 . 8080 }
	}
user@k8s-master-1:~$ sudo nft list table ip kube-proxy | grep -A2 'hello-near/tcp/http {'
	chain service-AKGLRN4Y-demo/hello-near/tcp/http {
		meta l4proto tcp dnat ip to numgen random mod 3 map { 0 : 172.20.172.17 . 8080, 1 : 172.20.172.19 . 8080, 2 : 172.20.211.48 . 8080 }
	}
$ kubectl -n demo delete svc hello-near
service "hello-near" deleted from demo namespace

Контроллер EndpointSlice проставил каждому адресу подсказку с его узлом. На k8s-node-1 в карте только свой под, а на мастере своих подов нет, и там в карте все три — в отличие от internalTrafficPolicy: Local, трафик не теряется.

Headless-сервис и ExternalName глазами DNS. Создадим hello-headless с clusterIP: None (тот же селектор и порт) и registry из раздела выше:

$ kubectl -n demo get svc hello-headless registry
NAME             TYPE           CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
hello-headless   ClusterIP      None         <none>        80/TCP    3s
registry         ExternalName   <none>       ghcr.io       <none>    3s
$ kubectl -n demo exec client -- nslookup hello-headless.demo.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53


Name:	hello-headless.demo.svc.cluster.local
Address: 172.20.172.17
Name:	hello-headless.demo.svc.cluster.local
Address: 172.20.211.48
Name:	hello-headless.demo.svc.cluster.local
Address: 172.20.172.19

$ kubectl -n demo exec client -- nslookup registry.demo.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53

registry.demo.svc.cluster.local	canonical name = ghcr.io

registry.demo.svc.cluster.local	canonical name = ghcr.io
Name:	ghcr.io
Address: 140.82.121.34

На имя headless-сервиса DNS вернул адреса всех трёх подов, а в таблице kube-proxy о hello-headless нет ни строчки, и в cluster-ips его нет. На registry пришёл CNAME на ghcr.io (busybox печатает его дважды) и уже настоящий адрес ghcr.io.

Наконец, адреса сервисов как объекты:

$ kubectl get servicecidrs
NAME         CIDRS          AGE
kubernetes   10.96.0.0/12   4d10h
$ kubectl get ipaddresses
NAME             PARENTREF
10.102.119.174   services/demo/hello-local
10.103.243.53    services/demo/hello-sticky
10.105.101.118   services/demo/hello
10.105.48.156    services/demo/hello-np
10.110.229.142   services/demo/legacy-api
10.96.0.1        services/default/kubernetes
10.96.0.10       services/kube-system/kube-dns
10.97.232.108    services/demo/hello-lb
10.97.64.183     services/calico-system/calico-api
10.98.231.113    services/calico-system/calico-typha

У headless-сервиса и ExternalName объекта IPAddress нет — адреса у них нет. Все автоматически выданные адреса — из верхней полосы диапазона, в нижней только 10.96.0.1 и 10.96.0.10.

Убрать учебные объекты: kubectl -n demo delete svc hello-np hello-lb hello-local hello-sticky hello-headless legacy-api registry, kubectl -n demo delete pod client node-debugger-k8s-node-1-2srlg. Если вы добавляли к legacy-api срез legacy-api-1, удалите и его (kubectl -n demo delete endpointslice legacy-api-1): у среза, созданного руками, нет ссылки на владельца, и вместе с сервисом он не исчезнет. Сервис hello оставляем — он понадобится дальше.


Источники

  • Service — типы сервисов, targetPort по имени, NodePort и диапазон портов, LoadBalancer, loadBalancerClass и allocateLoadBalancerNodePorts, headless, ExternalName и его проблемы с HTTP и HTTPS, сервис без селектора и ограничения на адреса.
  • Virtual IPs and Service Proxies — режимы kube-proxy iptables, IPVS и nftables, отличия nftables при переходе, устаревание IPVS, sessionAffinity, полосы диапазона ClusterIP, политики трафика, трафик на завершающиеся поды, trafficDistribution.
  • EndpointSlices — условия ready, serving, terminating, метка managed-by, 100 адресов в срезе.
  • Service Internal Traffic Policy — internalTrafficPolicy: Local.
  • Service ClusterIP allocation — как выдаются ClusterIP, нижняя и верхняя полосы, примеры расчёта.
  • Extend Service IP Ranges — объекты ServiceCIDR и IPAddress.
  • Create an External Load Balancer — externalTrafficPolicy и healthCheckNodePort.
  • Using Source IP — адрес клиента при Cluster и Local, проверка healthCheckNodePort.
  • kube-proxy — флаги --proxy-mode и --nodeport-addresses.
  • NFTables mode for kube-proxy — зачем nftables, требования к ядру и nft, переход без перезагрузки узлов.
  • Debugging Kubernetes Nodes With Kubectl — kubectl debug node и профиль sysadmin.
  • nicolaka/netshoot — образ с сетевыми утилитами для отладки.

Дальше — DNS в кластере: как CoreDNS отвечает на имена сервисов и подов, какие записи есть для обычных и headless-сервисов, что лежит в /etc/resolv.conf пода и почему из-за ndots:5 запрос api.example.com превращается в несколько запросов.

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

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