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