Профессиональные курсы по Kubernetes и Cilium.
Search icon Связаться с нами

Реализация L2-анонсов в Cilium для обеспечения доступа к Kubernetes-сервисам в bare-metal среде

Полное руководство: L2 анонсы Cilium для сервисов LoadBalancer в bare-metal Kubernetes

Оглавление

  1. Введение
  2. Настройка окружения
  3. Деплой Nginx
  4. Настройка Ingress
  5. Настройка LoadBalancer IP Pool
  6. Проблема с ARP
  7. Включение L2 анонсов
  8. Как это работает
  9. Путь пакета
  10. Дополнительные материалы

Введение: Зачем нужны L2 анонсы в Kubernetes?

Целевая аудитория

Материал предназначен для:

  • Администраторов Kubernetes-кластеров
  • Сетевых инженеров, работающих с технологиями Cilium (eBPF, CNI)
  • Специалистов по инфраструктуре, знакомых с основами L2/L3 сетей
  • Специалистов, интересующихся сетевыми технологиями в Kubernetes
📚 Терминология (рекомендуется к ознакомлению)

Gratuitous ARP - специальный ARP-пакет, рассылаемый узлом при изменении своего MAC-адреса или IP-адреса, чтобы обновить ARP-кеши соседних устройств.

Lease в Kubernetes - механизм аренды ресурсов через API, обеспечивающий эксклюзивный доступ к ресурсу (в данном случае - к IP-адресу) для одного узла кластера.

Bare-metal Kubernetes - кластерная инфраструктура, развернутая на физических серверах без использования облачных провайдеров.

Постановка проблемы

При работе с Kubernetes в bare-metal окружениях часто возникает сложность с доступом к сервисам типа LoadBalancer из внешних сетей. Традиционные решения вроде MetalLB требуют дополнительных компонентов и могут быть избыточны для простых сценариев.

Cilium предоставляет нативное решение для L2-анонсов, обладающее следующими возможностями:

  • Отвечать на ARP запросы для IP адресов LoadBalancer
  • Обеспечивать высокую доступность через механизм аренды (lease)
  • Интегрироваться с существующей сетевой инфраструктурой

В рамках данного материала рассматриваются:

  1. Как настроить L2 анонсы в Cilium
  2. Как работает механизм ARP ответов через BPF
  3. Узнаем полезные мелочи при работе с cilium.

Основная задача исследования - продемонстрировать реализацию доступа к Kubernetes-сервисам из внешних сетей в bare-metal окружении, используя исключительно встроенные механизмы Cilium.

Настройка окружения

Для начала надо настроить окружение, я его описал в README.md

Схема сети

