TT Lab
博客

한국어 日本語 中文

开始
博客

博客

MetalLB 源码阅读:决定地址的 controller、举手发声的 speaker,以及一行哈希的选举

状态:草案(2026-10-17),部分经过实测。 基准版本为 metallb v0.16.1(提交 791dfcc63ced)。由于小型集群正在运行该版本(包含用于 FRR 模式的 controller、speaker 以及 frr-k8s),因此实测于 2026-10-17 在该集群中通过 仅读取 的方式获取(读取 CR、节点标签、服务、事件和 speaker 日志)。未直接捕获 ARP 数据包。 编写规则:以实测数据为基础,未能测量的内容标记为「未实测」,未能确认的内容标记为「未确认」。

目录

1. 一行总结:发放号码牌的窗口,以及听到名字便举手的人

在云环境中,创建 type: LoadBalancer 服务时,云平台会为其附加一个外部 IP。而在裸金属环境中没有这种「云」,因此服务将永远保留为 <pending> 。MetalLB 填补了这一空白。

它主要负责两项工作。 号码牌窗口(controller)为每个服务从地址池中确定一个 IP,并写入服务的 status 中。 举手的人 (speaker)每个节点部署一个,当网络询问「这个 IP 是谁的?」时,仅指定的某一个节点回答「是我」(L2 模式)。或者向路由器宣告「将此 IP 发送给我」(BGP 模式)。

分工为两者的原因是所需的权限和位置不同。挑选 IP 的工作需要纵观整个集群并在 单一位置 进行以避免冲突,且不需要特殊权限。而宣告工作需要读取和写入每个节点网卡的 ARP 数据包,因此必须在 每个节点上 以 hostNetwork 和 NET_RAW 权限运行。权限较大的组件(speaker)不挑选 IP,而挑选 IP 的组件(controller)不接触数据包。

组件 职责 运行位置
controller 为每个服务分配地址池 IP、检查共享 IP 规则、记录 status、Web 钩子 1 个 Deployment
speaker 通过 memberlist 互相检查生死、判断每个服务「是否轮到我来宣告」、响应 ARP/NDP DaemonSet(每个节点,hostNetwork)
frr-k8s(FRR 模式) 将 speaker 编写的 FRRConfiguration 转换为 FRR 配置并运营 BGP 会话 DaemonSet(每个节点)
CRD IPAddressPool, L2Advertisement, BGPPeer, BGPAdvertisement, ServiceL2Status … API Server

2. 架构图

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 라우터 작은 클러스터엔 없음 색: 파랑 = 지금 광고, 초록 = 넘겨받을 후보, 빨강 = 라벨로 빠짐
图:MetalLB:确定地址的一方与宣告的一方

读图指南:

3. 代码地图

路径 内容 首次阅读的文件
controller/ controller 二进制文件:服务调谐、调用 IP 分配 main.go, service.go
speaker/ speaker 二进制文件:各协议处理器(L2・BGP)与公共流程 main.go, layer2_controller.go
internal/allocator/ 从地址池中选择 IP,共享 IP(sharing key)规则 allocator.go
internal/layer2/ ARP/NDP 响应器,免费 ARP(gratuitous ARP),遍历接口 announcer.go, arp.go
internal/speakerlist/ 封装 memberlist:加入、生死事件、可用的 speaker 列表 speakerlist.go
internal/config/ 将 CR 转换为内部配置(nodeSelectors → 节点集合等) config.go
internal/k8s/ Controller Runtime 上的监控与重新同步,节点状态及标签助手 k8s.go, nodes/nodes.go
internal/bgp/ BGP 会话实现集(native、frr、frr-k8s) frrk8s/frrk8s.go
api/ CRD Go 类型 v1beta1/l2advertisement_types.go
website/content/ 文档(概念、配置、故障排查) concepts/layer2.md

4. 跟踪核心路径

4.1 controller:一个服务对应一个 IP

