LoadBalancer-IPs auf Bare-Metal: Cilium BGP gegen die UDM-SE

Cilium als kube-proxy-freie CNI und seine BGP-Control-Plane, die Service-IPs per eBGP an eine Ubiquiti UDM-SE annonciert
Table of contents

In der Cloud ist ein Service vom Typ LoadBalancer ein gelöstes Problem — der Provider zaubert eine IP herbei. Auf Bare-Metal ist genau das die Lücke: Wer vergibt die IP, und wer sorgt dafür, dass das Netzwerk sie auch findet? In meinem Lab erledigt das Cilium — als CNI und gleichzeitig als BGP-Sprecher, der die Service-IPs an meine Ubiquiti UDM-SE annonciert.

Cilium als CNI#

Cilium ist auf jedem Cluster die einzige CNI, installiert direkt über die Talos-Maschinenkonfiguration. Es ersetzt kube-proxy vollständig (kubeProxyReplacement: true) und bewegt das gesamte Service- und NetworkPolicy-Handling per eBPF in den Kernel. Pod-IPAM läuft im kubernetes-Modus; die Verschlüsselung des Pod-Traffics ist bewusst aus, weil die clusterübergreifenden Pfade ohnehin über das WireGuard des Headscale-Tailnets laufen.

Spannend wird es beim Übergang vom Cluster ins LAN.

Das Problem: IPs, die das LAN kennt#

Ein LoadBalancer-Service braucht zwei Dinge: eine IP aus einem definierten Pool und einen Weg, wie der Router im LAN erfährt, dass diese IP hinter den Cluster-Knoten liegt. Den Pool definiere ich als CiliumLoadBalancerIPPool — ein kleines /28 reicht für mein Lab:

1apiVersion: cilium.io/v2
2kind: CiliumLoadBalancerIPPool
3metadata:
4  name: worker-ip-pool
5spec:
6  allowFirstLastIPs: No
7  blocks:
8    - cidr: "10.103.0.0/28"

Für die Bekanntmachung gäbe es zwei Wege: L2-Announcements (Gratuitous ARP, simpel, aber auf ein einzelnes Broadcast-Segment beschränkt und mit einem einzigen Knoten als Failover-Engpass) oder BGP. Ich nutze BGP, weil es die Service-IPs als echte, geroutete Präfixe ins LAN propagiert, sauber über mehrere Knoten lastverteilt und mit der UDM-SE ohnehin einen vollwertigen BGP-Sprecher als Gegenstelle vorfindet.

Die BGP-Control-Plane#

Ciliums BGP-Control-Plane wird per Helm-Value aktiviert (bgpControlPlane.enabled: true). Den Rest beschreiben drei CRDs. Zuerst die Cluster-Config: Welche Knoten sprechen mit wem? Bei mir sind das die Worker (Label bgp-policy: worker), die sich ihren Peer elegant selbst suchen — über das Default-Gateway:

 1apiVersion: cilium.io/v2
 2kind: CiliumBGPClusterConfig
 3metadata:
 4  name: cilium-bgp
 5spec:
 6  nodeSelector:
 7    matchLabels:
 8      bgp-policy: worker
 9  bgpInstances:
10    - name: "instance-64512"
11      localASN: 64512
12      peers:
13        - name: "peer-65535"
14          peerASN: 65535
15          autoDiscovery:
16            mode: "DefaultGateway"
17            defaultGateway:
18              addressFamily: ipv4
19          peerConfigRef:
20            name: "cilium-peer"

autoDiscovery: DefaultGateway ist mein Lieblingsdetail: Statt die Peer-IP hart zu verdrahten, peert jeder Worker einfach mit seinem Default-Gateway — also der UDM-SE. Zieht der Cluster in ein anderes Netz um, muss ich hier nichts anfassen.

Ebenso wichtig ist, welche Knoten der nodeSelector trifft: Nur Worker mit dem Label bgp-policy: worker werden zu BGP-Speakern. Die Control-Plane-Knoten (bgp-policy: controller) bleiben bewusst draußen — sie tragen keinen Ingress-Verkehr und sollen keine Service-IPs ankündigen. Dieser Selector ist ein stiller Single-Point-of-Failure: Trifft er versehentlich keinen Knoten, lernt die UDM-SE keine einzige VIP, und sämtliche Dienste sind von außen tot — ohne dass auch nur ein Pod-Log das Wort „BGP" in den Mund nimmt.

Die CiliumBGPPeerConfig legt die Session-Eigenschaften fest (IPv4 unicast, Graceful Restart) und welche Advertisements gelten, und die CiliumBGPAdvertisement sagt schließlich, was annonciert wird — nämlich die LoadBalancer-IPs:

 1apiVersion: cilium.io/v2
 2kind: CiliumBGPAdvertisement
 3metadata:
 4  name: bgp-advertisements
 5  labels:
 6    advertise: bgp
 7spec:
 8  advertisements:
 9    - advertisementType: "Service"
10      service:
11        addresses:
12          - LoadBalancerIP

Im Ergebnis sieht die Topologie so aus — meine Worker (AS 64512) als eBGP-Peers der UDM-SE (AS 65535):

flowchart TB subgraph K8S["hydra · Worker (bgp-policy: worker)"] W2["Worker .32<br/>Cilium · AS 64512"] W3["Worker .33<br/>Cilium · AS 64512"] W4["Worker .34<br/>Cilium · AS 64512"] end UDM["Ubiquiti UDM-SE<br/>Default-Gateway 192.168.100.1<br/>AS 65535"] LAN["LAN / Clients"] POOL["LB-Pool 10.103.0.0/28<br/>Service-IPs"] W2 & W3 & W4 -->|"eBGP · annonciert LoadBalancerIP"| UDM POOL -. vergeben an Services .-> K8S UDM --> LAN

Die Gegenstelle: BGP auf der UDM-SE#

Damit die Session zustande kommt, muss die UDM-SE als Neighbor konfiguriert sein. Die UniFi-Geräte bringen unter der Haube FRR mit, sodass sich eine simple BGP-Konfiguration hinterlegen lässt, die meine AS 64512 als Nachbarn akzeptiert und die gelernten /32-Routen aus dem LB-Pool ins LAN verteilt.

Vorsicht beim Firmware-Update: Die BGP-Konfiguration der UDM-SE wird als hochgeladene FRR-Konfiguration gehalten und überlebt Firmware-Upgrades nicht immer unbeschadet. Nach einem Update lohnt der kurze Blick mit vtysh -c 'show bgp summary', ob die Session zur Cluster-AS wieder steht.

Fazit#

Mit ein paar CRDs wird Cilium vom reinen CNI zum vollwertigen BGP-Sprecher. Jeder neue LoadBalancer-Service zieht automatisch eine IP aus dem /28, die Sekunden später im ganzen LAN geroutet ist — ohne MetalLB, ohne ARP-Tricks, ohne manuelles Zutun. Genau diese IPs sind später die Basis dafür, dass meine Dienste auch über das Tailnet erreichbar werden.