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):
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.
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.
