TT Lab
ブログ

한국어 日本語 中文

はじめる
ブログ

ブログ

MetalLB 読み解き: アドレスを決める controller、名乗り出る speaker、そして1行のハッシュによる選挙

ステータス: ドラフト(2026-10-17)、一部実測。 基準バージョン metallb v0.16.1(コミット 791dfcc63ced)。小さなクラスターがまさにこのバージョン(controller・speaker、FRR モード用の frr-k8s を含む)を稼働させているため、実測は2026-10-17にそのクラスターから 読み取りのみ を行って得ました(CR・ノードラベル・サービス・イベントの読み取り、speaker のログの読み取り)。ARP パケットを直接キャプチャしてはいません。執筆ルール: 実測データを基本とし、計測できなかったものは「未計測」、確認できなかったものは「未確認」と表記します。

目次

1. 1行要約: 番号札を配る窓口と、名前を呼ばれたら手を挙げる人

クラウドでは type: LoadBalancer サービスを作成すると、クラウド側が外部 IP を1つ割り当ててくれます。ベアメタルにはその「クラウド」がないため、サービスが永遠に <pending> のまま残ります。MetalLB がその穴埋めをします。

仕事は2つあります。 番号札の窓口(controller)はサービスごとにプールから IP を1つ決定し、サービスの status に書き込みます。 手を挙げる人 (speaker)はノードごとに1つずつ存在し、ネットワークから「この IP は誰のものか?」と問われると、決まった1つのノードだけが「自分だ」と答えます(L2 モード)。または、ルーターに「この IP は自分宛てに送るように」と経路を通知します(BGP モード)。

2つの仕事を分けた理由は、必要な権限と配置場所が異なるためです。IP を選ぶ作業はクラスター全体を見渡して 1か所で 行うことで競合がなくなり、特別な権限も必要ありません。通知する作業は各ノードのネットワークカードで ARP パケットを読み書きする必要があるため、 ノードごとに hostNetwork と NET_RAW 権限で動作させる必要があります。権限が大きい側(speaker)は IP を選ばず、IP を選ぶ側(controller)はパケットに触れません。

コンポーネント 役割 実行場所
controller サービスごとのプールからの IP 割り当て、共有 IP ルールの検証、status の記録、Webhook Deployment 1つ
speaker memberlist による互いの生死確認、サービスごとの「自分が広告する順番か」の判定、ARP・NDP 応答 DaemonSet(ノードごと、hostNetwork)
frr-k8s(FRR モード) speaker が書き込んだ FRRConfiguration を FRR 設定に変換して BGP セッションを運用 DaemonSet(ノードごと)
CRD IPAddressPool, L2Advertisement, BGPPeer, BGPAdvertisement, ServiceL2Status … API サーバー

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 応答機、gratuitous ARP、インターフェースのスキャン announcer.go, arp.go
internal/speakerlist/ memberlist のラッパー: 参加、生死イベント、利用可能な speaker のリスト speakerlist.go
internal/config/ CR を内部設定に変換(nodeSelectors → ノードのセットなど) config.go
internal/k8s/ コントローラーランタイム上の監視・再同期、ノードの条件・ラベルヘルパー 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: 1つのサービスに1つの IP

controller の SetBalancer, controller/main.go:52 はサービスが変更されるたびに呼び出されます。サービスをコピーして convergeBalancer, controller/service.go:49 により「あるべき状態」に合わせ、変更点があれば status のみを更新します。

convergeBalancer の思考順序は「現在持っている IP を維持できるか → できなければ新しく取得する」です。サービスがすでに IP を持っている場合、その IP をアロケーターに再度登録し(設定と一致していれば何もなし)、プールが変更されてもはや許可されていなければ削除して新しく取得します。新しく取得する場所は allocateIPs, controller/service.go:252 です。ユーザーが IP を指定している場合(metallb.io/loadBalancerIPs アノテーションなど)はその IP を、プールを指定している場合はそのプールから、何も指定されていない場合はすべてのプールを巡回して (Allocate, internal/allocator/allocator.go:363) 空きアドレスを探します。

1つの IP を複数のサービスで共有することも可能です。条件は同じ sharing key(metallb.io/allow-shared-ip アノテーションの値)、重複しないポート、そして同じ backend key です(checkSharing, allocator.go:728)。controller が1つのサービスを削除すると「すべてのサービスの再処理」(SyncStateReprocessAll)を実行します。空きを待っていたサービスにその空いた枠を割り当てるためです。

