Kubernetes для самых маленьких. Часть 17: Настраиваем LoadBalancer без облака на MetalLB
Все части серии «Kubernetes для самых маленьких»В части 15 сервис типа LoadBalancer так и остался с EXTERNAL-IP <pending>: kube-proxy сделал свою часть, а внешний адрес выдать было некому. Сегодня закроем этот пробел. Разберём, что в облаке делает за нас облачный контроллер и почему на собственном железе его роль должен взять кто-то другой; поставим MetalLB — самый распространённый балансировщик для кластеров без облака; дадим ему пул 192.168.10.200–192.168.10.220 и посмотрим, как в режиме L2 один из узлов начинает отвечать на ARP за адрес сервиса, как адрес переезжает на другой узел, а в конце переведём MetalLB в режим BGP и посмотрим на сессии и маршруты к адресу сервиса в настоящем роутере на FRR. DNS кластера из прошлой части сегодня не понадобится: работа идёт на уровне ARP, маршрутов и адресов узлов в сети 192.168.10.0/24.
Неймспейс demo с Deployment hello из трёх подов собран заново по части 11: два пода на k8s-node-2, один на k8s-node-1.
Что делает облачный балансировщик
type: LoadBalancer в Kubernetes — это только заявка. Сам kube-apiserver выделяет сервису ClusterIP и NodePort, kube-proxy настраивает на узлах правила, а внешний адрес должен появиться в status.loadBalancer.ingress стараниями кого-то третьего. В облаке это cloud controller manager — компонент, который знает API облачного провайдера. Увидев новый сервис типа LoadBalancer, он:
- создаёт у провайдера балансировщик и получает у него публичный адрес;
- добавляет в балансировщик узлы кластера (их адреса и NodePort сервиса) и проверку здоровья;
- записывает адрес в
status.loadBalancer.ingress; - следит за изменениями: добавились узлы — обновляет балансировщик, удалили сервис — удаляет балансировщик.
Сам трафик идёт через инфраструктуру провайдера: адрес принадлежит его балансировщику, а не узлу.
В кластере на своём железе ни провайдера, ни его API нет. Кто-то должен, во-первых, выбрать адрес из диапазона, который администратор выделил под сервисы, и, во-вторых, сделать так, чтобы сеть доставляла пакеты на этот адрес к узлам кластера. MetalLB делает обе вещи.
MetalLB: controller и speaker
MetalLB — проект CNCF, реализация сервисов типа LoadBalancer для кластеров без облака. Он состоит из двух компонентов и набора CRD:
| Компонент | Тип | Что делает |
|---|---|---|
controller |
Deployment, одна реплика | следит за сервисами типа LoadBalancer, выдаёт адреса из пулов, пишет их в status.loadBalancer.ingress; проверяет ресурсы MetalLB через вебхук |
speaker |
DaemonSet, в сети узла | объявляет выданные адреса в сеть: в режиме L2 отвечает на ARP, в режиме BGP анонсирует маршруты роутеру |
Важно понять, чем MetalLB не является: он не стоит на пути пакетов. Он не проксирует трафик и не занимается его распределением. Его работа — чтобы пакет, адресованный 192.168.10.200, пришёл на один из узлов кластера. Дальше пакет попадает в правила kube-proxy: адрес из status.loadBalancer.ingress kube-proxy добавляет в ту же карту service-ips, что и ClusterIP, и DNAT отправляет пакет на под.
ноутбук 192.168.10.50 k8s-node-2 192.168.10.21
│ кто 192.168.10.200? (ARP) ──────────► speaker: «это я, MAC 52:54:00:3a:10:21»
│ TCP 192.168.10.200:80 ──────────────► ens18 → nftables kube-proxy:
│ service-ips: 192.168.10.200 . tcp . 80
│ → external-WRGIWGFD-demo/hello-lb/tcp/http
│ → DNAT на под hello (на любом узле)
◄──────────────────────── ответ ───────┘
Адреса 192.168.10.200 нет ни на одном интерфейсе, так же как ClusterIP: speaker лишь отвечает за него на ARP-запросы MAC-адресом своего узла.
Режим L2
В режиме L2 за каждый адрес сервиса отвечает один узел — лидер этого адреса. Его speaker отвечает на ARP-запросы «у кого 192.168.10.200?» MAC-адресом своего ens18. ARP (RFC 826) — широковещательный вопрос «у кого этот IP-адрес?» в пределах своей сети и ответ владельца с его MAC-адресом; протокол разобран в части 7 сетевой серии. Роутер и клиенты в сети 192.168.10.0/24 записывают этот MAC в свои ARP-таблицы и шлют пакеты для 192.168.10.200 на узел-лидер.
Выбор лидера — без голосования и без хранилища. Каждый speaker знает, какие speaker'ы сейчас живы, и для каждого адреса считает одно и то же: список подходящих узлов, отсортированный по хешу от пары «имя узла + адрес». Первый в списке — лидер, и только его speaker отвечает на ARP. Поскольку все считают одинаково, договариваться не нужно. Разные адреса получают разных лидеров, так что нагрузка по сервисам расходится по узлам, но трафик одного адреса всегда идёт через один узел. Ушедший из списка узел лидера у других адресов не меняет, а вернувшийся забирает адрес обратно, только если по хешу он снова первый.
В список подходящих узлов попадают только узлы, где:
- работает живой speaker;
- нет метки
node.kubernetes.io/exclude-from-external-load-balancers— kubeadm ставит её на control plane, поэтомуk8s-master-1адреса не объявляет (флаг speaker'а--ignore-exclude-lbэто отключает, но для control plane так и надо); - узел подходит под
nodeSelectorsв L2Advertisement, если они заданы; - при
externalTrafficPolicy: Local— есть готовый под сервиса (об этом ниже).
Кто жив, speaker'ы узнают через memberlist — библиотеку HashiCorp, реализующую протокол слухов (gossip): каждый участник периодически проверяет случайных соседей по UDP и TCP на порту 7946, и сведения об упавшем участнике расходятся по группе за секунды. Speaker, который останавливается штатно, сам сообщает группе, что уходит, и тогда адрес переезжает почти мгновенно. Без memberlist (его выключают, убрав из DaemonSet переменную METALLB_ML_BIND_ADDR) об отказе узла speaker'ы узнали бы только из API Kubernetes, а статус узла там меняется через 50 секунд и больше (часть 13). Поэтому порт 7946 между узлами должен быть открыт. Ключ, которым шифруется трафик memberlist, controller при первом запуске генерирует сам и хранит в Secret memberlist в неймспейсе metallb-system.
Переключение. Когда у адреса меняется лидер, новый лидер рассылает gratuitous ARP — ARP-пакеты, о которых никто не спрашивал, — чтобы роутер и клиенты сразу обновили MAC-адрес для 192.168.10.200, не дожидаясь, пока устареет их ARP-кэш. На стенде speaker отправлял их пять раз с интервалом 1,1 секунды, каждый раз в двух формах — широковещательным ARP-запросом, в котором отправитель и искомый адрес совпадают (такой пакет RFC 5227 называет ARP Announcement), и широковещательным ARP-ответом. Большинство систем принимают такие пакеты, и переключение занимает несколько секунд; документация MetalLB считает переключение дольше 10 секунд ошибкой. Клиенты, которые gratuitous ARP игнорируют, будут слать пакеты на старый MAC, пока их кэш не устареет.
При плановом выводе узла (kubectl cordon, kubectl drain) speaker остаётся работать: DaemonSet не выселяется, и адрес с узла не уходит. Если нужно увести адреса с узла заранее, на него ставят метку node.kubernetes.io/exclude-from-external-load-balancers=true — speaker'ы пересчитают лидеров, и новый лидер объявит себя, пока старый ещё жив. Документация советует после этого не выключать старый узел ещё пару минут, чтобы он обслужил клиентов с устаревшим кэшем.
Ограничения режима L2, которые надо принимать сознательно:
- весь входящий трафик адреса — через один узел. Пропускная способность сервиса ограничена сетевой картой лидера. Это отказоустойчивость, а не балансировка: распределение по подам делает уже kube-proxy на лидере;
- все узлы и адреса пула — в одном L2-сегменте. ARP не проходит через маршрутизаторы, поэтому пул должен лежать в подсети узлов (у нас
192.168.10.0/24), а узлы из другой подсети объявлять эти адреса не смогут; - время переключения зависит от клиентов и их обработки gratuitous ARP;
- некоторые сети запрещают «чужие» ARP-ответы. Виртуальные коммутаторы гипервизоров и облаков с защитой от подмены MAC-адресов могут отбрасывать ответы за IP-адрес, не закреплённый за виртуальной машиной. Тогда это нужно разрешить в настройках гипервизора.
Похожий механизм — VRRP (Virtual Router Redundancy Protocol, RFC 5798), на котором работает Keepalived — демон Linux для отказоустойчивых адресов: несколько машин выбирают ведущего обменом сообщениями VRRP, и ведущий держит общий адрес. MetalLB делает то же без VRRP — выбор вычисляется из списка живых speaker'ов, поэтому нет ограничения на 255 групп и не нужно раздавать идентификаторы виртуальных роутеров.
Режим BGP
В режиме BGP speaker на каждом подходящем узле устанавливает сессию с роутером и анонсирует адрес сервиса как маршрут /32 через себя. BGP (RFC 4271) — протокол, которым маршрутизаторы обмениваются сведениями о доступных сетях (часть 22 сетевой серии). Роутер получает несколько одинаковых маршрутов к адресу сервиса — через каждый узел — и использует ECMP (Equal-Cost Multi-Path): раскладывает трафик по всем этим узлам, выбирая узел по хешу от полей пакета. Linux по умолчанию хеширует только адреса отправителя и получателя, поэтому все соединения одного клиента идут через один узел, а по узлам расходятся разные клиенты; если добавить в хеш порты, расходятся и соединения одного клиента. Это уже балансировка на уровне сети: нет узкого места в одном узле. Скорость переключения зависит от того, как узел пропал. Speaker, который останавливается штатно, закрывает TCP-соединение с роутером, и роутер убирает маршрут через этот узел сразу. При внезапном отказе узла роутер ждёт, пока истечёт hold time сессии — в MetalLB по умолчанию 90 секунд. Ускоряет это BFD (RFC 5880) — протокол быстрой проверки связности между соседями по маршрутизации: они обмениваются короткими пакетами каждые десятки или сотни миллисекунд и объявляют соседа недоступным после нескольких пропусков. Адреса пула не обязаны быть в подсети узлов, и лучше, когда они вне её, — почему, видно в разделе «Режим BGP на стенде».
Ресурсы этого режима — BGPPeer и BGPAdvertisement (полная конфигурация, проверенная с настоящим роутером, — в конце статьи). BGPPeer описывает соседа: номера автономных систем (AS — номер сети под единым управлением, которым представляется участник BGP) своей (myASN) и роутера (peerASN), адрес роутера. BGPAdvertisement — какие пулы анонсировать и с какими атрибутами: агрегация маршрутов, local preference (предпочтение маршрута внутри своей AS, поэтому соседу из чужой AS она не передаётся), community (метки, по которым роутер применяет к маршрутам свои политики). Минус ECMP — при изменении набора узлов (узел ушёл или пришёл) хеш перераспределяет часть соединений на другие узлы, и они рвутся, если роутер не умеет устойчивое хеширование (resilient hashing), при котором перераспределяются только соединения ушедшего узла.
MetalLB умеет говорить по BGP тремя способами. FRR-K8s — рекомендуемый и с версии 0.16 используемый по умолчанию: на каждом узле работает FRR (свободный набор демонов маршрутизации, в том числе BGP и BFD), которым управляет отдельный контроллер через свой API; так поддерживается BFD, а сессии можно делить с другими пользователями FRR на узле. native — собственная лёгкая реализация BGP в speaker'е, без BFD; её даёт манифест metallb-native.yaml, которым мы ставим MetalLB ниже. Режим FRR со встроенным в speaker FRR объявлен устаревшим и будет удалён.
BGP рядом с Calico. На каждом узле стенда уже работает BIRD — BGP-демон Calico из части 14 — в сети узла, и он слушает BGP-порт 179. С speaker'ом в режиме native они за порт не спорят: speaker сам открывает соединение к роутеру со случайного порта и порт 179 не слушает. Поэтому роутер должен ждать соединений от узлов, а не звонить им сам, — иначе он попадёт к BIRD. Настоящий конфликт другой, о нём предупреждает документация MetalLB: между парой «узел — роутер» BGP допускает только одну сессию, и если с тем же роутером уже говорит Calico, сессию MetalLB роутер отвергнет как дубликат. На стенде BIRD обменивается маршрутами только между узлами, с роутером сессий у него нет, и MetalLB поднимает свои без помех. В кластерах, где Calico уже говорит с роутером по BGP, документация MetalLB советует отдать анонс адресов сервисов самому Calico: MetalLB выдаёт адреса (пул без BGPAdvertisement), Calico их объявляет. Именно так сделано в серии «Отказоустойчивый Kubernetes на BGP».
Установка
Ставим MetalLB официальным манифестом в режиме native: для L2 FRR не нужен, а BGP в этом режиме speaker говорит сам. Актуальная версия на момент проверки — 0.16.1. Лучшая практика — не применять манифест прямо из интернета, а скачать, прочитать и сохранить рядом с остальными манифестами кластера, чтобы версия была зафиксирована:
$ curl -fsSLO https://raw.githubusercontent.com/metallb/metallb/v0.16.1/config/manifests/metallb-native.yaml
$ grep -E '^kind:' metallb-native.yaml | sort | uniq -c
2 kind: ClusterRole
2 kind: ClusterRoleBinding
1 kind: ConfigMap
9 kind: CustomResourceDefinition
1 kind: DaemonSet
1 kind: Deployment
1 kind: Namespace
2 kind: Role
2 kind: RoleBinding
1 kind: Secret
1 kind: Service
2 kind: ServiceAccount
1 kind: ValidatingWebhookConfiguration
$ grep -n 'image:' metallb-native.yaml
2136: image: quay.io/metallb/controller:v0.16.1
2233: image: quay.io/metallb/speaker:v0.16.1
$ kubectl apply -f metallb-native.yaml
namespace/metallb-system created
customresourcedefinition.apiextensions.k8s.io/bfdprofiles.metallb.io created
customresourcedefinition.apiextensions.k8s.io/bgpadvertisements.metallb.io created
customresourcedefinition.apiextensions.k8s.io/bgppeers.metallb.io created
customresourcedefinition.apiextensions.k8s.io/communities.metallb.io created
customresourcedefinition.apiextensions.k8s.io/configurationstates.metallb.io created
customresourcedefinition.apiextensions.k8s.io/ipaddresspools.metallb.io created
customresourcedefinition.apiextensions.k8s.io/l2advertisements.metallb.io created
customresourcedefinition.apiextensions.k8s.io/servicebgpstatuses.metallb.io created
customresourcedefinition.apiextensions.k8s.io/servicel2statuses.metallb.io created
serviceaccount/controller created
serviceaccount/speaker created
role.rbac.authorization.k8s.io/controller created
role.rbac.authorization.k8s.io/pod-lister created
clusterrole.rbac.authorization.k8s.io/metallb-system:controller created
clusterrole.rbac.authorization.k8s.io/metallb-system:speaker created
rolebinding.rbac.authorization.k8s.io/controller created
rolebinding.rbac.authorization.k8s.io/pod-lister created
clusterrolebinding.rbac.authorization.k8s.io/metallb-system:controller created
clusterrolebinding.rbac.authorization.k8s.io/metallb-system:speaker created
configmap/metallb-excludel2 created
secret/metallb-webhook-cert created
service/metallb-webhook-service created
deployment.apps/controller created
daemonset.apps/speaker created
validatingwebhookconfiguration.admissionregistration.k8s.io/metallb-webhook-configuration created
Первые полминуты поды speaker'а стоят в ContainerCreating с событиями FailedMount — MountVolume.SetUp failed for volume "memberlist" : secret "memberlist" not found: Secret с ключом memberlist создаёт controller, когда сам запустится. Ещё одна ловушка: kubectl wait -l app=metallb сразу после apply может не дождаться speaker'ов — он берёт список подов в момент запуска, а DaemonSet создаёт их чуть позже. Поэтому ждём отдельной командой, когда поды уже есть. На стенде controller был готов через 34 секунды, speaker'ы — примерно через минуту, вместе со скачиванием образов:
$ kubectl -n metallb-system wait --for=condition=Ready pod -l app=metallb --timeout=180s
pod/controller-658745d67-mlpqs condition met
pod/speaker-4ld9k condition met
pod/speaker-7zhm6 condition met
pod/speaker-lc7m7 condition met
$ kubectl -n metallb-system get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
controller-658745d67-mlpqs 1/1 Running 0 104s 172.20.172.26 k8s-node-2 <none> <none>
speaker-4ld9k 1/1 Running 0 103s 192.168.10.17 k8s-master-1 <none> <none>
speaker-7zhm6 1/1 Running 0 103s 192.168.10.21 k8s-node-2 <none> <none>
speaker-lc7m7 1/1 Running 0 103s 192.168.10.20 k8s-node-1 <none> <none>
$ kubectl get ns metallb-system --show-labels
NAME STATUS AGE LABELS
metallb-system Active 108s kubernetes.io/metadata.name=metallb-system,pod-security.kubernetes.io/audit=privileged,pod-security.kubernetes.io/enforce=privileged,pod-security.kubernetes.io/warn=privileged
Что появилось:
- неймспейс
metallb-systemс метками Pod Security Admission уровняprivileged(механизм, который ограничивает права подов в неймспейсе, — часть 30): speaker'у нужны сеть узла и правоNET_RAW, чтобы отправлять и принимать ARP напрямую, а для неймспейса с обычными приложениями такие права недопустимы; - CRD: пулы (
IPAddressPool), объявления (L2Advertisement,BGPAdvertisement), BGP-соседи, community и BFD-профили, а также статусные ресурсыServiceL2Status,ServiceBGPStatusиConfigurationState, в которых MetalLB сообщает, кто объявляет сервис и есть ли ошибки в конфигурации; - controller — обычный под в сети подов, от пользователя
65534(nobody), без привилегий Linux; speaker — по поду на каждый узел, включая мастер (там он работает, но из-за метки исключения ничего не объявляет), в сети узла. Speaker при всех правах сети узла ограничен разумно: файловая система контейнера только для чтения, повышение привилегий запрещено, из привилегий Linux оставлена однаNET_RAW; - вебхук — Service
metallb-webhook-serviceи ValidatingWebhookConfiguration: kube-apiserver перед сохранением ресурса MetalLB отправляет его controller'у на проверку, и пул с ошибкой в адресах будет отклонён сразу, а не молча проигнорирован. Сертификат вебхука controller выпускает себе сам и вписывает его CA в ValidatingWebhookConfiguration — cert-manager для этого не нужен.
Метка исключения на мастере стоит с kubeadm init (фаза mark-control-plane), значение у неё пустое:
$ kubectl get node k8s-master-1 -o jsonpath='{.metadata.labels}'
{"beta.kubernetes.io/arch":"amd64","beta.kubernetes.io/os":"linux","kubernetes.io/arch":"amd64","kubernetes.io/hostname":"k8s-master-1","kubernetes.io/os":"linux","node-role.kubernetes.io/control-plane":"","node.kubernetes.io/exclude-from-external-load-balancers":""}
Speaker слушает memberlist на адресе узла, а метрики (по HTTPS с версии 0.16) и проверки здоровья — на своих портах:
user@k8s-master-1:~$ sudo ss -lntup | grep speaker
udp UNCONN 0 0 192.168.10.17:7946 0.0.0.0:* users:(("speaker",pid=1119538,fd=9))
tcp LISTEN 0 4096 192.168.10.17:7946 0.0.0.0:* users:(("speaker",pid=1119538,fd=8))
tcp LISTEN 0 4096 127.0.0.1:17472 0.0.0.0:* users:(("speaker",pid=1119538,fd=15))
tcp LISTEN 0 4096 *:9120 *:* users:(("speaker",pid=1119538,fd=16))
Особая подготовка kube-proxy для MetalLB нужна только в режиме IPVS: там все адреса сервисов висят на интерфейсе kube-ipvs0, и узлы сами начали бы отвечать на ARP за них, поэтому документация MetalLB требует strictARP: true. Это настройка именно IPVS. В нашем режиме nftables адресов сервисов на интерфейсах нет, и ничего менять не надо — strictARP остаётся по умолчанию:
$ kubectl -n kube-system get configmap kube-proxy -o jsonpath='{.data.config\.conf}' | grep -E '^mode:|strictARP'
strictARP: false
mode: nftables
Без конфигурации MetalLB ничего не делает. Даём ему пул и разрешаем объявлять его в режиме L2:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- 192.168.10.200-192.168.10.220
autoAssign: true
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default
interfaces:
- ens18
addresses— диапазон или CIDR. Эти адреса должны быть исключены из DHCP-пула и не назначены вручную ни одной машине: иначе DHCP-сервер когда-нибудь выдаст192.168.10.205ноутбуку, а MetalLB — сервису, и два устройства будут отвечать на ARP за один адрес. Перед созданием пула стоит ещё раз убедиться, что адреса свободны:pingиarpingна них не должны получать ответа.autoAssign: true(по умолчанию) — выдавать адреса из этого пула сервисам, которые не попросили конкретный. Для пула «особых» адресов ставятfalseи выдают их только по явной просьбе. ПолеavoidBuggyIPs(по умолчаниюfalse) пропускает адреса, оканчивающиеся на.0и.255, — их не любят некоторые старые устройства.interfacesв L2Advertisement — с каких интерфейсов отвечать на ARP. Без него speaker отвечает на всех интерфейсах узла, кроме перечисленных в ConfigMapmetallb-excludel2(lo,cali*,docker*,kube-ipvs*и других).vxlan.calicoв этом списке нет: на стенде speaker при запуске создал ARP-ответчики и наens18, и наvxlan.calico. На узлах с несколькими сетями поле избавляет от ответов не туда.
$ kubectl apply -f metallb-config.yaml
ipaddresspool.metallb.io/default created
l2advertisement.metallb.io/default created
$ kubectl -n metallb-system get ipaddresspools,l2advertisements
NAME AUTO ASSIGN AVOID BUGGY IPS ADDRESSES
ipaddresspool.metallb.io/default true false ["192.168.10.200-192.168.10.220"]
NAME IPADDRESSPOOLS IPADDRESSPOOL SELECTORS INTERFACES
l2advertisement.metallb.io/default ["default"] ["ens18"]
В status пула controller ведёт счётчики assignedIPv4 и availableIPv4 — сколько адресов выдано и сколько осталось. Ошибку в пуле вебхук не пропускает:
$ kubectl apply -f bad-pool.yaml
Error from server (Forbidden): error when creating "bad-pool.yaml": admission webhook "ipaddresspoolvalidationwebhook.metallb.io" denied the request: parsing address pool bad: invalid CIDR "192.168.10.210-192.168.10.200" in pool "bad": invalid IP range "192.168.10.210-192.168.10.200": start IP "192.168.10.210" is after the end IP "192.168.10.200"
Так же отвергается пул, пересекающийся с уже существующим (… overlaps with already defined CIDR …).
Сервис для hello
apiVersion: v1
kind: Service
metadata:
name: hello-lb
namespace: demo
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: hello
ports:
- name: http
port: 80
targetPort: http
$ kubectl apply -f hello-lb.yaml && kubectl -n demo get svc hello-lb
service/hello-lb created
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-lb LoadBalancer 10.110.13.185 <pending> 80:30165/TCP 0s
$ kubectl -n demo get svc hello-lb
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-lb LoadBalancer 10.110.13.185 192.168.10.200 80:30165/TCP 4s
$ curl -s http://192.168.10.200/
hello from hello-6d5c6db6b4-k4mn2
Адрес выдан за секунды, и hello отвечает с ноутбука. События сервиса рассказывают, кто что сделал:
$ kubectl -n demo describe svc hello-lb | sed -n '/Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal IPAllocated 4s metallb-controller Assigned IP ["192.168.10.200"]
Normal nodeAssigned 4s metallb-speaker announcing from node "k8s-node-2" with protocol "layer2"
$ kubectl -n demo get svc hello-lb -o jsonpath='{.status}'
{"loadBalancer":{"ingress":[{"ip":"192.168.10.200","ipMode":"VIP"}]}}
controller выдал адрес, speaker на k8s-node-2 стал его лидером. ipMode: VIP значит, что пакеты приходят на узел с адресом назначения 192.168.10.200, и kube-proxy обрабатывает его как виртуальный адрес сервиса (поэтому он и есть в карте service-ips). Облачные балансировщики, которые сами проксируют трафик, ставят Proxy: тогда пакеты к адресу балансировщика из самого кластера kube-proxy не перехватывает, и они идут через балансировщик.
Конкретный адрес, общий адрес и ошибки выдачи
Если сервису нужен определённый адрес (на него уже настроена запись DNS), его просят аннотацией metallb.io/loadBalancerIPs. Старое поле spec.loadBalancerIP MetalLB ещё понимает (на стенде сервис с ним получил свой адрес, и kube-apiserver 1.36 даже не предупредил), но в Kubernetes оно объявлено устаревшим с версии 1.24. Из какого пула выдавать, указывают аннотацией metallb.io/address-pool. Аннотация metallb.io/allow-shared-ip с одинаковым ключом у двух сервисов позволяет им делить один адрес — если порты у них разные и оба с политикой Cluster (или оба выбирают ровно одни и те же поды):
apiVersion: v1
kind: Service
metadata:
name: hello-a
namespace: demo
annotations:
metallb.io/loadBalancerIPs: 192.168.10.210
metallb.io/allow-shared-ip: hello-shared
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: hello
ports:
- name: http
port: 8080
targetPort: http
Второй сервис hello-b — такой же, с портом 8081:
$ kubectl apply -f lb-extra.yaml
service/hello-a created
service/hello-b created
$ kubectl -n demo get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-a LoadBalancer 10.102.148.205 192.168.10.210 8080:30548/TCP 5s
hello-b LoadBalancer 10.109.27.181 192.168.10.210 8081:30273/TCP 5s
hello-lb LoadBalancer 10.110.13.185 192.168.10.200 80:30165/TCP 8m55s
$ curl -s http://192.168.10.210:8080/
hello from hello-6d5c6db6b4-vlh27
$ curl -s http://192.168.10.210:8081/
hello from hello-6d5c6db6b4-vlh27
На порт 80 адреса 192.168.10.210 никто не отвечает — curl выходит по таймауту: этот порт не описан ни в одном сервисе. Если адрес выдать нельзя, сервис остаётся <pending>, а у controller'а появляется событие Warning AllocationFailed. Тексты на стенде:
- пул кончился —
Failed to allocate IP for "demo/hello-c": no available IPs; - просили адрес вне всех пулов —
Failed to allocate IP for "demo/hello-d": ["192.168.10.230"] is not allowed in config; - просили несуществующий пул —
Failed to allocate IP for "demo/hello-e": unknown pool "special".
Сервис с полем spec.loadBalancerClass: example.com/other-lb MetalLB не трогает вовсе: он висит <pending> без единого события. Это поле нужно, когда в кластере несколько реализаций LoadBalancer: каждая обрабатывает только свой класс, а MetalLB без флага --lb-class — только сервисы без класса.
Сервис hello-lb учебный: в следующей части адрес 192.168.10.200 займёт обратный прокси, который станет единой точкой входа для всех HTTP-приложений, а отдельный адрес на каждое приложение — плохая практика, когда приложений больше двух.
externalTrafficPolicy: Local с MetalLB
С политикой по умолчанию Cluster (из части 15) лидер принимает трафик и раскидывает его по подам на всех узлах, маскируя адрес клиента своим. Под видит в журнале не 192.168.10.50, а адрес узла-лидера.
С externalTrafficPolicy: Local меняется и kube-proxy, и выбор лидера:
- kube-proxy на узле отправляет внешний трафик только на поды этого узла и не меняет адрес источника — под видит настоящий адрес клиента;
- MetalLB в режиме L2 выбирает лидера только среди узлов, где есть готовый под сервиса. Если под на узле-лидере исчез, лидерство переходит к узлу, где под есть.
Цена в L2 — весь трафик сервиса обслуживают поды одного узла: поды hello на k8s-node-1 будут простаивать, пока лидер — k8s-node-2. Для сервисов, где адрес клиента важен (обратный прокси с журналами и ограничением частоты запросов), это приемлемо, а реплики на других узлах служат горячим резервом. В режиме BGP с Local маршрут анонсируют только узлы с подами, и роутер распределяет трафик между ними — без двойной балансировки и с сохранением адреса клиента; неравномерность остаётся, если на узлах разное число подов (вес пути у роутера от числа подов не зависит), поэтому поды такого сервиса стоит разносить по узлам (часть 13). Как это выглядит на роутере — в разделе «Режим BGP на стенде».
Политика включается патчем сервиса. На стенде мы сделали это после опытов с переключением лидера из «Смотрим руками», а пока сервис остаётся с Cluster:
$ 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"}'
31655
kube-apiserver выделил healthCheckNodePort — порт, на котором kube-proxy каждого узла сообщает, есть ли на узле готовые поды сервиса. MetalLB в L2 его не опрашивает, он узнаёт о подах из EndpointSlice, но внешние балансировщики пользуются именно им. Как это выглядит вживую — в конце «Смотрим руками».
Альтернативы
- kube-vip — программа, которая держит виртуальный адрес для control plane (так он используется в серии про BGP) и умеет то же для сервисов типа LoadBalancer: адреса выдаёт его облачный контроллер kube-vip-cloud-provider, а объявляет сам kube-vip по ARP или BGP. Удобен, если kube-vip уже стоит ради control plane.
- Cilium LB IPAM — в кластерах с Cilium (сетевым плагином на eBPF — программах, которые загружаются в ядро Linux и обрабатывают пакеты) адреса LoadBalancer выдаёт сам Cilium из ресурсов
CiliumLoadBalancerIPPool, а объявляет их своими L2-анонсами или BGP — отдельный MetalLB не нужен. - Calico LoadBalancer IPAM — kube-controllers Calico выдают адреса сервисам из IP-пула с
allowedUses: LoadBalancer, а анонсирует их по BGP сам Calico (полеserviceLoadBalancerIPsв BGPConfiguration); в связке с BGP-роутером это закрывает задачу без MetalLB.
Смотрим руками
Кто лидер для адреса — статусный ресурс MetalLB:
$ kubectl -n metallb-system get servicel2statuses
NAME ALLOCATED NODE SERVICE NAME SERVICE NAMESPACE
l2-2rvxt k8s-node-2 hello-lb demo
Ресурс создаёт speaker-лидер, и его владелец (ownerReferences) — под speaker'а: kind: Pod, name: speaker-7zhm6. В status — узел и интерфейсы, с которых идут ответы (interfaces: [{name: ens18}]).
ARP из сети. arping шлёт ARP-запросы на заданный адрес и печатает, кто ответил; на узлах его нет, поэтому запускаем его в отладочном поде в сети узла k8s-node-1 (kubectl debug node из части 15):
$ 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-d29qs with container debugger on node k8s-node-1.
$ kubectl -n demo exec node-debugger-k8s-node-1-d29qs -- arping -I ens18 -c 3 192.168.10.200
ARPING 192.168.10.200 from 192.168.10.20 ens18
Unicast reply from 192.168.10.200 [52:54:00:3A:10:21] 1.817ms
Unicast reply from 192.168.10.200 [52:54:00:3A:10:21] 0.870ms
Unicast reply from 192.168.10.200 [52:54:00:3A:10:21] 0.873ms
Sent 3 probes (1 broadcast(s))
Received 3 response(s)
$ kubectl -n demo exec node-debugger-k8s-node-1-d29qs -- arping -I ens18 -c 2 192.168.10.210
ARPING 192.168.10.210 from 192.168.10.20 ens18
Sent 2 probes (2 broadcast(s))
Received 0 response(s)
command terminated with exit code 1
52:54:00:3a:10:21 — MAC ens18 узла k8s-node-2, тот же, что у 192.168.10.21. Адрес пула, который ещё никому не выдан, молчит: speaker отвечает только за выданные адреса. Если ответов от разных MAC несколько — два узла считают себя лидерами (обычно из-за закрытого порта 7946 между ними) или адрес из пула занят кем-то ещё в сети. Тот же обмен на лидере:
user@k8s-node-2:~$ sudo timeout 8 tcpdump -eni ens18 -c 4 arp and host 192.168.10.200
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ens18, link-type EN10MB (Ethernet), snapshot length 262144 bytes
00:36:06.770359 52:54:00:3a:10:20 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (ff:ff:ff:ff:ff:ff) tell 192.168.10.20, length 28
00:36:06.770494 52:54:00:3a:10:21 > 52:54:00:3a:10:20, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:21, length 46
00:36:07.770377 52:54:00:3a:10:20 > 52:54:00:3a:10:21, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (52:54:00:3a:10:21) tell 192.168.10.20, length 28
00:36:07.770483 52:54:00:3a:10:21 > 52:54:00:3a:10:20, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:21, length 46
4 packets captured
4 packets received by filter
0 packets dropped by kernel
Ответ ушёл через 0,13 мс после запроса. Первый запрос arping — широковещательный, следующие он шлёт уже прямо на найденный MAC. Длина ответа — 60 байт против 42 у запроса: ответ формирует не ядро, а сам speaker, и он дополняет кадр до минимального размера Ethernet. ARP-таблица ноутбука (на нём Windows, поэтому PowerShell):
PS> Get-NetNeighbor -IPAddress 192.168.10.20,192.168.10.21,192.168.10.200 | Format-Table -AutoSize
ifIndex IPAddress LinkLayerAddress State PolicyStore
------- --------- ---------------- ----- -----------
3 192.168.10.200 52-54-00-3A-10-21 Reachable ActiveStore
3 192.168.10.20 52-54-00-3A-10-20 Reachable ActiveStore
3 192.168.10.21 52-54-00-3A-10-21 Reachable ActiveStore
У адреса сервиса и у k8s-node-2 один MAC. Журнал speaker'а на k8s-node-2:
$ kubectl -n metallb-system logs speaker-7zhm6 | grep announc
{"caller":"announcer.go:126","event":"createARPResponder","interface":"ens18","level":"info","msg":"created ARP responder for interface","ts":"2026-10-28T21:32:36Z"}
…
{"caller":"announcer.go:126","event":"createARPResponder","interface":"vxlan.calico","level":"info","msg":"created ARP responder for interface","ts":"2026-10-28T21:32:36Z"}
…
{"caller":"main.go:481","event":"serviceAnnounced","ips":["192.168.10.200"],"level":"info","msg":"service has IP, announcing","pool":"default","protocol":"layer2","ts":"2026-10-28T21:34:06Z"}
Ответчики ARP созданы на ens18 и на vxlan.calico — вот зачем в L2Advertisement поле interfaces. Сами ARP-запросы speaker пишет только на уровне debug. Включим его на время — заменим аргумент --log-level=info в DaemonSet (speaker'ы перезапустятся по очереди, и на это время адреса будут переезжать между узлами):
$ kubectl -n metallb-system patch ds speaker --type=json -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/1","value":"--log-level=debug"}]'
daemonset.apps/speaker patched
$ kubectl -n metallb-system logs speaker-hjdtc | grep 'got ARP'
{"caller":"arp.go:110","interface":"ens18","ip":"192.168.10.200","level":"debug","msg":"got ARP request for service IP, sending response","responseMAC":"52:54:00:3a:10:20","senderIP":"192.168.10.17","senderMAC":"52:54:00:3a:10:17","ts":"2026-10-28T21:45:31Z"}
Здесь лидер уже k8s-node-1 (почему — ниже), а спрашивал мастер. Уровень debug не для постоянной работы: кроме строк об ARP speaker на каждой сверке пишет в журнал всю свою конфигурацию — пулы, объявления и метаданные Secret-ов — по нескольку раз в секунду.
Адрес в правилах kube-proxy на любом узле:
user@k8s-node-1:~$ sudo nft list map ip kube-proxy service-ips | grep hello-lb
10.110.13.185 . tcp . 80 : goto service-WRGIWGFD-demo/hello-lb/tcp/http,
192.168.10.200 . tcp . 80 : goto external-WRGIWGFD-demo/hello-lb/tcp/http,
Пакет на 192.168.10.200:80, пришедший на ens18 лидера, попадает в ту же цепочку services, что и пакеты к ClusterIP, и через external-… уходит в DNAT на под. Сам адрес на интерфейсах отсутствует:
user@k8s-node-2:~$ ip -br addr | grep -c 192.168.10.200
0
Переключение при уходе speaker'а. Политика сервиса пока Cluster. На ноутбуке запустим запросы к сервису раз в полсекунды, на мастере — tcpdump ARP-пакетов с адресом сервиса и arping из отладочного пода в сети мастера (сам мастер лидером не станет). Затем в другом терминале удалим под speaker'а на лидере k8s-node-2:
$ for i in $(seq 1 70); do echo "$(date -u +%T.%3N) $(curl -s -m1 http://192.168.10.200/ || echo FAIL)"; sleep 0.5; done > curl.log
$ kubectl -n metallb-system delete pod speaker-7zhm6
pod "speaker-7zhm6" deleted from metallb-system namespace
Под удалён в 21:37:45,96 UTC. На мастере (время узла — московское):
user@k8s-master-1:~$ sudo tcpdump -tttt -eni ens18 arp and host 192.168.10.200
…
2026-10-29 00:37:45.540076 52:54:00:3a:10:17 > 52:54:00:3a:10:21, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (52:54:00:3a:10:21) tell 192.168.10.17, length 28
2026-10-29 00:37:45.540310 52:54:00:3a:10:21 > 52:54:00:3a:10:17, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:21, length 46
2026-10-29 00:37:46.251298 52:54:00:3a:10:20 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 60: Request who-has 192.168.10.200 (ff:ff:ff:ff:ff:ff) tell 192.168.10.200, length 46
2026-10-29 00:37:46.251298 52:54:00:3a:10:20 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:20, length 46
2026-10-29 00:37:46.540077 52:54:00:3a:10:17 > 52:54:00:3a:10:21, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (52:54:00:3a:10:21) tell 192.168.10.17, length 28
2026-10-29 00:37:47.351672 52:54:00:3a:10:20 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 60: Request who-has 192.168.10.200 (ff:ff:ff:ff:ff:ff) tell 192.168.10.200, length 46
2026-10-29 00:37:47.351702 52:54:00:3a:10:20 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:20, length 46
2026-10-29 00:37:47.540075 52:54:00:3a:10:17 > 52:54:00:3a:10:21, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (52:54:00:3a:10:21) tell 192.168.10.17, length 28
…
2026-10-29 00:37:51.540065 52:54:00:3a:10:17 > 52:54:00:3a:10:21, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (52:54:00:3a:10:21) tell 192.168.10.17, length 28
2026-10-29 00:37:51.781789 52:54:00:3a:10:21 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 60: Request who-has 192.168.10.200 (ff:ff:ff:ff:ff:ff) tell 192.168.10.200, length 46
2026-10-29 00:37:51.781789 52:54:00:3a:10:21 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:21, length 46
2026-10-29 00:37:52.540076 52:54:00:3a:10:17 > 52:54:00:3a:10:21, ethertype ARP (0x0806), length 42: Request who-has 192.168.10.200 (52:54:00:3a:10:21) tell 192.168.10.17, length 28
2026-10-29 00:37:52.540295 52:54:00:3a:10:21 > 52:54:00:3a:10:17, ethertype ARP (0x0806), length 60: Reply 192.168.10.200 is-at 52:54:00:3a:10:21, length 46
Через 0,3 секунды после удаления пода k8s-node-1 (MAC …:20) разослал gratuitous ARP — пары «ARP Announcement + широковещательный ответ» каждые 1,1 секунды. Ещё через пять с половиной секунд на k8s-node-2 поднялся новый под speaker'а, и адрес вернулся к нему с такими же пакетами от …:21: по хешу k8s-node-2 для этого адреса первый. События сервиса и журнал speaker'а на k8s-node-1:
$ kubectl -n demo get events --field-selector involvedObject.name=hello-lb
LAST SEEN TYPE REASON OBJECT MESSAGE
4m38s Normal IPAllocated service/hello-lb Assigned IP ["192.168.10.200"]
4m38s Normal nodeAssigned service/hello-lb announcing from node "k8s-node-2" with protocol "layer2"
58s Normal nodeAssigned service/hello-lb announcing from node "k8s-node-1" with protocol "layer2"
53s Normal nodeAssigned service/hello-lb announcing from node "k8s-node-2" with protocol "layer2"
$ kubectl -n metallb-system logs speaker-lc7m7 --since=5m | grep -E 'NodeLeave|NodeJoin|serviceAnnounced|serviceWithdrawn'
{"caller":"speakerlist.go:343","level":"info","msg":"node event - forcing sync","node addr":"192.168.10.21","node event":"NodeLeave","node name":"k8s-node-2","ts":"2026-10-28T21:37:46Z"}
{"caller":"main.go:481","event":"serviceAnnounced","ips":["192.168.10.200"],"level":"info","msg":"service has IP, announcing","pool":"default","protocol":"layer2","ts":"2026-10-28T21:37:46Z"}
{"caller":"speakerlist.go:343","level":"info","msg":"node event - forcing sync","node addr":"192.168.10.21","node event":"NodeJoin","node name":"k8s-node-2","ts":"2026-10-28T21:37:51Z"}
{"caller":"main.go:525","event":"serviceWithdrawn","ip":["192.168.10.200"],"ips":["192.168.10.200"],"level":"info","msg":"withdrawing service announcement","pool":"default","protocol":"layer2","reason":"notOwner","ts":"2026-10-28T21:37:51Z"}
NodeLeave — штатный уход участника memberlist: удаляемый speaker успел попрощаться. В curl.log из 70 запросов с ноутбука ни одного FAIL: пока k8s-node-2 жив, его kube-proxy обрабатывает пакеты на 192.168.10.200 независимо от speaker'а, так что клиент со старым MAC тоже получает ответ. А вот arping на мастере семь раз из 39 остался без ответа (Sent 39 probes (1 broadcast(s)), Received 32 response(s)): после первого ответа он спрашивает уже напрямую старый MAC …:21, а без speaker'а ядро k8s-node-2 на ARP за адрес сервиса не отвечает — в режиме nftables адреса на интерфейсе нет. Так выглядит клиент, который не заметил переезда.
Speaker убит без прощания. Если процесс speaker'а убить kill -9, попрощаться он не успевает, и соседи узнают о нём по протоколу memberlist. На стенде через секунду в журнале k8s-node-1 появилось:
{"caller":"state.go:521","component":"Memberlist","level":"info","msg":"memberlist: Suspect k8s-node-2 has failed, no acks received","ts":"2026-10-28T21:46:36Z"}
Но kubelet перезапустил контейнер через две секунды, speaker вернулся в группу раньше, чем его объявили мёртвым, и адрес остался на k8s-node-2 (тот снова разослал gratuitous ARP). Отказ узла целиком — выключенную машину или оборванную сеть — на стенде не воспроизводили; по документации адрес переезжает, как только memberlist объявит узел мёртвым, за несколько секунд. С политикой Cluster при этом есть тонкость: новый лидер какое-то время продолжает отправлять часть соединений на поды выключенного узла — для Kubernetes эти поды ещё готовы, пока node lifecycle controller не пометит узел недоступным (около 50 секунд). Часть запросов в это окно зависнет до таймаута. Readiness-проба тут не поможет: её выполняет kubelet на том же мёртвом узле. Сократить окно можно уменьшением --node-monitor-grace-period, но всерьёз эту проблему решает только запас реплик и повтор запросов на стороне клиента.
Плановый вывод узла. kubectl cordon k8s-node-2 лидера не меняет: все 28 ответов arping -b (с ключом -b он каждый раз спрашивает широковещательно и видит нового владельца сразу) пришли с MAC k8s-node-2. drain тоже: speaker не выселяется, и с политикой Cluster узел остаётся лидером, даже когда подов hello на нём не осталось:
$ kubectl drain k8s-node-2 --ignore-daemonsets --delete-emptydir-data
node/k8s-node-2 already cordoned
Warning: ignoring DaemonSet-managed Pods: calico-system/calico-node-7mx4w, calico-system/csi-node-driver-6nzck, kube-system/kube-proxy-xjhjp, metallb-system/speaker-sqk47
evicting pod metallb-system/controller-658745d67-mlpqs
evicting pod demo/hello-6d5c6db6b4-fgzhx
evicting pod demo/hello-6d5c6db6b4-k4mn2
pod/controller-658745d67-mlpqs evicted
pod/hello-6d5c6db6b4-fgzhx evicted
pod/hello-6d5c6db6b4-k4mn2 evicted
node/k8s-node-2 drained
Один запрос с ноутбука в момент выселения подов ушёл в таймаут — он попал в завершающийся под hello, MetalLB тут ни при чём. Увести адрес заранее можно меткой исключения:
$ kubectl label node k8s-node-2 node.kubernetes.io/exclude-from-external-load-balancers=true
node/k8s-node-2 labeled
$ kubectl -n metallb-system get servicel2statuses
NAME ALLOCATED NODE SERVICE NAME SERVICE NAMESPACE
l2-hmxxn k8s-node-1 hello-lb demo
k8s-node-1 разослал gratuitous ARP через 0,1 секунды после команды, ARP-таблица ноутбука сменила MAC в ту же секунду, и ни один запрос не потерялся. После kubectl label node k8s-node-2 node.kubernetes.io/exclude-from-external-load-balancers- и kubectl uncordon k8s-node-2 адрес вернулся на k8s-node-2.
Cluster против Local. Сейчас все три пода hello — на k8s-node-1 (их туда выселил drain), а лидер — k8s-node-2. С политикой Cluster поды видят адрес узла, через который пришёл запрос:
$ kubectl -n demo logs -l app.kubernetes.io/name=hello --tail=2 --prefix
[pod/hello-6d5c6db6b4-vx7c4/hello] 2026/10/28 21:41:27 192.168.10.20:64880 GET /
[pod/hello-6d5c6db6b4-vx7c4/hello] 2026/10/28 21:41:29 192.168.10.20:57760 GET /
[pod/hello-6d5c6db6b4-8pzrk/hello] 2026/10/28 21:41:28 192.168.10.20:24832 GET /
[pod/hello-6d5c6db6b4-8pzrk/hello] 2026/10/28 21:41:42 192.168.10.21:62072 GET /
[pod/hello-6d5c6db6b4-vlh27/hello] 2026/10/28 21:41:42 192.168.10.21:61896 GET /
[pod/hello-6d5c6db6b4-vlh27/hello] 2026/10/28 21:41:42 192.168.10.21:1269 GET /
Ранние запросы пришли через k8s-node-1, пока лидером был он, поздние — через k8s-node-2; адреса ноутбука нет нигде. Переключаем сервис на Local (команда — в разделе выше): на k8s-node-2 подов нет, и лидерство в ту же секунду переходит к k8s-node-1, а поды начинают видеть настоящий адрес клиента:
$ kubectl -n metallb-system get servicel2statuses
NAME ALLOCATED NODE SERVICE NAME SERVICE NAMESPACE
l2-2n2z2 k8s-node-1 hello-lb demo
$ kubectl -n demo logs -l app.kubernetes.io/name=hello --tail=1 --prefix
[pod/hello-6d5c6db6b4-vlh27/hello] 2026/10/28 21:42:12 192.168.10.50:60634 GET /
[pod/hello-6d5c6db6b4-vx7c4/hello] 2026/10/28 21:42:15 192.168.10.50:51651 GET /
[pod/hello-6d5c6db6b4-8pzrk/hello] 2026/10/28 21:42:14 192.168.10.50:51649 GET /
В журнале speaker'а k8s-node-2 при этом — serviceWithdrawn с той же причиной notOwner. Проверка здоровья kube-proxy на healthCheckNodePort с ноутбука:
$ curl -si http://192.168.10.20:31655/ | head -4
HTTP/1.1 200 OK
Content-Type: application/json
X-Content-Type-Options: nosniff
X-Load-Balancing-Endpoint-Weight: 3
$ curl -si http://192.168.10.21:31655/ | head -4
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
X-Content-Type-Options: nosniff
X-Load-Balancing-Endpoint-Weight: 0
Внешний балансировщик по этим ответам отправлял бы трафик только на k8s-node-1, где три локальных пода; MetalLB приходит к тому же выводу сам, по EndpointSlice.
Ошибки конфигурации. Пересекающиеся пулы и неверные диапазоны отсекает вебхук. Что прошло вебхук, но не может быть применено, controller и каждый speaker записывают в свой ресурс ConfigurationState — это первое, что стоит посмотреть, если адрес не выдаётся или не объявляется:
$ kubectl -n metallb-system get configurationstates
NAME RESULT ERRORSUMMARY AGE
controller Valid 3m45s
speaker-k8s-master-1 Valid 11m
speaker-k8s-node-1 Valid 11m
speaker-k8s-node-2 Valid 6m16s
Этот вывод снят сразу после создания L2Advertisement со ссылкой на несуществующий пул nope: вебхук его принял, и MetalLB такую ссылку ошибкой не считает — объявление просто ничего не объявляет. Опечатку в имени пула придётся искать глазами.
Учебный сервис оставим до следующей части — удалим его, когда адрес понадобится обратному прокси: kubectl -n demo delete svc hello-lb; сервисы hello-a и hello-b удаляем сразу, отладочные поды — тоже. Если MetalLB в кластере больше не нужен, удаляют сначала конфигурацию (kubectl delete -f metallb-config.yaml), затем сам манифест (kubectl delete -f metallb-native.yaml): вместе с неймспейсом уходят CRD, роли и вебхук, и после этого никто в сети на ARP за адреса пула не отвечает.
Режим BGP на стенде
Режим BGP мы проверяли отдельным заходом, с чистой установки MetalLB тем же манифестом и без L2Advertisement, чтобы за адреса сервисов никто не отвечал на ARP. Роутер сети 192.168.10.1 — Linux-машина с FRR 8.4. Для BGP взят отдельный пул 192.168.20.100–192.168.20.103 вне подсети узлов: клиент ищет адрес из своей подсети ARP-запросом, а в режиме BGP на ARP никто не отвечает, тогда как к адресу из чужой подсети клиент и так идёт через свой шлюз — роутер. demo снова собран по части 11: два пода hello на k8s-node-1, один на k8s-node-2. MetalLB допускает L2 и BGP одновременно для разных пулов, но вместе на стенде мы их не запускали.
Роутер на FRR
Конфигурация FRR на роутере (/etc/frr/frr.conf; демон bgpd включается строкой bgpd=yes в /etc/frr/daemons):
frr version 8.4.4
frr defaults traditional
!
router bgp 64513
bgp router-id 192.168.10.1
no bgp default ipv4-unicast
neighbor METALLB peer-group
neighbor METALLB remote-as 64512
neighbor METALLB passive
neighbor 192.168.10.17 peer-group METALLB
neighbor 192.168.10.20 peer-group METALLB
neighbor 192.168.10.21 peer-group METALLB
!
address-family ipv4 unicast
neighbor METALLB activate
neighbor METALLB route-map METALLB-IN in
neighbor METALLB route-map DENY-ALL out
exit-address-family
exit
!
ip prefix-list METALLB-IN seq 10 permit 192.168.20.100/30 ge 32
!
route-map METALLB-IN permit 10
match ip address prefix-list METALLB-IN
exit
!
route-map DENY-ALL deny 10
exit
router bgp 64513— номер AS роутера, тот самыйpeerASNв BGPPeer; узлы кластера приходят из AS 64512 (remote-as), так что сессии внешние — eBGP (external BGP, между разными AS).peer-group METALLB— общие настройки для всех трёх узлов: каждый узел — отдельный сосед со своей сессией, включая мастер.passive— роутер сам соединение не открывает и только ждёт входящих. Это важно на узлах с Calico: порт 179 там занят BIRD (ниже видно вss), и попытка роутера подключиться к узлу попала бы к BIRD, а не к speaker'у.no bgp default ipv4-unicastиactivate— адресное семейство IPv4 включено для соседей явно.- Фильтры. С
frr defaults traditionalв FRR включёнbgp ebgp-requires-policy(требование RFC 8212): без входящего фильтра маршруты от eBGP-соседа не принимаются, без исходящего ему ничего не отправляется. Входящийroute-map METALLB-INпропускает только маршруты/32из192.168.20.100/30— узлы не смогут объявить роутеру чужую сеть даже по ошибке. ИсходящийDENY-ALLзапрещает всё: узлам маршруты роутера не нужны, у них есть маршрут по умолчанию.
Конфигурация MetalLB
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: bgp
namespace: metallb-system
spec:
addresses:
- 192.168.20.100-192.168.20.103
autoAssign: true
---
apiVersion: metallb.io/v1beta2
kind: BGPPeer
metadata:
name: router
namespace: metallb-system
spec:
myASN: 64512
peerASN: 64513
peerAddress: 192.168.10.1
---
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
name: bgp
namespace: metallb-system
spec:
ipAddressPools:
- bgp
$ kubectl apply -f metallb-bgp.yaml
ipaddresspool.metallb.io/bgp created
bgppeer.metallb.io/router created
bgpadvertisement.metallb.io/bgp created
Первая ловушка — в кластере с Calico kubectl get bgppeers показывает не то:
$ kubectl -n metallb-system get bgppeers
No resources found
$ kubectl get bgppeers -v=6 2>&1 | grep -E "GET|No resources" | head -3
I1029 06:10:14.276583 1181365 round_trippers.go:632] "Response" verb="GET" url="https://k8s-api.example.com:6443/apis/projectcalico.org/v3/bgppeers?limit=500" status="200 OK" milliseconds=7
No resources found
$ kubectl -n metallb-system get bgppeers.metallb.io -o wide
NAME ADDRESS ASN BFD PROFILE MULTI HOPS
router 192.168.10.1 64513
Ресурс bgppeers есть в трёх группах API — у MetalLB (metallb.io), у Calico (crd.projectcalico.org) и у сервера API Calico (projectcalico.org), — и короткое имя kubectl отдал Calico. С MetalLB рядом с Calico ресурсы bgppeers надо называть полным именем bgppeers.metallb.io.
Сессии
Через 15 секунд после apply на роутере:
user@router:~$ sudo vtysh -c "show bgp summary"
IPv4 Unicast Summary (VRF default):
BGP router identifier 192.168.10.1, local AS number 64513 vrf-id 0
BGP table version 0
RIB entries 0, using 0 bytes of memory
Peers 3, using 2172 KiB of memory
Peer groups 1, using 64 bytes of memory
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 2 2 0 0 0 00:00:15 0 0 N/A
192.168.10.21 4 64512 2 2 0 0 0 00:00:15 0 0 N/A
192.168.10.20 4 64512 2 2 0 0 0 00:00:15 0 0 N/A
Total number of neighbors 3
Все три сессии установлены: число в State/PfxRcd вместо слова состояния значит Established, и это число принятых маршрутов — пока сервисов нет, их ноль. До apply здесь стояло Active с Up/Down never: роутер ждал соединений. Кто кому звонит — захват на роутере в момент apply (время роутера, как и узлов, московское):
user@router:~$ sudo timeout 40 tcpdump -nni eth1 -c 40 "tcp port 179 and host 192.168.10.20"
…
06:09:04.268224 IP 192.168.10.20.59761 > 192.168.10.1.179: Flags [S], seq 2857158583, win 64240, options [mss 1460,sackOK,TS val 821419571 ecr 0,nop,wscale 9], length 0
06:09:04.268253 IP 192.168.10.1.179 > 192.168.10.20.59761: Flags [S.], seq 3676335871, ack 2857158584, win 65160, options [mss 1460,sackOK,TS val 2375575134 ecr 821419571,nop,wscale 7], length 0
06:09:04.268665 IP 192.168.10.20.59761 > 192.168.10.1.179: Flags [.], ack 1, win 126, options [nop,nop,TS val 821419571 ecr 2375575134], length 0
06:09:04.268701 IP 192.168.10.20.59761 > 192.168.10.1.179: Flags [P.], seq 1:50, ack 1, win 126, options [nop,nop,TS val 821419571 ecr 2375575134], length 49: BGP
06:09:04.268729 IP 192.168.10.1.179 > 192.168.10.20.59761: Flags [.], ack 50, win 509, options [nop,nop,TS val 2375575135 ecr 821419571], length 0
06:09:04.269311 IP 192.168.10.1.179 > 192.168.10.20.59761: Flags [P.], seq 1:111, ack 50, win 509, options [nop,nop,TS val 2375575135 ecr 821419571], length 110: BGP
06:09:04.269517 IP 192.168.10.20.59761 > 192.168.10.1.179: Flags [.], ack 111, win 126, options [nop,nop,TS val 821419572 ecr 2375575135], length 0
06:09:04.269539 IP 192.168.10.1.179 > 192.168.10.20.59761: Flags [P.], seq 111:130, ack 50, win 509, options [nop,nop,TS val 2375575135 ecr 821419572], length 19: BGP
06:09:04.269663 IP 192.168.10.20.59761 > 192.168.10.1.179: Flags [P.], seq 50:69, ack 111, win 126, options [nop,nop,TS val 821419572 ecr 2375575135], length 19: BGP
…
06:09:34.270734 IP 192.168.10.20.59761 > 192.168.10.1.179: Flags [P.], seq 69:88, ack 130, win 126, options [nop,nop,TS val 821449573 ecr 2375590427], length 19: BGP
06:09:34.271455 IP 192.168.10.1.179 > 192.168.10.20.59761: Flags [P.], seq 130:149, ack 88, win 509, options [nop,nop,TS val 2375605137 ecr 821449573], length 19: BGP
…
Speaker на k8s-node-1 открывает TCP-соединение со случайного порта 59761 на порт 179 роутера. BGP работает поверх TCP, и дальше видна его последовательность: оба соседа шлют OPEN (49 байт от speaker'а, 110 от FRR — в нём больше возможностей), затем KEEPALIVE по 19 байт — подтверждение, после которого сессия считается установленной. Следующие KEEPALIVE идут раз в 30 секунд. На узле это соединение принадлежит процессу speaker, а порт 179 слушает только BIRD:
user@k8s-node-1:~$ sudo ss -tnp | grep -E ":179\b"
ESTAB 0 0 192.168.10.20:42671 192.168.10.21:179 users:(("bird",pid=5097,fd=9))
ESTAB 0 0 192.168.10.20:179 192.168.10.17:43615 users:(("bird",pid=5097,fd=8))
ESTAB 0 0 192.168.10.20:59761 192.168.10.1:179 users:(("speaker",pid=716828,fd=19))
user@k8s-node-1:~$ sudo ss -lntp | grep -E "speaker|:179"
LISTEN 0 8 0.0.0.0:179 0.0.0.0:* users:(("bird",pid=5097,fd=7))
LISTEN 0 4096 127.0.0.1:17472 0.0.0.0:* users:(("speaker",pid=716828,fd=15))
LISTEN 0 4096 192.168.10.20:7946 0.0.0.0:* users:(("speaker",pid=716828,fd=8))
LISTEN 0 4096 *:9120 *:* users:(("speaker",pid=716828,fd=16))
Две первые строки — сетка BIRD между узлами из части 14, третья — сессия speaker'а с роутером. Так BGP MetalLB и BGP Calico и уживаются на одном узле: speaker в режиме native только звонит сам и порт 179 не занимает, а роутер, настроенный как passive, к узлам не подключается. В журнале speaker'а:
$ kubectl -n metallb-system logs speaker-dw7l2 --since=15m | grep -iE "bgp|session|peer"
…
{"caller":"bgp_controller.go:235","event":"peerAdded","level":"info","msg":"peer configured, starting BGP session","peer":"192.168.10.1","ts":"2026-10-29T03:09:04Z"}
{"caller":"native.go:123","event":"sessionUp","level":"info","localASN":64512,"msg":"BGP session established","peer":"192.168.10.1","peerASN":64513,"ts":"2026-10-29T03:09:04Z"}
…
Отдельного ресурса с состоянием сессий в манифесте native нет — его смотрят в журнале speaker'а и на роутере. Подробности сессии со стороны FRR:
user@router:~$ sudo vtysh -c "show bgp neighbors 192.168.10.20"
BGP neighbor is 192.168.10.20, remote AS 64512, local AS 64513, external link
…
Member of peer-group METALLB for session parameters
BGP version 4, remote router ID 192.168.10.20, local router ID 192.168.10.1
BGP state = Established, up for 00:00:48
Last read 00:00:18, Last write 00:00:18
Hold time is 90 seconds, keepalive interval is 30 seconds
Configured hold time is 180 seconds, keepalive interval is 60 seconds
…
Route map for incoming advertisements is *METALLB-IN
Route map for outgoing advertisements is *DENY-ALL
0 accepted prefixes
…
Local host: 192.168.10.1, Local port: 179
Foreign host: 192.168.10.20, Foreign port: 59761
…
Hold time — сколько сосед ждёт сообщений, прежде чем объявить сессию упавшей. Стороны предлагают свои значения в OPEN и берут меньшее: speaker предложил 90 секунд (умолчание MetalLB), FRR — 180, договорились о 90 с KEEPALIVE каждые 30 секунд. Router ID узла — его адрес.
Сервис и маршруты
$ kubectl apply -f hello-lb.yaml && kubectl -n demo get svc hello-lb
service/hello-lb created
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-lb LoadBalancer 10.104.217.188 <pending> 80:30277/TCP 1s
$ kubectl -n demo get svc hello-lb
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-lb LoadBalancer 10.104.217.188 192.168.20.100 80:30277/TCP 5s
$ kubectl -n demo describe svc hello-lb | sed -n '/Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal IPAllocated 5s metallb-controller Assigned IP ["192.168.20.100"]
Normal nodeAssigned 4s metallb-speaker announcing from node "k8s-node-2" with protocol "bgp"
Normal nodeAssigned 4s metallb-speaker announcing from node "k8s-node-1" with protocol "bgp"
$ kubectl -n metallb-system get servicebgpstatuses -o wide
NAME NODE SERVICE NAME SERVICE NAMESPACE
bgp-xpjhn k8s-node-1 hello-lb demo
bgp-ztrkj k8s-node-2 hello-lb demo
В этом заходе пул bgp единственный, и hello-lb (тот же манифест, что выше) получил первый его адрес. В отличие от L2, где объявляет один лидер, здесь адрес объявляют сразу оба воркера: событий nodeAssigned два, и на каждый узел есть свой ServiceBGPStatus, владелец которого — под speaker'а этого узла, а в status.peers — имена BGPPeer, которым ушёл маршрут ([router]). Мастер с меткой исключения сессию держит, но адрес не объявляет. На роутере:
user@router:~$ sudo vtysh -c "show bgp summary" -c "show bgp ipv4 unicast" -c "show ip route bgp"
…
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 4 4 0 0 0 00:01:24 0 0 N/A
192.168.10.21 4 64512 5 4 0 0 0 00:01:24 1 0 N/A
192.168.10.20 4 64512 5 4 0 0 0 00:01:24 1 0 N/A
Total number of neighbors 3
BGP table version is 1, local router ID is 192.168.10.1, vrf id 0
Default local pref 100, local AS 64513
Status codes: s suppressed, d damped, h history, * valid, > best, = multipath,
i internal, r RIB-failure, S Stale, R Removed
Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self
Origin codes: i - IGP, e - EGP, ? - incomplete
RPKI validation codes: V valid, I invalid, N Not found
Network Next Hop Metric LocPrf Weight Path
*= 192.168.20.100/32 192.168.10.20 0 64512 i
*> 192.168.10.21 0 64512 i
Displayed 1 routes and 2 total paths
…
B>* 192.168.20.100/32 [20/0] via 192.168.10.21, eth1, weight 1, 00:00:05
* via 192.168.10.20, eth1, weight 1, 00:00:05
user@router:~$ ip route | grep -A4 192.168.20.10
192.168.20.100 nhid 30 proto bgp metric 20
nexthop via 192.168.10.21 dev eth1 weight 1
nexthop via 192.168.10.20 dev eth1 weight 1
…
Один маршрут 192.168.20.100/32 пришёл двумя путями — через k8s-node-1 и k8s-node-2, с AS-путём 64512 и origin i. Оба пути равноценны: > — лучший, = — участвующий в multipath, и в таблицу маршрутизации FRR поставил маршрут с двумя следующими переходами ([20/0] — административное расстояние eBGP 20 и метрика 0). maximum-paths в конфигурации не задан, ECMP для eBGP FRR включает сам. В ядре Linux это один маршрут с двумя nexthop — так и выглядит ECMP. Запросы с самого роутера:
user@router:~$ for i in 1 2 3 4 5 6; do curl -s -m2 http://192.168.20.100/; done
hello from hello-6d5c6db6b4-5d5p4
hello from hello-6d5c6db6b4-x9lg9
hello from hello-6d5c6db6b4-x9lg9
hello from hello-6d5c6db6b4-5d5p4
hello from hello-6d5c6db6b4-x9lg9
hello from hello-6d5c6db6b4-x9lg9
user@router:~$ ip route get 192.168.20.100
192.168.20.100 via 192.168.10.20 dev eth1 src 192.168.10.1 uid 1000
cache
Ответы приходят от подов на обоих узлах, но все шесть соединений вошли в кластер через один узел, k8s-node-1, а по подам их раскидал уже kube-proxy с политикой Cluster. Пакеты для 192.168.20.100 роутер отправляет на MAC-адрес узла — маршрутизацией, без всякого ARP за адрес сервиса:
user@k8s-node-1:~$ sudo timeout 15 tcpdump -eni ens18 -c 3 "tcp port 80 and host 192.168.20.100"
…
06:16:29.422611 52:54:00:3a:10:01 > 52:54:00:3a:10:20, ethertype IPv4 (0x0800), length 74: 192.168.10.1.42288 > 192.168.20.100.80: Flags [S], seq 3527674648, win 64240, options [mss 1460,sackOK,TS val 1845417792 ecr 0,nop,wscale 7], length 0
06:16:29.422672 52:54:00:3a:10:20 > 52:54:00:3a:10:01, ethertype IPv4 (0x0800), length 74: 192.168.20.100.80 > 192.168.10.1.42288: Flags [S.], seq 2226412369, ack 3527674649, win 64308, options [mss 1410,sackOK,TS val 2203233316 ecr 1845417792,nop,wscale 9], length 0
06:16:29.423070 52:54:00:3a:10:01 > 52:54:00:3a:10:20, ethertype IPv4 (0x0800), length 66: 192.168.10.1.42288 > 192.168.20.100.80: Flags [.], ack 1, win 502, options [nop,nop,TS val 1845417792 ecr 2203233316], length 0
…
Как ядро выбирает путь. Хеш ECMP в Linux задаёт net.ipv4.fib_multipath_hash_policy, и по умолчанию он 0 — только адреса отправителя и получателя, без портов. Все соединения одного клиента идут через один узел, а разные клиенты расходятся по узлам:
user@router:~$ sysctl net.ipv4.fib_multipath_hash_policy
net.ipv4.fib_multipath_hash_policy = 0
user@router:~$ for p in 40001 40002 40003 40004; do ip route get 192.168.20.100 ipproto tcp sport $p dport 80 | head -1; done
192.168.20.100 via 192.168.10.20 dev eth1 src 192.168.10.1 uid 1000
192.168.20.100 via 192.168.10.20 dev eth1 src 192.168.10.1 uid 1000
192.168.20.100 via 192.168.10.20 dev eth1 src 192.168.10.1 uid 1000
192.168.20.100 via 192.168.10.20 dev eth1 src 192.168.10.1 uid 1000
user@router:~$ for s in 192.168.30.10 192.168.30.11 192.168.30.12 192.168.30.13; do ip route get 192.168.20.100 from $s iif eth2 2>&1 | head -1; done
192.168.20.100 from 192.168.30.10 via 192.168.10.20 dev eth1
192.168.20.100 from 192.168.30.11 via 192.168.10.21 dev eth1
192.168.20.100 from 192.168.30.12 via 192.168.10.21 dev eth1
192.168.20.100 from 192.168.30.13 via 192.168.10.21 dev eth1
Разные исходные порты при одном адресе — один и тот же узел; адреса клиентов из сети 192.168.30.0/24 за интерфейсом eth2 роутера (команда только ищет маршрут, пакетов не шлёт) — уже разные узлы. Значение 1 добавляет в хеш протокол и порты, и тогда по узлам расходятся соединения даже одного клиента; на стенде роутер мы не перенастраивали.
Атрибуты анонса
Добавим в BGPAdvertisement community и local preference:
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
name: bgp
namespace: metallb-system
spec:
ipAddressPools:
- bgp
localPref: 200
communities:
- 64512:100
Community (RFC 1997) — необязательный атрибут маршрута, четыре байта, которые записывают как AS:число; сам по себе он ничего не меняет, но роутер может по нему выбирать политику (снизить приоритет, не передавать дальше). Что получил роутер — в UPDATE (сообщение BGP с новыми и отозванными маршрутами) и в таблице:
user@router:~$ sudo timeout 25 tcpdump -vv -nni eth1 -c 6 "tcp port 179 and host 192.168.10.20 and greater 80"
…
Update Message (2), length: 55
Origin (1), length: 1, Flags [T]: IGP
0x0000: 00
AS Path (2), length: 6, Flags [T]: 64512
0x0000: 0201 0000 fc00
Next Hop (3), length: 4, Flags [T]: 192.168.10.20
0x0000: c0a8 0a14
Community (8), length: 4, Flags [OT]: 64512:100
0x0000: fc00 0064
Updated routes:
192.168.20.100/32
…
user@router:~$ sudo vtysh -c "show bgp ipv4 unicast 192.168.20.100/32"
BGP routing table entry for 192.168.20.100/32, version 2
Paths: (2 available, best #2, table default)
Not advertised to any peer
64512
192.168.10.20 from 192.168.10.20 (192.168.10.20)
Origin IGP, valid, external, multipath
Community: 64512:100
Last update: Thu Oct 29 06:11:00 2026
64512
192.168.10.21 from 192.168.10.21 (192.168.10.21)
Origin IGP, valid, external, multipath, best (Older Path)
Community: 64512:100
Last update: Thu Oct 29 06:11:00 2026
Community дошла, а local preference в UPDATE нет: этот атрибут передаётся только внутри своей AS (iBGP), и соседу из чужой AS MetalLB его не отправляет. localPref в BGPAdvertisement имеет смысл, когда роутер в той же AS, что и кластер.
aggregationLength объединяет адреса сервисов в один более короткий префикс. Поставим aggregationLength: 30:
user@router:~$ sudo timeout 20 tcpdump -vv -nni eth1 -c 4 "tcp port 179 and src host 192.168.10.20 and greater 80"
…
Update Message (2), length: 55
Origin (1), length: 1, Flags [T]: IGP
0x0000: 00
AS Path (2), length: 6, Flags [T]: 64512
0x0000: 0201 0000 fc00
Next Hop (3), length: 4, Flags [T]: 192.168.10.20
0x0000: c0a8 0a14
Community (8), length: 4, Flags [OT]: 64512:100
0x0000: fc00 0064
Updated routes:
192.168.20.100/30
…
Update Message (2), length: 28
Withdrawn routes:
192.168.20.100/32
End-of-Rib Marker (empty NLRI)
…
Через восемь секунд после изменения на роутере:
user@router:~$ sudo vtysh -c "show bgp summary" -c "show bgp ipv4 unicast" -c "show ip route bgp"
IPv4 Unicast Summary (VRF default):
BGP router identifier 192.168.10.1, local AS number 64513 vrf-id 0
BGP table version 3
RIB entries 0, using 0 bytes of memory
Peers 3, using 2172 KiB of memory
Peer groups 1, using 64 bytes of memory
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 7 7 0 0 0 00:02:45 0 0 N/A
192.168.10.21 4 64512 11 7 0 0 0 00:02:45 0 0 N/A
192.168.10.20 4 64512 11 7 0 0 0 00:02:45 0 0 N/A
Total number of neighbors 3
No BGP prefixes displayed, 0 exist
show ip route bgp ничего не напечатала. Speaker объявил 192.168.20.100/30 и отозвал /32, а роутер /30 не принял: входящий фильтр пропускает только /32 (ge 32), и маршрута к сервису не осталось. Так работает защита на стороне роутера. Возвращаем BGPAdvertisement без атрибутов (kubectl apply -f metallb-bgp.yaml), и маршрут /32 с двумя путями возвращается.
externalTrafficPolicy: Local в режиме BGP
С политикой Cluster все запросы с роутера входили через k8s-node-1, и поды видели его адрес — в том числе под на k8s-node-2, куда запрос kube-proxy переслал с k8s-node-1:
$ kubectl -n demo logs -l app.kubernetes.io/name=hello --tail=2 --prefix
[pod/hello-6d5c6db6b4-wl6kn/hello] 2026/10/29 03:12:23 192.168.10.20:41369 GET /
[pod/hello-6d5c6db6b4-wl6kn/hello] 2026/10/29 03:12:23 192.168.10.20:38250 GET /
[pod/hello-6d5c6db6b4-x9lg9/hello] 2026/10/29 03:10:35 192.168.10.20:37347 GET /
[pod/hello-6d5c6db6b4-x9lg9/hello] 2026/10/29 03:12:23 192.168.10.20:27460 GET /
[pod/hello-6d5c6db6b4-5d5p4/hello] 2026/10/29 03:10:35 192.168.10.20:31866 GET /
[pod/hello-6d5c6db6b4-5d5p4/hello] 2026/10/29 03:12:23 192.168.10.20:61935 GET /
После kubectl -n demo patch svc hello-lb -p '{"spec":{"externalTrafficPolicy":"Local"}}' оба узла продолжают объявлять адрес — поды есть на обоих, — а поды видят адрес клиента, роутера 192.168.10.1. Запросы получают только поды k8s-node-1: все соединения с роутера хеш отправляет на него, а с Local kube-proxy не пересылает их на другие узлы:
$ kubectl -n demo logs -l app.kubernetes.io/name=hello --tail=1 --prefix
[pod/hello-6d5c6db6b4-5d5p4/hello] 2026/10/29 03:12:27 192.168.10.1:37826 GET /
[pod/hello-6d5c6db6b4-wl6kn/hello] 2026/10/29 03:12:27 192.168.10.1:37814 GET /
[pod/hello-6d5c6db6b4-x9lg9/hello] 2026/10/29 03:12:23 192.168.10.20:27460 GET /
Уменьшим Deployment до одного пода — он остался на k8s-node-2, и k8s-node-1 отзывает маршрут:
$ kubectl -n demo scale deploy hello --replicas=1
deployment.apps/hello scaled
$ kubectl -n demo get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello-6d5c6db6b4-x9lg9 1/1 Running 0 5m46s 172.20.172.29 k8s-node-2 <none> <none>
$ kubectl -n metallb-system get servicebgpstatuses
NAME NODE SERVICE NAME SERVICE NAMESPACE
bgp-ztrkj k8s-node-2 hello-lb demo
$ kubectl -n metallb-system logs speaker-dw7l2 --since=4m | grep serviceWithdrawn
{"caller":"main.go:525","event":"serviceWithdrawn","ip":["192.168.20.100"],"ips":["192.168.20.100"],"level":"info","msg":"withdrawing service announcement","pool":"bgp","protocol":"bgp","reason":"noLocalEndpoints","ts":"2026-10-29T03:12:43Z"}
user@router:~$ sudo vtysh -c "show bgp summary" | tail -6; sudo vtysh -c "show ip route bgp" | tail -2
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 9 9 0 0 0 00:03:41 0 0 N/A
192.168.10.21 4 64512 15 9 0 0 0 00:03:41 1 0 N/A
192.168.10.20 4 64512 16 9 0 0 0 00:03:41 0 0 N/A
Total number of neighbors 3
B>* 192.168.20.100/32 [20/0] via 192.168.10.21, eth1, weight 1, 00:00:02
Причина отзыва — noLocalEndpoints (в L2 в такой ситуации было notOwner). Сессия k8s-node-1 осталась установленной, просто в ней ноль маршрутов. Вес путей при этом не зависит от числа подов: при двух подах на k8s-node-1 и одном на k8s-node-2 у обоих путей было weight 1. Возвращаем три реплики и политику Cluster.
Speaker ушёл, узел исключён
Удалим под speaker'а на k8s-node-1 — того самого узла, через который идут запросы роутера, — а на роутере запустим запросы к сервису каждые полсекунды и захват BGP с этим узлом:
$ kubectl -n metallb-system delete pod speaker-dw7l2
pod "speaker-dw7l2" deleted from metallb-system namespace
user@router:~$ sudo timeout 40 tcpdump -tttt -vv -nni eth1 -c 30 "tcp port 179 and host 192.168.10.20"
…
2026-10-29 06:13:26.611327 IP (tos 0x0, ttl 64, id 36076, offset 0, flags [DF], proto TCP (6), length 52)
192.168.10.20.59761 > 192.168.10.1.179: Flags [F.], cksum 0x5c00 (correct), seq 2857159142, ack 3676336153, win 126, options [nop,nop,TS val 821681914 ecr 2375830555], length 0
2026-10-29 06:13:26.622212 IP (tos 0xc0, ttl 1, id 24389, offset 0, flags [DF], proto TCP (6), length 52)
192.168.10.1.179 > 192.168.10.20.59761: Flags [F.], cksum 0x8726 (incorrect -> 0x3f6b), seq 1, ack 1, win 509, options [nop,nop,TS val 2375837488 ecr 821681914], length 0
2026-10-29 06:13:26.622882 IP (tos 0x0, ttl 64, id 36077, offset 0, flags [DF], proto TCP (6), length 52)
192.168.10.20.59761 > 192.168.10.1.179: Flags [.], cksum 0x40de (correct), seq 1, ack 2, win 126, options [nop,nop,TS val 821681926 ecr 2375837488], length 0
2026-10-29 06:13:31.100566 IP (tos 0x0, ttl 64, id 59586, offset 0, flags [DF], proto TCP (6), length 60)
192.168.10.20.59749 > 192.168.10.1.179: Flags [S], cksum 0x2431 (correct), seq 1175485718, win 64240, options [mss 1460,sackOK,TS val 2739229015 ecr 0,nop,wscale 9], length 0
…
Updated routes:
192.168.20.100/32
…
Удаляемый speaker закрыл TCP-соединение (F. — FIN) без сообщения BGP NOTIFICATION, и роутер сразу убрал путь через k8s-node-1, не дожидаясь hold time. Через 4,5 секунды новый под speaker'а на том же узле открыл новое соединение с другого порта и снова объявил маршрут. Состояние роутера через три секунды после удаления и через пятнадцать:
user@router:~$ date -u +%T; sudo vtysh -c "show bgp summary" | tail -6; sudo vtysh -c "show ip route bgp" | tail -2
03:13:29
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 10 10 0 0 0 00:04:25 0 0 N/A
192.168.10.21 4 64512 16 10 0 0 0 00:04:25 1 0 N/A
192.168.10.20 4 64512 18 10 0 0 0 00:00:03 Active 0 N/A
Total number of neighbors 3
B>* 192.168.20.100/32 [20/0] via 192.168.10.21, eth1, weight 1, 00:00:03
user@router:~$ date -u +%T; sudo vtysh -c "show bgp summary" | tail -6; sudo vtysh -c "show ip route bgp" | tail -2
03:13:41
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 11 11 0 0 0 00:04:37 0 0 N/A
192.168.10.21 4 64512 17 11 0 0 0 00:04:37 1 0 N/A
192.168.10.20 4 64512 21 12 0 0 0 00:00:11 1 0 N/A
Total number of neighbors 3
B>* 192.168.20.100/32 [20/0] via 192.168.10.21, eth1, weight 1, 00:00:10
* via 192.168.10.20, eth1, weight 1, 00:00:10
Из 60 запросов с роутера за это время ни один не сорвался: соединения, которые хеш теперь отправлял на k8s-node-2, обслуживал его kube-proxy, а соединения через k8s-node-1 и без speaker'а обслуживает kube-proxy этого узла. Отказ узла целиком на стенде не воспроизводили: тогда FIN никто не пошлёт, и роутер уберёт путь, только когда истечёт hold time — до 90 секунд с умолчаниями MetalLB, и всё это время часть соединений будет уходить на мёртвый узел.
Метка исключения убирает адрес с узла, не трогая сессию:
$ kubectl label node k8s-node-2 node.kubernetes.io/exclude-from-external-load-balancers=true
node/k8s-node-2 labeled
$ kubectl -n metallb-system get servicebgpstatuses
NAME NODE SERVICE NAME SERVICE NAMESPACE
bgp-vhz7r k8s-node-1 hello-lb demo
$ kubectl -n metallb-system logs -l component=speaker --since=30s --prefix | grep -E "serviceWithdrawn|nodeLabels"
[pod/speaker-s8swn/speaker] {"caller":"main.go:525","event":"serviceWithdrawn","ip":["192.168.20.100"],"ips":["192.168.20.100"],"level":"info","msg":"withdrawing service announcement","pool":"bgp","protocol":"bgp","reason":"nodeLabeledExcludeBalancers","ts":"2026-10-29T03:14:34Z"}
user@router:~$ sudo timeout 15 tcpdump -tttt -vv -nni eth1 -c 3 "tcp port 179 and src host 192.168.10.21 and greater 60"
…
2026-10-29 06:14:34.129944 IP (tos 0x0, ttl 64, id 59604, offset 0, flags [DF], proto TCP (6), length 80)
192.168.10.21.50677 > 192.168.10.1.179: Flags [P.], cksum 0x7dbb (correct), seq 353385230:353385258, ack 3835031817, win 126, options [nop,nop,TS val 310078514 ecr 3639304966], length 28: BGP
Update Message (2), length: 28
Withdrawn routes:
192.168.20.100/32
End-of-Rib Marker (empty NLRI)
…
user@router:~$ sudo vtysh -c "show bgp summary" | tail -6; sudo vtysh -c "show ip route bgp" | tail -2
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 13 13 0 0 0 00:05:33 0 0 N/A
192.168.10.21 4 64512 20 13 0 0 0 00:05:33 0 0 N/A
192.168.10.20 4 64512 23 14 0 0 0 00:01:07 1 0 N/A
Total number of neighbors 3
B>* 192.168.20.100/32 [20/0] via 192.168.10.20, eth1, weight 1, 00:00:03
Speaker k8s-node-2 отправил UPDATE с отозванным маршрутом, сессия осталась Established с нулём маршрутов — как у мастера. Это и есть плановый вывод узла в режиме BGP: роутер перестаёт слать на узел новые соединения, пока узел ещё работает. После снятия метки (kubectl label node k8s-node-2 node.kubernetes.io/exclude-from-external-load-balancers-) путь через k8s-node-2 вернулся.
BFD в режиме native
Ускорить обнаружение отказа узла мог бы BFD: BFDProfile и ссылка на него в BGPPeer (bfdProfile). Вебхук такую конфигурацию принимает, но speaker'ы в режиме native её отвергают:
$ kubectl apply -f bfd.yaml
bfdprofile.metallb.io/fast created
bgppeer.metallb.io/router configured
$ kubectl -n metallb-system get configurationstates
NAME RESULT ERRORSUMMARY AGE
controller Valid 8m9s
speaker-k8s-master-1 Invalid configuration error: bfd profiles section set 7m6s
speaker-k8s-node-1 Invalid configuration error: bfd profiles section set 2m13s
speaker-k8s-node-2 Invalid configuration error: bfd profiles section set 7m12s
Через секунду errorSummary speaker'ов сменился на configuration error: peer 192.168.10.1 has bfd-profile set on native bgp mode. Сессии и маршруты при этом не пострадали — speaker'ы продолжили работать с прежней конфигурацией, — но BFD не появился. BFD доступен только в режимах с FRR (FRR-K8s и устаревшем FRR), и на роутере тоже должен работать демон bfdd. Без BFD остаётся уменьшать holdTime и keepaliveTime в BGPPeer.
Уборка
На стенде после опытов мы удалили demo, BGP-конфигурацию (kubectl delete -f metallb-bgp.yaml) и сам MetalLB. Роутер снова ждёт соединений и не знает ни одного маршрута к сервисам:
user@router:~$ sudo vtysh -c "show bgp summary" -c "show ip route bgp" -c "show bgp ipv4 unicast"
…
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc
192.168.10.17 4 64512 17 17 0 0 0 00:00:20 Active 0 N/A
192.168.10.21 4 64512 26 17 0 0 0 00:00:20 Active 0 N/A
192.168.10.20 4 64512 29 19 0 0 0 00:00:20 Active 0 N/A
Total number of neighbors 3
No BGP prefixes displayed, 0 exist
Источники
- MetalLB: Concepts — controller и speaker, выдача адресов и их объявление.
- MetalLB: Layer 2 mode — выбор лидера по хешу, memberlist, gratuitous ARP, время переключения и ограничения, сравнение с Keepalived и VRRP.
- MetalLB: BGP mode — ECMP, разрыв соединений при изменении набора узлов, режимы native, FRR и FRR-K8s.
- MetalLB: Installation — установка манифестом,
strictARPдля IPVS, выбор реализации BGP,--lb-class. - MetalLB: Configuration — IPAddressPool и L2Advertisement; Advanced L2 configuration —
nodeSelectorsиinterfaces; Advanced IPAddressPool configuration —autoAssign,avoidBuggyIPs, выбор пула; Advanced BGP configuration —localPref, communities,aggregationLength, выбор узлов и соседей для анонса. - MetalLB: Usage — аннотации
metallb.io/loadBalancerIPs,address-pool,allow-shared-ip,externalTrafficPolicyв режимах L2 и BGP. - MetalLB: Issues with Calico — одна BGP-сессия на пару соседей, когда Calico и MetalLB говорят с одним роутером, и анонс адресов сервисов через Calico.
- MetalLB: Troubleshooting —
arping, несколько MAC на один адрес, уровень журналаdebug. - MetalLB: Release notes — версия 0.16.1, FRR-K8s по умолчанию с 0.16.0, ConfigurationState с 0.15.3.
- FRR-K8s — контроллер FRR для Kubernetes, на котором работает BGP MetalLB по умолчанию.
- FRR: BGP —
router bgp, peer-group, адресные семейства, route-map на соседе,bgp ebgp-requires-policy,maximum-paths(документация FRR 8.4); IP Prefix Lists и Route Maps — фильтр принимаемых маршрутов на роутере. - Linux: IP Sysctl —
fib_multipath_hash_policy, по каким полям ядро выбирает путь ECMP. - hashicorp/memberlist — библиотека членства в группе и обнаружения отказов.
- Kubernetes: Service, type LoadBalancer —
loadBalancerClass, устареваниеspec.loadBalancerIP,ipMode. - Kubernetes: Cloud Controller Manager — что делает облачный контроллер, в том числе для балансировщиков.
- Kubernetes: Create an External Load Balancer — сохранение адреса клиента и
healthCheckNodePort. - Kubernetes: Well-Known Labels, Annotations and Taints — метка
node.kubernetes.io/exclude-from-external-load-balancers. - Kubernetes: Virtual IPs and Service Proxies — kube-proxy в режимах nftables и IPVS.
- Calico: Advertise Kubernetes service IP addresses и LoadBalancer IP address management — анонс адресов сервисов по BGP и пулы
allowedUses: LoadBalancer. - kube-vip: Cloud Provider и Cilium: LB IPAM — альтернативы MetalLB.
- RFC 826 — ARP; RFC 5227 — ARP Announcement; RFC 4271 — BGP-4; RFC 1997 — community; RFC 8212 — eBGP без политик ничего не принимает и не отправляет; RFC 5880 — BFD; RFC 5798 — VRRP версии 3.
Дальше — Ingress и Gateway API: один адрес 192.168.10.200 на все HTTP-приложения, обратный прокси Traefik, который раскладывает запросы по сервисам по имени сайта и пути, и два API для описания маршрутов — привычный Ingress и новый Gateway API.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.