← 글 목록

VXLAN 터널링, 개념부터 다시 뜯어보기 (VXLAN 3부작 ②)

/ 39분 분량

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의 "미리 다 아는" 방식과 얼마나 다른지