controller 的 SetBalancer, controller/main.go:52 在每次服务变更时 会被调用。通过复制服务 convergeBalancer, controller/service.go:49 将其调整为「应有的状态」后,如果有变化,则仅更新 status。

convergeBalancer 的思考逻辑是「能否保留当前拥有的 IP → 不能则重新获取」。如果服务已经拥有 IP,则尝试将该 IP 重新注册到分配器中(若符合配置则无事发生);如果地址池发生变化并不再被允许,则将其删除并重新获取。获取新 IP 的地方是 allocateIPs, controller/service.go:252 。如果用户 指定了 IP(如metallb.io/loadBalancerIPs 注解等),则使用该 IP;如果指定了地址池,则从该地址池中获取;如果什么都没指定,则遍历所有地址池 (Allocate, internal/allocator/allocator.go:363)查找空闲地址。

多个服务也可以共用一个 IP。条件是相同的 sharing key(metallb.io/allow-shared-ip 注解值)、不冲突的端口、 以及相同的 backend key(checkSharing, allocator.go:728)。 当 controller 删除一个服务时,会触发「重新处理所有服务」 (SyncStateReprocessAll)。这是为了让因没有空位而等待的服务能够获得该空位。

controller 在没有领导选举(LeaderElection: false)的情况下以单个 Deployment 运行。分配状态虽然保存在内存中,但重启时会通过将各服务的 status 中记录的 IP 重新注册到分配器(convergeBalancer 的第一步)来恢复状态。这一设计之所以可行,是因为真实数据的源头是 API Server 的 status。

4.2 speaker:针对所有服务的「轮到我了吗」

speaker 的 SetBalancer, speaker/main.go:385 也会针对每个服务被调用。如果不是 LoadBalancer、尚未分配 IP,或者该 IP 不属于任何地址池,则撤销广播。否则,对于每个启用的协议调用 handleService, :446 ,若其中的协议处理器 ShouldAnnounce 返回空字符串则进行广播,若返回原因字符串则因该原因撤销广播 (deleteBalancerProtocol, :495)。广播时会留下 info 日志 serviceAnnounced 和服务事件 nodeAssigned(「announcing from node …」),撤销时会留下 serviceWithdrawn 及原因 (notOwner, noMatchingAdvertisement …)。第 6 节中的数字就是对这些日志行的统计。

4.3 L2 选举:过滤并按哈希排序

광고 노드 고르기: 거르고, 해시로 줄 세운다 모든 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 한 줄뿐
图:选择 L2 广播节点

L2 处理器的 ShouldAnnounce, speaker/layer2_controller.go:83 是整个选举的核心。按顺序阅读如下:

  1. 如果服务没有准备就绪的端点(endpoint),则 notOwner。没有可用 Pod 的 IP 不会由任何人进行广播。
  2. 如果在对应服务的 L2Advertisement 中 没有选中我的节点,则 noMatchingAdvertisement (adsMatchNodeL2, :213。nodeSelectors 在 读取配置时已被预先转换为节点名称集合 (l2AdvertisementFromCR, internal/config/config.go:743).
  3. 构建候选节点列表(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
		}
	}

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

eligibleNodes 是 memberlist 当前认为存活的 speaker(4.4 节)。在此基础上,处于 NetworkUnavailable 状态的节点、 附加了排除标签的节点以及 nodeSelectors 之外的节点会被剔除。排除标签的检查 是 IsNodeExcludedFromBalancers, internal/k8s/nodes/nodes.go:30 ,它 不检查标签值,只检查是否存在键 。由于 kubeadm 会将此标签设为空值,因此即使值为空也会被排除。 关闭此检查的配置是 speaker 的 --ignore-exclude-lb 标志(speaker/main.go:99)。 4. 如果服务是 externalTrafficPolicy: Local ,则只保留拥有该服务端点的节点。由于这是一种不希望将流量转发到其他节点的策略,因此没有端点的节点不能占用 IP。 5. 对剩余的节点进行排序。

	// 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 ""

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

