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:

apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
  name: worker-ip-pool
spec:
  allowFirstLastIPs: No
  blocks:
    - 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:

apiVersion: cilium.io/v2
kind: CiliumBGPClusterConfig
metadata:
  name: cilium-bgp
spec:
  nodeSelector:
    matchLabels:
      bgp-policy: worker
  bgpInstances:
    - name: "instance-64512"
      localASN: 64512
      peers:
        - name: "peer-65535"
          peerASN: 65535
          autoDiscovery:
            mode: "DefaultGateway"
            defaultGateway:
              addressFamily: ipv4
          peerConfigRef:
            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.

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:

apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisements
  labels:
    advertise: bgp
spec:
  advertisements:
    - advertisementType: "Service"
      service:
        addresses:
          - 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.