graph TD
    subgraph L2 
        L2["192.168.56.0/24"]
    end

    subgraph Интернет
        Internet["Глобальная сеть"]
    end

    Jumpbox["Jumpbox
    192.168.56.10
    MAC: 00:0c:29:e4:f1:a4"] --> L2
    Server["Server
    192.168.56.20
    MAC: 00:0c:29:34:87:0e"] --> L2
    Node0["Node-0
    192.168.56.50
    MAC: 00:0c:29:e3:b1:b2"] --> L2
    Node1["Node-1
    192.168.56.60
    MAC: 00:0c:29:0d:b7:76"] --> L2

    L2 --> Internet
  
  • Подсети для подов:
    • 10.200.0.0/24 для server
    • 10.200.1.0/24 для node-0
    • 10.200.2.0/24 для node-1
Конфигурация Kubernetes и Cilium

Виртуалки подняты на mac arm с помощью Vagrant.

jumpbox - 192.168.56.10 - клиент не входящий в кластер kubernetes.

graph TD
    subgraph Jumpbox[Jumpbox]
        eth0["eth0: 172.16.65.138/24<br>MAC: 00:0c:29:e4:f1:9a"]
        eth1["eth1: 192.168.56.10/24<br>MAC: 00:0c:29:e4:f1:a4"]
    end

    subgraph Routing[Routing Table]
        default["Default route: 172.16.65.2 via eth0"]
        pod_net["Pod network: 10.0.10.0/24 via eth1"]
    end

    eth0 -->|External Traffic| default
    eth1 -->|Pod Network| pod_net

    classDef node fill:#e6f3ff,stroke:#333,stroke-width:2px;
    classDef interface fill:#fff,stroke:#666,stroke-width:1px;
    class Jumpbox node;
    class eth0,eth1 interface;
    class Routing fill:#f0f0f0,stroke:#999,dashed;
  

server - 192.168.56.20 - control plane

graph TD
    subgraph Server[Server]
        eth0["eth0: 172.16.65.134/24<br>MAC: 00:0c:29:34:87:04"]
        eth1["eth1: 192.168.56.20/24<br>MAC: 00:0c:29:34:87:0e"]
        cilium_host["cilium_host: 10.200.0.7/32"]
    end

    subgraph Routing[Routing Table]
        default["Default route: 172.16.65.2 via eth0"]
        pod_net1["Pod network: 10.200.1.0/24 via 192.168.56.50"]
        pod_net2["Pod network: 10.200.2.0/24 via 192.168.56.60"]
    end

    eth0 -->|External Traffic| default
    eth1 -->|Pod Networks| pod_net1
    eth1 -->|Pod Networks| pod_net2

    classDef node fill:#e6f3ff,stroke:#333,stroke-width:2px;
    classDef interface fill:#fff,stroke:#666,stroke-width:1px;
    classDef cilium fill:#f0f0f0,stroke:#999,dashed;
    class Server node;
    class eth0,eth1 interface;
    class cilium_host,cilium_net cilium;
    class Routing fill:#f0f0f0,stroke:#999,dashed;
  

node-0 - 192.168.56.50 - нода k8s

graph TD
    subgraph Node-0[Node-0]
        eth0["eth0: 172.16.65.135/24<br>MAC: 00:0c:29:e3:b1:a8"]
        eth1["eth1: 192.168.56.50/24<br>MAC: 00:0c:29:e3:b1:b2"]
        cilium_host["cilium_host: 10.200.1.54/32"]
    end

    subgraph Routing[Routing Table]
        default["Default route: 172.16.65.2 via eth0"]
        pod_net0["Pod network: 10.200.0.0/24 via 192.168.56.20"]
        pod_net2["Pod network: 10.200.2.0/24 via 192.168.56.60"]
    end

    eth0 -->|External Traffic| default
    eth1 -->|Pod Networks| pod_net0
    eth1 -->|Pod Networks| pod_net2

    classDef node fill:#e6f3ff,stroke:#333,stroke-width:2px;
    classDef interface fill:#fff,stroke:#666,stroke-width:1px;
    classDef cilium fill:#f0f0f0,stroke:#999,dashed;
    class Node-0 node;
    class eth0,eth1 interface;
    class cilium_host,cilium_net cilium;
    class Routing fill:#f0f0f0,stroke:#999,dashed;
  

node-1 - 192.168.56.60 - нода k8s

graph TD
    subgraph Node-1[Node-1]
        eth0["eth0: 172.16.65.136/24<br>MAC: 00:0c:29:0d:b7:6c"]
        eth1["eth1: 192.168.56.60/24<br>MAC: 00:0c:29:0d:b7:76"]
        cilium_host["cilium_host: 10.200.2.17/32"]
    end

    subgraph Routing[Routing Table]
        default["Default route: 172.16.65.2 via eth0"]
        pod_net0["Pod network: 10.200.0.0/24 via 192.168.56.20"]
        pod_net1["Pod network: 10.200.1.0/24 via 192.168.56.50"]
    end

    eth0 -->|External Traffic| default
    eth1 -->|Pod Networks| pod_net0
    eth1 -->|Pod Networks| pod_net1

    classDef node fill:#e6f3ff,stroke:#333,stroke-width:2px;
    classDef interface fill:#fff,stroke:#666,stroke-width:1px;
    classDef cilium fill:#f0f0f0,stroke:#999,dashed;
    class Node-1 node;
    class eth0,eth1 interface;
    class cilium_host,cilium_net cilium;
    class Routing fill:#f0f0f0,stroke:#999,dashed;
  

Cilium с нативным роутингом. Туннели не используются. Версия v1.16.5.

helm upgrade --install cilium cilium/cilium --version 1.16.5 --namespace kube-system \
  --set l2announcements.enabled=true \
  --set externalIPs.enabled=true \
  --set kubeProxyReplacement=true \
  --set ipam.mode=kubernetes \
  --set k8sServiceHost=192.168.56.20 \
  --set k8sServicePort=6443 \
  --set operator.replicas=1 \
  --set routingMode=native \
  --set ipv4NativeRoutingCIDR=10.200.0.0/22 \
  --set endpointRoutes.enabled=true \
  --set ingressController.enabled=true \
  --set ingressController.loadbalancerMode=dedicated 

Ядро

# uname -a
Linux node-1 6.1.0-20-arm64 #1 SMP Debian 6.1.85-1 (2024-04-11) aarch64 aarch64 aarch64 GNU/Linux

Деплой Nginx

В этом разделе мы развернем простой веб-сервер на основе Nginx.

Шаг 1. Создание Deployment

# vagrant ssh server
# sudo bash

cat << EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:latest
        ports:
        - containerPort: 80
EOF

cat << EOF | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
EOF

Шаг 2. Проверка работы

Убедимся что сервис работает

# kubectl get pod -o wide
NAME                   READY   STATUS    RESTARTS   AGE   IP             NODE     NOMINATED NODE   READINESS GATES
nginx-96b9d695-25swg   1/1     Running   0          55s   10.200.2.189   node-1   <none>           <none>

# kubectl get svc nginx
NAME    TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
nginx   ClusterIP   10.96.79.80   <none>        80/TCP    89s

# curl 10.96.79.80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

# curl 10.200.2.189
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

↑ К оглавлению | ← Назад

Настройка Ingress

Документация на сайте cilium

Применяем манифест ingress

cat << EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: basic-ingress
  namespace: default
spec:
  ingressClassName: cilium
  rules:
  - http:
      paths:
      - backend:
          service:
            name: nginx
            port:
              number: 80
        path: /
        pathType: Prefix
EOF

# kubectl get ingress
NAME            CLASS    HOSTS   ADDRESS   PORTS   AGE
basic-ingress   cilium   *                 80      40s

Так же будет создан сервис для этого ingress

# kubectl get svc cilium-ingress-basic-ingress
NAME                           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
cilium-ingress-basic-ingress   LoadBalancer   10.96.156.194   <pending>     80:31017/TCP,443:32600/TCP   115s

Как видно из вывода, статус EXTERNAL-IP находится в состоянии pending ввиду отсутствия выделенного пула IP-адресов.

↑ К оглавлению | ← Назад

Настройка LoadBalancer IP Pool

Цилиум поддерживает назначение IP адреса на тип сервиса LoadBalancer.

CiliumLoadBalancerIPPool

Дока на сайте cilium

Создадим CiliumLoadBalancerIPPool

cat << EOF | kubectl apply -f -
apiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
  name: "blue-pool"
spec:
  blocks:
  - cidr: "10.0.10.0/24"
EOF

И у нас сразу появится IP у EXTERNAL-IP

# kubectl get svc cilium-ingress-basic-ingress
NAME                           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
cilium-ingress-basic-ingress   LoadBalancer   10.96.156.194   10.0.10.0     80:31017/TCP,443:32600/TCP   4m9s

И он сразу работает. (правда только с нод где запущен cilium)

# curl 10.0.10.0
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

↑ К оглавлению | ← Назад

Проблема с ARP

Зайдем в другой консоли на наш клиент jumpbox и убедимся что сайт открывается.

# vagrant ssh jumpbox
root@jumpbox:/home/vagrant# curl 10.0.10.0
curl: (7) Failed to connect to 10.0.10.0 port 80 after 3074 ms: Couldn't connect to server

Почему так?

Вы, конечно, догадались: на ARP запрос никто не отвечает.

root@server:/home/vagrant# tcpdump -n -i any arp host 10.0.10.0
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes

21:15:05.927064 eth1  B   ARP, Request who-has 10.0.10.0 tell 192.168.56.10, length 46
21:15:06.948513 eth1  B   ARP, Request who-has 10.0.10.0 tell 192.168.56.10, length 46
21:15:07.973210 eth1  B   ARP, Request who-has 10.0.10.0 tell 192.168.56.10, length 46
21:15:08.998950 eth1  B   ARP, Request who-has 10.0.10.0 tell 192.168.56.10, length 46
21:15:10.024080 eth1  B   ARP, Request who-has 10.0.10.0 tell 192.168.56.10, length 46
21:15:11.050053 eth1  B   ARP, Request who-has 10.0.10.0 tell 192.168.56.10, length 46

root@jumpbox:/home/vagrant# arp -n 10.0.10.0
Address                  HWtype  HWaddress           Flags Mask            Iface
10.0.10.0                        (incomplete)                              eth1

↑ К оглавлению | ← Назад

Включение L2 анонсов

Давайте включим ARP анонсы.

graph RL
   subgraph L2 Анонсы
       A["L2 Policy (Cilium CRD)"] --> B["Lease захват (Kubernetes API)"]
       B --> C["BPF Map (ARP обработка)"]
       C --> D["ARP Response"]
   end
  
root@server:/home/vagrant# tcpdump -n -i any arp host 10.0.10.0 & # запустили tcpdump в бекграунде, чтобы сразу увидеть что происходит.
[1] 17207

root@server:/home/vagrant# cat << EOF | kubectl apply -f -
apiVersion: "cilium.io/v2alpha1"
kind: CiliumL2AnnouncementPolicy
metadata:
  name: policy1
spec:
  nodeSelector:
    matchExpressions:
      - key: node-role.kubernetes.io/control-plane
        operator: DoesNotExist
  interfaces:
  - ^eth[0-9]+
  externalIPs: true
  loadBalancerIPs: true
EOF
ciliuml2announcementpolicy.cilium.io/policy1 created

21:18:52.093372 eth1  B   ARP, Reply 10.0.10.0 is-at 00:0c:29:0d:b7:76, length 46
21:18:52.102795 eth0  B   ARP, Reply 10.0.10.0 is-at 00:0c:29:0d:b7:6c, length 46

root@jumpbox:/home/vagrant# tcpdump -n -i any arp
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes

21:18:52.113122 eth1  B   ARP, Reply 10.0.10.0 is-at 00:0c:29:0d:b7:76, length 46
21:18:52.113211 eth1  B   ARP, Reply 10.0.10.1 is-at 00:0c:29:e3:b1:b2, length 46
21:18:52.122245 eth0  B   ARP, Reply 10.0.10.1 is-at 00:0c:29:e3:b1:a8, length 46
21:18:52.122495 eth0  B   ARP, Reply 10.0.10.0 is-at 00:0c:29:0d:b7:6c, length 46

root@jumpbox:/home/vagrant# arp -n 10.0.10.0
Address                  HWtype  HWaddress           Flags Mask            Iface
10.0.10.0                ether   00:0c:29:0d:b7:76   C                     eth1

Сразу после активации политики наблюдается генерация Gratuitous ARP-пакетов, что является стандартным механизмом Gratuitous ARP, обеспечивающим распространение информации о соответствии IP-MAC адресов в пределах L2-домена.

Итак, у нас есть mac 00:0c:29:0d:b7:76. Это мак-адрес интерфейса eth1 ноды node-1

root@node-1:/home/vagrant# ip addr sh | grep -1 00:0c:29:0d:b7:76
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:0d:b7:76 brd ff:ff:ff:ff:ff:ff
    altname enp18s0

Так же легко это выяснить поискав какая нода захватила лизу

# kubectl get lease -n kube-system cilium-l2announce-default-cilium-ingress-basic-ingress
NAME                                                     HOLDER   AGE
cilium-l2announce-default-cilium-ingress-basic-ingress   node-1   8m49s

↑ К оглавлению | ← Назад

Как это работает

sequenceDiagram
    participant Client as Jumpbox
    participant Node-0
    participant Node-1
    participant K8sAPI

    Client->>Node-0: ARP Request (Who has 10.0.10.0?)
    Node-0->>K8sAPI: Проверка Lease
    K8sAPI-->>Node-0: Lease принадлежит Node-1
    Node-0->>Client: Silence (не отвечает)
    Client->>Node-1: ARP Request
    Node-1->>K8sAPI: Проверка Lease
    K8sAPI-->>Node-1: Lease подтверждён
    Node-1->>Client: ARP Reply
  
  1. Настройка L2 Policy
  2. Lease захват
  3. BPF Map для ARP

Основную информацию вы, конечно, можете прочитать в документации, я же расскажу чуть-чуть побольше.

Настройка L2 Policy

1. Настраиваем policy включающие l2 анонсы
apiVersion: "cilium.io/v2alpha1"
kind: CiliumL2AnnouncementPolicy
metadata:
  name: policy1
spec:
  nodeSelector:
    matchExpressions:
      - key: node-role.kubernetes.io/control-plane
        operator: DoesNotExist
  interfaces:
  - ^eth[0-9]+
  externalIPs: true
  loadBalancerIPs: true

Lease захват

2. Цилиум захватывает лизу
root@server:/home/vagrant# kubectl get lease -n kube-system | grep l2announce
cilium-l2announce-default-cilium-ingress-basic-ingress   node-1                                                                      16m
cilium-l2announce-kube-system-cilium-ingress             node-0                                                                      16m

BPF Map для ARP

3. На ноде создается bpf map, отвечающая за ARP ответы по анонсируемому IP
# kubectl get svc cilium-ingress-basic-ingress
NAME                           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
cilium-ingress-basic-ingress   LoadBalancer   10.96.156.194   10.0.10.0     80:31017/TCP,443:32600/TCP   24h
root@node-1:/home/cilium# bpftool map show pinned /sys/fs/bpf/tc/globals/cilium_l2_responder_v4
72: hash  name cilium_l2_respo  flags 0x1
	key 8B  value 8B  max_entries 4096  memlock 65536B
	btf_id 125
root@node-1:/home/cilium# bpftool map dump pinned /sys/fs/bpf/tc/globals/cilium_l2_responder_v4
[{
        "key": {
            "ip4": 655370, # IP
            "ifindex": 2   # Номер интерфейса в системе.
        },
        "value": {
            "responses_sent": 0
        }
    },{
        "key": {
            "ip4": 655370,
            "ifindex": 3
        },
        "value": {
            "responses_sent": 3 # сколько ответов было послано на ARP запрос.
        }
    }
]

pinned - закреплена в файловой системе и сохраняется между рестартами.

ifindex - номера интерфейсов

root@node-1:/home/cilium# ip link show | grep eth
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    // ...
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000

Что за число 655370?

import socket
import struct

# Число в little-endian
number = 655370

# Преобразуем число в IP-адрес
# < — указывает на little-endian.
# I — беззнаковое 32-битное целое число.
ip = socket.inet_ntoa(struct.pack('<I', number))
print(ip)

10.0.10.0 - искомый IP адрес LB.

Сетевой путь ARP-пакета можно представить следующей схемой (для случая отсутствия записи в ARP-кэше):

graph RL
    J["jumpbox"] -->|"Broadcast ARP Request<br>'Who has 10.0.10.0?'"| L["L2 domain (все узлы)"]
    L -->|Receive| N["node-1"]
    N -->|"Unicast ARP Reply<br>'10.0.10.0 is-at 00:0c:29:0d:b7:76'"| J
  

jumpbox - посылает широковещательный запрос с вопросом кто отвечает за 10.0.10.0. все сервера l2 домена - получают этот запрос. node-1 - отвечает на этот запрос с мак адресом интерфейса, который принял ARP запрос.

↑ К оглавлению | ← Назад

Путь пакета

Рассмотрим механизм обработки ARP-запросов на node-1:

Предположим что запрос пришел на eth1.

На этом интерфейсе подцеплены bpf программы (у нас же цилиум, елки-палки!).

tc filter show dev eth1 ingress
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 cil_from_netdev-eth1 direct-action not_in_hw id 3640 tag e1f4a3d35ae9c0f0 jited

И входящий трафик обрабатывает программа cil_from_netdev-eth1. Чуть больше информации можно посмотреть так

root@node-1:/home/cilium# bpftool prog show id 3640
3640: sched_cls  name cil_from_netdev  tag e1f4a3d35ae9c0f0  gpl
	loaded_at 2025-01-19T18:24:42+0000  uid 0
	xlated 3856B  jited 3192B  memlock 4096B  map_ids 55,548,17,72,54
	btf_id 3385

direct-action - bpf программа сама примет решение о том что делать с принятым пакетом.

В цилиуме эта bpf программа аттачится к интерфейсу тут

// reloadHostDatapath (re)attaches programs from bpf_host.c to:
// - cilium_host: cil_to_host ingress and cil_from_host to egress
// - cilium_net: cil_to_host to ingress
// - native devices: cil_from_netdev to ingress and (optionally) cil_to_netdev to egress if certain features require it
func (l *loader) reloadHostDatapath(ep datapath.Endpoint, spec *ebpf.CollectionSpec, devices []string) error {
	// Replace programs on cilium_host.
    // ...
                // Attach cil_from_netdev to ingress.
                if err := attachSKBProgram(iface, coll.Programs[symbolFromHostNetdevEp], symbolFromHostNetdevEp,
                        linkDir, netlink.HANDLE_MIN_INGRESS, option.Config.EnableTCX); err != nil {
                        return fmt.Errorf("interface %s ingress: %w", device, err)
                }

Саму программу cil_from_netdev можно найти тут

/*
 * from-netdev is attached as a tc ingress filter to one or more physical devices
 * managed by Cilium (e.g., eth0). This program is only attached when:
 * - the host firewall is enabled, or
 * - BPF NodePort is enabled, or
 * - L2 announcements are enabled, or
 * - WireGuard's host-to-host encryption and BPF NodePort are enabled
 */
__section_entry
int cil_from_netdev(struct __ctx_buff *ctx)
// ...
return handle_netdev(ctx, false);

Запрос передается в handle_netdev и уходит в do_netdev.

/**
 * handle_netdev
 * @ctx		The packet context for this program
 * @from_host	True if the packet is from the local host
 *
 * Handle netdev traffic coming towards the Cilium-managed network.
 */
static __always_inline int
handle_netdev(struct __ctx_buff *ctx, const bool from_host)

// ...

return do_netdev(ctx, proto, from_host);

do_netdev обрабатывает ARP

do_netdev(struct __ctx_buff *ctx, __u16 proto, const bool from_host)
// ...
# if defined ENABLE_ARP_PASSTHROUGH || defined ENABLE_ARP_RESPONDER || \
     defined ENABLE_L2_ANNOUNCEMENTS
	case bpf_htons(ETH_P_ARP):
		#ifdef ENABLE_L2_ANNOUNCEMENTS
			ret = handle_l2_announcement(ctx);
		#else
			ret = CTX_ACT_OK;
		#endif
		break;
# endif
// ...

Заметьте, если анонсы не включены - то пакет просто пропустится (CTX_ACT_OK) и не будет обработан, иначе будет вызвана функция handle_l2_announcement.

handle_l2_announcement

#ifdef ENABLE_L2_ANNOUNCEMENTS
static __always_inline int handle_l2_announcement(struct __ctx_buff *ctx)
// ...

В которой будет:

  1. Проверено что брат цилиум-агент жив (опция agent-liveness-update-interval)
// ...
	__u32 index = RUNTIME_CONFIG_AGENT_LIVENESS;
	__u64 *time;

	time = map_lookup_elem(&CONFIG_MAP, &index);
	if (!time)
		return CTX_ACT_OK;

	/* If the agent is not active for X seconds, we can't trust the contents
	 * of the responder map anymore. So stop responding, assuming other nodes
	 * will take over for a node without an active agent.
	 */
	if (ktime_get_ns() - (*time) > L2_ANNOUNCEMENTS_MAX_LIVENESS)
		return CTX_ACT_OK;
// ...

Как вы думаете где хранится значение time?
Конечно! В еще одной bpf мапе!

root@node-1:/home/cilium# cilium-dbg map get cilium_runtime_config | head -3
Key             Value              State   Error
UTimeOffset     3393110285156250
AgentLiveness   53302401940736 # monolithic time https://docs.redhat.com/en/documentation/red_hat_enterprise_linux_for_real_time/7/html/reference_guide/sect-posix_clocks

  1. Что пакет реально ARP пакет
	if (!arp_validate(ctx, &mac, &smac, &sip, &tip))
		return CTX_ACT_OK;

  1. Проверено что мы вообще должны отвечать на этот arp (помните про лизу?)
	key.ip4 = tip;
	key.ifindex = ctx->ingress_ifindex;
	stats = map_lookup_elem(&L2_RESPONDER_MAP4, &key);
	if (!stats)
		return CTX_ACT_OK;

Вот эта мапка!

root@node-1:/home/cilium# bpftool map dump pinned /sys/fs/bpf/tc/globals/cilium_l2_responder_v4
[{
        "key": {
            "ip4": 655370,
            "ifindex": 2
        },
        "value": {
            "responses_sent": 0
        }
    },{
        "key": {
            "ip4": 655370,
            "ifindex": 3
        },
        "value": {
            "responses_sent": 0
        }
    }
]

А на node-0 будет в это время так:

root@node-0:/home/cilium# bpftool map dump pinned /sys/fs/bpf/tc/globals/cilium_l2_responder_v4
[]

  1. Вызовется arp_respond
ret = arp_respond(ctx, &mac, tip, &smac, sip, 0);

arp_respond вызовет arp_prepare_response и отправит ctx_redirect пакет на интерфейс.

static __always_inline int
arp_respond(struct __ctx_buff *ctx, union macaddr *smac, __be32 sip,
	    union macaddr *dmac, __be32 tip, int direction)
{
	int ret = arp_prepare_response(ctx, smac, sip, dmac, tip);

	if (unlikely(ret != 0)) // ну типа ошибка маловероятна
		goto error;

	cilium_dbg_capture(ctx, DBG_CAPTURE_DELIVERY,
			   ctx_get_ifindex(ctx));
	return ctx_redirect(ctx, ctx_get_ifindex(ctx), direction);

error:
	return send_drop_notify_error(ctx, UNKNOWN_ID, ret, CTX_ACT_DROP, METRIC_EGRESS);
}

ctx_redirect в итоге вызовет функцию bpf_redirect о которой хорошо написано тут

Важным аспектом является параметр direction = 0, определяющий направление перенаправления пакета (ingress/egress). Согласно документации bpf_redirect:

Except for XDP, both ingress and egress interfaces can be used
for redirection. The **BPF_F_INGRESS** value in *flags* is used
to make the distinction (ingress path is selected if the flag
is present, egress path otherwise).

Значение BPF_F_INGRESS можно подсмотреть в заголовках bpf:

root@node-1:/home/cilium# grep -Rw BPF_F_INGRESS /var/lib/cilium/bpf/include/linux/bpf.h
# ..
BPF_F_INGRESS			= (1ULL << 0),

Что по сути является числом 1, а у нас 0, то есть мы отправили пакет на egress (на выход с eth1).

Итого, путь пакета на node-1

 +-------------------+
 |   Внешний хост    |
 |    (jumpbox)      |
 +--------+----------+
          | ARP запрос "Who has 10.0.10.0?"
          v
 +-------------------+
 |   Интерфейс eth1  |
 |     node-1        |
 +--------+----------+
          | Пакет попадает в TC ingress
          v
 +-------------------+
 |  BPF программа    |
 | cil_from_netdev   |
 +--------+----------+
          | Обработка в handle_netdev
          v
 +-------------------+
 |  do_netdev()      |
 +--------+----------+
          | Проверка типа пакета (ARP)
          v
 +-------------------+
 | handle_l2_announcement() |
 +--------+----------+
          | Проверки:
          | 1. Жив ли Cilium агент
          | 2. Это ARP пакет
          | 3. Есть ли запись в L2_RESPONDER_MAP4
          v
 +-------------------+
 | arp_respond()     |
 +--------+----------+
          | Подготовка ARP ответа
          v
 +-------------------+
 | ctx_redirect()    |
 +--------+----------+
          | Перенаправление на egress (direction=0)
          v
 +-------------------+
 |   Интерфейс eth1  |
 |     node-1        |
 +--------+----------+
          | ARP ответ "10.0.10.0 is-at 00:0c:29:0d:b7:76"
          v
 +-------------------+
 |   Внешний хост    |
 |    (jumpbox)      |
 +-------------------+

↑ К оглавлению | ← Назад

Дополнительные материалы

Различия между cilium l2 анонсами и proxy_arp

Вопрос: Каковы отличия между L2-анонсами Cilium и активацией proxy_arp на узлах кластера?
Ответ: При использовании proxy_arp обработка ARP-запросов осуществляется на уровне ядра ОС, что сохраняет функциональность, но лишает преимуществ распределенного управления через Kubernetes API.

Отключили ARP анонсы.

root@server:/home/vagrant# kubectl delete -f workshop/l2.yaml
ciliuml2announcementpolicy.cilium.io "policy1" deleted

ARP перестал отвечать на jumphost

root@jumpbox:/home/vagrant# arping  10.0.10.0
ARPING 10.0.10.0
60 bytes from 00:0c:29:e3:b1:a8 (10.0.10.0): index=0 time=257.448 usec
60 bytes from 00:0c:29:e3:b1:a8 (10.0.10.0): index=1 time=377.882 usec

Timeout
Timeout
Timeout

Включили arp_proxy на node-1

root@node-1:/home/vagrant# sysctl -w net.ipv4.conf.eth1.proxy_arp=1
net.ipv4.conf.eth1.proxy_arp = 1

Все заработало

root@jumpbox:/home/vagrant# arping -I eth1 10.0.10.0
ARPING 10.0.10.0
Timeout
Timeout
Timeout
Timeout
60 bytes from 00:0c:29:0d:b7:76 (10.0.10.0): index=0 time=449.655 msec
60 bytes from 00:0c:29:0d:b7:76 (10.0.10.0): index=1 time=792.268 msec
60 bytes from 00:0c:29:0d:b7:76 (10.0.10.0): index=2 time=152.025 msec

root@jumpbox:/home/vagrant# curl 10.0.10.0
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
// ...

Чтобы убедиться что отвечает само ядро можно подцепиться к функции arp_process в линукс ядре

/*
 *	Process an arp request.
 */

static int arp_process(struct net *net, struct sock *sk, struct sk_buff *skb)
// ...

Напишем bpf программу

root@node-1:/home/vagrant#  bpftrace -e '
 kprobe:arp_process
 {
     printf("ARP process called (pid=%d)\n", pid);
     printf("  net: %lx\n", arg0);
     printf("  sock: %lx\n", arg1);
     printf("  sk_buff: %lx\n", arg2);

     // Доступ к данным из sk_buff
     $skb = (struct sk_buff *)arg2;
     printf("  skb->len: %d\n", $skb->len);
     printf("  skb->data: %lx\n", $skb->data);

     // Выводим первые 64 байт данных
     printf("  data: ");
     $data = (uint8*)$skb->data;
     unroll(64) {
         printf("%02x ", *$data);
         $data++;
     }
     printf("\n");
 }
 '

Пингуем с jumphost - 192.168.56.10

root@jumpbox:/home/vagrant# arping -c 1 -I eth1 10.0.10.0
ARPING 10.0.10.0
60 bytes from 00:0c:29:0d:b7:76 (10.0.10.0): index=0 time=276.063 msec

Видим что ядро обрабатывает пакет

ARP process called (pid=0)
  net: ffff80000a19ff00
  sock: 0
  sk_buff: ffff000006981000
  skb->len: 46
  skb->data: ffff00003b42e04e
  data: 00 01 08 00 06 04 00 01 00 0c 29 e4 f1 a4 c0 a8 38 0a 00 00 00 00 00 00 0a 00 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

Давайте расшифруем что получилось.

1 Заголовок ARP (хорошее описание):

   • 00 01 — Hardware type (Ethernet).  
   • 08 00 — Protocol type (IPv4).  
   • 06 — MAC address length (6 байт).  
   • 04 — IP address length (4 байта).  
   • 00 01 — Operation (ARP request).  

2 Данные ARP:

   • 00 0c 29 e4 f1 a4 — Sender MAC: 00:0c:29:e4:f1:a4.  
   • c0 a8 38 0a — Sender IP: 192.168.56.10.  
   • 00 00 00 00 00 00 — Target MAC: 00:00:00:00:00:00 (запрашивается).  
   • 0a 00 0a 00 — Target IP: 10.0.10.0.  

Это ARP запрос от устройства с MAC 00:0c:29:e4:f1:a4 и IP 192.168.56.10, которое ищет устройство с IP 10.0.10.0. В ответе ARP должно быть указано, какой MAC адрес соответствует IP 10.0.10.0.

А вот ответ (arp_send_dst)

 bpftrace -e '
 kprobe:arp_send_dst
 {
     printf("ARP send called (pid=%d)\n", pid);
     printf("  type: %d\n", arg0);  // Тип ARP пакета (1 - Request, 2 - Reply)
     printf("  ptype: %d\n", arg1); // Тип протокола (2054 для ARP)
     printf("  dest_ip: %d.%d.%d.%d\n", (arg2 >> 0) & 0xff, (arg2 >> 8) & 0xff, (arg2 >> 16) & 0xff, (arg2 >> 24) & 0xff);
     printf("  src_ip: %d.%d.%d.%d\n", (arg4 >> 0) & 0xff, (arg4 >> 8) & 0xff, (arg4 >> 16) & 0xff, (arg4 >> 24) & 0xff);
     printf("  dest_hw: %02x:%02x:%02x:%02x:%02x:%02x\n", *(uint8*)(arg5 + 0), *(uint8*)(arg5 + 1), *(uint8*)(arg5 + 2), *(uint8*)(arg5 + 3),
 *(uint8*)(arg5 + 4), *(uint8*)(arg5 + 5)); // MAC dst
     printf("  src_hw: %02x:%02x:%02x:%02x:%02x:%02x\n", *(uint8*)(arg6 + 0), *(uint8*)(arg6 + 1), *(uint8*)(arg6 + 2), *(uint8*)(arg6 + 3),
 *(uint8*)(arg6 + 4), *(uint8*)(arg6 + 5)); // MAC src
     printf("  target_hw: %02x:%02x:%02x:%02x:%02x:%02x\n", *(uint8*)(arg7 + 0), *(uint8*)(arg7 + 1), *(uint8*)(arg7 + 2), *(uint8*)(arg7 + 3),
 *(uint8*)(arg7 + 4), *(uint8*)(arg7 + 5)); // MAC target
 }
 '

ARP send called (pid=0)
  type: 2                     // Тип ARP пакета: 2 (ARP Reply, ответ)
  ptype: 2054                 // Тип протокола: 2054 (0x0806, ARP)
  dest_ip: 192.168.56.10      // IP назначения (устройство, которое запросило ARP, например, jumpbox)
  src_ip: 10.0.10.0           // IP отправителя (устройство, отправляющее ARP Reply, например, node-1)
  dest_hw: 00:0c:29:e4:f1:a4  // MAC назначения (устройство, которое запросило ARP, например, jumpbox)
  src_hw: 00:0c:29:0d:b7:76   // MAC отправителя (устройство, отправляющее ARP Reply, например, node-1)
  target_hw: 00:0c:29:e4:f1:a4 // MAC цели (обычно совпадает с dest_hw для ARP Reply)

Активация proxy_arp на всех узлах приведет к дублированию ARP-ответов и потенциальным конфликтам маршрутизации.

Особенности работы с кэшированными и некэшированными BPF-картами

Важно учитывать особенности работы с инструментами диагностики при анализе BPF-карт:

root@node-1:/home/cilium# cilium-dbg map list
Name                       Num entries   Num errors   Cache enabled
cilium_policy_00215        3             0            true
# ...
cilium_l2_responder_v4     0             0            false

В cilium_l2_responder_v4 0 записей как будто.
Но это не правда.

root@node-1:/home/cilium# bpftool map dump pinned /sys/fs/bpf/tc/globals/cilium_l2_responder_v4
[{
        "key": {
            "ip4": 655370,
            "ifindex": 2
        },
        "value": {
            "responses_sent": 0
        }
    },{
        "key": {
            "ip4": 655370,
            "ifindex": 3
        },
        "value": {
            "responses_sent": 40
        }
    }
]

Карта cilium_l2_responder_v4 работает в режиме прямого доступа (Cache enabled: false), что требует использования низкоуровневых инструментов вроде bpftool для получения актуальных данных.

↑ К оглавлению | ← Назад

Заключение: Преимущества использования L2-анонсов в Cilium

В данном исследовании была продемонстрирована реализация L2-анонсов в Cilium для обеспечения доступа к сервисам типа LoadBalancer в bare-metal Kubernetes-кластере. Рассмотрены механизмы обработки ARP-запросов с использованием BPF-программ, реализация отказоустойчивости через систему lease-захвата и особенности диагностики работы системы.

Ключевые преимущества подхода:

  1. Нативная интеграция с Kubernetes API
  2. Высокая производительность за счет обработки пакетов на уровне eBPF
  3. Автоматическое распределение нагрузки между узлами кластера
  4. Отсутствие зависимости от внешних компонентов балансировки нагрузки
  5. Поддержка стандартных сетевых протоколов L2-уровня

Решение особенно актуально для гибридных и on-premise сред, где требуется сохранить гибкость bare-metal инфраструктуры, обеспечивая при этом облачно-ориентированную модель доступа к сервисам.

Представленные материалы позволяют сделать вывод о эффективности использования встроенных механизмов Cilium для организации сетевой инфраструктуры Kubernetes-кластеров, обеспечивающей соответствие современным требованиям к отказоустойчивости и производительности.

↑ К оглавлению

Больше информации о Kubernetes и Cilium вы найдете в Telegram-канале: https://t.me/azalio_tech

Начните изучать облачные технологии!

Получите современные знания и навыки работы с облачными платформами. Освойте Kubernetes, Docker, CI/CD и другие ключевые технологии для успешной карьеры в облачных вычислениях.

Начать обучение