这种排序是设计的核心。由于所有 speaker 使用 相同的输入(存活节点列表、相同的配置、相同的 IP)计算 相同的函数,因此无需相互询问就能确定唯一的第 1 名。既不需要投票,也不需要加锁。由于是通过将 IP 附加到节点名称进行哈希计算,因此每个 IP 的第 1 名各不相同;如果存在多个服务,广播就会分散在各个节点上(即第 6 节中分为 4 个和 6 个的情况)。由于哈希的原料是 IP 而不是服务,因此共用 IP 的服务如果候选列表相同,则会由同一个节点进行广播。这是因为同一个 IP 不能拥有两个 MAC 地址。

代价也源于这种排序。如果作为输入的「存活节点列表」发生变化,第 1 名可能会随之改变。如果某个节点的 speaker 不断重复宕机和恢复,那么该节点原本作为第 1 名的那些 IP 就会每次都更换所有者。在 「公网访问中断的那一天」中记载的 「广播节点一天内变动了 1,275 次」正是这段代码的必然结果。因此,使用 nodeSelectors 将候选范围缩小到 稳定可靠的节点便成为了一种解决方案。

并且在第 3 阶段和第 2 阶段交汇的地方有一个陷阱。如果 nodeSelectors 仅选择带有排除标签的节点,这些节点虽然能通过第 2 阶段,但会在 第 3 阶段被排除,而其他节点则会在第 2 阶段 noMatchingAdvertisement 处被排除。候选者变为 0, 所有 speaker 都会停止广播。 剩下的痕迹只有一行 debug 级别的日志。这就是第 5 节的 PR 在文档中写的内容。

4.4 memberlist:存活判断的来源

作为选举输入的“存活的 speaker”由 HashiCorp memberlist 提供。 speakerlist.New, internal/speakerlist/speakerlist.go:57 使用节点名称作为 memberlist 的名称(为了与端点的 nodeName 进行匹配),绑定到 Pod IP 和 7946 端口,如果存在密钥,则用其哈希来 加密流量。如果没有绑定地址或标签设置,memberlist 将被关闭,此时会假设“speaker 存在于所有节点上”, 并直接使用 API Server 的节点列表作为候选者。

UsableSpeakers, :303 会直接返回 memberlist 的当前成员。当成员发生变化(加入或离开)时, memberlistWatchEvents, :339 会留下 “node event - forcing sync” 并 重新计算所有服务。

为什么不只使用 Kubernetes 的节点 Ready 条件?默认情况下,节点控制器将节点更改为 NotReady 需要数十秒钟, 在此期间死掉的节点会一直占有 IP。memberlist 通过节点之间直接发送 PING,在几秒钟内将其标记为可疑(Suspect)并显示为失败。 相反,如果有 speaker 频繁崩溃和恢复的节点,就会频繁发生“重新计算”。我们将在第 6 节中看到这一痕迹。

4.5 响应 ARP:应答器与免费 ARP(Gratuitous ARP)

如果在选举中获胜,L2 处理器的 SetBalancer, layer2_controller.go:131 会将 IP 注册到 Announce.SetBalancer, internal/layer2/announcer.go:258 。数据包处理是单独运行的。

4.6 BGP 与 FRR 模式(简介)

在 BGP 处理器的 ShouldAnnounce, speaker/bgp_controller.go:155 中没有选举。满足条件的所有节点各自向路由器通告路由,负载均衡由路由器的 ECMP 完成。不过,排除标签 检查在这里也存在(:178).

实际打开 BGP 会话的实现通过环境变量 METALLB_BGP_TYPE 在三者中选择 (speaker/main.go:109, newBGP, bgp_controller.go:492). frr-k8s 如果是 FRRConfiguration ,speaker 不会直接使用 BGP 协议,而是为每个节点创建一个updateConfig, internal/bgp/frrk8s/frrk8s.go:193CR (

)。 frr-k8s 守护进程集(DaemonSet)会将该 CR 转换成 FRR 配置。这种分离是为了让同一节点上的 FRR 也能被 MetalLB 以外的其他组件(例如节点自身的路由通告)共同使用。

