TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Reading MetalLB: The controller that decides addresses, the speaker that raises its hand, and a one-hash election

Status: Draft (2026-10-17), partially measured. Reference version: metallb v0.16.1 (commit 791dfcc63ced). Since a small cluster is running this exact version (including controller, speaker, and frr-k8s for FRR mode), measurements were read-only obtained from that cluster on 2026-10-17 (reading CRs, node labels, services, events, and speaker logs). ARP packets were not directly captured. Writing convention: Measured data is the baseline; unmeasured items are marked as "Unmeasured", and unverified items are marked as "Unverified".

Table of Contents

1. One-line Summary: The ticket counter that hands out numbers, and the person who raises their hand when called

In the cloud, when you create a type: LoadBalancer service, the cloud attaches an external IP. Bare metal lacks that "cloud", so services remain forever as <pending> . MetalLB fills that gap.

It has two jobs. The ticket counter(controller) determines one IP from a pool for each service and writes it into the service's status. The person raising their hand (speaker) runs one per node, and when the network asks, "Whose IP is this?", only a designated single node answers "Mine" (L2 mode), or it announces the route to the router saying "Send this IP to me" (BGP mode).

The two jobs are separated because they require different permissions and locations. Choosing an IP must be done in one place while looking at the entire cluster to avoid collisions, and it requires no special privileges. Announcing must read and write ARP packets on each node's network card, so it must run on every node with hostNetwork and NET_RAW privileges. The side with greater privileges (speaker) does not choose IPs, and the side that chooses IPs (controller) does not touch packets.

Component Role Where it runs
controller Assigns IPs from pools per service, checks shared IP rules, records status, webhooks 1 Deployment
speaker Checks liveness of peers via memberlist, determines "is it my turn to advertise" per service, ARP/NDP responder DaemonSet (per node, hostNetwork)
frr-k8s (FRR mode) Converts FRRConfiguration written by the speaker into FRR configurations to operate BGP sessions DaemonSet (per node)
CRD IPAddressPool, L2Advertisement, BGPPeer, BGPAdvertisement, ServiceL2Status … API Server

2. Architecture Diagram

MetalLB: 주소를 정하는 쪽과 알리는 쪽 controller Deployment 1개 Service 마다 풀에서 IP status 에 IP 를 적는다 ARP·BGP 는 하지 않는다 쿠버네티스 API Service · Node EndpointSlice IPAddressPool L2Advertisement speaker 데몬셋 (노드마다, hostNetwork) 감시 워커 A 광고 중 워커 B 대기 CP 1~3 제외 라벨 GPU 노드 선택 밖 memberlist (7946): 서로 살아 있는지 확인 L2 모드 한 노드만 ARP·NDP 응답 BGP 모드 (frr-k8s) FRRConfiguration → FRR 공유기·스위치 「이 IP 의 MAC 은?」 BGP 라우터 작은 클러스터엔 없음 색: 파랑 = 지금 광고, 초록 = 넘겨받을 후보, 빨강 = 라벨로 빠짐
Figure: MetalLB: The side that decides addresses and the side that announces them

How to read the diagram:

3. Code Map

Path What Files to read first
controller/ controller binary: service convergence, IP allocation calls main.go, service.go
speaker/ speaker binary: protocol-specific handlers (L2, BGP) and common flow main.go, layer2_controller.go
internal/allocator/ Selecting IPs from pools, shared IP (sharing key) rules allocator.go
internal/layer2/ ARP/NDP responder, gratuitous ARP, scanning interfaces announcer.go, arp.go
internal/speakerlist/ Wrapping memberlist: joining, liveness events, list of usable speakers speakerlist.go
internal/config/ Converting CRs into internal configurations (nodeSelectors -> node sets, etc.) config.go
internal/k8s/ Watch and resynchronization on controller runtime, node condition/label helpers k8s.go, nodes/nodes.go
internal/bgp/ BGP session implementation set (native, frr, frr-k8s) frrk8s/frrk8s.go
api/ CRD Go types v1beta1/l2advertisement_types.go
website/content/ Documentation (concepts, configuration, troubleshooting) concepts/layer2.md

4. Following the Core Path