controller はリーダー選出なしで(LeaderElection: false)Deployment 1つで動作します。割り当て状態はメモリ上に存在しますが、再起動時には各サービスの status に記載された IP をアロケーターに再登録し(convergeBalancer の最初のステップ)、状態を復元します。真実のソース(Single Source of Truth)が API サーバーの 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)が1つもない場合、 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位が1人に決まります。投票もロックも存在しません。ノード名に IP を付与してハッシュ化するため、IP ごとに1位が異なり、サービスが複数あれば広告が各ノードに分散します(セクション 6 で 4個と 6個に分かれていた理由です)。ハッシュの材料がサービスではなく IP であるため、IP を共有するサービスは候補リストが同じであれば同じノードが広告します。1つの IP に対して MAC アドレスが複数存在してはならないためです。

代償もこのソートから生じます。入力である「生きているノードのリスト」が変更されると、1位が変わる可能性があります。あるノードの speaker がクラッシュと復旧を繰り返すと、そのノードが1位であった IP はその都度所有者が変わります。 「外部アクセスが途絶えた日」に記載した「広告ノードが1日に 1,275 回変更された」という出来事は、このコードの自然な結果です。そのため、nodeSelectors によって候補を 揺らがないノードに絞り込むことが処方箋となります。

そして3段階と2段階が交わる場所に罠があります。nodeSelectorsが除外ラベルの付いたノードだけを選ぶと、それらのノードは2段階目を通過しますが、 3段階目で脱落し、他のノードは2段階目で noMatchingAdvertisement に落ちます。候補が0になって すべてのspeakerが広告を引っ込めます。 残る痕跡はdebugレベルのログが1行だけです。これが第5節のPRがドキュメントに記した内容です。

4.4 memberlist: 「生きている」の出どころ

選挙の入力である「生きているspeaker」はHashiCorp memberlistが提供します。 speakerlist.New, internal/speakerlist/speakerlist.go:57 はノード名をmemberlistの名前として使い(エンドポイントのnodeNameと合わせるため)、ポッドIPと7946ポートにバインドし、秘密鍵があればそのハッシュで トラフィックを暗号化します。バインドアドレスやラベルの設定がなければmemberlistをオフにし、その場合は「speakerがすべてのノードにいる」と仮定して APIサーバーのノードリストをそのまま候補として使います。

UsableSpeakers, :303 はmemberlistの現在の 構成員をそのまま返します。構成員が変わると(参加・離脱) memberlistWatchEvents, :339 が「node event - forcing sync」を残し、 すべてのサービスを再計算させます。

なぜKubernetesのノードのReady条件だけを使わないのでしょうか。ノードコントローラーがノードをNotReadyに変更するまでにはデフォルトの設定で数十秒かかり、 その間、死んだノードがIPを握ったまま残ります。memberlistはノード間で直接pingをやり取りし、数秒以内に疑い(Suspect)として失敗を示します。 代わりにspeakerが頻繁に死に生き返るノードがあれば、その分頻繁に「再計算」が発生します。第6節でその痕跡を見ます。

4.5 ARPへの応答: 応答機とgratuitous ARP

選挙に勝つとL2ハンドラーの SetBalancer, layer2_controller.go:131 が Announce.SetBalancer, internal/layer2/announcer.go:258 にIPを登録します。パケット処理は別で稼働しています。

4.6 BGPとFRRモード(簡単に)

BGPハンドラーの ShouldAnnounce, speaker/bgp_controller.go:155 には選挙がありません。条件を満たすすべてのノードが各自ルーターに経路を通知し、負荷分散はルーターのECMPが行います。それでも除外ラベルの チェックはここにもあります(:178).

BGPセッションを実際に開く実装は3つのうち1つを環境変数 METALLB_BGP_TYPE で選択します (speaker/main.go:109, newBGP, bgp_controller.go:492). frr-k8s の場合 speakerはBGPを直接話さず、ノード別の FRRConfiguration CRを作成しておきます (updateConfig, internal/bgp/frrk8s/frrk8s.go:193)。 そのCRをfrr-k8sデーモンセットがFRR設定に変更します。同じノードのFRRをMetalLB以外のもので(例:ノード独自の経路広告)も併用できるようにするための 分離です。

5. 執筆者の貢献と続く場所

metallb/metallb#3156(2026-10-17時点オープン)はドキュメントのPRです。修正箇所は2つです。

コードとの結びつきは4.3節の通りです。ラベルのチェックは nodes.go:30、L2でそれを使用している箇所は layer2_controller.go:268、残るログは同じファイル 269行目のdebugログ1行です(このログはissue #2505をきっかけに#2538で追加されたとPR本文に記載されています)。ドキュメントがコードに追いついていなかった 箇所は「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 1つ(残りはすべてdrop)、読み取り専用ルートファイルシステム
speakerポート 9120(メトリクス)、7946 TCP・UDP(memberlist)
speaker環境変数 METALLB_BGP_TYPE=frr-k8s, METALLB_ML_BIND_ADDR=ポッド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 [ワーカー A, ワーカー B]、 interfaces なし
LoadBalancerサービス 14個がIP 11個を使用(4つのサービスが同じsharing keyでIP 1つを共有)
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 は8つのノードすべてで 空欄でした。ラベルの値が空文字であるため、「存在しない」のと全く同じに見えます。キーが存在するかどうかで選ぶ -l node.kubernetes.io/exclude-from-external-load-balancers で改めて確認すると コントロールプレーンセットが浮かび上がりました。4.3節のチェックが値ではなくキーを見る仕様と噛み合い、人間の目には「ラベルなし」、コードの 目には「除外」となる組み合わせです。NetworkUnavailable条件は8つのノードすべてでFalseでした。

選挙結果。 speakerが広告中のサービスごとに残る ServiceL2Status が10個ありました。

広告ノード サービス数
ワーカー A 4
ワーカー 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と応答機。 ワーカー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 を記載するのが適切です。

揺らいだ日の痕跡。 ワーカー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に 1,343件、10-05に322件、その後は1日あたり10件前後でした。同時期のGPUノードCのspeaker再起動回数は360(最後の終了10-08、終了コード1) です。1つのspeakerが死に生き返るたびにmemberlistの構成員が変わり(4.4節)、すべてのspeakerが再計算を行い、そのノードが1位だったIPは主が 変わります(4.3節)。 noMatchingAdvertisement 2件(10-14)はnodeSelectorsをコントロールプレーンに変更した瞬間にワーカーBが候補から外れて残した行のようです。

speakerの再起動回数。 コントロールプレーンセット 0、ワーカーA 2、ワーカーB 10、GPUノードA 8、GPUノードB 0(ポッドの稼働日数2日)、GPUノードC 360。原因はこの記事では 追っていません(未確認)。

バージョン文字列。 speakerとcontrollerの開始ログがいずれも MetalLB … starting version 0.16.1 (commit dev, branch dev) でした。 公式イメージであるにもかかわらずコミットが dev として記録されます。第8節の最初の項目がその理由です。

7. どこから読むべきか

  1. website/content/concepts/layer2.md。概念をまず 掴みます(ドキュメントは5分で読めます)。
  2. speaker/layer2_controller.go:83。選挙の1つの関数を 最後まで読みます。4.3節の5段階がこの中にすべて含まれています。
  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。2つのプロトコルが1つのフローにどのように組み込まれるのかを 最後に見ます。

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イメージの2つが commit dev, branch dev を記録していることを確認しました。 = に変更する1行の修正候補です。

貢献手順は CONTRIBUTING が DCO 署名(Signed-off-by)を求め、詳細な案内はウェブサイトのコミュニティページに委ねています。PR テンプレートでは、小さくない変更であれば事前に Issue を開くよう求めています。2026-10-17 時点で good first issue のラベルで開かれた Issue は5件以上ありました(テスト実行のドキュメント化、コンテナ単位のテストなど)。

9. 制限事項

Titi の一言。 クラスメートの8人がそれぞれ同じ数式で「このボールは誰の番?」を計算すれば、会議をしなくてもたった1人だけが手を挙げます。 ところが、名札の裏に「私はパスする」というステッカーが貼られた友達だけを選り分けておくと、ボールは床に転がったままになってしまうのです!

参考

すべての資料は 2026-10-17 に閲覧しました。

引用したコードと設定は metallb のものであり、Apache License 2.0 に準拠しています。

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます