Bare-metal k8s: keep Cilium L2 LoadBalancer & node maintenance reliable
Published: 2026-07-13
On bare-metal Kubernetes, the first production problem is not scheduling pods — it is keeping the external IP reachable when the node behind it changes. This post shows how to make Cilium L2 LoadBalancer IPs stable, what node-level networking settings matter, and how to keep upgrades and reboots from breaking service reachability.
The problem
A bare-metal cluster can allocate a LoadBalancer service IP, but no cloud controller puts that IP on the wire. That leaves you with a service whose EXTERNAL-IP is assigned in Kubernetes, but the rest of the network still does not know where to send packets.
This becomes worse when nodes are rebooted or drained: the service may move, but clients still use stale ARP/NDP entries. On a physical LAN, a single wrong announcement or missing rp_filter setting can make the load balancer address disappear.
bash$ kubectl get svc apisix -n ingress-apisix
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
apisix LoadBalancer 10.96.45.23 172.16.57.193 80:32000/TCP
The IP is there, but the cluster still has to own it on the wire.
How it works
Cilium's L2 announcement feature solves the traffic path at the network layer.
CiliumLoadBalancerIPPooldefines the pool of host-local IPs allowed forLoadBalancerservices.CiliumL2AnnouncementPolicycontrols which nodes advertise those IPs and on which host interfaces.- When a service is created, Cilium allocates an IP from the pool and starts answering ARP/NDP for that address.
- If the service moves to another node, Cilium retires the old announcement and creates a new one from the same IP.
The key point is that Kubernetes only knows the service IP. The real network reachability depends on Cilium managing ARP/NDP on the physical interface.
Why host networking tuning matters
On bare metal, Linux packet forwarding and reverse path filtering are the usual gotchas:
net.ipv4.ip_forward=1is required for the node to forward traffic between interfaces.net.ipv4.conf.all.rp_filter=0andnet.ipv4.conf.default.rp_filter=0are required when the node receives traffic for a service IP and forwards it to a pod via Cilium.net.ipv4.conf.<iface>.rp_filter=0is useful when the announcement interface is not the same as the primary route.
If rp_filter is 1, the kernel may drop valid replies because the return path does not match the incoming interface.
When you need a fixed IP
Assigning a specific loadBalancerIP is the usual production pattern for landing DNS, firewall rules, or port-based access control.
yamlapiVersion: v1
kind: Service
metadata:
name: apisix
namespace: ingress-apisix
spec:
type: LoadBalancer
loadBalancerIP: 172.16.57.193
ports:
- port: 80
targetPort: 80
selector:
app: apisix
Cilium will allocate this exact IP from the pool, or fail if it is already taken.
Setup
Step 1: define the pool
Choose a block that is inside your LAN and outside DHCP.
yamlapiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
name: baremetal-lb-pool
spec:
blocks:
- cidr: 172.16.57.192/28 # 14 usable IPs
Apply it with kubectl apply -f cilium-lb-pool.yaml.
Step 2: restrict announcements to the right nodes and interface
Pick the physical interface that reaches the upstream router or switch. If your node uses bonded interfaces, use the bond name.
yamlapiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: baremetal-l2-policy
spec:
serviceSelector:
matchLabels:
app.kubernetes.io/managed-by: Flux
nodeSelector:
matchLabels:
kubernetes.io/os: linux
node-role.kubernetes.io/worker: ""
interfaces:
- eth0
externalIPs: true
loadBalancerIPs: true
The interfaces field is the most important guard. If Cilium announces on the wrong NIC or on a bridge interface that does not carry the LAN, the address will never reach clients.
Step 3: tune node networking
On every bare-metal node, set these sysctls:
bashcat <<'EOF' | sudo tee /etc/sysctl.d/99-cilium-l2.conf
net.ipv4.ip_forward=1
net.ipv4.conf.all.rp_filter=0
net.ipv4.conf.default.rp_filter=0
net.ipv4.conf.eth0.rp_filter=0
EOF
sudo sysctl --system
If your nodes have multiple physical NICs or VLANs, repeat net.ipv4.conf.<iface>.rp_filter=0 for the chosen interface.
Step 4: verify Cilium sees the pool and policy
bashkubectl get ciliumloadbalancerippool -A
kubectl get ciliuml2announcementpolicy -A
kubectl describe ciliuml2announcementpolicy baremetal-l2-policy
The output should show the pool in Active state and the policy bound to worker nodes.
Step 5: provision a LoadBalancer service
Create a service using loadBalancerIP, then verify the external IP appears:
bashkubectl apply -f apisix-service.yaml
kubectl get svc apisix -n ingress-apisix -o wide
Then test reachability from outside the cluster:
bashcurl -I http://172.16.57.193
If packets do not arrive, check ARP on the upstream router or client:
baship neigh show 172.16.57.193
A healthy result looks like:
text172.16.57.193 dev eth0 lladdr 52:54:00:12:34:56 REACHABLE
Step 6: make maintenance safe
For bare-metal maintenance, use the same drain/reboot workflow as for any Kubernetes node, but keep the load balancer IP behavior in mind.
bashkubectl drain node-01 --ignore-daemonsets --delete-emptydir-data
sudo reboot
On a reboot, Cilium stops announcing the address. The service will become unreachable until another node takes ownership. To keep failover predictable:
- use a single pool shared across the cluster
- pin
loadBalancerIPfor production services - mark only worker nodes as announcers
- keep ARP cache timeouts short on upstream devices if you control them
If the node enters the cluster again, Cilium should restore the announcement automatically.
What can go wrong
The IP stays pending. That usually means the service is not matching the policy, or the pool does not have free addresses.
bashkubectl get svc apisix -n ingress-apisix -o jsonpath='{.status.loadBalancer.ingress}'
- Check
CiliumLoadBalancerIPPoolfor free blocks. - Check
CiliumL2AnnouncementPolicyselectors andinterfaces. - Check that the service label selector matches the policy.
Clients see the wrong MAC. If two nodes announce the same IP, upstream ARP caches can alternate between MACs.
- Use
ip neigh show <ip>on a client. - Use
sudo tcpdump -nei eth0 arp or icmpon the candidate nodes. - Verify policies are not overlapping and the IP pool is unique.
After reboot, the service is still unreachable. The cluster may have recovered, but the router or client still has stale ARP.
- Clear the upstream ARP entry if possible.
- Reduce the ARP cache timeout on your switch/router, or use a shorter
ip neighentry on Linux clients. - On the Kubernetes node,
kubectl get pods -n kube-system -l k8s-app=ciliumandkubectl logscan confirm Cilium restarted cleanly.
The announcement happens on the wrong interface. This is the most common bare-metal bug.
bashkubectl describe ciliuml2announcementpolicy baremetal-l2-policy
- Confirm
interfaces:points to the LAN-facing device. - Confirm the node's firmware or cloud-init did not rename the interface.
- Confirm
ip routeandip addrshow the chosen interface as the one used for external traffic.
Summary
- Bare-metal Kubernetes needs a network-level owner for
LoadBalancerIPs; Cilium L2 announcements provide it. - Define a pool, restrict announcements to the LAN-facing interface, and pin service IPs for production.
- Tune
ip_forwardandrp_filteron bare-metal nodes so reply packets are not dropped. - Drain and reboot with the same
LoadBalancerIP behavior in mind: the address may disappear until another node reacquires it. - Diagnosing failures is usually one of: policy mismatch, wrong interface, overlapping IP ranges, or stale ARP on the upstream network.