5. 作者的贡献与后续metallb/metallb#3156(截至 2026-10-17 仍然处于开启状态)是一个文档 PR。修改的地方有两个。

与代码的连接与 4.3 节完全相同。标签检查位于 nodes.go:30,L2 中使用它的地方是 layer2_controller.go:268,剩下的日志是同一文件 第 269 行的一行 debug 日志(PR 正文中提到,这条日志是以 Issue #2505 为契机在 #2538 中添加的)。文档未能跟上代码的 地方在于“nodeSelectors 是硬过滤器”这一解释。虽然它是硬过滤器没错,但漏掉了在其之前还有 另一个硬过滤器(排除标签)这一点。事故的来龙去脉 记录在 “公网访问中断的那一天”中。

6. 实测:小型集群观察

2026-10-17,所有命令均为只读(kubectl get·logs)。节点名称已替换为角色,地址已被遮罩,原始数据已单独保存。

架构。

项目 值
controller Deployment 1个, controller:v0.16.1,参数为 --webhook-mode=enabled
speaker DaemonSet 8/8, speaker:v0.16.1,hostNetwork,权限仅有 NET_RAW 一个(其余全部 drop),只读根文件系统
speaker 端口 9120(指标),7946 TCP·UDP(memberlist)
speaker 环境变量 METALLB_BGP_TYPE=frr-k8s, METALLB_ML_BIND_ADDR=Pod IP, METALLB_ML_BIND_PORT=7946
frr-k8s DaemonSet 8/8(5 个容器),用于状态清理的 Deployment 1 个。FRRNodeState 8 个,FRRConfiguration 0 个,BGPPeer 0 个
CRD metallb.io 9 种, frrk8s.metallb.io 4 种
IPAddressPool 1 个,16 个地址, autoAssign: true。status 中已分配 11,剩余 5
L2Advertisement 1 个, nodeSelectors = 主机名 In [Worker A, Worker B], interfaces 无
LoadBalancer 服务 14 个服务共用 11 个 IP(四个服务通过相同的 sharing key 共享一个 IP)
metallb-excludel2 基本列表 14 个(^docker.*, ^veth.*, ^lxc.*, ^cali.* …)

即使安装了 FRR 模式,由于没有 BGPPeer,也没有创建 FRRConfiguration。4.6 节的 frr-k8s 路径在当前小型集群的配置中处于 闲置状态。

排除标签在 -L 中是看不出来的。 kubectl get nodes -L node.kubernetes.io/exclude-from-external-load-balancers 八个节点的这部分内容全都是 空白。由于标签值为空字符串,看起来和“不存在”完全一样。通过按键是否存在来筛选的 -l node.kubernetes.io/exclude-from-external-load-balancers 再次查看时, 控制平面集出现了。结合 4.3 节的检查看的是键而不是值,这造成了在人眼看来是“无标签”,而在代码 眼中则是“被排除”的组合。NetworkUnavailable 条件在所有八个节点上均为 False。

选举结果。 每个正在由 speaker 广播的服务所留下的 ServiceL2Status 有 10 个。

广播节点 服务数
Worker A 4
Worker B 6
其他 6 台 0

这是 2 台候选者(图 2 的第 4 阶段)按 sha256 顺序分配 IP 的结果。共享 IP 的那组服务没有 L2 状态,这四组服务的就绪端点数 全部为 0。正如 4.3 节第 1 阶段(notOwner)所述,没有任何人进行广播。在服务事件中, announcing from node "<워커 A>" with protocol "layer2" 每个服务留下了 8 条(在事件保留期限内的值)。

memberlist 与应答器。 这是 Worker A 的 speaker(当前容器从 10-14 开始)的启动日志。

日志 次数·内容
node event - forcing sync(NodeJoin) 8(包含自身共 8 个节点)
memberlist join successfully 1, number of other nodes: 7
created ARP responder for interface 3:有线网卡、无线网卡、 cilium_vxlan
created NDP responder for interface 2:有线网卡、 cilium_vxlan

明明是只使用有线的节点, 无线网卡和 Cilium 的 VXLAN 设备上也 附加了 ARP 应答器。这是因为默认排除列表中没有 cilium_* 且 L2Advertisement 中没有写入 interfaces (4.5 节)。虽然目前没有暴露出问题,但如果想把广播限定在特定的网卡上, 写入 interfaces 才是正确的做法。

动荡之日的痕迹。 统计了 Worker B 的 speaker(当前容器,约 22 天的日志)。

日志 次数
service has IP, announcing 1,948
withdrawing service announcement 920(原因: notOwner 918, noMatchingAdvertisement 2)
node event - forcing sync 434
memberlist: Suspect … has failed, no acks received 166(大部分为 GPU 节点 C)
memberlist: Marking … as failed, suspect timeout reached 106

按日期划分,memberlist 可疑消息在 10-04 有 138 条,10-05 有 39 条,10-07 有 10 条,10-13 有 1 条,同一天的广播日志在 10-04 有 1343 条,10-05 有 322 条,此后每天大约 10 条左右。同期 GPU 节点 C 的 speaker 重启次数为 360 次(最后一次退出在 10-08,退出代码为 1)。 每当一个 speaker 崩溃和恢复时,memberlist 成员就会发生变化(4.4 节),所有 speaker 都会重新计算,该节点曾排在第一位的 IP 的所有者 也会发生变化(4.3 节)。 noMatchingAdvertisement 2 条(10-14)似乎是把 nodeSelectors 更改为控制平面那一瞬间,Worker B 从候选者中剔除时留下的日志。

speaker 重启次数。 控制平面集 0,Worker A 2,Worker B 10,GPU 节点 A 8,GPU 节点 B 0(Pod 存活 2 天),GPU 节点 C 360。原因未在本文中 进行追查(未确认)。

版本字符串。 speaker 和 controller 的启动日志全都是 MetalLB … starting version 0.16.1 (commit dev, branch dev) 。这是官方镜像,但 commit 却显示为 dev 。第 8 节的第一项就是原因所在。

7. 从哪里开始阅读

  1. website/content/concepts/layer2.md。首先掌握概念 (文档 5 分钟即可读完)。
  2. speaker/layer2_controller.go:83。完整阅读 选举的一个函数。4.3 节的五个阶段全都在这里。
  3. internal/speakerlist/speakerlist.go:57。 查看选举的输入来源。
  4. internal/layer2/announcer.go:61 和 arp.go:73。这是获胜后实际响应数据包的地方。
  5. controller/service.go:49。另一侧,即决定 IP 的部分。
  6. speaker/main.go:385。最后查看两个协议是如何组合到一个流程中的。

8. 适合进行第一次贡献的地方

这是在阅读源码时实际注意到的一些问题。尚未提交到任何地方。

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

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

docker/build-push-action 的 build-args 每行都期望有 키=값 ,因此 Dockerfile 的默认值 ARG GIT_COMMIT=dev 似乎直接保留了下来。我们在第 6 节中确认了两个官方 v0.16.1 镜像都打印了 commit dev, branch dev 。这是一个可以将其更改为 = 的单行修改候选项。

贡献流程要求 CONTRIBUTING 进行 DCO 签名 (Signed-off-by),详细说明请参考网站的社区页面。PR 模板提到,如果变动不小,请先开启一个 Issue。在 2026-10-17,以 good first issue 标签开启的 Issue 有 5 个以上(测试执行文档化、容器级测试等)。

9. 局限性

Titi 的一句话。 如果八位同学分别用相同的公式计算“这球轮到谁?”,即使不进行会议,也恰好只有一个人会举手。但是,如果只挑出那些名字牌背面贴着“我退出”贴纸的同学,那球就只能在地 板上白白滚来滚去啦!

参考

所有资料均查阅于 2026-10-17。

引用的代码和配置属于 metallb,遵循 Apache License 2.0 协议。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论