4.1 Controller: One IP per service

The controller's SetBalancer, controller/main.go:52 is called whenever a service changes. After copying the service and matching it to the convergeBalancer, controller/service.go:49 as the "desired state", if anything has changed, it updates only the status.

The thought process of convergeBalancer is: "Can I keep the IP I currently have -> If not, get a new one". If the service already has an IP, it re-registers that IP with the allocator (no-op if it matches the configuration); if the pool has changed and it is no longer allowed, it deletes it and gets a new one. The place where it gets a new one is allocateIPs, controller/service.go:252 . If the user specified an IP (viametallb.io/loadBalancerIPs annotation, etc.), it uses that IP; if they specified a pool, it uses that pool; if nothing is specified, it loops through all pools andAllocate, internal/allocator/allocator.go:363finds an empty address.

Multiple services can share a single IP. The conditions are the same sharing key(metallb.io/allow-shared-ip annotation value), non-overlapping ports, and the same backend key (checkSharing, allocator.go:728). When the controller deletes a single service, it triggers "reprocess all services" (SyncStateReprocessAll). This is intended to let a service that was waiting because there was no slot take that empty slot.

The controller runs as a single Deployment without leader election (LeaderElection: false). Allocation state resides in memory, but upon restarting, it revives the state by re-registering the IP written in each service's status into the allocator (the first step of convergeBalancer). This design is possible because the source of truth is the API server's status.

4.2 Speaker: "Is it my turn?" for every service

The speaker's SetBalancer, speaker/main.go:385 is also called per service. If it is not a LoadBalancer, has no IP yet, or that IP does not belong to any pool, it withdraws its announcement. Otherwise, for each enabled protocol, it calls handleService, :446 , and if the protocol handler's ShouldAnnounce returns an empty string, it announces; if it returns a reason string, it withdraws the announcement with that reason (deleteBalancerProtocol, :495). When announcing, it leaves an info log serviceAnnounced and a service event nodeAssigned("announcing from node …"), and when withdrawing, it leaves serviceWithdrawn and the reason (notOwner, noMatchingAdvertisement …). The numbers in Section 6 are counts of these lines.

4.3 L2 Election: Filtering and lining up by hash

