Skip to content

Migrate LoadBalancer announcer from MetalLB to Cilium native (LB-IPAM + L2) #2586

Closed as not planned

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/ with enableLBIPAM: true already on by default.
  • CiliumLoadBalancerIPPool is rendered by the tenant chart in packages/apps/tenant/templates/cilium-lb-pool.yaml.
  • Branch feat/ingress-lb-ipam switches publishing.exposure from the deprecated Service.spec.externalIPs (removal scheduled around k8s v1.43) to loadBalancer mode, 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 mixed metallb.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 | cilium

Behavior:

  • 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, a CiliumL2AnnouncementPolicy is 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:

  1. Operator changes loadBalancerAnnouncer from metallb to cilium and applies platform values.
  2. 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.
  3. 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 loadBalancerClass per 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-pods and 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-ipam merged.
  • networking.loadBalancerAnnouncer flag added to packages/core/platform/values.yaml with schema validation.
  • cilium mode installs CiliumL2AnnouncementPolicy covering the same selectors MetalLB used.
  • cilium mode ensures MetalLB chart is uninstalled (no co-existence in the same cluster).
  • Existing chart annotations referring to metallb.universe.tf/* are translated to lbipam.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 loadBalancerAnnouncer if/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

  1. added
    kind/featureCategorizes issue or PR as related to a new feature
    epicA large development increment that brings definite value to Cozystack users
    area/platformIssues or PRs related to platform infrastructure (bundle, flux, talos, installer)
    area/networkingIssues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)
    on May 7, 2026
  2. added
    triage/acceptedIndicates an issue is ready to be actively worked on
    priority/important-longtermImportant over the long term, but may not be staffed and/or may need multiple releases to complete
    on Jul 1, 2026
  3. lexfrei commented on Jul 17, 2026

    @lexfrei
    ContributorAuthor

    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-ipam no longer exists on origin, and the work it carried — publishing.exposure=loadBalancer — was reverted in f919afe15 ("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 on main.

    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, or CiliumL2AnnouncementPolicy instance. packages/extra/gateway/README.md documents this directly: "Cozystack itself ships MetalLB installed but does not render any IPAddressPool / L2Advertisement / BGPAdvertisement from 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 — see publishing.loadBalancerClass in packages/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.

  4. lexfrei commented on Aug 17, 2026

    @lexfrei
    ContributorAuthor

    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: Local limitation 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>. Namespace foo + service bar-baz and namespace foo-bar + service baz produce 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.

  5. lexfrei commented on Aug 19, 2026

    @lexfrei
    ContributorAuthor

    Filed the lease-name collision upstream: cilium/cilium#48084.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/networkingIssues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)area/platformIssues or PRs related to platform infrastructure (bundle, flux, talos, installer)epicA large development increment that brings definite value to Cozystack userskind/featureCategorizes issue or PR as related to a new featurepriority/important-longtermImportant over the long term, but may not be staffed and/or may need multiple releases to completetriage/acceptedIndicates an issue is ready to be actively worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions