Bare-metal load balancers for Kubernetes in 2026: revisit MetalLB, kube-vip and Cilium LB IPAM before you expose another Service
On a cloud cluster, you write type: LoadBalancer in a Service and a minute later an IP address appears. On your own servers, the same line gives you a Service that sits in <pending> forever. Kubernetes defines the LoadBalancer type, but it does not ship anything that implements it. Somebody has to pick an address, tell the network where that address lives, and move it when a node dies.
Most teams on bare metal solve this the first time with whatever is nearest. A NodePort and an HAProxy VM in front. A keepalived pair copied from an old wiki page. The ServiceLB that came with k3s. A MetalLB install from a blog post, in L2 mode, with an address range somebody picked by pinging addresses until one did not answer. It works, and so nobody looks at it again until a node goes down during business hours and the “load balancer” turns out to be one node answering ARP for everything.
This post revisits the three tools that most on-premises Kubernetes teams choose between in 2026: MetalLB, the oldest and most widely used; kube-vip, which started as a virtual IP for the control plane and grew into a Service load balancer; and Cilium LB IPAM with L2 announcements and the BGP control plane, which is attractive if Cilium is already your CNI. We will also look at PureLB, OpenELB, k3s ServiceLB, and the plain option of NodePort with an external HAProxy and keepalived. The aim is not to crown a winner. The aim is to be clear about what each mode really does on the wire, how long failover really takes, and what your network team has to agree to.
If you are new to this series, a few earlier posts connect directly. How pods get their addresses in the first place, and how Cilium, Calico and Flannel differ, is in Kubernetes pod networking in 2026. The load balancer IP usually lands on an ingress controller or gateway, so read the ingress-nginx retirement post and API gateways in 2026 alongside this one. Small clusters with k3s and its built-in ServiceLB are covered in lightweight Kubernetes in 2026. The HAProxy side of the external option is in reverse proxies in 2026. Leader election through Kubernetes Lease objects, which two of these tools rely on, is explained in distributed coordination in 2026, and the Cilium overlap with east-west traffic is in service mesh in 2026.
A scenario to keep us honest
The company in this section is invented. Any match with a real company is an accident.
Shyamla Diagnostics is a pathology lab chain based in Bhopal, with a central processing lab, about 45 collection centres across Madhya Pradesh, and a technology team of 11. Its setup grew over five years:
- A kubeadm cluster on six physical servers in a rented rack at a Bhopal data centre: three control plane nodes and three workers, all on one VLAN behind a pair of layer 3 switches that the data centre’s network team manages. Flannel is the CNI.
- The patient report portal, the doctor app API and the collection centre booking API, all behind ingress-nginx.
- A lab information system integration service that receives results from analyser middleware in the central lab. The middleware vendor’s firewall allows only a fixed list of source IPs, and the integration service in turn checks the client IP of each middleware box.
- In front of the cluster: two HAProxy VMs with keepalived sharing one virtual IP, forwarding ports 80 and 443 to a NodePort on all six nodes. The HAProxy backend list is a hand-written file.
- A small k3s cluster in the central lab for label printing, using the built-in ServiceLB.
In August, a worker node’s motherboard failed. The data centre replaced it the same day, and the new node joined with a new IP address. Nobody updated the HAProxy backend list. HAProxy’s health checks marked the old address down, so traffic still flowed, but a week later a second worker was drained for a kernel update and only one healthy backend was left. When that node had a short network flap, the report portal was down for about 40 minutes on a Monday morning, the busiest time for report downloads. Separately, the integration service had been logging every middleware request as coming from a node IP, because the NodePort hop was rewriting the source address, so its IP check had been quietly turned off months earlier “until we fix it”.
The team lead, Farhan, asked the obvious question: “Should we install MetalLB like everyone else?” The network team at the data centre asked a sharper one: “Do you want us to give you an address range on the server VLAN, or do you want to talk BGP to our switches?” We will come back to Shyamla at the end with a worked plan.
The short version
- Kubernetes gives you the LoadBalancer Service type but no implementation on bare metal. A bare-metal load balancer does two jobs: it allocates an IP from a pool you own, and it announces that IP to the network so packets reach a node. After that, kube-proxy or Cilium’s eBPF datapath takes the packet to a pod.
- Layer 2 mode (ARP for IPv4, NDP for IPv6) is failover, not load balancing. One node answers for each IP and receives all its traffic. When that node dies, another node starts answering and sends unsolicited ARP replies so clients update their caches. It needs nothing from your network team except a free address range on the same subnet.
- BGP mode is real load balancing if your routers do ECMP. Every eligible node advertises the IP, and the router spreads connections across them. The cost is a BGP conversation with your network team, and the fact that when the set of nodes changes, most routers rehash and break many existing connections once.
- MetalLB is the default general choice: Apache 2.0, a CNCF sandbox project, L2 and BGP, with FRR-K8s now the default BGP backend since v0.16.0 in May 2026.
- kube-vip is the natural choice when you also need a virtual IP for the Kubernetes API server, especially on kubeadm or k3s clusters without an external load balancer. It handles Services too, with per-service leader election, BGP, a routing table mode and WireGuard. Current release is v1.3.1.
- Cilium LB IPAM with BGP control plane is the cleanest choice if Cilium is already your CNI and your network speaks BGP. Its L2 announcements feature is still marked beta in the 1.20 documentation, needs kube-proxy replacement, and by default fails over in 10 to 20 seconds.
externalTrafficPolicy: Localkeeps the client source IP and avoids a second hop, but only works well if your announcer knows which nodes have ready pods. MetalLB takes it into account. Cilium L2 announcements, by its own documentation, does not.- NodePort with external HAProxy and keepalived is still a valid design, especially when a separate network team must own the edge. Treat the backend list as generated, not hand-written.
Where things stand on 31 October 2026
MetalLB:
- The latest release is v0.16.0, published on 20 May 2026, with a Helm chart 0.16.1 on 27 May that fixes chart rendering, a schema type for
localASNon BGPPeer, controller health probes, and Prometheus scraping over HTTPS. There has been no newer application release since May. The previous line ended at v0.15.3 in December 2025. - v0.16.0 makes FRR-K8s the default BGP backend. The older FRR mode is deprecated and will be removed in a future release, and the project says new BGP features will land only in FRR-K8s. The native Go BGP implementation stays for small deployments.
- Other changes in v0.16.0: a per-peer local AS override, an optional ServiceSelector on advertisement resources so one advertisement can target only some Services in a pool, memberlist settings exposed through environment variables, and a fix for L2 leader election that ignored service selectors. The kube-rbac-proxy sidecar was replaced with built-in TLS and RBAC, so the metrics endpoints are now HTTPS only. If your Prometheus scraped MetalLB over plain HTTP, check it after upgrading.
- FRR-K8s, the separate project that wraps FRR with a Kubernetes API, released v0.0.27 on 14 October 2026.
- MetalLB is a CNCF sandbox project, accepted on 14 September 2021, under the Apache 2.0 licence. The GitHub repository was created in November 2017.
- I found no security advisories published in the MetalLB GitHub repository at the time of writing.
kube-vip:
- The latest release is v1.3.1, published on 19 October 2026. It is a re-release of v1.3.0 (16 October), whose binary wrongly reported its version as v1.2.4. The project asks users not to install v1.3.0.
- v1.3.0 has a breaking change: shared VIPs are now restricted to Services with
externalTrafficPolicy: Clusterthat share the same lease. If you share one IP across several Services, read the release note before upgrading. It also records VIP ownership in lease annotations, fixes several locking and concurrency issues, and moves to GoBGP v4.10.0. - kube-vip reached v1.0.0 on 31 July 2025, after more than 1,200 pull requests by the project’s own count. The 1.x line has had a release roughly every month since.
- The companion kube-vip-cloud-provider, which does IP allocation for Services, is at v0.0.13 (28 September 2026).
- kube-vip is a CNCF sandbox project, accepted on 26 June 2021, under Apache 2.0. I found no security advisories published in its GitHub repository.
Cilium LB IPAM, L2 announcements and BGP control plane:
- Cilium v1.20.4, v1.19.10 and v1.18.16 were released together on 23 October 2026. Cilium 1.20.0 came out on 29 July 2026, with GoBGP v4.6.1 under the BGP control plane, better BGP inspection commands, and support for Kubernetes 1.36.
- LB IPAM is always present in Cilium but stays dormant until you create the first
CiliumLoadBalancerIPPool. It only allocates; announcement is done by L2 announcements or the BGP control plane. - L2 announcements is still labelled beta in the 1.20.4 documentation and requires kube-proxy replacement.
- Cilium is a CNCF graduated project (graduated 11 October 2023), Apache 2.0 for the user space code.
- Cilium published many security advisories in 2026, including a critical one (CVE-2026-49445, Envoy admin socket access) and several rated high, such as CVE-2026-83620 in mutual authentication and CVE-2026-104149 on VLAN interfaces. From their titles, none concern LB IPAM, L2 announcements or BGP, but you upgrade Cilium as a whole, so the patch cadence matters to you anyway.
Around them:
- PureLB is at v0.16.6 (28 May 2026). v0.16.0 in April 2026 added a GoBGP-based sidecar to the default install. Apache 2.0, small community.
- OpenELB’s last release was v0.6.0 in June 2024, and the CNCF archived the project on 22 May 2025. Do not start new work on it.
- k3s ServiceLB, formerly Klipper LoadBalancer, still ships with k3s. The klipper-lb image had its latest release, v0.4.17, in April 2026.
The first question: what do you actually need?
Before choosing a tool, answer four questions. They decide more than any feature list.
- How many IPs do you really need? Many clusters need one or two: one for the ingress controller or gateway, maybe one for a TCP service like an MQTT broker or a database proxy. If everything goes through one ingress, the load balancer tool is a small piece and the ingress is the big one.
- Can your network give you a range on the node subnet, or will it peer BGP with you? This is a conversation with people, not a configuration choice. In many Indian data centres and office networks, L2 is the only option offered.
- Does any client need the real source IP? Allowlists, rate limits, audit logs and fraud checks all care. If yes, you will need
externalTrafficPolicy: Localor a proxy protocol setup, and your tool must handle that correctly. - What failover time can you live with? If two to ten seconds of broken connections during a node failure is fine, L2 is usually enough. If you need traffic spread across nodes and quick removal of a dead node, you need BGP with BFD or an external load balancer.
How a packet reaches your Service
It helps to have one picture in mind. A LoadBalancer Service has a ClusterIP, a NodePort on every node (unless you turn NodePort allocation off), and one or more external IPs written into status.loadBalancer.ingress. A bare-metal load balancer controller watches for such Services, picks an IP from a pool, and writes it into the status. Then a speaker on one or more nodes makes the network deliver packets for that IP to the node.
In L2 mode, the speaker on one node answers ARP requests (“who has 10.20.30.50?”) with its own MAC address. The IP is not usually configured on an interface; the speaker simply answers. Packets arrive at that node, and kube-proxy rules (or Cilium’s eBPF programs) forward them to a pod, which may be on any node.
In BGP mode, the speaker on each eligible node tells the router “10.20.30.50/32 is reachable through me”. The router installs several equal routes and hashes each connection to one of them.
The last hop is always the same: kube-proxy or Cilium picks a pod. So none of these tools is a load balancer in the sense of a proxy. Each is an address allocator plus an announcer.
externalTrafficPolicy, source IP and the second hop
This setting causes more confusion than any tool choice, so it deserves its own section.
With externalTrafficPolicy: Cluster, which is the default, any node that receives traffic will forward it to any ready pod, even one on another node. To make the reply come back through the same node, kube-proxy rewrites the source address to the node’s own IP. The pod sees a node IP, not the client. Load is spread well, and every node can accept traffic.
With externalTrafficPolicy: Local, a node only forwards to pods on itself, and does not rewrite the source. The pod sees the real client IP. But a node with no ready pod for that Service will drop the traffic. Kubernetes allocates a healthCheckNodePort for such Services so that an external load balancer can ask each node whether it has local endpoints.
What this means for each mode:
- L2 with
Local: the node that answers ARP must be a node with a ready pod. If the announcer picks a node without one, traffic is dropped. MetalLB’s L2 election takes external traffic policy and active endpoints into account when it builds the list of possible announcers. Cilium’s documentation says its L2 announcements is not currently aware of this setting and will announce on all matching nodes, and suggests usingCluster. For kube-vip, check the behaviour on your version before relying on it. - BGP with
Local: only nodes with ready pods should advertise the route. MetalLB does this. For the Cilium BGP control plane, confirm the behaviour in the service advertisement section of the documentation for your version before relying on it. When it works, ECMP spreads traffic only across nodes that actually have pods, but unevenly if pod counts differ per node, because the router balances per node, not per pod. - External HAProxy with
Local: point HAProxy health checks at thehealthCheckNodePort, not at the service port, so nodes without pods are marked down.
If you need source IPs but cannot use Local, the other route is to terminate at a proxy that adds a header (X-Forwarded-For) or uses the PROXY protocol, and have the application or ingress trust only that proxy.
What each tool is
MetalLB
MetalLB is a controller plus a speaker DaemonSet. The controller allocates IPs from IPAddressPool resources. Speakers announce them according to L2Advertisement or BGPAdvertisement resources, and talk BGP to peers defined in BGPPeer resources. Since v0.13 configuration is through these custom resources rather than a ConfigMap.
A minimal L2 setup looks like this:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: dc-vlan20
namespace: metallb-system
spec:
addresses:
- 10.20.30.50-10.20.30.69
autoAssign: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: dc-vlan20-l2
namespace: metallb-system
spec:
ipAddressPools:
- dc-vlan20
interfaces:
- bond0
With autoAssign: false, a Service only gets an address from this pool if it asks for it by annotation, which stops a stray type: LoadBalancer in a test namespace from eating a production address.
In L2 mode, MetalLB uses the memberlist library to know which speakers are alive, and does a stateless election: every speaker builds the same list of eligible nodes for an IP, sorts a hash of node name and IP, and the first node announces. Its documentation is direct about two limits. All traffic for the IP enters through one node, so you are limited to one node’s bandwidth. And failover depends on clients honouring the unsolicited ARP replies MetalLB sends; on modern Windows, macOS and Linux this takes a few seconds, and anything over about ten seconds is something the project wants reported as a bug. For planned moves, it advises keeping the old node up for a couple of minutes so slow clients still reach it. The same page notes a split-brain risk: if a speaker wrongly thinks other nodes are down, two nodes, or none, may announce the same IP.
In BGP mode, each speaker peers with your routers. With FRR-K8s, the default backend since v0.16.0, you get BFD for fast failure detection, IPv6, multiprotocol BGP, and the ability to merge your own FRR configuration so that other components can share the same BGP sessions. A peer and advertisement look like this:
apiVersion: metallb.io/v1beta2
kind: BGPPeer
metadata:
name: tor-a
namespace: metallb-system
spec:
myASN: 64512
peerASN: 64513
peerAddress: 10.20.30.1
bfdProfile: fast
---
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
name: dc-public
namespace: metallb-system
spec:
ipAddressPools:
- dc-public
What MetalLB does not do: it does not proxy, terminate TLS, or health check your application. It does not manage the control plane VIP for the API server. It does not configure your router.
kube-vip
kube-vip began as a way to give a highly available Kubernetes control plane one virtual IP without an external load balancer, and that is still where it is strongest. It usually runs as a static pod on each control plane node (for kubeadm, so it is up before the API server is reachable) or as a DaemonSet (for k3s and others). For the control plane, the elected leader holds the VIP in ARP mode, or all nodes advertise it in BGP mode. It can also run IPVS to spread API traffic across control plane nodes.
For Services, kube-vip watches LoadBalancer Services and announces their addresses. IP allocation is done by the separate kube-vip-cloud-provider, which reads ranges or CIDRs from a ConfigMap, globally or per namespace, and writes the address into the kube-vip.io/loadbalancerIPs annotation. kube-vip then announces it in one of several modes:
- ARP, with leader election. By default one leader holds all Service VIPs, which concentrates traffic and makes that node a large failure domain. The
svc_electionoption runs a separate election per Service, so different Services land on different nodes. - BGP, through an embedded GoBGP, where all nodes advertise.
- Routing table mode, where kube-vip adds and removes routes in a node routing table and leaves the actual advertisement to routing software you already run, for example BIRD or FRR.
- WireGuard mode, which advertises Services over a WireGuard interface, mainly for connecting distributed clusters.
kube-vip’s leader election uses Kubernetes Lease objects, with settings for lease duration, renew deadline and retry period. Its example static pod manifest uses 5, 3 and 1 seconds. Its documentation also reminds you that without gratuitous ARP, a client keeps the old IP to MAC mapping until its ARP entry expires, typically within 30 seconds; with gratuitous ARP enabled, clients usually update immediately.
A trimmed environment block for a control plane plus Services setup in ARP mode looks like this:
env:
- name: vip_arp
value: "true"
- name: vip_interface
value: bond0
- name: address
value: 10.20.30.10
- name: cp_enable
value: "true"
- name: svc_enable
value: "true"
- name: svc_election
value: "true"
- name: vip_leaderelection
value: "true"
- name: vip_leaseduration
value: "5"
- name: vip_renewdeadline
value: "3"
- name: vip_retryperiod
value: "1"
Short leases fail over faster but lean harder on the API server, and an overloaded or briefly unreachable API server can make a healthy leader lose its lease. That trade-off is the same for every Lease-based tool.
Cilium LB IPAM, L2 announcements and BGP control plane
If Cilium is your CNI, it already contains the parts. LB IPAM allocates IPs from CiliumLoadBalancerIPPool resources, each with one or more blocks (a CIDR, or a start and stop address) and an optional service selector. Pools with overlapping CIDRs are flagged as conflicting. A pool can be disabled, which stops new allocations without removing existing ones, a gentle way to drain a range. A Service asks for a specific address with the lbipam.cilium.io/ips annotation.
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
name: dc-vlan20
spec:
blocks:
- start: 10.20.30.50
stop: 10.20.30.69
serviceSelector:
matchLabels:
expose: datacentre
---
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: dc-vlan20-l2
spec:
interfaces:
- ^bond0$
loadBalancerIPs: true
nodeSelector:
matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
L2 announcements uses one Kubernetes Lease per Service, named like cilium-l2announce-<namespace>-<service>, so you can see the current holder with kubectl get lease -n kube-system. The defaults are a 15 second lease duration, 5 second renew deadline and 2 second retry period. The documentation works out the consequence: failover happens between lease duration minus renew deadline and lease duration plus renew deadline, which is 10 to 20 seconds with defaults. Its own example of 3 seconds, 1 second and 200 milliseconds gives 2 to 4 seconds. Each Service adds API traffic, so the agent’s Kubernetes client rate limit (5 queries per second, burst 10, by default) must be raised. The documented sizing is roughly the number of Services divided by the renew deadline: 65 Services with a 2 second renew deadline need about 32.5 queries per second.
The BGP control plane uses CiliumBGPClusterConfig to select nodes and define BGP instances, CiliumBGPPeerConfig for peer settings, and CiliumBGPAdvertisement to say what to advertise, including LoadBalancer IPs. Cilium also has load balancer classes, io.cilium/l2-announcer and io.cilium/bgp-control-plane, so one cluster can use both for different Services.
The catch is that you are now tying your edge to your CNI. Upgrading Cilium upgrades your load balancer, and a CNI change becomes a load balancer migration as well.
The other options
PureLB takes a different approach: it adds the address to a local or virtual interface on the node, and relies on the Linux routing stack and a routing daemon for BGP or OSPF. Since v0.16.0 the default install includes a GoBGP-based sidecar. It is Apache 2.0 and active, but small; check whether you can get help when something breaks.
OpenELB, formerly PorterLB, offered BGP, L2 and VIP modes and was in the CNCF sandbox. It was archived by the CNCF in May 2025, and its last release was in June 2024. If you have it, plan a move.
k3s ServiceLB is not a VIP manager at all. For each LoadBalancer Service it runs a DaemonSet of small pods that bind host ports on nodes and forward to the Service, and it writes the node IPs into the Service status. It is perfect for a single-node or small edge cluster where “the node’s IP” is a fine answer. It cannot give two Services the same port, and it gives you no floating address. On k3s you disable it with --disable=servicelb before installing MetalLB, kube-vip or Cilium LB features.
NodePort with external HAProxy and keepalived keeps the edge outside Kubernetes. Two VMs or small servers run keepalived (VRRP) for a floating IP and HAProxy forwarding to NodePorts on the nodes. It is well understood, gives you real health checks, connection draining, TLS termination if you want it, and PROXY protocol for source IP. The weak spot is the backend list. If nodes come and go, generate it from the Kubernetes API, use DNS service discovery in HAProxy, or point at a small fixed set of ingress nodes with stable addresses.
Side by side
How the address reaches the network
- MetalLB: L2 (ARP and NDP) or BGP, with native BGP or FRR-K8s. FRR-K8s brings BFD.
- kube-vip: ARP, BGP via GoBGP, routing table mode for external routing daemons, WireGuard.
- Cilium: L2 announcements (ARP and NDP, beta) or BGP control plane (GoBGP), both fed by LB IPAM.
- PureLB: interface addresses plus the Linux routing stack and a routing daemon.
- k3s ServiceLB: node IPs and host ports, no floating address.
- HAProxy and keepalived: VRRP for one floating IP outside the cluster.
Failover behaviour and timing
The honest numbers come from each project’s own documentation, not from tests I ran.
- MetalLB L2: memberlist failure detection, then unsolicited ARP or NDP. A few seconds on modern clients by the project’s account; over about ten seconds is treated as a bug.
- kube-vip ARP: Lease-based election, configurable. With the documented 5, 3 and 1 second example, a dead leader is replaced after a few seconds, plus client ARP update time. Without gratuitous ARP, clients may keep the old mapping for up to around 30 seconds.
- Cilium L2: Lease-based, 10 to 20 seconds with defaults, 2 to 4 seconds in the documented tuned example, at the cost of more API traffic.
- BGP modes: depends on how quickly the router notices the session is gone. Default BGP hold timers on many routers are long (often tens of seconds), so BFD or short hold timers matter. When a node is withdrawn, the ECMP set changes, and on most routers many existing connections are rehashed to other nodes and reset once. MetalLB’s documentation lists mitigations: resilient ECMP hashing if your router supports it, keeping a stateful ingress layer in front of applications, and client retries.
- keepalived: VRRP advertisement intervals are typically one second, with a few missed adverts before the backup takes over, plus gratuitous ARP. Similar to the L2 modes above.
In every L2 design, the time to failover is only part of the outage. Connections that were open through the dead node are gone, and clients must reconnect.
Load spreading
- L2, any tool: no spreading before the cluster. One node takes all traffic for an IP. With per-service election (kube-vip
svc_election, MetalLB and Cilium both elect per IP), different Services land on different nodes, which spreads Services but not traffic for a single Service. - BGP with ECMP: per-connection spreading across all advertising nodes. With 5-tuple hashing, connections from the same client spread too; with 3-tuple hashing, one busy client sticks to one node.
- HAProxy: real per-connection or per-request balancing, with health checks and weights.
IP pool management
- MetalLB:
IPAddressPoolwith ranges or CIDRs,autoAssignto keep pools opt-in, namespace and service selectors on pools, annotations to request a pool or a specific address, and IP sharing between Services through a sharing key annotation. - kube-vip: kube-vip-cloud-provider ConfigMap with global or per-namespace ranges or CIDRs. You can also set the annotation yourself and skip the cloud provider.
- Cilium:
CiliumLoadBalancerIPPoolwith blocks and service selectors, conflict detection, pool disabling, andallowFirstLastIPsto keep network and broadcast addresses out. The documentation warns that updating a pool can cause IPs to be reassigned, so treat pool edits as a change with a window. - Whatever the tool, the address range must be reserved in the network team’s IP plan and excluded from DHCP. A load balancer IP that is also handed out by DHCP is a very confusing outage.
Note also that spec.loadBalancerIP has been deprecated in Kubernetes since 1.24. Use the tool’s annotation instead.
Control plane VIP
Only kube-vip is built for this. MetalLB and Cilium need the API server to be reachable before they start, so they cannot provide its address. Common pairings are kube-vip for the control plane and MetalLB or Cilium for Services, or kube-vip for both. If you use both kube-vip and MetalLB, make sure kube-vip’s Service feature is off, so two controllers are not fighting over the same Services. loadBalancerClass is the clean way to split Services between implementations.
Operations and observability
- MetalLB: Prometheus metrics (now HTTPS only since v0.16.0), events on Services when allocation fails,
ConfigurationStateresources that show whether configuration was applied, and FRR’s own tools inside the FRR-K8s pods for BGP state. - kube-vip: Lease objects show who holds each VIP, and v1.3.0 records VIP ownership in lease annotations. Metrics were refactored in v1.3.0, and there is now a pprof endpoint for debugging.
- Cilium: Lease objects for L2,
cilium bgpcommands and the Hive shell commands added in 1.20 for BGP peers and routes, pool status with conflict conditions. - All of them: alert on Services stuck in
<pending>, on pool exhaustion, on BGP sessions down, and on Lease holder changes happening often, which signal API server trouble or flapping nodes.
Security
- An L2 speaker answers ARP for addresses. Anyone on the same VLAN can also answer ARP for those addresses. L2 modes assume a trusted segment. Cilium’s documentation says its interface selector is not a security feature.
- BGP sessions should use a password (TCP MD5) or at least a filtered peering with prefix limits on the router side, so a misconfigured cluster cannot advertise routes it does not own. Ask the network team to accept only your pool prefixes from your nodes.
- Restrict who can create LoadBalancer Services. A namespace admin who can create one can take an address from a shared pool, unless pools are opt-in or namespace-scoped.
- Patch level: no published advisories for MetalLB or kube-vip in their GitHub repositories as I write this. Cilium had many in 2026, none of them, by title, in these features.
Licence and governance
- MetalLB: Apache 2.0, CNCF sandbox since September 2021. Releases are slower and larger than kube-vip’s.
- kube-vip: Apache 2.0, CNCF sandbox since June 2021, monthly-ish releases since 1.0.
- Cilium: Apache 2.0 (eBPF code under dual GPL and BSD), CNCF graduated since October 2023, with three supported minor lines and regular patch releases.
- PureLB: Apache 2.0, not a CNCF project.
- OpenELB: Apache 2.0, archived by the CNCF in May 2025.
- k3s (with ServiceLB): Apache 2.0, itself a CNCF sandbox project.
Sandbox status is not a quality score. It says the CNCF hosts the project, not that it has passed the graduation bar. MetalLB has been in wide use for years at sandbox level.
Performance
None of these tools sits in the data path in L2 or BGP mode; they only announce. Throughput is decided by the node’s network card, kube-proxy mode or Cilium’s eBPF datapath, and your ingress controller. In L2 mode the ceiling is one node’s link per IP. I did not find a current, independent benchmark comparing these tools, and I am not quoting any.
A worked plan for Bhopal
Shyamla’s choices are shaped by three facts: the data centre network team will give them a /27 on the server VLAN quickly but needs weeks to approve BGP; they have three control plane nodes and no external API load balancer except the HAProxy pair; and the integration service needs real source IPs.
- Agree the address plan first. Reserve 10.20.30.32/27 on the server VLAN, out of DHCP, documented in the data centre’s IP register. Split it: .40 for the control plane VIP, .50 to .54 for production Services, .55 to .59 for staging, the rest held back.
- Keep Flannel for now. Moving to Cilium only to get LB IPAM would mix two large changes. Note it for the next cluster rebuild.
- Install kube-vip as a static pod on the three control plane nodes for the API server VIP in ARP mode, with Service handling turned off. Move kubeconfigs and kubelet configuration to the VIP over a planned window. This removes the HAProxy pair from the control plane path.
- Install MetalLB in L2 mode for Services, with two
IPAddressPoolresources (production and staging), both withautoAssign: false, and anL2Advertisementlimited to the worker nodes and the bonded interface. - Give ingress-nginx’s Service the address 10.20.30.50 with
externalTrafficPolicy: Local. Run ingress-nginx as a DaemonSet on the three workers, so every eligible announcer has a local pod. MetalLB’s election considers endpoints, so the announcer will always be a node with an ingress pod. The ingress controller now sees client IPs and passes them inX-Forwarded-For. - Give the integration service its own LoadBalancer address, 10.20.30.51, with
externalTrafficPolicy: Localand a PodDisruptionBudget, and turn its IP check back on. Ask the middleware vendor to allow 10.20.30.51 as the destination; their source IPs now reach the pod unchanged. - Test failover in a quiet hour: power off the announcing worker from the BMC, measure from a collection centre laptop and from the middleware box how long requests fail, and record it. Repeat with a planned drain and check the old node stays up for two minutes after the move.
- Keep the HAProxy pair for one month as a fallback, then retire it. Its hand-written backend file goes away entirely.
- On the lab k3s cluster, leave ServiceLB in place. It serves one label printing app on one node, and a floating IP adds nothing there.
- Raise the BGP request with the data centre in parallel. When approved, switch MetalLB to BGP with FRR-K8s and BFD for the production pool, keep L2 for staging, and put the ingress layer in front of everything so ECMP rehashing only resets connections to ingress pods, which clients already retry.
What this plan deliberately does not do: it does not chase sub-second failover, it does not put the database behind a LoadBalancer Service, and it does not change the CNI.
When each is better
- MetalLB is better when you want one well-known tool for Services in L2 today and BGP tomorrow, when you need
externalTrafficPolicy: Localto work correctly in L2, and when you want FRR features such as BFD or to share BGP sessions with other components. - kube-vip is better when you need the control plane VIP and want one small component for both the API server and Services, when you need per-service leader election in ARP mode, or when you already run a routing daemon and want routing table mode.
- Cilium LB IPAM is better when Cilium is already your CNI with kube-proxy replacement, when your network speaks BGP, and when you want fewer components in the cluster. Pool service selectors and pool disabling are neat tools for multi-team clusters.
- PureLB is better when your team thinks in Linux routing and wants addresses visible on interfaces and in the routing table.
- k3s ServiceLB is better for single-node and small edge clusters, where node IPs are good enough.
- NodePort with HAProxy and keepalived is better when a separate network or security team must own the edge, when you need real health checks and draining at the edge, or when the same edge fronts VMs and several clusters.
When not to adopt each
- Do not adopt MetalLB if you need a control plane VIP from it, or if you expect fast feature releases; its release cadence is a few per year. Do not stay on FRR mode for new BGP setups now that it is deprecated.
- Do not adopt kube-vip for Services if you already run MetalLB or Cilium for them; pick one Service announcer. Do not install v1.3.0; use v1.3.1. Do not upgrade to 1.3.x without checking the shared VIP change.
- Do not adopt Cilium L2 announcements for a Service that needs
externalTrafficPolicy: Local, or if you are not running kube-proxy replacement, or if a beta label is a problem for your change board. Do not move to Cilium only to get LB IPAM. - Do not adopt OpenELB for anything new.
- Do not adopt k3s ServiceLB for multi-node clusters that need a stable address that survives a node failure.
- Do not adopt BGP mode in any tool if no one on your side can read a BGP session state or talk to the network team about prefix filters.
- Do not adopt the HAProxy option with a hand-written backend list.
A short history
The LoadBalancer Service type was designed around cloud provider integrations, and for years bare-metal clusters had only NodePort, hostNetwork ingress pods, or external load balancers. MetalLB’s repository dates from November 2017, and it became the common answer by doing one thing: announcing addresses with protocols every network already understands. It later moved from a ConfigMap to custom resources, added FRR as a BGP backend, then FRR-K8s, and in May 2026 made FRR-K8s the default.
kube-vip’s repository dates from February 2020. It solved the awkward bootstrapping problem of a highly available control plane address, and grew Service support, BGP, routing table mode and WireGuard, reaching 1.0 in July 2025. Both MetalLB and kube-vip joined the CNCF sandbox in 2021.
Cilium added the BGP control plane, LB IPAM and L2 announcements over several releases from 2022 onwards, folding the job into the CNI for clusters that already used it, and moved its BGP resources to the newer cluster, peer and advertisement model. OpenELB, which came from the KubeSphere community, joined the sandbox in November 2021 and was archived in May 2025.
Decision guide
You run kubeadm on bare metal with no external load balancer for the API
Use kube-vip for the control plane VIP. For Services, either enable kube-vip’s Service support with svc_election, or add MetalLB and keep kube-vip for the control plane only. Choose by which you are more comfortable operating.
Your network team gives you addresses but no BGP
Use MetalLB in L2 mode, with opt-in pools. Put an ingress controller or gateway on the main address. Accept single-node bandwidth per IP and a few seconds of failover. Ask for BGP in parallel if you outgrow it.
Your network team will peer BGP and Cilium is your CNI
Use Cilium LB IPAM with the BGP control plane, with BFD or short timers if your routers allow, and prefix filters on the router side. Keep a stateful ingress layer in front of applications to limit the effect of ECMP rehashing.
Your network team will peer BGP and you do not run Cilium
Use MetalLB with FRR-K8s. If you already run FRR or BIRD on nodes, kube-vip routing table mode or PureLB are also reasonable.
You run a small k3s cluster at a site
Keep ServiceLB if node IPs are acceptable. If you need a floating address, disable ServiceLB and use kube-vip or MetalLB in L2 mode.
A separate team owns the edge, or one edge fronts several clusters
Keep NodePort with HAProxy and keepalived, or an existing hardware load balancer, but generate the backend list from the cluster, use the healthCheckNodePort for Local Services, and use PROXY protocol for source IPs.
Checklist
- An address range reserved in the network IP plan, excluded from DHCP, and written down with owners.
- Pools opt-in (
autoAssign: falsein MetalLB, service selectors in Cilium, per-namespace ranges in kube-vip) so test Services cannot take production addresses. - A clear choice of one Service announcer per Service, using
loadBalancerClassif more than one implementation exists. externalTrafficPolicychosen per Service on purpose, with the source IP need written next to it.- L2: announcement limited to the right interfaces and nodes. Failover measured from a real client, not assumed.
- BGP: sessions authenticated or filtered, router accepting only your prefixes, BFD or tuned timers, and someone on your team who can read session state.
- Lease-based tools: API server healthy and close, client rate limits sized (Cilium), lease timings chosen knowingly.
- Alerts for pending Services, exhausted pools, BGP sessions down and frequent leader changes.
- A runbook entry for “the load balancer IP does not answer” that starts with “which node holds it?”
- Upgrade notes read for every release: MetalLB HTTPS metrics in v0.16.0, kube-vip shared VIP change in v1.3.0, Cilium as a whole.
- k3s: ServiceLB disabled before adding another implementation.
Common mistakes
- Calling L2 mode load balancing. It is failover. All traffic for an IP enters through one node.
- Picking the address range by pinging. An address that does not answer today may belong to a switched-off printer or a DHCP lease tomorrow.
- Running two Service announcers on the same Services, for example kube-vip with Service support on and MetalLB at the same time, or ServiceLB left on in k3s.
- Using
externalTrafficPolicy: Localwith Cilium L2 announcements and wondering why some requests vanish. - Using
externalTrafficPolicy: Clusterand then building IP allowlists in the application. The application sees node IPs. - Expecting BGP failover to be instant with default hold timers, or expecting existing connections to survive an ECMP change.
- Setting very short leases on a busy API server, so leaders lose their leases during an API slowdown and VIPs move for no reason.
- Editing a Cilium pool in production without expecting reassignment.
- Upgrading MetalLB to v0.16.0 and losing metrics because Prometheus still scrapes plain HTTP.
- Installing kube-vip v1.3.0 instead of v1.3.1.
- Staying on OpenELB because it still runs.
- Keeping a hand-written HAProxy backend list next to a cluster whose nodes change.
The drill we ran
No hands-on drill was run for this post. A meaningful test of L2 failover or BGP with ECMP needs several nodes on a real layer 2 segment or a router to peer with, and that could not be done safely inside a loopback-only scratch setup on our shared machine. Everything about failover timing above comes from each project’s own documentation, and is labelled that way.
If you want to run your own, here is what is worth measuring on a staging cluster:
- Create one LoadBalancer Service in L2 mode, run a loop that makes one HTTP request every 200 milliseconds from a laptop on the same VLAN, and record timestamps of failures.
- Find the announcing node (MetalLB speaker logs or events, or
kubectl get leasefor kube-vip and Cilium), then power it off from the BMC. Measure the gap. - Repeat with a planned drain, and with the laptop’s ARP cache inspected before and after (
ip neighon Linux,arp -aon Windows). - Repeat with
externalTrafficPolicy: Localand check the source IP the pod logs. - For BGP, watch the router’s ECMP set and count reset connections when a node is withdrawn, with and without BFD.
Unlearn, re-learn
- Unlearn “LoadBalancer means a load balancer”. Re-learn that on bare metal it means an allocated address plus an announcement, and the balancing happens in kube-proxy, Cilium or your ingress.
- Unlearn “L2 gives us high availability, so we are done”. Re-learn that it gives single-node failover in seconds, with one node’s bandwidth per address.
- Unlearn “BGP is too complex for us”. Re-learn that the configuration is small; the real work is agreeing prefixes, filters and timers with the network team, and that is a conversation worth having once.
- Unlearn “source IP is lost in Kubernetes”. Re-learn
externalTrafficPolicy: Local, what it needs from the announcer, and the proxy header options when it does not fit. - Unlearn “the load balancer is a separate product decision”. Re-learn that it is tied to your CNI choice (Cilium), your control plane bootstrapping (kube-vip) and your ingress design.
Sources
MetalLB:
- MetalLB release notes, versions 0.16.1 and 0.16.0: https://metallb.universe.tf/release-notes/
- MetalLB in layer 2 mode: https://metallb.universe.tf/concepts/layer2/
- MetalLB in BGP mode, including FRR-K8s and limitations: https://metallb.universe.tf/concepts/bgp/
- MetalLB GitHub releases: https://github.com/metallb/metallb/releases
- FRR-K8s releases: https://github.com/metallb/frr-k8s/releases
kube-vip:
- kube-vip documentation and architecture: https://kube-vip.io/docs/about/architecture/
- kube-vip Kubernetes Services usage: https://kube-vip.io/docs/usage/kubernetes-services/
- kube-vip static pod installation: https://kube-vip.io/docs/installation/static/
- kube-vip releases v1.3.1, v1.3.0 and v1.0.0: https://github.com/kube-vip/kube-vip/releases
- kube-vip-cloud-provider releases: https://github.com/kube-vip/kube-vip-cloud-provider/releases
Cilium:
- LB IPAM: https://docs.cilium.io/en/stable/network/lb-ipam/
- L2 Announcements / L2 Aware LB (Beta): https://docs.cilium.io/en/stable/network/l2-announcements/
- BGP control plane: https://docs.cilium.io/en/stable/network/bgp-control-plane/bgp-control-plane/
- Cilium releases, including 1.20.0 notes: https://github.com/cilium/cilium/releases
- Cilium security advisories: https://github.com/cilium/cilium/security/advisories
Others and governance:
- k3s networking services, ServiceLB: https://docs.k3s.io/networking/networking-services
- PureLB releases: https://github.com/purelb/purelb/releases
- OpenELB releases: https://github.com/openelb/openelb/releases
- CNCF landscape data for project status and dates: https://github.com/cncf/landscape/blob/master/landscape.yml
- Kubernetes Service documentation on external traffic policy: https://kubernetes.io/docs/concepts/services-networking/service/
Related posts on Revisit.Tech:
Releted Posts
Distributed coordination in 2026: revisit etcd, ZooKeeper and Consul before you write another leader election
Most teams do not decide to build a distributed coordination system. It creeps in. A scheduled job runs on one VM.
Read moreInfrastructure monitoring in 2026: revisit Zabbix, Checkmk and Netdata before you add another host to your monitoring
In most Indian companies, monitoring starts with a person, not a tool. Somebody in the IT team logs in to the file server every morning, checks the disk, looks at the UPS panel, pings the branch router, and goes back to work.
Read moreKubernetes storage in 2026: revisit Rook-Ceph, Longhorn and OpenEBS before you put another database on a persistent volume
Running stateless services on Kubernetes is routine now. Running state is where the calm ends. The moment a database or a file store asks for a PersistentVolumeClaim, somebody must answer: where do those bytes live, how many copies exist, and what happens when a node dies at 2 AM?
Read more