광고 노드 고르기: 거르고, 해시로 줄 세운다 모든 speaker 가 같은 계산을 따로 하고, 1등만 답한다 1. memberlist 에 살아 있는 speaker 8 2. NetworkUnavailable 조건이 아님 8 3. 제외 라벨 없음 (--ignore-exclude-lb 면 생략) 5 4. L2Advertisement 의 nodeSelectors 2 5. ETP=Local 이면 끝점이 있는 노드만 – 6. sha256(노드#IP) 순 정렬, 맨 앞 1 오른쪽 숫자: 작은 클러스터 노드 8대 기준(2026-10-17) 워커 A 서비스 4개를 광고 워커 B 서비스 6개를 광고 그날의 첫 처방: nodeSelectors = 컨트롤 플레인 셋 3단계에서 셋 다 빠지고 4단계와 겹치는 노드가 0 → 모든 speaker 가 notOwner, 로그는 debug 한 줄뿐
Figure: Choosing the L2 announcement node

The L2 handler's ShouldAnnounce, speaker/layer2_controller.go:83 is the entirety of the election. Reading it in order goes like this:

  1. If the service has no prepared endpoints, notOwner. An IP with no pods to receive traffic is advertised by no one.
  2. If none of the L2Advertisements matching the service select my node, noMatchingAdvertisement (adsMatchNodeL2, :213). nodeSelectors are pre-converted into a set of node names when reading the configuration (l2AdvertisementFromCR, internal/config/config.go:743).
  3. It builds the candidate node list (speakersForAds, :253).
	for s := range eligibleNodes {
		if k8snodes.IsNetworkUnavailable(nodes[s]) {
			level.Debug(l).Log("event", "skipping should announce l2", "service", name, "reason", "speaker's node has NodeNetworkUnavailable condition")
			continue
		}

		if !c.ignoreExcludeLB && k8snodes.IsNodeExcludedFromBalancers(nodes[s]) {
			level.Debug(l).Log("event", "skipping should announce l2", "service", name, "reason", "speaker's node has labeled 'node.kubernetes.io/exclude-from-external-load-balancers'")
			continue
		}

		if adsMatchNodeL2(ads, s) {
			res[s] = true
		}
	}

Source: metallb v0.16.1 speaker/layer2_controller.go, Apache-2.0

eligibleNodes are the speakers that memberlist currently considers alive (Section 4.4). Here, nodes with the NetworkUnavailable condition, nodes with exclusion labels, and nodes outside nodeSelectors are filtered out. The exclusion label check checks IsNodeExcludedFromBalancers, internal/k8s/nodes/nodes.go:30 , but it checks only for the presence of the key without looking at the value . Because kubeadm applies this label with an empty value, it is excluded even if the value is empty. Turning off this check is the speaker's --ignore-exclude-lb flag (speaker/main.go:99). 4. If the service is externalTrafficPolicy: Local , it keeps only nodes that have endpoints for that service. Since this is a policy to avoid forwarding across nodes unnecessarily, a node without endpoints must not seize the IP. 5. It lines up the remaining nodes.

	// Using the first IP should work for both single and dual stack.
	ipString := toAnnounce[0].String()
	// Sort the slice by the hash of node + load balancer ips. This
	// produces an ordering of ready nodes that is unique to all the services
	// with the same ip.
	sort.Slice(availableNodes, func(i, j int) bool {
		hi := sha256.Sum256([]byte(availableNodes[i] + "#" + ipString))
		hj := sha256.Sum256([]byte(availableNodes[j] + "#" + ipString))

		return bytes.Compare(hi[:], hj[:]) < 0
	})

	// Are we first in the list? If so, we win and should announce.
	if len(availableNodes) > 0 && availableNodes[0] == c.myNode {
		return ""

Source: metallb v0.16.1 speaker/layer2_controller.go, Apache-2.0

This sorting is the core of the design. Because every speaker calculates the same inputs(list of alive nodes, same configuration, same IP) using the same function, a single first-place winner is determined without asking each other. There is no voting and no locking. Since it hashes the node name appended with the IP, the winner differs per IP, and if there are multiple services, announcements are scattered across nodes (split into 4 and 6 in Section 6). Because the ingredient of the hash is the IP rather than the service, services sharing an IP will be advertised by the same node if their candidate lists are identical. This is because a single IP must not have two MAC addresses.

The tradeoff also stems from this sorting. If the input—the "list of alive nodes"—changes, the winner can change. If a node's speaker repeatedly dies and revives, the IPs for which that node was ranked first change owners every time. The event where "announcement nodes changed 1,275 times a day" recorded in "The Day Public Access Was Cut Off"is a natural consequence of this code. Therefore, narrowing down candidates with nodeSelectors to stable nodesserves as a remedy.

And there is a trap where step 3 and step 2 meet. If nodeSelectors select only nodes with the exclusion label attached, those nodes pass step 2 but noMatchingAdvertisement drop out at step 3, while other nodes drop out at step 2 into all speakers withdraw their advertisements. The only trace left is a single debug-level log line. This is what the PR in section 5 describes in the documentation.

4.4 memberlist: The Source of "Alive"

The input to the election, "alive speakers," is provided by HashiCorp memberlist. speakerlist.New, internal/speakerlist/speakerlist.go:57 uses the node name as the memberlist name (to match the endpoint's nodeName), binds to the pod IP and port 7946, and encrypts traffic with that hash if a secret key exists. If no bind address or label configuration is provided, it turns off memberlist; in that case, it assumes "speakers exist on all nodes" and uses the API server's node list as candidates as-is.

UsableSpeakers, :303 returns the current members of memberlist as-is. When members change (join/leave), memberlistWatchEvents, :339 leaves "node event - forcing sync" and forces recalculation of all services.

Why not just use Kubernetes' node Ready condition? It takes tens of seconds by default for the node controller to change a node to NotReady, during which a dead node remains holding its IP. Memberlist has nodes ping each other directly to suspect and mark failure within seconds. Instead, if there are nodes where speakers frequently die and revive, "recalculation" happens just as frequently. You can see traces of this in section 6.

4.5 Answering ARP: Responders and Gratuitous ARP

When winning an election, the L2 handler's SetBalancer, layer2_controller.go:131 registers the IP in Announce.SetBalancer, internal/layer2/announcer.go:258 . Packet processing runs separately.

4.6 BGP and FRR Modes (Briefly)

The BGP handler's ShouldAnnounce, speaker/bgp_controller.go:155 has no elections. Every node satisfying the conditions announces routes to its router, and load balancing is handled by the router's ECMP. However, exclusion label checks are present here as well (:178).

The implementation that actually opens BGP sessions is selected via one of three choices in the environment variable METALLB_BGP_TYPE (speaker/main.go:109, newBGP, bgp_controller.go:492). frr-k8s , the speaker does not speak BGP directly and instead creates per-node FRRConfiguration CRs (updateConfig, internal/bgp/frrk8s/frrk8s.go:193). The frr-k8s daemonset then translates those CRs into FRR configurations. This separation allows things other than MetalLB (such as the node's own route advertisements) to share FRR on the same node.

5. Author's Contributions and References

metallb/metallb#3156(open as of 2026-10-17) is a documentation PR. It modifies two areas.

The connection to the code remains as described in section 4.3. The label check is nodes.go:30, where L2 uses it is layer2_controller.go:268, and the remaining log is a single debug line at line 269 of the same file (the PR body notes this log was added in #2538 following issue #2505). The place where documentation lagged behind the code was the explanation that "nodeSelectors are a hard filter." While they are indeed a hard filter, it omitted the fact that there is another hard filter(exclusion labels) in front of it. The background story can be found in "The Day Public Access Was Cut Off".

6. Measurements: Insights from a Small Cluster

2026-10-17, all commands are read-only (kubectl get·logs). Node names have been replaced with roles, addresses masked, and raw data archived separately.

Configuration.

Item Value
controller 1 Deployment, controller:v0.16.1, arguments --webhook-mode=enabled
speaker DaemonSet 8/8, speaker:v0.16.1, hostNetwork, privileges restricted to NET_RAW (all others dropped), read-only root filesystem
speaker ports 9120 (metrics), 7946 TCP/UDP (memberlist)
speaker environment variables METALLB_BGP_TYPE=frr-k8s, METALLB_ML_BIND_ADDR=pod IP, METALLB_ML_BIND_PORT=7946
frr-k8s DaemonSet 8/8 (5 containers), 1 Deployment for status cleanup. 8 FRRNodeStates, 0 FRRConfigurations, 0 BGPPpeers
CRD metallb.io 9 types, frrk8s.metallb.io 4 types
IPAddressPool 1 pool, 16 addresses, autoAssign: true. status shows 11 allocated and 5 remaining
L2Advertisement 1 advertisement, nodeSelectors = hostname In [Worker A, Worker B], interfaces none
LoadBalancer services 14 services sharing 11 IPs (four services share a single IP via the same sharing key)
metallb-excludel2 14 default items (^docker.*, ^veth.*, ^lxc.*, ^cali.* …)

Even though installed in FRR mode, since there are no BGPPeers, no FRRConfigurations were created. The frr-k8s path from section 6 is currently idle in this small cluster configuration.

Exclusion labels are not visible via -L . kubectl get nodes -L node.kubernetes.io/exclude-from-external-load-balancers was blank across all eight nodes. Because the label value is an empty string, it looks identical to "non-existent." Checking by key existence with -l node.kubernetes.io/exclude-from-external-load-balancers revealed the control plane set. Combined with the check in section 4.3 that looks for the key rather than the value, this creates a combination where it appears as "no label" to human eyes, but "excluded" to code eyes. The NetworkUnavailable condition was False on all eight nodes.

Election Results. There were 10 ServiceL2Status entries left for each service being advertised by a speaker.

Advertising Node Number of Services
Worker A 4
Worker B 6
Other 6 nodes 0

This is the result of 2 candidate nodes (step 4 in Figure 2) dividing the IPs in sha256 order. The service net sharing IPs had no L2 status, and all four nets had 0 ready endpoints. As per step 1 in section 4.3 (notOwner), no one advertises. Service events had announcing from node "<워커 A>" with protocol "layer2" logged 8 times per service (within the event retention period).

Memberlist and Responders. Startup logs of Worker A's speaker (current container started 10-14).

Log Count/Content
node event - forcing sync(NodeJoin) 8 (8 nodes including self)
memberlist join successfully 1, number of other nodes: 7
created ARP responder for interface 3: wired card, wireless card, cilium_vxlan
created NDP responder for interface 2: wired card, cilium_vxlan

Although it is a node using only wired networking, ARP responders were attached to the wireless card and Cilium's VXLAN device as well , because there is no cilium_* in the default exclusion list and interfaces was not specified in L2Advertisement (section 4.5). No problems are apparent right now, but if you want to restrict advertisements to a specific interface, specifying interfaces is correct.

Traces of a Turbulent Day. Counted logs from Worker B's speaker (current container, approximately 22 days of logs).

Log Count
service has IP, announcing 1,948
withdrawing service announcement 920 (reason: notOwner 918, noMatchingAdvertisement 2)
node event - forcing sync 434
memberlist: Suspect … has failed, no acks received 166 (mostly GPU Node C)
memberlist: Marking … as failed, suspect timeout reached 106

Breaking down by date, memberlist suspect messages were 138 on 10-04, 39 on 10-05, 10 on 10-07, and 1 on 10-13, while advertisement lines on those same days were 1,343 on 10-04, 322 on 10-05, and around 10 per day thereafter. During the same period, GPU Node C's speaker restart count was 360 (last termination 10-08, exit code 1). Every time a speaker dies and restarts, memberlist membership changes (section 4.4), all speakers recalculate, and IPs for which that node was ranked first change owners (section 4.3). The noMatchingAdvertisement 2 entries (10-14) appear to be lines left when Worker B dropped out of candidates the moment nodeSelectors were changed to the control plane.

Speaker Restart Counts. Control plane set 0, Worker A 2, Worker B 10, GPU Node A 8, GPU Node B 0 (pod age 2 days), GPU Node C 360. Causes were not pursued in this article (unconfirmed).

Build String. Startup logs for both speaker and controller were MetalLB … starting version 0.16.1 (commit dev, branch dev) . Being an official image, the commit is recorded as dev . The first item in section 8 explains why.

7. Where to Start Reading

  1. website/content/concepts/layer2.md. Grasp the concepts first (the documentation takes about 5 minutes to read).
  2. speaker/layer2_controller.go:83. Read through the election function to the end. All five steps from section 4.3 are contained within it.
  3. internal/speakerlist/speakerlist.go:57. Examine where election inputs come from, along with
  4. internal/layer2/announcer.go:61 and arp.go:73. Where packets are actually answered after winning.
  5. controller/service.go:49. The other side, where IPs are determined.
  6. speaker/main.go:385. Finally, see how the two protocols fit into a single flow.

8. Good Places for a First Contribution

Things actually noticed while reading the source code. These have not been submitted anywhere yet.

          build-args: |
            GIT_BRANCH: ${{ github.ref_name }}
            GIT_COMMIT: ${{ github.sha }}

Source: metallb v0.16.1 .github/workflows/publish.yaml, Apache-2.0

docker/build-push-action ). Because build-args expects 키=값 per line, the Dockerfile default value ARG GIT_COMMIT=dev appears to remain as-is. Section 6 confirmed that two official v0.16.1 images display commit dev, branch dev . This is a candidate for a one-line fix to change it to = .

For the contribution process, CONTRIBUTING requires a DCO sign-off (Signed-off-by), and detailed guidance is deferred to the community page on the website. The PR template states that for non-trivial changes, you should open an issue first. As of 2026-10-17, there were 5 or more issues opened with the good first issue label (such as test execution documentation, container-level testing, etc.).

9. Limitations

TT's Word. If eight classmates each calculate "Whose turn is this ball?" using the same formula, exactly one person raises their hand without any meeting. But if you pick only the friends who have a "Count me out" sticker behind their name tags, the ball just rolls around on the floor!

References

All materials were accessed on 2026-10-17.

Quoted code and configurations belong to MetalLB and are licensed under the Apache License 2.0.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment