상태: 초안 (2026-10-17), 일부 실측. 기준 판 metallb v0.16.1(커밋 791dfcc63ced). 작은 클러스터가 바로 이 판(controller·speaker, FRR 모드용 frr-k8s 포함)을 돌리고 있어서, 실측은 2026-10-17 그 클러스터에서 읽기만 해서 얻었습니다(CR·노드 라벨·서비스·이벤트 읽기, speaker 로그 읽기). ARP 패킷을 직접 잡아 보지는 않았습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「미확인」으로 표시합니다.
목차
- 1. 한 줄 요약: 번호표를 나눠 주는 창구와, 이름이 불리면 손을 드는 사람
- 2. 구조도
- 3. 코드 지도
- 4. 핵심 경로 따라가기
- 5. 글쓴이의 기여와 이어지는 곳
- 6. 실측: 작은 클러스터에서 읽은 것
- 7. 어디부터 읽을까
- 8. 첫 기여하기 좋은 곳
- 9. 한계
- 참고
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 기록, 웹훅 | Deployment 1개 |
| speaker | memberlist 로 서로 생사 확인, 서비스마다 「내가 광고할 차례인가」 판단, ARP·NDP 응답 | 데몬셋(노드마다, hostNetwork) |
| frr-k8s(FRR 모드) | speaker 가 쓴 FRRConfiguration 을 FRR 설정으로 바꿔 BGP 세션 운영 | 데몬셋(노드마다) |
| CRD | IPAddressPool, L2Advertisement, BGPPeer, BGPAdvertisement, ServiceL2Status … | API 서버 |
2. 구조도
그림 읽는 법:
- 위 왼쪽 controller 는 API 서버의 Service 와 IPAddressPool 만 보고 IP 를 정해
status.loadBalancer.ingress에 적습니다. 네트워크로는 아무것도 보내지 않습니다. - 가운데 speaker 들은 status 에 적힌 IP 를 읽고, 서로 memberlist(포트 7946)로 살아 있는지 확인합니다. 서비스 하나에 대해 모든 speaker 가 같은 계산을 각자 하고, 계산 결과 1등인 노드 하나만 광고합니다. 따로 대표를 뽑는 합의(리더 선출) 과정이 없습니다.
- 색은 작은 클러스터의 지금 상태입니다. 워커 A 가 광고 중, 워커 B 가 대기, 컨트롤 플레인 셋은 제외 라벨로 후보에서 빠지고, GPU 노드들은 L2Advertisement 의 nodeSelectors 밖입니다(6절).
- 아래 오른쪽 BGP 는 점선입니다. 작은 클러스터는 FRR 모드로 설치돼 있지만 BGPPeer 가 없어 BGP 광고는 하지 않습니다.
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: 서비스 하나에 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)
빈 주소를 찾습니다.
IP 하나를 서비스 여러 개가 나눠 쓸 수도 있습니다. 조건은 같은 sharing key(metallb.io/allow-shared-ip 주석 값), 겹치지 않는 포트,
그리고 같은 backend key 입니다(checkSharing, allocator.go:728).
controller 가 서비스 하나를 지우면 「모든 서비스 다시 처리」(SyncStateReprocessAll)를 돌려줍니다. 자리가 없어 기다리던 서비스가 그
빈자리를 받게 하려는 것입니다.
controller 는 리더 선출 없이(LeaderElection: false) Deployment 1개로 돕니다. 할당 상태는 메모리에 있지만, 다시 뜨면 각 서비스의
status 에 적힌 IP 를 할당기에 다시 등록하며(convergeBalancer 의 첫 단계) 상태를 되살립니다. 진실의 원본이 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 선거: 거르고, 해시로 줄 세운다
L2 처리기의 ShouldAnnounce, speaker/layer2_controller.go:83
가 선거의 전부입니다. 순서대로 읽으면 이렇습니다.
- 서비스에 준비된 끝점(endpoint)이 하나도 없으면
notOwner. 받을 파드가 없는 IP 는 아무도 광고하지 않습니다. - 서비스에 해당하는 L2Advertisement 중 내 노드를 고른 것이 없으면
noMatchingAdvertisement(adsMatchNodeL2,:213). nodeSelectors 는 설정을 읽을 때 노드 이름 집합으로 미리 바뀌어 있습니다 (l2AdvertisementFromCR,internal/config/config.go:743). - 후보 노드 목록을 만듭니다(
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 과 맞춰 보려고), 파드 IP 와 7946 포트에 묶고, 비밀 키가 있으면 그 해시로
트래픽을 암호화합니다. 바인드 주소나 라벨 설정이 없으면 memberlist 를 끄는데, 그때는 「speaker 가 모든 노드에 있다」고 가정하고
API 서버의 노드 목록을 그대로 후보로 씁니다.
UsableSpeakers, :303 는 memberlist 의 현재
구성원을 그대로 돌려줍니다. 구성원이 바뀌면(가입·이탈) memberlistWatchEvents, :339
가 「node event - forcing sync」 를 남기고 모든 서비스를 다시 계산하게 합니다.
왜 쿠버네티스의 노드 Ready 조건만 쓰지 않을까요. 노드 컨트롤러가 노드를 NotReady 로 바꾸기까지는 기본 설정으로 수십 초가 걸리고, 그동안 죽은 노드가 IP 를 쥔 채로 남습니다. memberlist 는 노드끼리 직접 핑을 주고받아 몇 초 안에 의심(Suspect)하고 실패로 표시합니다. 대신 speaker 가 자주 죽고 사는 노드가 있으면 그만큼 자주 「다시 계산」 이 일어납니다. 6절에서 그 흔적을 봅니다.
4.5 ARP 에 답하기: 응답기와 gratuitous ARP
선거에서 이기면 L2 처리기의 SetBalancer, layer2_controller.go:131
가 Announce.SetBalancer, internal/layer2/announcer.go:258
에 IP 를 등록합니다. 패킷 처리는 따로 돌고 있습니다.
- 어느 카드에서 답하나.
updateInterfaces,announcer.go:61가 노드의 네트워크 카드를 주기적으로 훑어, 켜져 있고 브리지 하위가 아니며 NOARP 가 아닌 카드마다 ARP 응답기를, IPv6 링크 로컬 주소가 있는 카드마다 NDP 응답기를 만듭니다. 제외 목록(정규식)은 ConfigMapmetallb-excludel2에서 읽습니다 (parseAnnouncedInterfacesToExclude,speaker/main.go:572). L2Advertisement 에interfaces를 적으면 그 카드에서만 답합니다. - 누가 물으면.
processRequest,internal/layer2/arp.go:73는 ARP 요청이고 브로드캐스트이거나 나에게 온 것일 때, 그 IP 가 등록돼 있고 카드도 맞으면 (shouldAnnounce,announcer.go:237) 내 MAC 으로 답합니다. 커널이 아니라 speaker 가 원시 소켓으로 답하므로 노드의 IP 설정은 바뀌지 않습니다.NET_RAW권한이 필요한 이유입니다. - 주인이 바뀌면. 새로 광고를 시작한 IP 는
spamLoop,announcer.go:157가 5초 동안 1.1초 간격으로 gratuitous ARP 를 뿌립니다. 옛 MAC 을 기억하는 스위치·공유기의 표를 빨리 고치게 하려는 것입니다. 간격 1100ms 는 이슈 #172 의 경험값이라고 주석이 밝힙니다.
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 이면
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 입니다. 고친 곳은 둘입니다.
- L2 고급 설정 문서의
nodeSelectors설명에 「제외 라벨이 붙은 노드는 nodeSelectors 에 맞아도 광고하지 않는다, kubeadm 은 컨트롤 플레인에 이 라벨을 붙인다, 그 노드들만 고르면 아무도 광고하지 않는다, 우회는--ignore-exclude-lb(Helmspeaker.ignoreExcludeLB)」 라는 안내를 더했습니다. - 문제 해결 문서의 「speaker 가 서비스를 광고하지 않는 경우」 목록에 이 라벨을 더했습니다.
코드와의 연결은 4.3절 그대로입니다. 라벨 검사는
nodes.go:30, L2 에서 그것을 쓰는 곳은
layer2_controller.go:268, 남는 로그는 같은 파일
269번째 줄의 debug 한 줄입니다(이 로그는 이슈 #2505 를 계기로 #2538 에서 더해졌다고 PR 본문에 적었습니다). 문서가 코드를 따라가지 못하던
곳은 「nodeSelectors 는 하드 필터」라는 설명이었습니다. 하드 필터인 것은 맞지만, 그 앞에 또 하나의 하드 필터(제외 라벨)가 있다는 것이
빠져 있었습니다. 사고의 경위는 「공인 접속이 끊긴 날」에 있습니다.
6. 실측: 작은 클러스터에서 읽은 것
2026-10-17, 모든 명령은 읽기(kubectl get·logs)입니다. 노드 이름은 역할로, 주소는 가렸고 원자료는 따로 보관했습니다.
구성.
| 항목 | 값 |
|---|---|
| controller | Deployment 1개, controller:v0.16.1, 인자 --webhook-mode=enabled |
| speaker | 데몬셋 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=파드 IP, METALLB_ML_BIND_PORT=7946 |
| frr-k8s | 데몬셋 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개를 씀(서비스 넷이 같은 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개 있었습니다.
| 광고 노드 | 서비스 수 |
|---|---|
| 워커 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건, 그 뒤로는 하루 10건 안팎이었습니다. 같은 시기 GPU 노드 C 의 speaker 재시작 수는 360(마지막 종료 10-08, 종료 코드 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. 어디부터 읽을까
website/content/concepts/layer2.md. 개념을 먼저 잡습니다(문서는 5분이면 읽힙니다).speaker/layer2_controller.go:83. 선거 한 함수를 끝까지 읽습니다. 4.3절의 다섯 단계가 이 안에 다 있습니다.internal/speakerlist/speakerlist.go:57. 선거의 입력이 어디서 오는지 봅니다.internal/layer2/announcer.go:61과arp.go:73. 이긴 뒤 실제로 패킷에 답하는 곳입니다.controller/service.go:49. 반대편, IP 를 정하는 쪽입니다.speaker/main.go:385. 두 프로토콜이 한 흐름에 어떻게 끼워지는지 마지막으로 봅니다.
8. 첫 기여하기 좋은 곳
소스를 읽으며 실제로 눈에 띈 것들입니다. 아직 아무 데도 올리지 않았습니다.
- 릴리스 이미지의 커밋이
dev. 이미지를 만드는 워크플로가build-args를키: 값꼴로 넘깁니다 (.github/workflows/publish.yaml:109).
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 를 찍는 것을 확인했습니다. = 로 바꾸는 한 줄짜리 수정 후보입니다.
- memberlist 설정 파일의 주석과 동작이 다름.
memberlistFileConfig주석은 「파일이 있으면 모든 필드가 필수, 빠지면 오류」라고 하지만 (speakerlist.go:357), 실제로 오류가 나는 것은 시간 문자열 필드뿐이고 정수·불리언 필드는 빠지면 조용히 0·false 가 됩니다(예:IndirectChecks0). 또 파일을 읽으면--ml-wan-config와 무관하게 LAN 기본값에서 시작합니다(:397).internal/speakerlist에는 시험 파일이 하나도 없어서, 시험을 더하는 일부터 좋은 첫 기여입니다. - 오류 문구. backend key 가 다를 때 「does not match existing sharing key」 라고 말합니다
(
allocator.go:623). 제외 라벨 함수 주석의 「labeld」 와 짝 없는 따옴표(nodes.go:29)도 같은 결입니다. - 조용한 「광고 없음」. 4.3절의 함정은 debug 로그 한 줄만 남깁니다. 후보가 제외 라벨 때문에 0 이 된 경우 서비스 이벤트를 하나 남기는 것은 문서 PR(#3156) 다음 단계로 생각해 볼 만합니다. 동작을 바꾸는 일이라 이슈로 먼저 묻는 편이 좋습니다.
- 기본 제외 목록과 Cilium. 6절에서
cilium_vxlan에 ARP 응답기가 붙는 것을 봤습니다. 기본 목록 (config/native/exclude-l2-config.yaml)에는 Calico·Flannel 장치 이름은 있지만 Cilium 의 것은lxc하나뿐입니다. 해가 있는지부터 확인이 필요합니다(미확인).
기여 절차는 CONTRIBUTING 이 DCO 서명(Signed-off-by)을 요구하고, 자세한 안내는 웹사이트의 커뮤니티 페이지로 넘깁니다. PR 템플릿은 작지 않은
변경이면 이슈를 먼저 열라고 합니다. 2026-10-17 에 good first issue 라벨로 열린 이슈는 5건 이상이었습니다(테스트 실행 문서화, 컨테이너
단위 시험 등).
9. 한계
- BGP 모드는 개요만 다뤘습니다. 세션 관리, BFD, 커뮤니티, frr-k8s 내부는 열어 보지 않았고, 작은 클러스터에는 BGP 피어가 없어 재지도 못했습니다(미실측).
- IPv6·이중 스택 배정,
avoidBuggyIPs, 풀 선택기(serviceAllocation), 웹훅 검증은 다루지 않았습니다. - 광고 노드가 바뀔 때 실제로 몇 초 끊기는지, gratuitous ARP 가 공유기 표를 언제 고치는지는 패킷을 잡아 재지 않았습니다(미실측).
- 6절의 로그 숫자는 각 speaker 의 현재 컨테이너 로그만 센 것이라, 그 전 재시작 이전의 기록은 들어 있지 않습니다.
- 사고 자체의 경위와 진단 순서는 「공인 접속이 끊긴 날」에 맡겼습니다.
티티 한마디. 반 친구 여덟 명이 각자 같은 공식으로 「이 공은 누구 차례?」를 계산하면, 회의 없이도 딱 한 명만 손을 들어요. 그런데 이름표 뒤에 「난 빠질게」 스티커가 붙은 친구만 골라 놓으면, 공은 바닥에 그냥 굴러다닌답니다!
참고
모든 자료는 2026-10-17 에 열람했습니다.
- metallb v0.16.1 소스: https://github.com/metallb/metallb/tree/v0.16.1
- L2 개념 문서: https://github.com/metallb/metallb/blob/v0.16.1/website/content/concepts/layer2.md
- L2 고급 설정 문서: https://github.com/metallb/metallb/blob/v0.16.1/website/content/configuration/_advanced_l2_configuration.md
- 문제 해결 문서: https://github.com/metallb/metallb/blob/v0.16.1/website/content/troubleshooting/_index.md
- 기여 안내: https://github.com/metallb/metallb/blob/v0.16.1/CONTRIBUTING.md
- HashiCorp memberlist: https://github.com/hashicorp/memberlist
- 쿠버네티스 공식 라벨 목록(제외 라벨): https://kubernetes.io/docs/reference/labels-annotations-taints/#node-kubernetes-io-exclude-from-external-load-balancers
- 글쓴이의 문서 PR: https://github.com/metallb/metallb/pull/3156
- 같은 시리즈: CloudNativePG 읽기. 다른 시리즈: 공인 접속이 끊긴 날
인용한 코드와 설정은 metallb 의 것으로, Apache License 2.0 입니다.