VXLAN 터널링, 개념부터 다시 뜯어보기 (VXLAN 3부작 ②)
3부작의 2편. 1편에서 다진 fd/소켓/net_device 기초 위에서, 패킷이 목적지를 찾아가는 세 단계 조회(라우팅 테이블 → neighbour → FDB)와 브릿지·VTEP·VXLAN 캡슐화를 정리했다.
1편에서 fd·파이프·소켓·net_device의 기초를 다졌습니다. 이번 편은 그 위에서, 라즈베리파이 3대로 만든 k3s 클러스터에서 VXLAN 통신 장애를 진단하다가 정작 제대로 설명 못 했던 부분 — 패킷이 실제로 어떻게 목적지를 찾아가는지, 그리고 VXLAN이 그 위에 뭘 더 얹는지를 다룹니다.
범위를 하나씩 넓혀가는 순서로 갑니다: 같은 노드 안 → 목적지를 찾는 세 단계 조회 → 노드와 노드 사이(VXLAN) → 전체 그림 → 프로덕션에서 실제로 걸리는 것들.
1부 — 같은 노드 안에서
네임스페이스 — 파드가 자기만의 eth0를 갖는 이유
파드(정확히는 그 안의 컨테이너)는 특별한 가상머신이 아니라 호스트에서 돌고 있는 평범한 리눅스 프로세스입니다. 실행 시 커널에게 "새 네트워크 네임스페이스 하나 파서 그 안에 넣어줘"라고 요청한 것뿐입니다.
인터페이스 목록과 라우팅 테이블은 원래 컴퓨터 전체에 딱 하나씩입니다. 네트워크 네임스페이스는 커널에게 "이 목록과 테이블을 통째로 복사해서 이 프로세스 그룹 전용으로 새로 만들어라"라고 시키는 기능입니다. 파드가 늘어나면 네임스페이스도 그만큼 늘어나지만, 각각은 완전히 독립된 몇 줄짜리 구조라 서로 간섭이 없습니다.
veth — 파드와 호스트를 잇는 가상 케이블
veth는 항상 둘이 한 쌍으로 태어납니다. 진짜 랜케이블처럼 한쪽 끝에 뭘 넣으면 반대쪽 끝으로 나옵니다.
파드 내부에서 본 이름: eth0
호스트에서 본 이름: veth18e64ff ← 같은 케이블의 반대쪽 끝
veth18e64ff 같은 이름 자체는 주소가 아니라 인터페이스 이름표일 뿐입니다. 실제 패킷 헤더 어디에도 이 이름은 들어가지 않습니다.
cni0 — 여러 인터페이스를 묶어 거느리는 인터페이스
파드들의 veth를 한데 묶는 게 cni0입니다(리눅스에서는 "브릿지"라고도 부릅니다). "브릿지"는 일반 개념(net_device의 한 종류)이고, cni0는 그 타입으로 실제 만들어진 인스턴스 하나입니다.
브릿지가 옛날 공유 동축 케이블의 후계자인지 보면, 절반만 맞습니다.
- 동축 케이블: 프레임을 보내면 그 선에 물린 모든 장치가 다 같이 신호를 받고, 각자 "목적지 MAC이 나야?"를 대조합니다. 동시 전송 시 충돌하고, 이걸 피하려고 CSMA/CD가 필요했습니다.
- 스위치/브릿지: 각 장치는 전용선을 갖고, 신호가 물리적으로 공유되지 않습니다. 목적지 MAC으로 FDB를 조회해서 해당 인터페이스로만 골라 내보냅니다.
신호는 더 이상 공유되지 않지만, "MAC으로 서로를 구별해야 하는 하나의 그룹"이라는 경계는 그대로 이어받았습니다.
리눅스 CLI 명령어(bridge fdb, bridge link)는 브릿지에 묶인 각 net_device를 "포트"라고 부르지만, 실제로는 그냥 net_device 하나를 가리킬 뿐이라 이 글에서는 혼동을 피하려고 계속 **"인터페이스"**라고 부르겠습니다. L4 포트(소켓 포트, UDP/TCP 헤더 안 16비트 숫자)와는 완전히 다른 개념이라는 것만 기억하면 됩니다.
| 브릿지가 묶는 인터페이스 | L4 포트(소켓 포트) | |
|---|---|---|
| 정체 | net_device 하나를 브릿지에 연결한 것 | UDP/TCP 헤더 안 16비트 숫자 |
| 구별 대상 | 어느 케이블(인터페이스)로 나갈지 | 같은 컴퓨터 안 어느 프로그램(소켓)으로 줄지 |
| 계층 | 2계층 | 4계층 |
구현 레벨에서 이 연결 관계(net_bridge_port)는 별도의 새로운 실체가 아니라, 이미 있던 net_device를 감싸는 얇은 포장입니다.
struct net_bridge_port {
struct net_device *dev; // veth-a1이라는 net_device를 가리키는 포인터일 뿐
struct net_bridge *br; // 어느 브릿지(cni0) 소속인지
};
ip link set veth18e64ff master cni0를 실행하는 순간, 커널은 이 구조체를 만들어 "이 net_device는 지금부터 cni0에 연결됐다"고 기록합니다. veth-a1 입장에서는 자기가 net_device라는 사실이 전혀 안 바뀌고, dev_addr도 그대로입니다. 다만 cni0가 자기 인터페이스 목록에 이 net_device를 끼워넣고 취급하게 된 것뿐입니다.
왜 wlan0에 파드를 바로 안 붙이고 cni0를 따로 두나: (1) 역할이 다릅니다 — wlan0는 밖으로 나가는 파이프 하나일 뿐 여러 로컬 장치를 위한 자리(인터페이스 여러 개)가 없고, cni0는 여러 net_device를 연결하고 MAC 기반으로 중계하려고 만들어진 장치입니다. cni0 자신도 IP(10.42.0.1 같은)를 가질 수 있어서, 호스트 자신이 파드들과 통신할 때는 그냥 평범한 인터페이스 하나처럼도 동작하는 이중 역할입니다. (2) 격리 목적도 있습니다 — 파드의 사설 주소(10.42.x.x)와 MAC이 물리 와이파이망에 그대로 노출되는 걸 막습니다.
cni0의 이중 역할 — MAC/IP/프로토콜이 갈리는 지점
방금 나온 "이중 역할"을 프레임 단위로 뜯어보면, cni0에 프레임이 도착할 때마다 커널이 가장 먼저 하는 판단은 목적지 MAC이 cni0 자신의 것인지 아닌지입니다. 이 갈림길에 따라 프로토콜이 아예 개입하느냐 마느냐가 완전히 달라집니다.
프레임 도착 (veth-a1에서 들어옴)
│
▼ 목적지MAC이 cni0 자신의 dev_addr과 같은가?
│
같음 다름 (다른 파드 MAC)
│ │
▼ ▼
로컬 IP 스택으로 전달 브릿지 전달 (FDB 조회 → 인터페이스 선택)
(PACKET_HOST) (PACKET_OTHERHOST → 다른 인터페이스로만)
│ │
▼ ▼
여기서부터 IP/UDP/TCP 등 안의 내용물이 IP든 ARP든 뭐든
"프로토콜"이 처음 등장 상관없이 그대로 통째로 복사돼 나감
(호스트가 파드와 직접 통신할 때) (프로토콜은 안 쳐다봄, MAC만 봄)
브릿지로서 중계할 때, cni0는 프로토콜을 아예 신경 쓰지 않습니다. MAC 주소만 보고 FDB로 어느 인터페이스로 내보낼지 정할 뿐, 그 프레임 속에 IP 패킷이 들었는지 ARP 요청이 들었는지는 상관없이 그대로 복사해서 넘깁니다. "프로토콜은 규칙, 인터페이스는 출입구"라는 원칙 그대로, 브릿지는 그 규칙 내용물을 열어보지 않고 배송만 담당합니다.
반대로 cni0 자기 자신에게 온 프레임에 대해서만 비로소 프로토콜이 등장합니다. 예를 들어 호스트가 10.42.0.5(파드)로 ping을 보내거나, 파드가 10.42.0.1(cni0 자신)로 ping을 보낼 때입니다.
목적지MAC = cni0의 dev_addr → 로컬 스택 진입 → eth_type_trans()가 프로토콜 필드 확인
│
├─ IPv4(0x0800)면 → IP 함수로 (여기서부터 아래 3부의 라우팅 테이블 조회 등)
└─ ARP(0x0806)면 → ARP 처리 (neighbour 테이블 갱신)
정리하면 cni0는 "여러 인터페이스를 가진 순수 스위치"와 "IP를 가진 평범한 인터페이스" 두 얼굴을 동시에 갖고 있고, 어느 얼굴이 작동하는지는 도착한 프레임의 목적지 MAC이 cni0 자신인지 아닌지로 매 프레임마다 갈립니다. 파드끼리 통신(A→B)은 항상 앞 경로(브릿지, 프로토콜 무관)를 타고, 호스트↔파드 통신은 뒤 경로(로컬 스택, 프로토콜 등장)를 탑니다.
2부 — 목적지를 찾는 세 단계 조회
패킷 하나를 내보내려면 커널이 순서대로 세 개의 질문에 답해야 합니다. 이 세 조회를 정확히 나누는 게 이번 편의 핵심입니다.
질문①: "이 최종 목적지 IP로 가려면, 어느 인터페이스로 내보내야 하고
지금 이 구간에서 누구한테 넘겨야(다음 홉) 하나?" → 라우팅 테이블
질문②: "그 다음 홉 IP의 MAC 주소가 뭔가?" → neighbour 테이블
질문③: "그 MAC은 실제로 어느 net_device/어느 물리 주소 뒤에 있나?" → FDB (장치 종류마다 다른 구조체)
왜 "다음 홉"이 필요한가
핵심 전제: MAC으로는 바로 옆(같은 스위치/같은 와이파이) 상대만 직접 부를 수 있습니다. 목적지가 그 범위 밖에 있으면 MAC으로 직접 못 부릅니다. 우편에 비유하면, 편지를 최종 수신자에게 바로 던져줄 수 없고 반드시 가까운 우체국에 먼저 넘겨야 하는 것과 같습니다. 그 우체국이 다음 우체국으로, 계속 넘겨서 결국 도착합니다.
PC ── (같은 사무실 스위치) ── 우체국R1 ── (인터넷) ── 우체국R2 ── (같은 건물 스위치) ── 서버
1구간: [목적지MAC=R1] [목적지IP=서버(변함없음)]
2구간: [목적지MAC=R2] [목적지IP=서버(변함없음)]
3구간: [목적지MAC=서버] [목적지IP=서버(변함없음)]
IP 헤더의 목적지는 처음부터 끝까지 최종 목적지 그대로입니다. 매 구간 바뀌는 건 이더넷 헤더의 목적지 MAC뿐이고, 그 MAC은 "지금 이 구간에서 직접 넘길 상대"에 따라 매번 다시 계산됩니다. 이 한 구간씩 넘기는 것을 홉(hop), 지금 직접 넘길 다음 상대를 다음 홉이라 부릅니다.
① 라우팅 테이블: 목적지 IP → (다음 홉 IP, 나갈 인터페이스)
라우팅 테이블은 (대역, 다음 홉) 목록입니다.
목적지 대역 다음 홉 / 인터페이스
10.42.0.0/24 dev cni0 (직접 연결, 다음홉 없음)
10.42.1.0/24 via 10.42.1.0 dev flannel.1
0.0.0.0/0 via 172.30.1.1 dev wlan0 ← "그 외 전부" (기본 경로)
여러 줄이 걸리면 **최장 일치(가장 좁고 정확한 대역)**가 이깁니다. 이 표는 실시간으로 "생각해서" 채워지는 게 아니라, 커널이 자동으로(직접 연결된 대역), 사람이 수동으로(ip route add), 또는 소프트웨어가 동적으로 미리 써놓은 것을 그냥 대조만 합니다. 인터넷 전체처럼 아무도 전체 지도를 모르는 큰 네트워크에서는, 라우터들이 "나는 이 대역을 이만큼 걸려서 갈 수 있어"를 옆 라우터에게 계속 알려주는 라우팅 프로토콜(OSPF, BGP 등)이 이 표를 채우고, 링크가 끊기거나 혼잡해지면 다시 갱신됩니다. flannel처럼 작고 폐쇄된 클러스터에서는 얘기가 훨씬 단순한데, 뒤에서 다룹니다.
실제 구현은 "목록을 순서대로 대조"가 아닙니다. 커널은 이 표를 트라이(trie) 자료구조로 들고 있습니다.
struct fib_table {
struct trie *tb_data; // LPC-트라이(압축 트라이) — 대역들을 빠르게 찾는 자료구조
};
struct fib_alias {
u8 fa_slen; // 프리픽스 길이 (예: /24의 24) — 최장 일치 판단에 이 값을 씀
struct fib_info *fa_info; // 실제 "어떻게 보낼지" 정보로 연결
};
struct fib_info {
struct fib_nh fib_nh[]; // 다음 홉(들) 배열 — 같은 다음홉을 여러 대역이 공유하기도 함
};
struct fib_nh {
struct net_device *nh_dev; // 나갈 인터페이스 (1편에서 본 그 net_device!)
__be32 nh_gw; // 다음 홉 IP (0이면 "직접 연결", via 없는 그 줄)
};
목적지 IP를 이진수로 놓고 트라이를 따라 내려가면서 "매칭되는 가장 깊은(=가장 긴 프리픽스) 노드"를 찾는 구조라, 표를 하나하나 순서대로 비교하지 않고도 최장 일치가 자동으로 나옵니다. fib_nh.nh_dev가 바로 1편에서 본 net_device 포인터라는 점도 눈여겨볼 만합니다 — 라우팅 테이블도 결국 "net_device를 가리키는 또 다른 데이터 표"일 뿐입니다.
② neighbour 테이블: 다음 홉 IP → MAC
struct neighbour {
__be32 primary_key; // IP 주소 (다음 홉 IP)
u8 ha[6]; // 그 IP를 가진 net_device의 dev_addr
};
ip neigh show로 보이는 표가 이 구조체들의 모음입니다. **이 MAC은 "상대 컴퓨터의 MAC"이 아니라, 정확히는 "그 다음 홉 IP를 자기 in_ifaddr에 달고 있는, 바로 그 net_device의 dev_addr"**입니다. 한 컴퓨터에 인터페이스가 여러 개일 수 있으니, "어느 인터페이스냐"까지 특정해야 정확합니다.
일반 ARP는 "몰라서 브로드캐스트로 물어봄"이라는 동적 과정입니다. neigh 조회에는 "여러 후보 중 최적을 고른다"는 개념이 없습니다 — 특정 IP를 지금 갖고 있는 장치의 MAC은 하나뿐이라, 선택이 아니라 사실 조회입니다. "최적"이 필요한 지점은 ①(라우팅 테이블)이지 ②가 아닙니다.
왜 하필 "IP → MAC" 방향으로만 이런 캐시가 성립하는지는 1편에서 본 net_device의 비대칭 구조 때문입니다. net_device는 MAC이 정확히 1개(dev_addr), IP는 0개~N개(in_ifaddr 리스트)입니다.
IP → MAC: "이 IP를 가진 net_device"를 찾으면 → dev_addr은 무조건 1개 → 답이 하나로 딱 정해짐
MAC → IP: "이 MAC을 가진 net_device"를 찾으면 → in_ifaddr은 리스트(N개) → 답이 여러 개일 수 있음
그래서 커널은 struct neighbour를 IP→MAC 방향으로만 설계해뒀습니다. 반대 방향(MAC→IP)은 "그 인터페이스에 달린 IP 중 어느 게 맞는 답인지"가 구조적으로 하나로 안 정해져서, 캐시 테이블로 만들기 애매합니다.
두 조회를 합쳐서 이더넷 헤더를 완성한 뒤, 발신자가 하는 일은 그 MAC을 써서 자기 로컬 배선에 그냥 내보내는 것뿐입니다. "그 MAC을 가진 상대를 찾아가는" 일은 발신자가 아니라 중간의 스위치가 합니다.
③ FDB: MAC → "이 MAC을 가진 net_device로 그대로 넘겨라"
FDB(Forwarding Database)가 필요한 이유는, 이더넷 헤더에 목적지 MAC까지 다 써놨어도 **스위치 입장에서는 "이 MAC이 내가 거느린 여러 net_device(veth들) 중 어디 물려있는지"**를 알아야 그 방향으로만 골라 보낼 수 있기 때문입니다.
struct net_bridge_fdb_entry {
u8 addr[6]; // MAC 주소
struct net_bridge_port *dst; // 어느 net_device로 나가야 하는지 (앞서 본 그 포장)
};
리눅스 CLI의 "포트"라는 이름에 낚이기 쉬운데, 여기서 새로운 종류의 객체가 등장하는 게 아닙니다. dst가 가리키는 값은 숫자도 물리적 슬롯도 아니고, 그냥 veth-a1이라는 net_device 그 자체입니다. 1부에서 본 struct net_bridge_port { struct net_device *dev; ... }가 정확히 이 자리에 들어가는 값이고, 결국 "MAC_A → veth-a1로 넘겨라"라는 뜻일 뿐입니다. 그래서 이 글에서는 "포트" 대신 계속 **"인터페이스"**로 부릅니다.
이게 왜 헷갈렸는지 조금 더 파면, FDB는 사실 하나의 구조체가 상황마다 다른 걸 알려주는 게 아니라, 장치 종류마다 완전히 다른 별개의 구조체입니다.
net_bridge_fdb_entry (cni0, 브릿지) |
vxlan_fdb (flannel.1, VXLAN) |
|
|---|---|---|
| 이 장치가 하는 일 | 자기가 거느린 여러 net_device 중 하나로 그대로 전달 | UDP+IP로 포장해서 물리망으로 내보냄 |
| 그래서 필요한 답 | "어느 net_device로?" (실체: veth-a1) |
"어느 물리 IP로?" (실체: 172.30.1.84) |
| 왜 그 답이 필요한가 | 브릿지는 애초에 "거느린 net_device 여러 개" 중 골라야 함 | VXLAN 장치는 거느린 net_device가 없고, 그냥 포장해서 wlan0로 던짐 |
브릿지는 "여러 net_device를 거느리고 그중 하나를 고르는" 장치라 답이 net_device고, VXLAN 장치는 거느리는 net_device가 아예 없는 대신 "포장해서 어디로 보낼지"만 알면 되니 답이 물리 IP입니다. "FDB"라는 이름은 둘 다 "MAC을 키로 쓰는 조회 표"라는 공통점 때문에 붙었을 뿐, 커널 구조체 자체는 서로 무관한 별개입니다. 뒤에서 VXLAN을 다룰 때 나오는 vxlan_fdb가 두 번째 것입니다.
neighbour와 결정적으로 다른 점 — neighbour는 "몰라서 물어봄"이고, FDB는 "묻지 않고 지나가는 트래픽을 곁눈질로 엿봐서 배움"입니다.
neighbour: 모르면 브로드캐스트로 "누구세요?" 물어봄 → 대답 받아서 저장 (능동적 질문)
FDB: 묻지 않음. 그냥 지나가는 프레임의 "출발지 MAC"을
"이 프레임이 들어온 인터페이스"와 짝지어 조용히 기록 (수동적 관찰)
1. 파드A가 veth-a1으로 프레임을 내보냄
프레임 내용: [출발지MAC=A] [목적지MAC=B] [...]
2. cni0가 이 프레임이 지나가는 걸 봄
→ IP나 그 안의 내용물은 안 열어봄, 딱 이더넷 헤더의 "출발지MAC"만 봄
→ "MAC_A가 veth-a1에서 들어왔다"만 기록: FDB에 {addr: A, dst: veth-a1} 저장
3. 나중에 다른 파드가 목적지MAC=A로 프레임을 보냄
→ cni0가 FDB 조회: "MAC_A는 veth-a1 쪽이었지" → veth-a1로만 내보냄
여기서 기록되는 건 "MAC과 그 프레임의 IP"가 아니라 "MAC과 그 프레임이 들어온 인터페이스"입니다. IP는 이 과정에 아예 등장하지 않습니다 — cni0는 지나가는 프레임의 이더넷 헤더 딱 한 줄(출발지 MAC)만 보고 "어느 인터페이스에서 들어왔는지"와 짝지을 뿐, 그 프레임 속에 어떤 IP 패킷이 들었는지는 처음부터 관심 밖입니다. "MAC↔IP"를 짝짓는 건 FDB가 아니라 앞서 본 neighbour의 일입니다.
처음엔 FDB가 비어있어서 모든 인터페이스에 다 뿌려보는(플러딩) 경우가 있지만, 보통은 neighbour가 먼저 "누구세요" ARP를 던지는 과정에서 이미 그 응답 프레임이 지나가며 FDB 학습이 끝나 있어 잘 일어나지 않습니다.
같은 노드 안 배송을 세 조회로 다시 쓰면
목적지 파드 IP → ① 라우팅 테이블(직접 연결이라 다음홉=목적지 자신) → ② neighbour로 MAC 확인
→ ③ (cni0의) FDB 조회 → 어느 veth로 보낼지
3부 — 노드와 노드 사이: VXLAN
캡슐화 — 왜 4계층 안에 다시 2계층이 들어있나
같은 노드 안이면 cni0로 끝나지만, 목적지가 다른 노드면 얘기가 달라집니다. 원래 보내려던 이더넷 프레임(속MAC+속IP+데이터) 전체가 UDP 상자(목적지포트 8472)에 그대로 들어가고, 그 상자가 다시 겉 IP 봉투에 담겨 나갑니다.
[겉MAC][겉IP][겉UDP, 목적지포트=8472][VXLAN헤더(VNI=1)][속MAC][속IP][속데이터]
└──────── 외부 포장 (물리망 배송용) ────────┘└── 내부 소포 (원래 보내려던 것) ──┘
포트(L4)의 역할은 "이 바이트 뭉치를 어떤 소프트웨어에 넘길지 결정"하는 것뿐이고, 포트 8472를 받는 vxlan 커널 모듈에게 데이터가 넘겨지는 순간 바깥 스택의 임무는 끝납니다. vxlan 모듈은 안에 든 내용물을 "방금 새로 도착한 진짜 이더넷 프레임"인 척 커널 네트워크 스택에 다시 던져 넣습니다. "L4에서 L2로 내려간 것"이 아니라, L4 스택 하나가 완전히 끝난 자리에서 별개의 두 번째 스택이 시작된 것입니다.
이것도 결국 net_device라는 같은 틀 안의 얘기입니다. 모든 인터페이스는 struct net_device로 같은 틀을 쓰고, 차이는 전송 함수(ndo_start_xmit)가 뭘로 채워져 있느냐뿐입니다.
wlan0: ndo_start_xmit = "전파로 그대로 내보내는 함수"
veth-a1: ndo_start_xmit = "반대쪽 끝(eth0)으로 그대로 전달하는 함수"
cni0: ndo_start_xmit = "FDB 보고 맞는 인터페이스로 골라 전달하는 함수"
flannel.1: ndo_start_xmit = "UDP+IP로 포장해서 wlan0에 떠넘기는 함수" ← 캡슐화
라우팅 테이블 입장에서는 flannel.1도 그냥 "나갈 인터페이스 중 하나"로 똑같이 취급되고, 실제로 전송 함수가 호출되는 순간에야 "포장부터 해야 하는구나"가 실행됩니다.
캡슐화가 필요한 근본 이유는 두 가지입니다. 첫째, VXLAN 표준 자체가 "레거시 L2 애플리케이션도 그대로 오버레이 위에서 돌게 하자"는 게 설계 목적이라 원본 이더넷 프레임을 통째로 실어날라야 합니다. 둘째, 파드 IP(10.42.x.x)는 물리망 라우터가 전혀 모르는 사설 대역이라, 물리망이 아는 주소로 다시 포장해야 배달이 됩니다.
VTEP과 터널
터널은 물리적으로 존재하는 게 아니라 약속으로 만든 가상의 전용 통로입니다. 겉에서 보면 그냥 평범한 UDP 트래픽으로 보입니다. VTEP은 터널 입출구에서 캡슐화·디캡슐화를 수행하는 장치이고, flannel 환경에서는 flannel.1 인터페이스 자체가 VTEP입니다.
"터널 = 가상 케이블(veth) = 인터페이스"로 뭉치면 안 됩니다. 셋 다 struct net_device라는 같은 틀을 쓴다는 점(veth·flannel.1 둘 다 ip link show에 똑같이 뜨는 인터페이스라는 점)은 같지만, "서로 연결되는 방식"은 완전히 다릅니다.
veth: 만들어질 때부터 커널 안에 "이 인터페이스의 짝은 저 인터페이스다"라는
명시적 포인터가 있음 (peer) — 한쪽에 넣으면 무조건 반대쪽으로 나오는,
진짜 케이블의 물리적 성질을 그대로 흉내 낸 1:1 페어링
flannel.1(VXLAN 터널): 이런 짝 포인터가 아예 없음. Worker1의 flannel.1과
Master의 flannel.1은 커널 안에서 서로를 가리키는 관계가 아니라,
그냥 각자 독립적으로 "VNI=1, UDP 8472를 쓴다"는 같은 설정을 갖고 있어서
물리망(wlan0)을 거쳐 우연히 말이 통하는 것뿐
즉 veth는 "짝이 있는 가상 케이블"이고, flannel.1은 "짝 없이, 같은 설정끼리 약속으로 통하는 터널 인터페이스"입니다. 둘 다 인터페이스이지만, 연결 방식(명시적 페어링 vs 암묵적 약속)이 근본적으로 다릅니다.
vxlan id 1 local 172.30.1.84 dev wlan0 ... dstport 8472
id 1은 VNI, local은 이 VTEP의 겉주소, dev wlan0은 실제 내보낼 때 빌려쓰는 물리 인터페이스, dstport 8472는 포장할 때 쓰는 UDP 목적지 포트입니다.
flannel.1이 도착한 걸 "내 VXLAN 패킷"이라고 판단하는 기준은 목적지 IP 프리픽스가 아니라 포트 번호 하나입니다. ss -tulnp에서 DNS가 53번 포트로 걸러지는 것과 같은 구조로, 8472번 포트를 받아가는 게 사용자 프로그램이 아니라 vxlan 커널 모듈이라는 점만 다릅니다.
물리 인터페이스(wlan0)로 패킷 도착
│
▼ 목적지 포트 = 8472? yes
┌─────────────────────────┐
│ vxlan 커널 모듈 │ (일반 프로그램 아님)
│ 겉UDP + 겉IP 벗김 │
│ VNI 확인 (=1) │
└────────────┬─────────────┘
▼
[속프레임: 속MAC+속IP+데이터]
│
▼ "방금 막 도착한 새 프레임"인 척 재주입
┌─────────────────────────┐
│ flannel.1 │
│ 속목적지MAC이 │
│ 내 MAC과 같은가? │
└────────────┬─────────────┘
│ yes
▼
┌─────────────────────────┐
│ 속목적지IP가 │
│ 내 flannel.1 IP와 같은가? │
└──────┬─────────────┬─────┘
같음 다름
│ │
▼ ▼
"나한테 온 거" "내 담당구역 안의
(VTEP까지만 다른 대상"
핑 등) → cni0로 넘김 (2부의 세 조회 절차 반복)
벗기는 판단 기준은 포트(8472) 하나뿐이고, 벗긴 다음엔 MAC 대조 → IP 대조 순서로 갈립니다. 캡슐화된 프레임의 속목적지MAC은 항상 상대 노드의 flannel 인터페이스 자신의 MAC이지, 그 뒤에 있는 파드의 MAC이 아닙니다 — "MAC은 다음 홉, IP는 최종목적지"라는 원칙이 여기서도 그대로입니다.
참고로 flannel은 별도 프로세스(flanneld)가 아니라 k3s 바이너리 안에 코드로 내장돼 있습니다. ps aux에 flanneld가 없고, kubectl get pods -A에도 kube-flannel-ds 데몬셋이 없는데도 VXLAN이 동작하는 걸 보고 확인한 사실입니다.
발신자 쪽: 세 조회를 VXLAN에 그대로 대입
Worker1이 10.42.1.204(Master 위의 파드)로 보낼 때:
① 라우팅 테이블 조회
조회: 10.42.1.204는 어디로?
표: 10.42.1.0/24 via 10.42.1.0 dev flannel.1
출력: (다음 홉 IP = 10.42.1.0, 나갈 인터페이스 = flannel.1)
└─ 파드 IP가 아니라 Master의 flannel.1 자기 IP인 이유:
VXLAN 터널에서 "다음 홉"은 항상 상대 노드의 VTEP이기 때문
② neighbour 테이블 조회
조회: 10.42.1.0(Master flannel.1)의 MAC은?
표: 10.42.1.0 lladdr 0e:49:70:0e:bf:ee PERMANENT
출력: 0e:49:70:0e:bf:ee
③ FDB(vxlan 전용) 조회
조회: 0e:49:70:0e:bf:ee는 실제로 어느 물리 IP 뒤에 있나?
표: 0e:49:70:0e:bf:ee dst 172.30.1.84 self permanent
출력: 172.30.1.84
struct vxlan_fdb {
u8 eth_addr[6]; // 상대 노드 flannel.1의 MAC (neighbour가 준 값)
struct vxlan_rdst rdst; // 그 MAC이 실제로 어느 물리 IP 뒤에 있는지
};
조립 결과:
[속목적지MAC=0e:49:70:...] [속목적지IP=10.42.1.204] ← ①②로 만든 속프레임
│
▼ 통째로 캡슐화
[겉목적지IP=172.30.1.84] [UDP dst=8472] [VXLAN VNI=1] [위 속프레임] ← ③으로 만든 겉포장
PERMANENT가 핵심입니다. 일반 ARP는 몰라서 브로드캐스트로 물어보고 학습하는 동적 과정인데, 이 항목은 flannel이 클러스터 시작 시점에 미리 박아넣은 고정값입니다. 각 노드의 flannel이 자기 VTEP MAC을 Kubernetes Node annotation(flannel.alpha.coreos.com/backend-data)에 적어 API 서버를 통해 공유하고, 다른 노드들이 그 값을 읽어 자기 neighbour 테이블에 미리 채워둡니다. 사람이 직접 설정하는 값이 아니라 소프트웨어끼리 주고받는 내부 상태입니다.
일반 ARP는 "몰라서 실시간으로 물어봄", VXLAN의 neighbour는 "클러스터 제어 채널로 미리 공유해둠" — 이 차이가 실제 사고(annotation에 두 노드의 MAC이 중복 등록됐던 사고)의 근본 원인과 바로 연결됩니다. 자세한 진단 과정과, 그 중복이 정확히 커널 어느 지점에서 통신을 끊었는지는 3편: VXLAN MAC 주소 중복 사고에서 다룹니다.
4부 — 전체 그림
스위치/브릿지는 프레임 겉면 MAC만 보고 바로 옆 인터페이스로 던집니다. 라우터는 한 겹 더 열어 IP까지 확인해서 방향을 판단합니다.
10.42.1.0/24 via 10.42.0.0 dev flannel.1 onlink ← via 있음 → 원격 (터널 필요)
10.42.1.0/24 dev cni0 proto kernel scope link ← via 없음 → 로컬 (브릿지만)
via(경유지) 유무로 로컬/원격이 갈립니다. 커널이 목적지 IP를 라우팅 테이블에 대조해서 자동 판단하므로, 파드는 상대가 같은 노드인지 신경 쓸 필요가 없습니다.
┌───────────────────────── Node A: Worker1 (172.30.1.94) ─────────────────────────┐
│ PodA1 (10.42.0.2) PodA2 (10.42.0.3) │
│ │eth0 │eth0 │
│ veth-a1 ──────┐ veth-a2 ──────┐ │
│ ▼ ▼ │
│ ┌─────────────── cni0 (10.42.0.1) ───────────────┐ │
│ └─────────────────────┬───────────────────────────┘ │
│ flannel.1 (10.42.0.0) │
│ 라우팅 테이블: │
│ 10.42.0.0/24 dev cni0 ← "내 담당 구역"(로컬) │
│ 10.42.1.0/24 via 10.42.1.0 dev flannel.1 ← "남의 구역"(원격, Master 것) │
└──────────────────────────────────┼────────────────────────────────────────────────┘
(wlan0 물리망 터널, 172.30.1.94 ↔ 172.30.1.84)
┌──────────────────────────────────▼──────────── Node B: Master (172.30.1.84) ─────┐
│ flannel.1 (10.42.1.0) │
│ ┌─────────────── cni0 (10.42.1.1) ───────────────┐ │
│ └──────┬──────────────────────────────────────────┘ │
│ ▼ │
│ veth-b1 ─── PodB1 (10.42.1.5) │
│ 라우팅 테이블: │
│ 10.42.1.0/24 dev cni0 ← "내 담당 구역"(로컬) │
│ 10.42.0.0/24 via 10.42.0.0 dev flannel.1 ← "남의 구역"(원격, Worker1 것) │
└─────────────────────────────────────────────────────────────────────────────────┘
- PodA1 → PodA2: 둘 다 Worker1 소속 →
dev cni0줄 적용 → cni0에서 neighbour+FDB로 바로 전달. flannel.1은 안 거칩니다. - PodA1 → PodB1: 목적지가 Master 소속 →
via ... dev flannel.1줄 적용 → 캡슐화해서 물리망 건너 Master로. 도착 후 Master의dev cni0줄이 다시 적용됩니다.
5부 — 프로덕션에서 실제로 걸리는 것들
여기까지는 개념이고, 실제로 이 구성을 클라우드나 프로덕션에 올릴 때 이 글에서 다룬 개념만으로는 안 보이는 함정들이 있습니다.
MTU. VXLAN 캡슐화는 오버헤드(대략 50바이트)가 붙습니다. 물리 인터페이스 MTU가 1500이면 캡슐화된 패킷이 그 한도를 넘기기 쉽고, "작은 패킷은 되는데 큰 응답만 막힌다" 같은 증상으로 나타납니다. flannel은 보통 flannel.1의 MTU를 자동으로 물리 MTU보다 낮춰 잡지만, MTU를 수동으로 건드리는 환경(점보 프레임, VPN 이중 터널 등)에서는 직접 확인해야 합니다.
방화벽/보안그룹. 클라우드에서 이 구성을 재현하려면 노드 간 UDP 8472(flannel 기본값, VXLAN 표준 포트는 4789)가 반드시 열려있어야 합니다. AWS 보안그룹이나 GCP 방화벽 규칙에서 이 포트를 막아두면 캡슐화된 패킷이 물리망에서부터 드롭되고, 증상은 "MAC 중복"과 겉보기엔 비슷하게(핑 안 됨) 보이지만 원인은 완전히 다릅니다 — tcpdump -i <물리인터페이스> udp port 8472로 물리망 도달 여부부터 확인하는 게 순서입니다.
암호화. VXLAN은 기본적으로 평문입니다. 오버레이 트래픽이 물리망에서 그대로 스니핑 가능하므로, 신뢰할 수 없는 네트워크(퍼블릭 클라우드의 공유 서브넷 등)에 올릴 땐 IPsec이나 WireGuard 같은 별도 암호화 계층을 추가로 고려해야 합니다.
확장성. flannel의 이 방식(모든 노드가 다른 모든 노드의 neighbour/FDB 항목을 미리 다 앎)은 노드 수가 늘수록 O(n²)로 커집니다. 수십~수백 노드 규모에서는 Cilium(eBPF)이나 Calico(BGP)처럼 다른 방식을 쓰는 CNI가 더 흔한 이유이기도 합니다.
비유로 다시 보기
전문용어 없이, 건물/우편물 비유로 핵심만 다시 정리하면:
- neighbour: 출입구마다 붙은 게시판. "302호 계세요!" 외쳐서(ARP) 물어보고, 대답을 메모해둡니다 — 물어봐서 압니다.
- FDB: 우편물 분류실 장부. 지나가는 우편물의 발신 구멍을 지켜보다가 조용히 적어둡니다 — 엿봐서 압니다.
- VTEP(지하 통로 사무소): 다른 건물(노드)로 보낼 소포를 담당하는 특수 창구. 상대 세대(파드)가 누군지는 몰라도 되고, 상대 건물의 통로 사무소 위치만 알면 됩니다 — 입주 전부터 미리 공유해둔 명단(Kubernetes annotation)에 있어서 물어볼 필요가 없습니다.
- 큰 상자의 "받는 표찰(MAC)" 자리엔 항상 다음 사무소의 표찰이 들어가고, "받는 세대번호(IP)"는 끝까지 진짜 최종 목적지로 고정됩니다.
| 이야기 | 기술 개념 |
|---|---|
| 출입구 게시판 | neighbour 테이블 |
| 우편물 분류실 + 장부 | 브릿지(cni0, flannel.1) + FDB |
| 지하 통로 전담 사무소 | flannel.1 (VTEP) |
| 입주 전 미리 공유해둔 명단 | Kubernetes annotation 기반 PERMANENT neighbour |
| 큰 상자에 다시 담기 + 특수 번호 | VXLAN 캡슐화 (겉UDP, 목적지포트 8472) |
다음에 더 볼 것
- Calico, Cilium의 캡슐화 방식이 flannel과 어떻게 다른지 (특히 Cilium의 eBPF 기반 방식)
- VXLAN 외 IPIP, Geneve 같은 다른 오버레이 방식
- k3s가 flannel 외에 또 어떤 컴포넌트를 내장하는지 소스 레벨 확인
- 인터넷 규모에서 라우팅 테이블이 실제로 라우팅 프로토콜(BGP 등)로 어떻게 갱신되는지, flannel의 "미리 다 아는" 방식과 얼마나 다른지