라즈베리파이 k3s 클러스터에서 마주친 VXLAN MAC 주소 중복 사고 (VXLAN 3부작 ③)
3부작의 3편. 3대짜리 라즈베리파이 k3s 클러스터에서 두 노드의 flannel VXLAN MAC이 중복 등록됐던 사고 진단 기록. 처음엔 추측으로 남겨뒀던 근본 메커니즘을, 실제 리눅스 커널 소스(vxlan_set_mac, SKB_DROP_REASON_LOCAL_MAC)로 확정했다.
학습 주제
- 주제: k3s 클러스터의 flannel VXLAN 통신 장애 원인 진단 및 해결
- 환경: 라즈베리파이 3대 (raspiWorker1, raspiMaster, raspberrypi)로 구성한 k3s 클러스터
- 날짜: 2026-08-21 (진단), 2026-08-22 (근본 원인을 커널 소스 레벨로 확정)
탐구 과정
시작은 단순한 점검 요청이었다. "노드 간 VXLAN 통신이 다 정상인지 봐줘." SSH로 포트 2222/2223/2224에 접속하면 각각 다른 노드로 붙는 구조라 세 창을 띄워놓고 하나씩 확인하기 시작했다.
가장 처음 hostname, ip -d link show type vxlan, ip -o -4 addr show로 각 노드의 flannel.1 상태를 봤을 때 이미 단서가 있었다. raspiWorker1과 raspiMaster의 flannel.1 MAC 주소가 0e:49:70:0e:bf:ee로 똑같았던 것. 근데 그때는 "설마 우연이겠지" 하고 그냥 넘어갔다. 이게 나중에 결정타가 될 줄은 몰랐다.
그다음 6방향(각 노드 쌍의 왕복)으로 flannel.1 IP끼리 ping을 돌렸는데, raspiMaster만 양방향으로 완전히 고립돼 있었다. Worker1↔raspberrypi는 멀쩡한데 Master가 낀 조합은 전부 100% 손실. 여기서부터 진짜 탐정 놀이가 시작됐다.
핵심 학습 내용
1단계 — FDB는 정상이었다 (그런데 함정이었다)
bridge fdb show dev flannel.1
이 명령은 VXLAN의 MAC→VTEP IP 매핑 테이블(FDB, Forwarding Database)을 보여준다. Worker1에서 확인해보니 raspberrypi와 raspiMaster 둘 다 dst IP가 정확하게 찍혀 있었다. "FDB가 정상이니 원인이 여기는 아니겠구나" 싶어서 넘길 뻔했는데, 결과적으로 이게 함정이었다. FDB가 정상이어도 다른 레이어에서 문제가 날 수 있다는 걸 이때 처음 체감했다.
2단계 — underlay는 완전히 배제
물리 네트워크(와이파이 IP, 172.30.1.x)끼리 순수하게 ping을 쐈다. 전부 정상. 이 결과로 "공유기나 와이파이 자체 문제"라는 가능성은 완전히 지웠다.
3단계 — iptables 확인
iptables -L -n
INPUT/FORWARD/OUTPUT 체인 정책이 전부 ACCEPT였고, 명시적으로 패킷을 막는 DROP 룰도 안 보였다. kube-router와 flannel이 만든 체인들이 있었지만 정상 범위였다. 방화벽 쪽도 용의선상에서 제외.
4단계 — tcpdump로 캡슐화 패킷 추적 (여기서 분기점을 찾았다)
이게 진단의 핵심 전환점이었다. Master에서 물리 인터페이스에 tcpdump를 걸어두고, Worker1에서 동시에 ping을 쐈다.
tcpdump -i wlan0 udp port 8472
-i wlan0은 물리 인터페이스를 지정하는 옵션이고, udp port 8472는 flannel이 VXLAN 캡슐화에 쓰는 UDP 포트다(참고로 VXLAN 표준 포트는 4789인데 flannel은 8472를 쓴다). 결과:
10:50:40.105444 IP 172.30.1.94.51860 > 172.30.1.84.8472: OTV, flags [I] (0x08), overlay 0, instance 1
IP 10.42.0.0 > 10.42.1.0: ICMP echo request, id 26494, seq 1, length 64
캡슐화된 패킷이 Master의 wlan0까지 정상 도착하고 있었다. (여기 "OTV"라고 뜬 건 tcpdump가 비표준 포트 8472를 보고 프로토콜을 잘못 라벨링한 것뿐이고, flags [I]는 VNI 필드가 유효하다는 뜻, instance 1이 VNI 값이니 이건 진짜 VXLAN 트래픽이 맞다.) 즉 물리망 전달과 UDP 캡슐화는 완전히 정상이었다.
그런데 동시에 flannel.1(디캡슐화된 이후 인터페이스)에도 tcpdump를 걸어보니:
0 packets captured
포장(캡슐)은 도착했는데 뜯어서 상위 계층으로 올리는 단계에서 아예 막혀 있었다. 방화벽 문제가 아니라 VXLAN 자체의 L2 처리 로직 문제라는 게 이 순간 확정됐다.
5단계 — neigh 테이블에서 진짜 원인 발견
ip neigh show dev flannel.1
이 명령은 flannel.1 인터페이스가 상대 노드들을 어떤 MAC 주소로 인식하고 있는지 보여준다. Master에서 확인한 결과:
10.42.3.0 lladdr 36:22:7b:de:f1:64 PERMANENT (raspberrypi, 정상)
10.42.0.0 lladdr 0e:49:70:0e:bf:ee PERMANENT (Worker1인데...)
Worker1로 가는 MAC이 0e:49:70:0e:bf:ee인데, 이게 Master 자기 자신의 flannel.1 MAC과 완전히 똑같았다. 1단계에서 지나쳤던 그 우연이 여기서 결정적 증거로 돌아왔다.
6단계 — Kubernetes annotation으로 최종 확인
flannel은 각 노드의 VTEP MAC 정보를 Node 오브젝트 annotation에 기록해서 클러스터 전체에 공유한다.
sudo -n k3s kubectl get nodes -o custom-columns='NAME:.metadata.name,SUBNET:.metadata.annotations.flannel\.alpha\.coreos\.com/backend-data'
-o custom-columns는 원하는 필드만 골라서 표 형태로 뽑아주는 출력 옵션이다. 결과:
raspberrypi {"VNI":1,"VtepMAC":"36:22:7b:de:f1:64"}
raspimaster {"VNI":1,"VtepMAC":"0e:49:70:0e:bf:ee"}
raspiworker1 {"VNI":1,"VtepMAC":"0e:49:70:0e:bf:ee"}
raspimaster와 raspiworker1의 VtepMAC이 문자 그대로 동일했다. 클러스터 데이터베이스 레벨에서부터 두 노드가 같은 VTEP MAC으로 등록돼 있었던 것. 그리고 이 값은 neigh 테이블에만 잘못 적힌 게 아니라, 두 노드의 flannel.1 인터페이스 자체의 실제 dev_addr이 물리적으로 동일했다 — flanneld가 인터페이스를 만들 때 이 annotation 값을 그대로 읽어와 강제로 씌우는 구조이기 때문이다.
왜 MAC 중복이 통신을 깨뜨리는가 — 커널 소스로 확정 (2026-08-22 갱신)
진단 당일에는 "송신 시점에 목적지가 자기 자신이라 못 내보내는 것 아닐까" 정도로 추론만 하고, dmesg나 ip -s link의 드롭 카운터로 커널이 정확히 어느 지점에서 프레임을 거부하는지까지는 확인하지 못한 채 남겨뒀었다. 다음 날 개념을 다시 정리하다가 이 지점이 걸려서, 실제 리눅스 커널 소스(drivers/net/vxlan/vxlan_core.c)를 직접 확인했다.
static enum skb_drop_reason vxlan_set_mac(struct vxlan_dev *vxlan,
struct vxlan_sock *vs,
struct sk_buff *skb, __be32 vni)
{
skb_reset_mac_header(skb);
skb->protocol = eth_type_trans(skb, vxlan->dev);
...
/* Ignore packet loops (and multicast echo) */
if (ether_addr_equal(eth_hdr(skb)->h_source, vxlan->dev->dev_addr))
return SKB_DROP_REASON_LOCAL_MAC;
이 함수는 vxlan_rcv()(수신 경로)에서 디캡슐화 직후 호출된다. 정확히 확인되는 사실:
- 디캡슐화된 속(inner) 이더넷 프레임의 출발지 MAC(
h_source)을, 자기 자신(flannel.1)의 MAC(dev_addr)과 비교한다. - 두 값이 같으면
SKB_DROP_REASON_LOCAL_MAC이라는 사유로 그 자리에서 즉시 버린다. 주석 그대로 "패킷 루프(그리고 멀티캐스트 에코)를 무시하기 위해서"다. - 이건 발신 시점(송신)이 아니라 수신 시점에 일어나는 일이다.
당일 진단 로그에 남긴 가설(송신 시 자기참조를 감지해서 못 내보냄)과는 실제로 다른 지점이었다. Worker1이 Master로 ping을 보낼 때 실제로 일어난 일은 이렇다:
Worker1 → Master로 ping (10.42.0.0 → 10.42.1.0)
│
▼ Worker1이 속프레임 구성:
[속출발지MAC = Worker1의 flannel.1 MAC = 0e:49:70:0e:bf:ee]
[속목적지MAC = 0e:49:70:0e:bf:ee (neigh 테이블이 알려준 "Master" MAC)]
│
▼ 캡슐화 → wlan0로 전송 (4단계에서 확인된 대로, 여기까진 정상)
│
▼ Master 도착, vxlan 모듈이 디캡슐화 → vxlan_set_mac() 호출
eth_hdr(skb)->h_source (=0e:49:70:0e:bf:ee)
==
vxlan->dev->dev_addr (Master flannel.1 자기 MAC, =0e:49:70:0e:bf:ee)
│
▼ 같음! → SKB_DROP_REASON_LOCAL_MAC으로 즉시 드롭
→ flannel.1까지 절대 못 올라감 → tcpdump에 0 packets (4단계에서 관찰된 증상과 정확히 일치)
즉 "MAC 중복이 왜 통신을 깨뜨리는가"의 정답은, 수신 측 vxlan 드라이버가 자기 MAC과 같은 출발지 MAC을 가진 프레임을 루프 방지 목적으로 명시적으로 드롭한다는 것이었다. 두 노드의 MAC이 같았기 때문에, 어느 쪽이 보내든 상대방 입장에서는 "내가 나한테 보낸 프레임"처럼 보였고, 그래서 예외 없이 드롭됐다.
해결 시도 1 — k3s 재시작, 효과 없음
systemctl restart k3s
재시작 전후로 flannel.1 MAC이 정확히 동일하게 나왔다. 커널이 인터페이스를 새로 만들 때 무작위 MAC을 생성한다는 통념과 다른 결과라서, flanneld가 인터페이스를 재사용하거나 강제로 같은 값을 씌우고 있다는 게 이때 의심됐다.
해결 시도 2 — 인터페이스 강제 삭제, 그래도 효과 없음
sudo ip link delete flannel.1
sudo systemctl restart k3s
ip link delete로 인터페이스를 완전히 지우고 k3s를 재시작해도 정확히 같은 MAC(0e:49:70:0e:bf:ee)이 다시 나왔다. 커널의 무작위 MAC 생성 로직이 정상 작동했다면 삭제 후 재생성 시 다른 값이 나와야 하는데, 완전히 동일한 값이 반복된 것이다.
이게 결정적 단서였다. MAC이 인터페이스 자체의 속성이 아니라, flanneld가 어딘가에 저장된 "원본 값"을 그대로 읽어와서 강제로 씌우고 있다는 뜻이었고, 그 원본이 바로 Kubernetes Node annotation이었다.
진짜 해결 — annotation 직접 수정
sudo -n k3s kubectl annotate node raspimaster \
flannel.alpha.coreos.com/backend-data='{"VNI":1,"VtepMAC":"c2:74:91:aa:4b:07"}' \
--overwrite
kubectl annotate는 오브젝트의 메타데이터(annotation)를 직접 수정하는 명령이고, --overwrite는 이미 존재하는 annotation 값을 강제로 덮어쓰겠다는 옵션이다. 새 MAC c2:74:91:aa:4b:07은 기존 MAC들과 같은 규칙(첫 바이트 하위 2비트 중 "locally administered" 비트는 켜고 "멀티캐스트" 비트는 끈 상태)을 따르도록 골랐다.
annotation을 고친 뒤 다시 인터페이스 삭제 + 재시작을 반복했다.
sudo ip link delete flannel.1
sudo systemctl restart k3s
이번엔 새 MAC이 실제로 적용됐다.
ip -d link show flannel.1 | grep ether
link/ether c2:74:91:aa:4b:07 ...
kubectl get nodes -o custom-columns=...
raspberrypi {"VNI":1,"VtepMAC":"36:22:7b:de:f1:64"}
raspimaster {"VNI":1,"VtepMAC":"c2:74:91:aa:4b:07"} ← 새 값
raspiworker1 {"VNI":1,"VtepMAC":"0e:49:70:0e:bf:ee"}
재검증
6방향 ping을 다시 돌려서 전부 0% 손실인 걸 확인했고, k3s 노드 3대 모두 Ready 상태를 유지했다.
여기서 끝내지 않고 애플리케이션 레벨까지 확인했다. 3대 노드에 하나씩 떠 있는 podinfo 파드(HTTP 서버)를 대상으로 kubectl exec ... -- wget으로 6방향 curl 테스트를 돌렸는데, 전부 정상 응답이 왔고 응답의 hostname 필드가 정확히 상대 파드명과 일치하는 것까지 확인했다. VTEP 레벨 ping만으로는 부족하다고 생각해서 실제 파드 간 HTTP 통신까지 검증한 것.
후속 점검 (2026-08-22) — 낡은 FDB 항목이 아직 남아있었다
사고 해결 한참 뒤, 개념을 다시 정리하는 김에 클러스터 상태를 다시 훑어봤다. 세 노드 다 여전히 Ready, flannel.1 MAC도 전부 고유했다:
Worker1(raspiworker1) 0e:49:70:0e:bf:ee
Master(raspimaster) c2:74:91:aa:4b:07 (사고 때 고친 값)
raspberrypi 36:22:7b:de:f1:64
근데 bridge fdb show dev flannel.1로 각 노드의 vxlan FDB(MAC → 물리IP 매핑)를 직접 찍어보다가 걸리는 게 하나 있었다.
Master에서 본 vxlan FDB:
0e:49:70:0e:bf:ee dst 172.30.1.94 (Worker1) ← 정상
36:22:7b:de:f1:64 dst 172.30.1.64 (raspberrypi) ← 정상
Worker1에서 본 vxlan FDB:
c2:74:91:aa:4b:07 dst 172.30.1.84 (Master, 현재 MAC) ← 정상
36:22:7b:de:f1:64 dst 172.30.1.64 (raspberrypi) ← 정상
0e:49:70:0e:bf:ee dst 172.30.1.84 ← 이게 문제
Worker1의 FDB에 0e:49:70:0e:bf:ee(=Worker1 자기 자신의 현재 MAC)를 Master의 물리IP(172.30.1.84)로 보내라는 항목이 아직도 남아있었다. 이건 사고 당시 Master의 MAC이 이 값으로 중복돼있던 시절에 Worker1이 학습해뒀던 낡은 항목인데, annotation을 고쳐서 neigh 테이블은 새 값(c2:74:91:aa:4b:07)으로 갱신됐지만, FDB 쪽은 그때 청소가 안 되고 그대로 남아있던 것이었다.
당장 통신에 지장은 없다 — 발신 경로는 이미 고쳐진 neigh 테이블(10.42.1.0 lladdr c2:74:91:aa:4b:07)을 거치기 때문에 이 낡은 FDB 항목을 안 탄다. 하지만 잠재적 위험은 남는다: 만약 나중에 다른 노드가 실수로 0e:49:70:0e:bf:ee라는 MAC을 다시 쓰게 되면, 이 낡은 FDB 항목 때문에 혼란이 즉시 재발할 수 있다.
이번에 얻은 교훈: annotation처럼 "제어 채널로 미리 공유되는 값"을 고칠 때, 그 값을 소비하는 표(여기선 neigh)만 갱신되고 그 표에서 파생된 다른 캐시(여기선 FDB)는 별도로 안 지워질 수 있다. 근본 설정을 고쳤다고 해서 관련된 모든 캐시 표가 자동으로 청소된다는 보장은 없으니, 이런 사고 이후엔 관련 표들을 한 번씩 다 훑어보는 습관이 필요하다는 걸 배웠다.
이해한 내용
이번 진단에서 새롭게 얻은 게 몇 가지 있다.
- FDB가 정상이라고 해서 통신이 정상이라는 뜻은 아니다. FDB는 어디로 보낼지에 대한 정보고, 실제로 프레임을 받아들일지는 목적지 MAC 일치 여부와, 이번에 확인한 것처럼 출발지 MAC 대조 같은 별도 검증에 달려 있다.
- 캡슐화된 패킷이 물리 인터페이스에 도착했다고 해서 상위 계층까지 정상 전달된다는 보장은 없다. wlan0(물리)와 flannel.1(오버레이) 두 레벨에서 각각 따로 tcpdump를 찍어봐야 어느 구간에서 막히는지 특정할 수 있다.
- 인터페이스를 삭제하고 재시작해도 같은 값이 반복된다면, 그 값은 커널이 아니라 상위 애플리케이션(여기선 flanneld)이 어딘가에 저장해두고 강제로 씌우는 값이라는 신호라는 걸 배웠다. 이 원칙은 VXLAN이 아니어도 다른 장애 진단에 그대로 쓸 수 있을 것 같다.
- flannel이 VTEP MAC 정보를 Kubernetes Node annotation에 저장해서 클러스터 전체와 공유한다는 구조 자체도 이번에 처음 명확히 알았다.
- "증상으로부터의 추론"과 "커널 소스로 확정된 사실"은 다르다. 당일엔 송신 측 자기참조 차단으로 추측했지만, 실제로는
vxlan_set_mac()의 수신 측 출발지 MAC 대조(SKB_DROP_REASON_LOCAL_MAC)였다. 증상(0 packets on flannel.1, 즉 수신 실패)은 사실 처음부터 수신 측 문제를 가리키고 있었는데, 그 순간엔 놓쳤다.
실전 적용
- 앞으로 오버레이 네트워크(VXLAN, GRE 등) 장애를 볼 때는 underlay → 캡슐화 → 디캡슐화 → 상위 계층, 이렇게 레이어별로 tcpdump를 나눠서 찍는 습관을 들이려 한다.
- MAC 중복처럼 "이상하게 재현되는" 문제를 만나면, 삭제·재시작으로 값이 그대로 유지되는지부터 확인해서 그 값이 저장된 곳(설정 파일, DB, annotation 등)을 역추적하는 방식을 다른 인프라 트러블슈팅에도 적용해볼 수 있을 것 같다.
- podinfo 같은 경량 테스트 파드를 클러스터에 상시 배치해두고, 인프라 변경이 있을 때마다 curl 헬스체크를 돌리는 걸 습관화하면 좋겠다.
- 증상만으로 메커니즘을 추론할 때는, "증상이 발생한 쪽(송신/수신)"을 먼저 명확히 특정하고 그 방향에서부터 커널 소스나 카운터를 확인하는 순서를 지키는 게 낫겠다.
추가 학습 계획
- annotation이 애초에 왜 중복됐는지는 SD카드 이미지 클론 가설로 짐작만 했지 확정하지 못했다.
/var/lib/rancher/k3s데이터스토어와/etc/machine-id의 관계, 그리고 클론 시 flannel이 어떤 초기화 로직을 타는지 더 파봐야 할 것 같다. vxlan_set_mac()의 드롭이 실제로 이 사고 당시에도 정확히 이 경로를 탔는지,dmesg나ip -s link의 드롭 카운터로 실측하는 건 여전히 못 해봤다 — 지금은 커널 소스상 로직으로 원인을 확정한 것이고, 실제 이 클러스터에서 그 카운터가 올라가는 걸 직접 본 건 아니다.- 2편에서 정리한 것처럼, OSI 7계층, MAC/IP/Port, 프레임/패킷/세그먼트, 캡슐화, VTEP, FDB/neighbour 같은 기초 개념들을 이번 진단 경험과 엮어서 정리했다: 1편: 네트워킹 기초 다시 쌓기, 2편: VXLAN 터널링, 개념부터 다시 뜯어보기
- Worker1에 남아있는 낡은 vxlan FDB 항목(
0e:49:70:0e:bf:ee dst 172.30.1.84)을 안전하게 정리하는 방법(bridge fdb del)과, 애초에 annotation을 고칠 때 관련 FDB/neighbour 캐시를 자동으로 같이 청소해주는 절차가 flannel에 있는지 확인이 필요하다.