Repository navigation
Migrate LoadBalancer announcer from MetalLB to Cilium native (LB-IPAM + L2) #2586
Description
Summary
Replace MetalLB with Cilium-native LoadBalancer announcement (LB-IPAM for IP allocation, CiliumL2AnnouncementPolicy for ARP/NDP). Drop the packages/system/metallb/ chart once the migration is complete. The IP-allocation half is already in flight on feat/ingress-lb-ipam; this issue tracks the announcer half and the operator-facing migration story.
Current state
- MetalLB v0.15.2 in
packages/system/metallb/, L2 mode, no BGP configuration. - Cilium 1.19.3 in
packages/system/cilium/charts/cilium/withenableLBIPAM: truealready on by default. CiliumLoadBalancerIPPoolis rendered by the tenant chart inpackages/apps/tenant/templates/cilium-lb-pool.yaml.- Branch
feat/ingress-lb-ipamswitchespublishing.exposurefrom the deprecatedService.spec.externalIPs(removal scheduled around k8s v1.43) toloadBalancermode, so IP allocation already flows through Cilium LB-IPAM. MetalLB remains as the L2 announcer only.
Motivation
- Removes a second LoadBalancer controller from the platform: one chart, one CRD set, one DaemonSet less to maintain and update.
- Removes the soft dependency on MetalLB's lifecycle (chart updates, CRD migrations) for a feature that the CNI already provides natively.
- Aligns with Cilium's own deprecation of the MetalLB-derived BGP v1 path (
CiliumBGPPeeringPolicy) in favor of native announcement primitives. - Unblocks consistent annotations across the platform (
lbipam.cilium.io/*instead of mixedmetallb.universe.tf/*+lbipam.cilium.io/*).
Why both can coexist on the dataplane
This matters for the migration mechanism, so worth being explicit. Both MetalLB-L2 and Cilium L2 are pure userspace ARP/NDP responders. Neither binds the LoadBalancer IP to an interface, neither installs iptables or eBPF rules for the ingress traffic. The actual LB datapath is Cilium eBPF on every node (kube-proxy-replacement is a hard prereq for Cilium L2 anyway, and is already on). Whoever answered the ARP request only decides which node receives the packet on the wire; once it arrives, eBPF forwards it to a backend pod regardless. This means the announcer can be swapped without touching the dataplane and without a real connection-level outage — only an ARP cache propagation window on the upstream router.
Proposal — phased
Phase 0 — prerequisite
Land feat/ingress-lb-ipam. After this, every LoadBalancer Service receives its IP from a CiliumLoadBalancerIPPool. MetalLB is no longer the IPAM source, only the announcer. This is independent and useful on its own (it removes the externalIPs dependency).
Phase 1 — opt-in Cilium announcer
Introduce a platform-level switch:
# packages/core/platform/values.yaml
networking:
loadBalancerAnnouncer: metallb # metallb | ciliumBehavior:
metallb(default): MetalLB chart is installed,cilium.l2announcements.enabled=false. Current behavior, no change for existing clusters.cilium: MetalLB chart is not installed (or is uninstalled on switch),cilium.l2announcements.enabled=true, aCiliumL2AnnouncementPolicyis rendered to cover the same node and Service set MetalLB used to.
Mutually exclusive by construction — no risk of both controllers being active simultaneously.
Documented as experimental in this phase. Release notes call out the migration steps, the ARP-propagation window, and the known limitations below.
Phase 2 — flip the default
After at least one bake-in cycle on real clusters, change the default to loadBalancerAnnouncer: cilium. MetalLB remains installable for clusters that need to delay the switch.
Phase 3 — drop MetalLB
Remove packages/system/metallb/, the corresponding PackageSource, the bundle entry, and the loadBalancerAnnouncer flag itself.
Migration mechanism for the operator
The switch is a sequential cutover, not a parallel run:
- Operator changes
loadBalancerAnnouncerfrommetallbtociliumand applies platform values. - Installer/controller scales MetalLB speakers to zero (or uninstalls the HelmRelease). MetalLB stops responding to ARP and stops sending gratuitous ARP. Existing TCP connections on every LoadBalancer IP keep working: the upstream router still has the old MAC cached, packets still arrive at the previous leader node, eBPF on that node still routes to backend pods.
- Installer applies the
CiliumL2AnnouncementPolicy. Cilium agents elect a leader per IP (independent from MetalLB's prior election — may be the same node, may be a different one) and send gratuitous ARP. The upstream router updates its cache. From the client's perspective, no flow is broken.
Brief edge cases:
- Clients with no ARP cache for the IP (cold) and arriving in the gap before Cilium sends its first gratuitous ARP — they will ARP the network, get no reply for a few seconds, retry, succeed once Cilium answers.
- Stateful UDP flows (notably DNS over UDP for long-running clients) where the upstream router's MAC actually changes mid-flow — the conntrack on the new leader node will treat the first packet as a new flow. For typical ingress workloads this is invisible.
- If the operator is cautious, draining one Service at a time is also possible by setting
loadBalancerClassper Service, but this adds Helm-template complexity for limited gain.
Known limitations
- Cilium L2 is incompatible with
Service.externalTrafficPolicy: Local. MetalLB-L2 has the same limitation by construction (only one node responds to ARP), so this is not a regression — but it's worth documenting against the platform's ingress chart. - Sharing one IP across Services with different pod selectors is gated behind an opt-in annotation in upstream Cilium (cilium/cilium#45819, tracking cilium/cilium#38766). Once that lands, the annotation is
lbipam.cilium.io/sharing-permit-different-podsand must be set on both Services in the sharing group. Not used by any in-tree cozystack chart today; flagged here so the platform can pick it up if a tenant scenario starts depending on it. - Lease-based leader election uses the apiserver. At cozystack-typical scale (tens of LoadBalancer IPs per cluster) this is negligible; flagging it because it's a real architectural difference vs MetalLB's gossip.
- Avoid Cilium versions with active L2 / LB-IPAM regressions on the upgrade path: 1.19.0 / 1.19.1 had several. Pin to a stable 1.18.x or 1.19.2+ for the cutover.
Acceptance criteria
-
feat/ingress-lb-ipammerged. -
networking.loadBalancerAnnouncerflag added topackages/core/platform/values.yamlwith schema validation. -
ciliummode installsCiliumL2AnnouncementPolicycovering the same selectors MetalLB used. -
ciliummode ensures MetalLB chart is uninstalled (no co-existence in the same cluster). - Existing chart annotations referring to
metallb.universe.tf/*are translated tolbipam.cilium.io/*equivalents in the tree. - e2e test exercises the switch and verifies LoadBalancer IPs remain reachable across the transition.
- Migration documentation in
docs/operations/covering the cutover steps and the known limitations. - Default flipped to
cilium(Phase 2) — separate PR. - MetalLB chart removed (Phase 3) — separate PR.
Out of scope
- BGP announcement. The default cozystack LoadBalancer story is L2; BGP can be added separately as another value of
loadBalancerAnnouncerif/when there's user demand. - Gateway API integration. Tracked separately; the LB-IPAM + Gateway combination has had a known-bad interaction recently upstream and shouldn't be conflated with this change.
Activity
feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClas…
lexfrei commentedon Jul 17, 2026
Closing as obsolete — the premise this epic is built on no longer holds on
main.Its Phase-0 prerequisite never landed. The branch
feat/ingress-lb-ipamno longer exists on origin, and the work it carried —publishing.exposure=loadBalancer— was reverted inf919afe15("revert(ingress)!: remove broken publishing.exposure=loadBalancer mode", 2026-05-11). The tenant-chart file this issue references,packages/apps/tenant/templates/cilium-lb-pool.yaml, does not exist onmain.More fundamentally, "MetalLB is our announcer, migrate it to Cilium" was never accurate. Cozystack installs MetalLB by default but configures no announcer: no chart renders an
IPAddressPool,L2Advertisement,BGPAdvertisement,CiliumLoadBalancerIPPool, orCiliumL2AnnouncementPolicyinstance.packages/extra/gateway/README.mddocuments this directly: "Cozystack itself ships MetalLB installed but does not render anyIPAddressPool/L2Advertisement/BGPAdvertisementfrom this chart; admins set up the allocator that suits their environment". There is no announcer configuration in the tree to migrate.The direction has since settled elsewhere. The pool and announcer are stood up by the admin outside the platform, and per-Service selection is native
Service.spec.loadBalancerClass(added by the merged #3218 — seepublishing.loadBalancerClassinpackages/core/platform/values.yaml). The umbrella discussion of whether the platform should own any of this is #3164, and the design for announcer/IP selection as a first-class resource now lives in community#35 (PublicIP / PublicIPClaim / PublicIPClass). This epic predates all of it.If dropping the dormant default MetalLB install is worth doing on its own, that is a small standalone issue rather than a descendant of this one.
lexfrei commentedon Aug 17, 2026
Went through the current upstream state of L2 Announcements while answering a question about dropping MetalLB. A few premises in the body above are wrong or stale by now, so dumping the findings here since this is the issue people will find.
The
externalTrafficPolicy: Locallimitation is not "same as MetalLB by construction". MetalLB filters announcement candidates to nodes with ready endpoints, so Local works there. Cilium's announcer ignores the field and can put the lease on a node with no endpoints: cilium/cilium#27800, open since 2023, fix PR cilium/cilium#46399 unmerged. Switching announcers is a regression for every Local service.The feature is still beta, and there is now a maintainer statement that this is deliberate: cilium/cilium#47775 (Aug 2026). The lease-per-service design "doesn't scale and can put significant pressure on the API server", and ARP-in-BPF "is something I regret" (this is why VLAN sub-interfaces don't work: cilium/cilium#37959). No CFP for a redesign exists.
"Pin to a stable 1.18.x or 1.19.2+" is stale. 1.19 is the branch where IPv6/NDP landed, with three multicast bugs following it; the MLD-join fix landed only in 1.19.6 on that branch (cilium/cilium#46274). cilium/cilium#44222 (sharing-key services broken by the upgrade from 1.18.6 to 1.19) is still open, and rollback did not recover without node restarts. The worst one: a Service observed by the announcer before its LoadBalancer frontend is written is never announced until an agent restart, fixed by cilium/cilium#47579 in 1.20 only, no 1.19 backport. That ordering is the normal creation path for a new LoadBalancer Service. If this gets revived, the floor should be 1.20.x.
On lease load: non-holder nodes poll the lease, they don't watch it, so load scales with services times eligible nodes. The documented formula is QPS = services / leaseRenewDeadline; the docs' own example is 65 services at defaults = 32.5 QPS. The agent's k8s client default is 10 QPS / 20 burst since 1.17, while the docs still say 5/10. Undersizing shows up as random VIP outages after client-side throttling delays a renewal, not as an error anywhere.
Other gaps MetalLB does not have: failover is 10-20 s on default lease timings vs a few seconds; one gratuitous ARP per failover vs around 10 over 5 s; no L2-announce metrics at all, so lease churn (the main reported failure mode) can't be alerted on; no health check of the announcing path beyond the lease itself (cilium/cilium#35507); a lease held by a powered-off node is not reclaimed (cilium/cilium#32148).
Talos: kernel 6.12 consumes ARP between XDP and TC (cilium/cilium#36829, workaround is
loadBalancer.acceleration: best-effort), and the BPF verifier bug cilium/cilium#44216 needs Talos 1.12.7 or newer. The feature broke twice on Talos bumps alone, so migration docs should say to re-verify the announcer after every Talos upgrade.One thing I have not found reported upstream: the lease name is plain concatenation,
cilium-l2announce-<namespace>-<service>. Namespacefoo+ servicebar-bazand namespacefoo-bar+ servicebazproduce the same lease name, so two unrelated Services end up in one leader election with two renew loops writing the same object. With generated tenant namespace and service names this is reachable. Worth filing upstream before any adoption here.For calibrating a future default flip: every success story I found is a homelab, zero production-scale reports with measured failover exist, and I count four independent rollbacks from Cilium L2 back to MetalLB with none in the other direction. MetalLB itself is active (v0.16.0 in May 2026), so there is no urgency from that side.
lexfrei commentedon Aug 19, 2026
Filed the lease-name collision upstream: cilium/cilium#48084.