Cilium L2 LoadBalancer on bare-metal k3s
Published: 2026-03-29
On cloud Kubernetes, LoadBalancer services get external IPs automatically from the cloud provider. On bare-metal k3s, there's no cloud controller. Cilium fills the gap with its L2 announcement feature — a pure eBPF alternative to MetalLB.
The problem
A bare-metal Kubernetes cluster has no mechanism to respond to ARP requests for a LoadBalancer service IP. Without it, the IP is assigned to the Service object but unreachable from outside the cluster.
bash$ kubectl get svc apisix -n ingress-apisix
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
apisix LoadBalancer 10.96.45.23 <pending> 80:32000/TCP
<pending> means no cloud controller allocated the IP.
Cilium's L2 announcement
Two CRDs control it:
CiliumLoadBalancerIPPool — defines the IP range:
yamlapiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
name: dev-pool
spec:
blocks:
- cidr: "172.16.57.192/28" # 14 usable IPs for dev cluster LB services
CiliumL2AnnouncementPolicy — defines which nodes announce which services:
yamlapiVersion: "cilium.io/v2alpha1"
kind: CiliumL2AnnouncementPolicy
metadata:
name: dev-l2-policy
spec:
serviceSelector:
matchLabels:
app.kubernetes.io/managed-by: Helm
nodeSelector:
matchLabels:
kubernetes.io/os: linux
interfaces:
- "eth0"
externalIPs: true
loadBalancerIPs: true
When a LoadBalancer service is created, Cilium picks an IP from the pool, assigns it, and starts responding to ARP requests on eth0.
Where these objects live
Both objects in spoke/:
fluxcd/projects/dev/kustomization/spoke/
├── cilium-lb-pool.yaml
├── cilium-l2-announcement.yaml
├── namespaces.yaml
└── kustomization.yaml
Applied via the dev-spoke Kustomization (not dev-apps) because they're plain Kubernetes resources, not HelmReleases.
Yandex Cloud: no L2 needed
On YC Managed Kubernetes environments (sre, loadgds, demo), the cloud load balancer handles external IPs. The Cilium LB pool and L2 announcement objects are absent from those spoke overlays.
Assigning a specific IP to a service
yaml# projects/dev/kustomization/hub/patches/apisix-int.yaml
spec:
values:
service:
loadBalancerIP: "172.16.57.193"
Cilium allocates that specific IP from the pool if available. Without this annotation, Cilium assigns the next free IP.
Prerequisites
Cilium with L2 announcements enabled:
yamll2announcements:
enabled: true
l2podAnnouncements:
enabled: true
kubeProxyReplacement: true
k8sServiceHost: "API_SERVER_IP"
k8sServicePort: "6443"
kubeProxyReplacement: true enables the full eBPF datapath. k8sServiceHost is required when using kube-proxy replacement — Cilium needs the API server address directly.
Verifying allocation
bash# Pool status
kubectl get ciliumloadbalancerippool -A
# Allocated IPs
kubectl get svc -A -o wide | grep LoadBalancer
# ARP announcements
kubectl get ciliuml2announcementpolicy -A
# Describe pool to see allocations
kubectl describe ciliumloadbalancerippool dev-pool
After a LoadBalancer service is created, you should see an external IP in seconds:
bashNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
apisix LoadBalancer 10.96.45.23 172.16.57.193 80:32000/TCP
Cilium vs MetalLB
MetalLB was the standard before Cilium added native L2 support. With Cilium already handling CNI and kube-proxy replacement, adding MetalLB means two network controllers on every node. Cilium's built-in L2 announcements eliminate that overhead — one less component, same result.
| Cilium L2 | MetalLB | |
|---|---|---|
| Protocol | ARP/NDP | ARP/NDP or BGP |
| BGP support | CiliumBGPPeeringPolicy | Yes, native |
| Integration | Part of Cilium | Separate component |
| Config | CRDs | CRDs |
| kube-proxy replacement | Same component | Separate |