데이터 경로 (L2~L4)

VPP 데이터 경로: L2~L4 노드 심화, DPDK 통합, 커널 인터페이스, L2 브릿징, L3 라우팅, SRv6, QoS, VXLAN, L3 NAT44 및 memif 서비스 체이닝 실전을 다룹니다.

전제 조건: 기초와 아키텍처의 벡터 처리 모델·그래프 노드·vlib_buffer_t를 먼저 이해하세요. 또한 DPDK의 폴 모드 드라이버와 Hugepage 개념, 라우팅의 LPM(Longest Prefix Match) 개념이 필요합니다.

핵심 요약

  • 데이터 경로 전체 흐름NIC → dpdk-input → ethernet-input → ip4-input → ip4-lookup → ip4-rewrite → interface-tx 순의 그래프 노드가 기본 포워딩을 담당합니다. Feature Arc로 ACL·NAT·IPsec을 이 경로 중간에 삽입합니다.
  • L2 브릿징·스위칭 — MAC 학습 테이블(Table), VLAN 태깅(Tagging), Bridge Domain(Bridge Domain), 포트 미러링(Mirroring) 같은 L2 기능을 l2-input·l2-learn·l2-fwd 노드로 구현합니다.
  • L3 라우팅과 FIB(Forwarding Information Base) — VPP FIB는 mtrie 기반 LPM으로 최대 수백만 경로를 처리합니다. ECMP·Multipath·Recursive FIB로 다중 경로를 지원합니다.
  • DPDK 통합 — VFIO/UIO로 NIC를 유저스페이스에 바인딩하고, PMD(Poll Mode Driver)로 인터럽트(Interrupt) 없이 폴링(Polling)합니다. RSS(Receive Side Scaling)로 워커 스레드(Thread)를 병렬화합니다.
  • 커널 인터페이스 — TAP/TUN, vhost-user, AF_PACKET, AF_XDP, memif 등 다양한 백엔드로 커널·컨테이너(Container)·VNF와 연결합니다. 각 백엔드는 성능과 호환성이 다릅니다.
  • memif 서비스 체이닝 — memif는 VPP↔외부 프로세스 사이의 제로카피(Zero-copy) 공유 메모리 링으로, 컨테이너 기반 VNF 파이프라인(Pipeline)(NFV) 구성에 최적화되어 있습니다.
  • NAT44/NAT64 — 세션 기반 주소 변환을 nat44-in2out·nat44-out2in 그래프 노드로 수행하며, Endpoint-Independent Mapping, Port Block 할당, CGNAT 확장을 지원합니다. 세션 생명주기·ED 해시 테이블·정책 구성 상세는 보안/터널링 — NAT44/NAT64를 참고합니다.
  • 터널·오버레이(Overlay) — VXLAN, GENEVE, SRv6(Segment Routing v6), QoS(Quality of Service) 마킹 같은 오버레이 네트워크 기능을 그래프 노드로 구현하여 물리 경로 위에 가상 경로를 얹습니다.

단계별 이해

  1. NIC 바인딩과 DPDK 준비
    lspci | grep Ethernet으로 장치를 확인하고, dpdk-devbind.py -b vfio-pci <PCI>로 DPDK PMD에 바인딩합니다. isolcpus로 CPU 격리(Isolation)를 선행합니다.
  2. startup.conf 기본 구성
    워커 수·버퍼(Buffer) 풀·DPDK 디바이스 목록·NUMA 배치를 정의합니다. Hugepage 2MB × 1024 이상을 확보하고 워커를 NUMA 로컬 코어에 핀닝합니다.
  3. 인터페이스 생성
    물리 NIC은 DPDK 바인딩으로 자동 인식되며, create host-interface로 AF_PACKET, create memif socket로 memif 소켓, create tap로 커널 TAP을 만듭니다.
  4. L3 포워딩 구성
    set interface ip address GigabitEthernet0/8/0 10.0.0.1/24, ip route add 192.168.0.0/24 via 10.0.0.2 식으로 기본 라우팅을 설정하고 show ip fib로 FIB를 확인합니다.
  5. NAT/ACL 정책 적용
    nat44 add interface address로 외부 인터페이스를 지정하고, nat44 add static mapping으로 고정 매핑(Mapping)을, classify table + set interface acl로 ACL 정책을 각각 적용합니다. NAT44 ED 모드·세션 타임아웃·CGNAT 튜닝은 보안/터널링 — NAT44/NAT64에 정리되어 있습니다.
  6. 실전 검증
    show runtime으로 그래프 노드별 벡터 크기·클럭 사이클을, show errors로 에러 카운터를, show interface로 패킷/바이트 통계를 확인하여 구성이 기대대로 동작하는지 검증합니다.

L2 ~ L4 핵심 노드 심화 가이드

앞 절들에서 개별 노드를 따로 살펴보았다면, 이 절은 OSI 계층별로 묶어 기초 개념 → 헤더 구조 → VPP 그래프 노드 책임 → 내부 구현 → 실전 예시의 순서로 다시 정리합니다. 커널 네트워크 스택 경험은 있지만 VPP 내부는 처음 보는 독자를 염두에 두고, 동일한 프레임이 L2 → L3 → L4를 통과할 때 어느 노드가 어떤 필드를 건드리는지를 하나씩 따라갈 수 있게 구성했습니다.

이 절을 읽는 법: 각 소절은 기초 개념 → 헤더 필드 → VPP 책임 분담 → 내부 구현 코드 → 트러블슈팅 관점의 5단 구조를 반복합니다. 이미 핵심 그래프 노드 내부 구현에서 본 코드는 다시 옮겨 적지 않고 참조만 남깁니다. 대신 그 절에서 다루지 않은 arp-input, l2-output, l2-flood, ip4-local, ip6-input, udp-local, session-queue 등을 새로 파고듭니다.

네트워크 계층 기초 — VPP가 바라보는 프레임·패킷·세그먼트

네트워크 프로토콜은 데이터 앞에 계층별 헤더를 양파처럼 감싸는 구조로 전송됩니다. 수신 측은 바깥쪽(L2)부터 한 겹씩 벗겨내며 각 헤더를 해석하는데, VPP의 그래프 노드는 정확히 이 "한 겹 벗기기" 한 단계씩에 대응하도록 설계되어 있습니다. 즉 특정 노드가 어느 계층을 다루는지를 알면, 해당 노드에서 읽고 쓰는 필드가 자연스럽게 결정됩니다.

L2 / L3 / L4 헤더 캡슐화와 VPP 노드 책임 매핑 Ethernet Header (14B + 4B VLAN) DMAC(6) · SMAC(6) · [VLAN TPID/TCI(4)] · EtherType(2) VPP: ethernet-input — EtherType 분기, VLAN pop, subinterface 매칭 IP Header (IPv4 20B / IPv6 40B) Version · TTL · Protocol · Src/Dst IP · Checksum(v4만) VPP: ip4-input → ip4-lookup → ip4-rewrite / ip4-local / ip4-punt L4 Header (UDP 8B / TCP 20B+) Src/Dst Port · [TCP Seq/Ack/Flags/Window] · Checksum VPP: udp-local / tcp4-input-nolookup → tcp4-established Payload (Application Data) VPP: session-queue → RX FIFO → VCL/VLS → nginx / Envoy parse 방향 off 0 off 14 off 34 off 54 수신 측 파싱은 바깥층(L2)부터 한 겹씩 벗겨 내며, 각 노드는 vlib_buffer_advance()current_data를 뒷 계층 헤더 시작점으로 옮깁니다. 오프셋은 표준 Ethernet II + VLAN 없는 경우의 값이며, Q-in-Q·MPLS·GRE 터널이 겹치면 실제 값은 달라집니다.

세 계층은 각자의 "관심사"가 뚜렷이 분리되어 있어, 실무 디버깅에서도 "이 문제는 L2 문제인가, L3 문제인가, L4 문제인가"라는 질문을 먼저 던지는 편이 빠릅니다. 아래 표는 각 계층이 답해야 하는 질문과 VPP에서 그 답을 내는 노드를 요약합니다.

계층핵심 질문다루는 식별자주요 VPP 노드실패 시 증상
L2이 프레임은 어느 이웃에서 왔고, 다음으로 어느 포트에 넣을까?MAC, VLAN, EtherTypeethernet-input, l2-input, l2-learn, l2-fwd, l2-flood, l2-output, arp-input브릿지 ping 불가, MAC 미학습, VLAN 태그 문제
L3이 패킷을 어느 next-hop으로 보내야 하고, TTL을 얼마나 깎을까?IP 주소, TTL, Protocolip4-input, ip4-lookup, ip4-rewrite, ip4-local, ip4-punt, ip6-input라우팅 루프, 블랙홀, TTL 만료
L4이 세그먼트는 어느 연결·어느 포트에 속하며, 순서·신뢰성은 어떻게 보장할까?Port, Seq/Ack, Flagsudp-local, tcp4-input-nolookup, tcp4-established, session-queue포트 unreachable, TCP 재전송(Retransmission) 폭주, 세션 불균형

이후 소절에서는 계층별로 헤더의 비트 구조 → 해당 계층을 담당하는 VPP 노드 집합 → 아직 앞에서 보지 못한 노드의 내부 구현 → 트러블슈팅 팁의 순서로 파고듭니다.

L2 심화 — Ethernet 프레임을 바라보는 VPP

L2(데이터 링크 계층)의 책임은 단순합니다. "같은 브로드캐스트 도메인 안에서 어느 포트로 프레임을 넣을지"를 MAC 주소만 보고 결정하는 것입니다. 라우팅·혼잡 제어(Congestion Control) 같은 개념은 아직 등장하지 않으며, 모든 판단은 ethernet_header_t에 들어 있는 14바이트(+ VLAN 4바이트)로 이루어집니다.

Ethernet 헤더 비트 구조

Ethernet II 프레임 + 802.1Q VLAN 태그 VLAN 없는 기본 프레임 (14B 헤더) Destination MAC 6 B Source MAC 6 B EtherType 2 B Payload (L3 Header + Data) 46 ~ 1500 B 802.1Q VLAN 태그가 있는 프레임 (18B 헤더) Destination MAC 6 B Source MAC 6 B TPID 0x8100 2 B TCI PCP(3) · DEI(1) · VID(12) 2 B EtherType 2 B Payload 46 ~ 1500 B 주요 EtherType 값과 VPP 다음 노드 • 0x0800 IPv4 → ip4-input • 0x86DD IPv6 → ip6-input • 0x0806 ARP → arp-input • 0x8100 802.1Q VLAN (recursive) • 0x88A8 QinQ • 0x8847 MPLS unicast → mpls-input • 0x88CC LLDP → llc-input • 0x8864 PPPoE Session → pppoe-input ethernet-input은 이 값을 sparse vector로 O(1)에 인덱싱하여 next_index를 채웁니다 (기본 ~1500 항목).

ethernet-input의 sparse vector 구조는 ethernet-input 노드 (node.c)에서 이미 다루었습니다. 여기서는 "ethernet-input이 처리하지 않고 넘기는 것들"에 초점을 맞춥니다. ARP는 별도 노드로, VLAN은 태그 pop으로, 그리고 L2 브릿지 모드는 l2-input으로 분기됩니다.

VLAN 태그 처리의 3가지 모드

VPP의 VLAN 처리는 인터페이스 단위 설정에 따라 세 가지 경로를 탑니다. 어느 모드를 쓰는지에 따라 l2-input 또는 ip4-input 진입 시 이미 VLAN 태그가 벗겨져 있거나, 그대로 남아 있습니다.

모드CLI 예시동작진입 시점 헤더
Subinterface (exact-match)create sub GigabitEthernet0/0/0 100 dot1q 100 exact-matchTCI의 VID가 100과 일치하는 프레임만 해당 subif로 진입, 태그 popVLAN 태그 제거됨
Default subinterfaceset interface l2 tag-rewrite ... default매칭되지 않은 VLAN 트래픽을 catch-all 처리태그 유지
Tag rewrite (push/pop/translate)set interface l2 tag-rewrite GigabitEthernet0/0/0.100 pop 1입력/출력 시 태그를 넣고 빼는 변환, 주로 브릿지 도메인에서 사용설정에 따라 다름

VPP 내부에서 VLAN 매칭은 ethernet_input_inline() 내부의 identify_subint() 호출에서 이뤄지며, main_intf->dot1q_vlans[vid]를 인덱싱해 subinterface index를 찾아냅니다. 이때 미스(miss)가 나면 unknown-vlan-error 카운터가 증가하고 프레임은 드롭되는데, 실무에서 "특정 VLAN만 안 된다"는 장애의 70% 정도는 이 카운터에서 단서가 잡힙니다.

arp-input 노드 내부 구현

ARP(Address Resolution Protocol)는 L2와 L3의 경계에 걸쳐 있는 프로토콜입니다. 페이로드는 L3 주소(src_ip, dst_ip)를 품지만, 프레임 자체는 IP 라우팅을 거치지 않고 같은 브로드캐스트 도메인 안에서만 소화됩니다. VPP는 이를 위해 arp-input이라는 별도 노드를 두며, 구현은 src/vnet/arp/arp.c에 있습니다.

arp-input이 담당하는 두 가지 책임은 다음과 같습니다. 첫째, ARP request를 받으면 VPP가 소유한 IP 주소 중 일치하는 것이 있는지 확인하여 ARP reply를 생성·송신합니다. 둘째, 수신한 ARP 패킷의 (IP, MAC) 쌍을 인접(adjacency) 테이블에 학습시켜 이후 ip4-rewrite 노드가 바로 사용할 수 있도록 만듭니다.

/* arp_input_inline() 핵심 경로 — 수신 ARP 패킷 1개 처리 (개념 코드) */
static inline uword
arp_input_inline (vlib_main_t *vm, vlib_node_runtime_t *node,
                  vlib_frame_t *frame)
{
  u32 n_left = frame->n_vectors;
  u32 *from  = vlib_frame_vector_args (frame);

  while (n_left > 0)
    {
      vlib_buffer_t *b0   = vlib_get_buffer (vm, from[0]);
      ethernet_arp_header_t *a0 = vlib_buffer_get_current (b0);
      u32 sw_if_index = vnet_buffer (b0)->sw_if_index[VLIB_RX];
      u32 next        = ARP_INPUT_NEXT_DROP;

      /* 1. 기본 위생 검사 — hw/proto 타입과 길이를 확인합니다. */
      if (clib_net_to_host_u16 (a0->l2_type) != ETHERNET_ARP_HARDWARE_TYPE_ethernet ||
          clib_net_to_host_u16 (a0->l3_type) != ETHERNET_TYPE_IP4)
        {
          b0->error = node->errors[ARP_ERROR_L3_TYPE_NOT_IP4];
          goto enqueue;
        }

      /* 2. 학습 — source IP/MAC을 인접 테이블에 등록합니다.
          같은 sw_if_index에서 들어온 것만 신뢰하며, 다른 포트에서 온 동일 IP는
          MAC 이동(migration)으로 처리하거나 경합(race) 에러 카운터를 올립니다. */
      arp_learn (sw_if_index, &a0->ip4_over_ethernet[0]);

      /* 3. request? reply? 분기 */
      if (a0->opcode == clib_host_to_net_u16 (ETHERNET_ARP_OPCODE_request))
        {
          /* 대상 IP가 이 인터페이스에 설정되어 있는가? */
          if (arp_dst_is_local (sw_if_index, &a0->ip4_over_ethernet[1].ip4))
            {
              /* reply 패킷을 제자리에서 생성 — DMAC을 요청자 MAC으로 교체하고
                 opcode를 reply로 바꿔 그대로 송신 경로로 보냅니다. */
              arp_mk_reply_hdr (b0, sw_if_index);
              next = ARP_INPUT_NEXT_REPLY_TX;
            }
          else
            {
              /* 다른 IP에 대한 request는 프록시 ARP 규칙이 있으면 대행하고,
                 없으면 드롭합니다. */
              next = arp_proxy_lookup (sw_if_index, a0) ?
                     ARP_INPUT_NEXT_REPLY_TX : ARP_INPUT_NEXT_DROP;
            }
        }
      else if (a0->opcode == clib_host_to_net_u16 (ETHERNET_ARP_OPCODE_reply))
        {
          /* reply는 학습만 하면 충분합니다 — 이미 위에서 arp_learn()을 호출했습니다. */
          next = ARP_INPUT_NEXT_DROP;
        }

    enqueue:
      vlib_validate_buffer_enqueue_x1 (vm, node, /*...*/, from[0], next);
      from++; n_left--;
    }
  return frame->n_vectors;
}
코드 설명
  • 1~11행표준 VPP 노드 함수 시그니처입니다. vlib_frame_vector_args()로 버퍼 인덱스 배열을 얻어 1개씩 처리하는 single-loop 구조인데, ARP는 핫 패스가 아니므로 quad-loop까지 최적화할 필요가 없습니다.
  • 14~19행L2 hardware type이 Ethernet(1), L3 protocol type이 IPv4(0x0800)인지 확인합니다. 여기서 이상한 값이 오면 L3_TYPE_NOT_IP4 에러 카운터로 드롭합니다. 실무에서 이 카운터가 증가한다면 미설정된 InfiniBand/토큰링 카드를 VPP에 붙였거나, 멀웨어 수준의 이상 프레임이 들어오는 경우입니다.
  • 22~24행ARP의 본질은 "학습"입니다. request든 reply든 출발지의 (IP, MAC) 쌍을 인접 테이블에 등록합니다. arp_learn()은 내부적으로 adj_nbr_add_or_update()를 호출하며, 이 순간부터 같은 next-hop으로 가는 IP 패킷은 ip4-rewrite에서 즉시 MAC을 채울 수 있게 됩니다.
  • 27~43행opcode가 request면 대상 IP가 이 인터페이스에 바인딩된 주소인지 확인합니다. 맞다면 reply를 만들어 TX로 보냅니다. 여기서 arp_mk_reply_hdr()은 새 버퍼를 할당하지 않고 수신 버퍼를 재활용(Recycling)합니다 — DMAC/SMAC 교환, opcode 교체, src/target 주소 스왑(Swap)만으로 reply가 완성되기 때문입니다.
  • 33~37행프록시 ARP는 VPP가 "내 것이 아니지만 내가 대신 응답해 주겠다"고 선언한 주소에 대한 요청을 가로채는 기능입니다. IPsec 터널 없는 L2 오버레이나 게스트 VM 마이그레이션 시나리오에서 쓰입니다.
  • 44~49행reply 패킷 자체는 이미 학습이 끝났으므로 별도 처리 없이 드롭합니다. VPP는 리눅스 커널처럼 "보류 중(pending)" 큐를 두지 않고, 학습된 인접이 생기면 이후 IP 패킷이 자연스럽게 새 adj_index를 참조하도록 설계되어 있습니다.
자주 하는 오해: VPP는 리눅스처럼 "ARP를 커널 neigh 서브시스템에 위임"하지 않습니다. arp-input이 직접 학습하고 직접 응답하므로, 리눅스 ip neigh show는 VPP ARP 테이블을 보여 주지 않습니다. VPP에서는 show ip neighbors(또는 IPv6는 show ip6 neighbors)로 확인합니다. linux-cp 플러그인을 쓰는 경우에만 ARP 이벤트가 netlink로 리눅스 네임스페이스에 미러링됩니다.

l2-output / l2-flood 노드 내부 동작

L2 브릿징의 송신 경로는 "알려진 목적지"와 "모르는 목적지"에서 갈라집니다. 알려진 DMAC은 l2-fwd에서 L2 FIB 해시 lookup으로 단일 포트를 결정한 뒤 l2-output을 거쳐 해당 인터페이스의 TX 노드로 가고, 모르는 DMAC·브로드캐스트·알 수 없는 멀티캐스트(BUM 트래픽)는 l2-flood버퍼 복제를 수행하여 같은 브릿지 도메인의 모든 포트로 전송합니다.

/* l2_flood_node_fn() — BUM 트래픽을 같은 BD의 모든 포트로 복제 (개념 코드) */
static uword
l2flood_node_fn (vlib_main_t *vm, vlib_node_runtime_t *node,
                 vlib_frame_t *frame)
{
  u32 *from = vlib_frame_vector_args (frame);
  u32 n_left = frame->n_vectors;

  while (n_left > 0)
    {
      vlib_buffer_t *b0 = vlib_get_buffer (vm, from[0]);
      u32 bd_index = vnet_buffer (b0)->l2.bd_index;
      l2_bridge_domain_t *bd_config = l2input_bd_config (bd_index);

      /* 1. BD에 속한 멤버 목록과 플러드 포트 수를 읽습니다. */
      l2_flood_member_t *members = bd_config->flood_members;
      u32 n_members = vec_len (members);
      u32 in_sw_if_index = vnet_buffer (b0)->sw_if_index[VLIB_RX];

      /* 2. split-horizon 그룹이 같은 포트는 제외합니다 (MEF의 Hair-pin 방지). */
      u8 in_shg = vnet_buffer (b0)->l2.shg;

      for (u32 i = 0; i < n_members; i++)
        {
          l2_flood_member_t *m = &members[i];

          /* 입력 포트와 같거나 split-horizon이 같으면 건너뜁니다. */
          if (m->sw_if_index == in_sw_if_index) continue;
          if (m->shg != 0 && m->shg == in_shg) continue;

          /* 3. 마지막 멤버에게는 원본 버퍼를, 나머지에게는 복제본을 보냅니다.
             복제는 buffer clone 메커니즘(ref-count 증가)을 사용하여
             헤더만 복사하고 데이터는 공유합니다. */
          vlib_buffer_t *c0;
          if (i < n_members - 1)
            c0 = vlib_buffer_copy (vm, b0);
          else
            c0 = b0;

          vnet_buffer (c0)->sw_if_index[VLIB_TX] = m->sw_if_index;
          l2output_enqueue (vm, node, c0);
        }

      from++; n_left--;
    }
  return frame->n_vectors;
}
코드 설명
  • 11~13행bd_indexl2-input에서 이미 세팅되어 버퍼의 opaque 영역(vnet_buffer(b)->l2.bd_index)에 들어 있습니다. 같은 브릿지 도메인이면 이 값이 동일하므로 플러드 대상 포트 목록을 O(1)에 가져올 수 있습니다.
  • 16~17행플러드 멤버는 BD 생성 시점에 미리 벡터로 구성되며, 포트 추가/삭제 시에만 갱신됩니다. 핫 패스에서는 단순 배열 순회로 처리합니다.
  • 21행Split-horizon 그룹(SHG)은 MEF E-Tree 서비스에서 "root는 leaf로 보낼 수 있고, leaf끼리는 못 보낸다"를 구현하기 위한 태그입니다. 같은 SHG 간의 플러드를 차단합니다.
  • 25~31행입력 포트 자신(in_sw_if_index)은 당연히 제외해야 합니다 — 안 그러면 보낸 놈에게 다시 돌아가는 echo가 발생합니다. split-horizon 0이 아니면서 입력과 같은 그룹인 멤버도 제외합니다.
  • 34~39행VPP 버퍼 복제의 핵심 최적화입니다. N개 멤버에게 보내야 할 때 N-1개는 복제본, 마지막 1개는 원본을 그대로 사용합니다. 그리고 vlib_buffer_copy()는 실제로는 데이터 세그먼트의 ref-count만 증가시키고 헤더 메타데이터만 새로 할당하는 zero-copy 복제입니다.
  • 41~42행각 복제본의 TX 방향 sw_if_index를 해당 멤버 포트로 설정하고 l2-output에 큐잉합니다. 이후 l2-output은 인터페이스별 rewrite(VLAN push, MAC 스왑 등)를 적용한 뒤 해당 인터페이스의 TX 노드로 보냅니다.
성능 관점: l2-flood는 버퍼 복제를 동반하므로 같은 벡터 크기에서 l2-fwd보다 몇 배 느립니다. show bridge-domain <bd> detail에서 Flooding 열이 켜져 있는데 트래픽 대부분이 flood로 간다면, 학습이 제대로 되지 않고 있거나(MAC 이동 이슈), 트래픽 자체가 BUM-heavy(멀티캐스트·ARP 폭주)일 가능성이 큽니다. 이 경우 ip mroute나 IGMP snooping, 또는 MAC 학습 파라미터(set bridge-domain learn-limit)를 점검합니다.

L2 트러블슈팅 체크포인트(Checkpoint)

증상의심 지점확인 명령
브릿지 내 ping 불가BD 멤버십, MAC 학습, split-horizonshow bridge-domain <bd> detail, show l2fib bd_id <bd> learn
특정 VLAN만 안 됨subinterface 설정, unknown-vlan 카운터show hardware-interfaces verbose, show errors | include ethernet-input
MAC 플립/이동토폴로지(Topology) 루프, 같은 MAC이 두 포트에서 수신show l2fib | grep <mac>, trace add dpdk-input 100
ARP 응답 없음인터페이스 IP 바인딩, 프록시 ARP 설정show ip neighbors, show proxy-arp
flood가 이상하게 많음학습 실패, BUM 트래픽 폭주show node counters | include l2-flood

L3 심화 — IPv4 헤더 파싱부터 로컬 전달까지

L3의 핵심 질문은 "이 패킷을 어디로 보낼 것인가"입니다. L2가 한 홉(hop) 단위의 전달을 다룬다면, L3는 네트워크 전체를 가로지르는 종단 간(end-to-end) 전달을 책임지며, 중간에 라우터는 TTL을 깎고 체크섬(Checksum)을 갱신하며 next-hop을 결정합니다. VPP의 L3 경로는 ip4-input → ip4-lookup → ip4-rewrite의 3단 파이프라인이 핵심이고, 로컬 목적지와 예외 경로(punt, mcast, drop)는 별도 노드로 분기됩니다.

IPv4 헤더 비트 필드 상세

IPv4 헤더 (20 바이트) — 비트 단위 구조 각 행 = 32비트(4B), 총 5행 = 20바이트 0 8 16 24 31 Version 4b · 0x4 IHL 4b · ≥5 DSCP / ECN 8b · QoS Total Length 16b · 전체 패킷 크기 Identification 16b · 프래그먼트 재조립 키 Flags 3b · DF/MF Fragment Offset 13b · 8B 단위 TTL 8b · ip4-rewrite에서 감소 Protocol 8b · 6=TCP, 17=UDP, 1=ICMP Header Checksum 16b · rewrite에서 incremental update Source Address 32b · 수신 판단 기준은 아님(스푸프 가능) Destination Address 32b · ip4-lookup의 검색 키 Options (0~40B, IHL>5일 때만) — VPP에서는 ip4-local의 slow path에서만 처리됩니다. VPP가 건드리는 필드: TTL·Checksumip4-rewrite에서 갱신 (1 감소 + incremental checksum) Protocolip4-local에서 L4 디스패치 키로 사용 (ip4_local_next[protocol])

ip4_header_t 구조체(Struct)는 src/vnet/ip/ip4_packet.h에 정의되어 있으며, C 비트필드 대신 u16 ip_version_and_header_length 한 바이트로 Version + IHL을 묶어 저장합니다. 이는 네트워크 바이트 오더와 비트필드의 컴파일러 의존성을 피하기 위한 VPP의 관용구입니다.

ip4-input 노드는 이미 ip4-input 노드 (ip4_input.c)에서 보았듯이 위생 검사(sanity check)만 담당합니다: 버전 4 확인, IHL ≥ 5, TTL ≠ 0, 체크섬 검증(NIC 오프로드가 있으면 스킵), Total Length ≥ IHL*4. 실제 포워딩 결정은 ip4-lookup이 내립니다. 이 분리가 VPP의 vector-first 설계의 전형적인 예입니다 — 같은 종류의 판단을 모아서 처리해야 I-cache가 재사용되기 때문입니다.

ip4-local 노드 내부 구현

ip4-local은 라우팅 테이블에서 "목적지가 나(VPP 자신)"로 판명된 패킷을 받아 L4 프로토콜별 핸들러(Handler)로 디스패치(Dispatch)하는 노드입니다. ip4-lookup의 결과가 RECEIVE FIB 엔트리일 때 이 노드로 분기되며, 내부적으로 ip4_local_next[proto] 테이블을 인덱싱하여 TCP는 tcp4-input-nolookup으로, UDP는 ip4-udp-lookup 또는 udp4-local으로, ICMP는 ip4-icmp-input으로 보냅니다.

/* ip4_local_inline() — VPP 로컬 전달 핵심 경로 (개념 코드) */
static inline uword
ip4_local_inline (vlib_main_t *vm, vlib_node_runtime_t *node,
                  vlib_frame_t *frame, int head_of_feature_arc)
{
  ip4_main_t *im = &ip4_main;
  u32 *from = vlib_frame_vector_args (frame);
  u32 n_left = frame->n_vectors;

  while (n_left >= 2)    /* dual-loop: 두 개씩 처리 */
    {
      vlib_buffer_t *b0 = vlib_get_buffer (vm, from[0]);
      vlib_buffer_t *b1 = vlib_get_buffer (vm, from[1]);
      ip4_header_t *ip0 = vlib_buffer_get_current (b0);
      ip4_header_t *ip1 = vlib_buffer_get_current (b1);

      /* 1. L4 프로토콜별로 다음 노드 인덱스를 룩업합니다.
         ip4_local_next[]는 register_ip4_local_next()로 등록된 핸들러 테이블입니다. */
      u8 proto0 = ip0->protocol;
      u8 proto1 = ip1->protocol;
      u32 next0 = im->ip4_local_next_by_protocol[proto0];
      u32 next1 = im->ip4_local_next_by_protocol[proto1];

      /* 2. Fragment 재조립 힌트를 확인합니다. 프래그먼트라면 재조립 노드로 우회합니다. */
      if (PREDICT_FALSE (ip4_get_fragment_more (ip0) ||
                          ip4_get_fragment_offset (ip0)))
        next0 = IP4_LOCAL_NEXT_REASSEMBLY;

      /* 3. Source address 위생 검사 — RPF 체크에 해당합니다.
         src IP가 라우팅 가능해야 로컬로 받아들입니다. martian 주소는 드롭. */
      u32 src_adj_index0 = ip4_fib_forwarding_lookup (
                               vnet_buffer (b0)->ip.fib_index,
                               &ip0->src_address);
      const dpo_id_t *src_dpo0 = load_balance_get_bucket_i (src_adj_index0, 0);
      if (PREDICT_FALSE (src_dpo0->dpoi_type == DPO_DROP))
        {
          b0->error = node->errors[IP4_ERROR_SRC_LOOKUP_MISS];
          next0 = IP4_LOCAL_NEXT_DROP;
        }

      /* 4. L4 체크섬 사전 검증은 여기서 하지 않고, 다음 노드(tcp/udp)가
         자체적으로 수행합니다. 대신 hw offload 플래그만 전달합니다. */
      vnet_buffer (b0)->ip.fib_index =
        vec_elt (im->fib_index_by_sw_if_index,
                  vnet_buffer (b0)->sw_if_index[VLIB_RX]);

      vlib_validate_buffer_enqueue_x2 (vm, node, /*...*/,
                                        from[0], from[1], next0, next1);
      from += 2; n_left -= 2;
    }

  /* tail: n_left == 1인 경우의 single-loop 처리 (생략) */
  return frame->n_vectors;
}
코드 설명
  • 4행head_of_feature_arc 인자는 이 노드가 Feature Arc 진입점(Entry Point)으로 호출되었는지(TRUE), 아니면 체인 중간에서 호출되었는지(FALSE)를 구분합니다. 진입점일 때만 FIB 인덱스 재설정 등 초기화 작업을 수행합니다.
  • 10~16행VPP는 두 패킷을 동시에 처리하는 dual-loop 패턴을 기본으로 씁니다. 서로 다른 proto0/proto1이 나와도 각자 next_index 변수에 분기 정보를 쌓은 뒤 한 번에 enqueue합니다. 이 "분기는 하되 즉시 점프하지 않는다"가 VPP의 분기 예측(Branch Prediction) 친화성의 비결입니다.
  • 18~24행ip4_local_next_by_protocol[]은 부팅 시 각 프로토콜 핸들러가 ip4_register_protocol()을 호출하여 채우는 배열입니다. 그래서 UDP 플러그인이 초기화되기 전에는 UDP 패킷이 이 테이블 기본값(drop)으로 처리됩니다. 초기화 순서 버그의 단서가 여기서 자주 잡힙니다.
  • 27~30행IP 프래그먼트는 재조립 노드로 우회합니다. VPP의 재조립은 설정으로 끌 수 있으며, 끄면 이 분기가 그대로 드롭으로 이어집니다. 레거시 NFS·DNS 응답 지원이 필요하다면 재조립을 켜 두어야 합니다.
  • 33~41행uRPF(unicast Reverse Path Forwarding) 체크입니다. src IP를 역방향으로 FIB에 돌려 봐서 경로가 존재하지 않으면(DPO_DROP) 스푸프 가능성이 있다고 보고 드롭합니다. 이 체크는 ip-local feature를 끄면 스킵되며, 그러면 성능은 약간 올라가지만 보안상 위험합니다.
  • 44~47행L4 체크섬은 NIC 오프로드가 있다면 여기서 검증하지 않고, 오프로드 플래그를 그대로 다음 노드로 전달합니다. 다음 노드(tcp/udp)는 플래그가 세팅되어 있으면 체크섬 계산을 건너뛰고, 없으면 직접 계산합니다.
흔한 함정: "VPP가 ping에 응답하지 않는다"는 이슈의 상당수는 ip4-local에서 SRC_LOOKUP_MISS 카운터가 올라가는 형태로 나타납니다. 원인은 대부분 ① 송신 측 IP가 VPP FIB에 없는 private range여서 uRPF가 drop하거나, ② RPF 모드가 strict인데 asymmetric 경로가 있거나, ③ 송신 측이 스푸프된 src IP를 쓰는 경우입니다. show errors | include ip4-localtrace add dpdk-input 100을 함께 보면 원인이 금방 보입니다.

ip6-input — IPv4와의 핵심 차이점

IPv6 처리 경로는 전반적인 구조가 IPv4와 거의 동일하지만, 몇 가지 결정적 차이가 있습니다. 이를 모르면 IPv6 디버깅에서 "왜 같은 로직인데 다르게 동작하지?" 하는 혼란이 발생합니다.

항목IPv4 (ip4-input)IPv6 (ip6-input)
헤더 체크섬있음 — 매 홉에서 재계산 필요없음 — L4 체크섬이 pseudo-header를 포함
헤더 크기가변 20~60B (IHL에 의존)고정 40B (옵션은 Extension Header로 분리)
TTL 필드 이름TTL (8b)Hop Limit (8b) — 동작은 동일
Protocol 필드Protocol (8b)Next Header (8b, 체인 가능)
프래그먼트 처리라우터가 분할 가능송신자만 분할 — 라우터는 PTB 전송
ARP 대응arp-inputip6-icmp-neighbor-solicitation-input (ND)
lookup 자료구조mtrie 8-8-8-8 (4바이트)bihash (16바이트, prefix 단위)
Lookup 성능~10ns / 패킷~20ns / 패킷 (주소가 4배)

특히 Extension Header 체인은 실무 성능 문제의 원인이 됩니다. IPv6는 Hop-by-Hop, Routing, Fragment, AH, ESP, Destination 옵션을 Extension Header로 표현하고 Next Header를 따라가며 연쇄 파싱합니다. ip6-input은 이 체인을 최대 4개까지는 fast path에서 처리하지만, 그 이상이나 알려지지 않은 EH가 오면 slow path로 빠지며 per-packet 비용이 급증합니다. SRv6처럼 Segment Routing Header(SRH)를 깊게 사용하는 환경에서는 sr-localsid 노드를 Feature Arc에 걸어 SRH를 먼저 소비하도록 구성합니다.

L4 심화 — UDP·TCP와 세션 큐의 세계

L4는 VPP가 "단순 포워더"에서 "풀 스택 네트워크 엔진"으로 변신하는 경계선입니다. 기본 설치된 VPP는 L2/L3까지만 완결된 처리를 하고, L4는 로컬 목적지 트래픽에 한해 호스트 스택(host stack)으로 넘어갑니다. 호스트 스택은 세션 레이어(session_t)와 전송 프로토콜 구현(tcp_connection_t, udp_connection_t)을 유저 공간에서 직접 돌리며, 여기서 처리된 페이로드는 공유 메모리 FIFO를 통해 VCL/VLS 기반 애플리케이션으로 전달됩니다.

UDP와 TCP 헤더 구조

UDP(8B) vs TCP(20B+) 헤더 비교 UDP Header — 8 바이트 고정 0 16 31 Source Port 16b Destination Port 16b · udp-local 분기 키 Length 16b · UDP 헤더+페이로드 Checksum 16b · pseudo-header 포함 TCP Header — 20 바이트 + 옵션 0 8 16 24 31 Source Port (16b) Destination Port (16b) Sequence Number (32b) — 재정렬 키 Acknowledgment Number (32b) Data Off Reserved Control Flags CWR ECE URG ACK PSH RST SYN FIN Window Size (16b) Checksum Urgent Pointer Options (0~40B) — MSS, SACK permitted, Timestamps, Window Scale

두 헤더의 결정적 차이는 상태의 유무입니다. UDP는 (src/dst IP, src/dst port) 4-tuple만 있으면 세션이 없는 개별 데이터그램을 처리할 수 있고, 재정렬·재전송·혼잡 제어가 없기 때문에 구현도 단순합니다. TCP는 같은 4-tuple에 대해 tcp_connection_t 상태 머신이 LISTEN → SYN_RCVD → ESTABLISHED → FIN_WAIT → TIME_WAIT 등을 순회하며, 매 세그먼트에 대해 ack·재전송·혼잡 윈도우·SACK 스코어보드를 갱신해야 합니다.

udp-local 노드 내부 구현

UDP는 두 가지 경로로 처리됩니다. 첫째, 터널 decap(VXLAN·GTP·GENEVE·QUIC)이 UDP 포트를 소비(consume)하는 경우 udp4-input이 해당 터널 플러그인으로 next를 돌립니다. 둘째, 호스트 스택의 로컬 수신(udp-local)은 udp_connection_t가 붙은 세션을 찾아 session-queue로 전달합니다.

/* udp46_input_inline() — UDP 수신 디스패치 (개념 코드) */
static inline uword
udp46_input_inline (vlib_main_t *vm, vlib_node_runtime_t *node,
                    vlib_frame_t *frame, int is_ip4)
{
  udp_main_t *um = &udp_main;
  u32 *from = vlib_frame_vector_args (frame);
  u32 n_left = frame->n_vectors;

  while (n_left > 0)
    {
      vlib_buffer_t *b0 = vlib_get_buffer (vm, from[0]);
      udp_header_t *udp0 = vlib_buffer_get_current (b0);
      u16 dst_port0 = clib_net_to_host_u16 (udp0->dst_port);
      u32 next0 = UDP_LOCAL_NEXT_DROP;

      /* 1. 포트별 등록된 핸들러 확인 — VXLAN decap(4789), GTP-U(2152) 등 */
      if (sparse_vec_index (um->next_by_dst_port, dst_port0) != 0)
        {
          next0 = vec_elt (um->next_by_dst_port, dst_port0);
          goto enqueue;
        }

      /* 2. 세션 레이어 바인딩 확인 — 애플리케이션이 udp listener를 열었는가? */
      transport_connection_t *tc0 =
        session_lookup_connection_wt4 (vnet_buffer (b0)->ip.fib_index,
                                        &ip4_src (b0), &ip4_dst (b0),
                                        udp0->src_port, udp0->dst_port,
                                        TRANSPORT_PROTO_UDP,
                                        thread_index, &is_filtered);

      if (tc0)
        {
          /* 바인딩이 있으면 session-queue로 넘깁니다. */
          vnet_buffer (b0)->tcp.connection_index = tc0->c_index;
          next0 = UDP_LOCAL_NEXT_SESSION_QUEUE;
        }
      else
        {
          /* 3. 리스너 없음 — ICMP Port Unreachable을 생성할지 결정합니다.
             silent drop 모드라면 그냥 드롭합니다. */
          b0->error = node->errors[UDP_ERROR_NO_LISTENER];
          next0 = um->icmp_send_unreachable ? UDP_LOCAL_NEXT_ICMP
                                            : UDP_LOCAL_NEXT_DROP;
        }

    enqueue:
      vlib_validate_buffer_enqueue_x1 (vm, node, /*...*/, from[0], next0);
      from++; n_left--;
    }
  return frame->n_vectors;
}
코드 설명
  • 4행is_ip4 템플릿 인자는 IPv4와 IPv6 경로를 하나의 함수로 공유하기 위한 VPP 관용구입니다. 컴파일러가 상수 전파로 해당 분기를 제거하므로 런타임 오버헤드(Overhead)가 없습니다.
  • 17~22행VXLAN·GTP·GENEVE 같은 터널 플러그인은 부팅 시 udp_register_dst_port()로 자신의 포트를 등록합니다. 이 스파스 벡터에 엔트리가 있으면 세션 lookup을 건너뛰고 바로 터널 decap 노드로 보냅니다 — 포트가 이미 "소비"되었기 때문입니다.
  • 25~30행터널 포트가 아니면 세션 레이어 lookup을 수행합니다. session_lookup_connection_wt4()는 4-tuple 해시 테이블로 transport_connection_t를 반환합니다. VPP의 세션 해시는 워커 스레드별로 샤딩되어 있어 lock-free lookup이 가능합니다.
  • 33~37행세션이 있으면 connection_index를 버퍼 opaque에 저장하고 session-queue로 보냅니다. session-queue는 해당 연결의 RX FIFO에 payload를 복사하고, 이벤트 큐를 통해 애플리케이션에 "데이터 있음"을 알립니다.
  • 38~44행세션이 없으면 ICMP Port Unreachable을 돌려줄지 선택합니다. RFC 1122는 보내도록 권고하지만, 실제 프로덕션에서는 port scan에 악용되거나 ICMP amplification의 원인이 되므로 udp icmp-unreachable 설정을 끄는 경우가 많습니다.

session-queue 노드 내부 구현

session-queue는 호스트 스택의 핵심 "연결고리" 노드입니다. tcp4-established·udp-local·tcp-input이 도착 데이터를 검증하면 이 노드가 받아 (1) 해당 세션의 RX FIFO에 payload 복사, (2) 애플리케이션의 이벤트 큐에 SESSION_IO_EVT_RX 포스팅, (3) 필요 시 epoll 유사 notification 트리거의 3가지를 수행합니다.

/* session_queue_node_fn() — 세션 이벤트 처리 루프 (개념 코드) */
static uword
session_queue_node_fn (vlib_main_t *vm, vlib_node_runtime_t *node,
                       vlib_frame_t *frame)
{
  session_worker_t *wrk = &session_main.wrk[vm->thread_index];
  session_event_t *ev;
  u32 n_events;

  /* 1. 워커별 이벤트 큐에서 대기 중인 이벤트를 배치로 가져옵니다.
     이벤트 종류: NEW_RX, NEW_TX, BUILTIN_RX, RPC, CTRL 등. */
  n_events = session_evt_q_dequeue_batch (wrk, SESSION_EVT_BATCH);

  for (u32 i = 0; i < n_events; i++)
    {
      ev = &wrk->events[i];
      session_t *s = session_get (ev->session_index, vm->thread_index);

      switch (ev->event_type)
        {
        case SESSION_IO_EVT_RX:
          {
            /* 2. 도착한 버퍼의 payload를 세션의 RX FIFO(svm_fifo)에 씁니다.
               FIFO는 공유 메모리에 있으므로 애플리케이션이 즉시 읽을 수 있습니다.
               FIFO가 가득 차면 flow control을 발동하여 윈도우를 0으로 보냅니다. */
            u32 n_written = svm_fifo_enqueue (s->rx_fifo,
                                              ev->len, ev->data);
            if (PREDICT_FALSE (n_written < ev->len))
              session_flow_control_paused (s);

            /* 3. 애플리케이션 이벤트 큐에 notification을 넣습니다. */
            app_worker_lock_and_send_event (
              app_worker_get (s->app_wrk_index), s,
              SESSION_IO_EVT_RX);
          }
          break;

        case SESSION_IO_EVT_TX:
          {
            /* 4. TX FIFO에 쌓인 데이터를 전송 프로토콜로 내려보냅니다.
               TCP라면 tcp_send()가 세그먼트를 만들어 ip4-output으로 보내고,
               UDP라면 udp_build_datagram()이 즉시 송신합니다. */
            transport_custom_tx (s->thread_index, s);
          }
          break;

        case SESSION_CTRL_EVT_CLOSE:
          session_transport_close (s);
          break;
        }
    }
  return n_events;
}
코드 설명
  • 5~11행각 워커 스레드는 자기 전용 이벤트 큐(session_worker_t::event_queue)를 가집니다. 이벤트는 MPMC ring buffer로 구현되어 다른 워커 또는 애플리케이션이 RPC 식으로 넣을 수 있습니다. 배치(SESSION_EVT_BATCH)는 보통 16~32입니다.
  • 20~33행RX 이벤트의 핵심은 svm_fifo_enqueue()입니다. svm_fifo는 lock-free SPSC 링인데, VPP 워커가 producer, 애플리케이션이 consumer입니다. FIFO 풀 발생 시 flow control은 TCP 레벨에서 윈도우 0을 보내 bursty input을 억제합니다.
  • 29~32행애플리케이션 이벤트 큐는 svm_msg_q_t로 구현되며, mutex + eventfd의 하이브리드입니다. 애플리케이션이 vppcom_session_read()로 블로킹 중이면 eventfd가 깨우고, 폴링 중이면 mutex-free 큐 체크로 바로 읽습니다.
  • 35~43행TX 이벤트는 TX FIFO에 애플리케이션이 데이터를 써 두고 "보내 달라"고 통보한 것입니다. transport_custom_tx()는 프로토콜별 콜백(Callback)을 호출하며 TCP의 경우 congestion window가 허용하는 만큼만 세그먼트를 만들어 tcp_send()로 보냅니다.
  • 45~47행CTRL 이벤트는 close, listen, connect 등의 상태 변경 요청입니다. 이들은 tcp_connection_t의 상태 머신을 건드리므로 워커 스레드 내부에서 직렬화(Serialization)되어야 하며, 그래서 이벤트 큐 경유로 처리됩니다.
관측 포인트: 호스트 스택에서 "패킷은 도착하는데 애플리케이션이 못 받는다"는 장애는 대개 ① RX FIFO 풀(크기 부족) ② 애플리케이션 event dequeue 실패(eventfd 놓침) ③ 워커 어피니티 불일치(세션이 다른 워커에 바인딩)에서 발생합니다. show session verbose 2로 각 세션의 RX/TX FIFO 사용률을, show session dequeue로 워커별 이벤트 처리 통계를 확인할 수 있습니다.

미니 시나리오 — TCP SYN 하나가 L2·L3·L4를 관통하는 여정

지금까지의 이론을 하나의 실제 패킷 처리 흐름에 엮어 봅니다. 외부 호스트 10.0.0.5가 VPP가 소유한 주소 10.0.0.1:80으로 TCP SYN을 보내는 상황입니다. nginx가 VCL로 VPP 호스트 스택에 붙어 listen 중이라고 가정합니다.

#노드계층주요 동작버퍼에 남는 변화
1dpdk-inputL1NIC RX 링에서 64바이트 mbuf burst 획득, vlib_buffer_t 초기화sw_if_index[RX]=1, current_data=0 (Ethernet 시작)
2ethernet-inputL2DMAC 확인, EtherType 0x0800 → sparse vector lookup → next=ip4-input, vlib_buffer_advance(14)current_data=14 (IP 시작), l2.bd_index 미설정
3ip4-inputL3Version=4, IHL=5, TTL=64, checksum 검증 OK → ip4-lookupFIB 인덱스 결정됨 (기본 0)
4ip4-lookupL3mtrie로 10.0.0.1/32 검색 → RECEIVE DPO 히트 → ip4-localadj_index에 RECEIVE 인덱스
5ip4-localL3/L4 경계Protocol=6(TCP), uRPF로 src 10.0.0.5 경로 확인 OK → next=tcp4-input-nolookupTCP 헤더 시작 오프셋이 opaque에 기록
6tcp4-input-nolookupL44-tuple 해시 lookup → LISTEN 상태의 tcp_connection_t 발견 → next=tcp4-listentcp.connection_index 세팅
7tcp4-listenL4SYN 플래그 확인, 새 tcp_connection_t(SYN_RCVD 상태) 생성, ISN 선택, SYN+ACK 응답 생성새 세션 레코드 생성, 원본 버퍼는 소비
8tcp-outputL4SYN+ACK 세그먼트 빌드, pseudo-header 체크섬 계산 (HW offload 없으면)새 버퍼에 TCP+IP 헤더 prepend
9ip4-outputL3adjacency 조회로 next-hop MAC 획득, MAC rewrite, TTL=64 세팅, IP 체크섬 계산L2 헤더까지 완성
10interface-outputL2출력 sw_if_index의 TX 노드로 디스패치(Patch) (dpdk-tx)TX 큐 인덱스 세팅
11dpdk-txL1NIC TX 링에 mbuf 큐잉, rte_eth_tx_burst()버퍼 반환(pool로 돌려보냄)

같은 연결의 이후 ACK(3-way handshake 완성), 그리고 데이터 세그먼트는 tcp4-input-nolookup에서 ESTABLISHED 상태의 tcp_connection_t를 만나 tcp4-established로 갑니다. 이때부터는 위 11단계 중 #7, #8이 생략되고, 대신 #6 이후 바로 session-queue를 거쳐 RX FIFO에 payload가 쌓입니다. nginx는 epoll처럼 깨어나 vppcom_session_read()로 데이터를 가져갑니다.

실전 팁 — 이 경로를 직접 따라가 보기:
vppctl trace add dpdk-input 200
# (SYN 패킷 1개를 유발)
vppctl show trace
show trace 출력에는 위 표의 #1~#11이 거의 그대로 계층별로 찍혀 나옵니다. 각 노드에서 next=...로 이어지는 체인을 따라가면 "어느 노드에서 내 패킷이 사라졌는가"를 한눈에 볼 수 있으며, 이것이 VPP 트러블슈팅의 가장 강력한 도구입니다.

요약 — 계층별 "어디를 봐야 하는가" 치트시트

증상 카테고리1차 의심 계층확인 노드/카운터확인 명령
ping 불통 (같은 서브넷)L2arp-input, l2-fwd, ethernet-inputshow ip neighbors, show l2fib
ping 불통 (다른 서브넷)L3ip4-input, ip4-lookup drop 카운터show ip fib, show errors | include ip4-
특정 포트만 안 됨L4udp-local, tcp4-input, Feature Arc ACLshow session verbose, show acl-plugin acl
성능 급락전 계층노드별 vectors/callshow runtime
무작위 드롭L3 uRPF / L4 체크섬ip4-local SRC_LOOKUP_MISS, tcp4-input bad-csumshow errors
brige 외부로 새지 않음L2BD 설정, split-horizonshow bridge-domain <bd> detail

이 치트시트가 의미하는 바는 단순합니다. "먼저 L2를 확인하고, 막히면 L3로 올라가고, 그다음에 L4를 본다"는 원칙입니다. VPP는 계층별 노드 분리가 명확하므로 이 순서만 지켜도 대부분의 디버깅에서 원인 범위를 빠르게 좁힐 수 있습니다.

DPDK 통합

📌 문서 기준 버전: 이 페이지는 v26.02(2026-02-25 릴리스)를 기준선으로 작성되어 있으며, 구버전 차이는 "25.02 대비 변경" 박스로 표기합니다. 페이지 간 통일된 변경 요약은 Host Stack 문서의 변경 요약을 참고하시기 바랍니다.
🔄 25.02 → 26.02 데이터 평면 변경 요약
  • DPDK: 25.02 기준 DPDK 24.x → 25.10에서 DPDK 25.07 + rdma-core 58.0 → 26.02에서 DPDK 25.11 + rdma-core 60.0. 메이저 점프가 두 번 있어, 25.02에서 바로 올라가시는 경우 기본 설정 변경과 deprecated PMD 옵션이 누적되어 있으니 DPDK 릴리스 노트도 함께 확인하시기 바랍니다.
  • 네이티브 드라이버 확장: 26.02에서 Intel Gigabit Adapters(i211, i225, i226) 네이티브 드라이버가 추가되었습니다. 그전에는 DPDK PMD 경유만 가능했습니다.
  • GRE 키: 25.10에서 GRE 플러그인이 key 필드를 정식 지원합니다. 25.02에서는 키 없는 GRE만 동작합니다.
  • Virtio/TAP 이름 지정: 25.10부터 create ... name <...> 옵션이 들어와, 인터페이스 이름을 생성 시점에 지정할 수 있습니다.
  • Marvell Octeon L4 체크섬 플래그: 25.10에서 추가되었습니다.
  • AF_XDP: 25.10에서 xdp-tools 1.5.5로 업데이트되었습니다.

VPP는 DPDK(Data Plane Development Kit)를 기본 패킷 I/O 백엔드로 사용합니다. DPDK는 커널의 네트워크 드라이버를 우회하여 NIC 하드웨어에 직접 접근하는 Poll Mode Driver(PMD)를 제공합니다.

DPDK 드라이버 바인딩

DPDK PMD를 사용하려면 NIC를 커널 드라이버에서 분리(unbind)하고 UIO 또는 VFIO 드라이버에 바인딩해야 합니다:

# 현재 NIC 드라이버 확인
$ lspci -k -s 0000:03:00.0
03:00.0 Ethernet controller: Intel Corporation 82599ES ...
    Kernel driver in use: ixgbe

# VFIO-PCI 드라이버로 바인딩 (IOMMU 필요)
$ modprobe vfio-pci
$ dpdk-devbind --bind=vfio-pci 0000:03:00.0

# 또는 UIO 드라이버 (IOMMU 불필요, 보안 취약)
$ modprobe uio_pci_generic
$ dpdk-devbind --bind=uio_pci_generic 0000:03:00.0

# 상태 확인
$ dpdk-devbind --status
커널 관점: VFIO(drivers/vfio/)는 IOMMU를 통한 디바이스 격리(Isolation)를 제공하여 DMA 공격을 방지합니다. UIO(drivers/uio/)는 IOMMU 없이 동작하지만, 잘못된 DMA 요청이 임의 메모리를 덮어쓸 수 있어 프로덕션에서는 VFIO를 권장합니다.

Hugepages 설정

DPDK와 VPP는 Hugepages를 사용하여 TLB 미스를 줄이고 메모리 접근 성능을 높입니다. 커널의 hugetlbfs(fs/hugetlbfs/)를 활용합니다:

# 부팅 시 1GB hugepage 할당 (GRUB)
GRUB_CMDLINE_LINUX="default_hugepagesz=1G hugepagesz=1G hugepages=4 iommu=pt intel_iommu=on"

# 런타임 2MB hugepage 할당
$ echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# hugetlbfs 마운트 (VPP가 자동으로 사용)
$ mount -t hugetlbfs none /dev/hugepages

# 할당 상태 확인
$ cat /proc/meminfo | grep -i huge
HugePages_Total:    1024
HugePages_Free:      512
Hugepagesize:       2048 kB
Hugepages가 TLB 미스를 줄이는 원리 기본 4KB 페이지 CPU TLB (64~1024 엔트리) 가상→물리 주소 변환 캐시 엔트리당 4KB만 커버 최대 커버리지: ~4 MB VPP 버퍼 풀 (1 GB) 262,144개 4KB 페이지 TLB 미스 폭주 결과: CPU 사이클의 ~20-40%가 페이지 워크에 소모 벡터 처리 이점이 크게 상쇄됨 2MB Hugepages CPU TLB (동일 엔트리) 엔트리당 2MB 커버 (500배 효율) 최대 커버리지: ~2 GB VPP 버퍼 풀 (1 GB) 512개 2MB 페이지 결과: TLB 미스 ~95% 감소 VPP 권장 최소 구성 런타임 할당 가능 1GB Hugepages CPU TLB (동일 엔트리) 엔트리당 1GB 커버 (262,144배 효율) 최대 커버리지: ~TB VPP 버퍼 풀 (1 GB) 1개의 1GB 페이지 결과: TLB 미스 거의 0 고성능 프로덕션 권장 부팅 시에만 할당 가능 같은 1GB 메모리라도 페이지 크기에 따라 TLB 히트율이 극단적으로 달라져 Mpps 성능에 직접 영향을 미칩니다.

멀티큐와 RSS

최신 NIC은 여러 개의 RX/TX 큐를 제공하며, RSS(Receive Side Scaling)를 통해 패킷을 큐에 분산합니다. VPP는 각 워커 스레드에 큐를 할당하여 병렬 처리합니다:

/* startup.conf — DPDK 멀티큐 설정 */
dpdk {
    dev 0000:03:00.0 {
        num-rx-queues 4        /* RX 큐 4개 */
        num-tx-queues 4        /* TX 큐 4개 */
    }
}

cpu {
    main-core 0                /* 메인 스레드: 코어 0 */
    corelist-workers 1-3       /* 워커 스레드: 코어 1,2,3 */
}
dpdk-input 노드와 NIC RX 링 상호작용 NIC Hardware RX Descriptor Ring DMA Engine RSS Hash → Queue N Flow Director DMA Hugepage Buffers mbuf 0 mbuf 1 mbuf 2 mbuf 3 zero-copy DMA 대상 poll dpdk-input rte_eth_rx_burst() mbuf → vlib_buffer 변환 벡터 프레임 생성 Vector Frame [bi0, bi1, bi2, ..., biN] → ethernet-input 전달 NIC DMA → hugepage 버퍼 → dpdk-input 폴링 → vlib_buffer 변환 → 벡터 프레임 디스패치

RSS 워커 친화성과 해시 키 설계

RSS는 NIC 하드웨어가 수신 패킷의 튜플(기본: src/dst IP + src/dst port)을 해싱해 RX 큐에 분배하는 기법입니다. VPP 워커는 각 RX 큐를 독점 폴링하므로, RSS 해시 키 설계가 바로 워커 부하 균형(balance)을 결정합니다. 대부분의 장애는 "워커는 많이 띄웠는데 단일 워커만 100%에 달하고 나머지는 유휴"인 형태로 나타나며, 원인은 거의 항상 RSS 키 부적합입니다.

네 가지 전형적인 오구성:

  1. 단일 플로우 편중 — 동일 5-튜플의 단일 TCP 세션이 대역폭(Bandwidth)을 지배하는 경우(예: iperf 단일 스트림). RSS는 이 플로우 전체를 단일 큐에 고정하므로 워커 분산이 불가능합니다. 해결: 애플리케이션 층 병렬화(다중 세션) 또는 Flow Director로 수동 분산.
  2. 터널 외부 헤더 해싱 — VXLAN/GENEVE 캡슐화 트래픽은 외부 IP가 고정(예: 터널 양 끝 두 IP)되어 내부 플로우가 모두 같은 큐에 몰립니다. 해결: NIC이 내부(inner) 헤더 해싱을 지원하면(ethtool -N ... rx-flow-hash tcp4 sdfn) inner 5-tuple 해싱 활성화, 미지원이면 VXLAN source port 엔트로피(RFC 7348)를 제대로 활용하도록 터널 측에서 보정.
  3. IPsec ESP 해싱 — ESP는 L4 포트가 없으므로 기본 RSS는 2-튜플(src/dst IP)로만 해싱합니다. 동일 터널의 트래픽 전체가 단일 큐에 고정됩니다. 해결: SPI(ESP Security Parameter Index)를 해시에 포함하는 NIC(Intel 82599+)에서 rx-flow-hash esp4 sdn 활성화.
  4. 워커 수 ≠ 큐 수 — RX 큐 8개 + 워커 4개면 한 워커가 2개 큐를 폴링하므로 부하가 비대칭이 됩니다. 원칙은 1 worker : 1 RX queue이며, 예외로 동일 NUMA 노드 내에서 워커당 2큐까지 허용합니다.
# 진단: 워커별 벡터 크기와 큐 폴링 효율
vpp# show runtime
  Thread 1 vpp_wk_0
    dpdk-input           active  1234567   [벡터/호출 평균 42.5]  ← 정상
    ethernet-input              1234567   [벡터/호출 평균 42.5]
  Thread 2 vpp_wk_1
    dpdk-input           active    12345   [벡터/호출 평균  0.8]  ← 유휴
  Thread 3 vpp_wk_2
    dpdk-input           active    12000   [벡터/호출 평균  0.7]  ← 유휴

# NIC RSS 키·해시 튜플 확인
$ ethtool -x  enp4s0
$ ethtool -n  enp4s0 rx-flow-hash tcp4  # 예상: s d f n (src IP, dst IP, src port, dst port)

# NUMA 친화성 확인 — NIC과 워커 CPU가 같은 노드여야 함
$ cat /sys/class/net/enp4s0/device/numa_node
0
$ lstopo --only pu  | grep -E "NUMANode|L#"
# startup.conf — 워커/큐 1:1 + NUMA 고정
cpu {
  main-core 0
  corelist-workers 1,2,3,4         # 4 워커 — NIC와 동일 소켓
}

dpdk {
  dev 0000:04:00.0 {
    num-rx-queues 4                # 워커 수와 동일
    num-tx-queues 4
    # RSS 해시 키 선택 (드라이버 지원 시)
    rss { ipv4 tcp udp sctp }
  }
  # NIC과 다른 소켓 워커는 금지 — cross-NUMA DMA는 성능 반감
  socket-mem 2048,0
}

튜닝 팁: Intel XL710/E810은 port 해싱 외에도 VXLAN/NVGRE inner header offload를 내장하고 있습니다. Mellanox ConnectX-5/6은 mlx5_core 드라이버에서 동적 RSS 키 재생성(ethtool -X ... hfunc toeplitz)이 가능해, 공격자가 해시 충돌을 예측하는 시나리오에서 주기적 키 회전을 적용할 수 있습니다. VPP는 재시작(Reboot) 없이 이를 반영하지 않으므로, 키 회전 시에는 set dpdk interface flow-hash 명령으로 플러그인 상태를 동기화해야 합니다.

실전 DPDK 기동 절차

실무에서 가장 자주 실패하는 구간은 "드라이버를 VFIO로 바인딩했습니다"에서 끝내는 경우입니다. 실제로는 리눅스 준비, Hugepage와 큐 수 설계, 워커 배치, 기동 직후 검증이 한 번에 맞아야 합니다. 아래 절차는 2포트 NIC와 워커 4개를 기준으로 한 전형적인 기동 순서입니다.

DPDK 기동은 준비, 큐 배치, 검증까지 한 묶음으로 봐야 합니다. 1. 리눅스 준비 irqbalance 정리 Hugepage 확보 전용 코어 분리 2. VFIO 바인딩 vfio-pci 로드 NIC 커널 드라이버 분리 dpdk-devbind 검증 3. startup.conf num-rx-queues = workers buffers-per-numa 설정 main-core 분리 4. 큐와 워커 배치 queue0 → worker0 queue1 → worker1 RSS 해시 일관성 5. 기동 직후 검증 show threads rx-placement runtime / buffers 정상적인 기동 후에는 다음 상태가 한 번에 확인되어야 합니다. NIC가 vfio-pci에 묶여 있고 커널 드라이버가 비어 있습니다. show threads에 메인과 워커가 분리되어 있습니다. show interface rx-placement에 큐마다 담당 워커가 보입니다. show runtime에서 dpdk-input 벡터가 꾸준히 증가합니다.
# 1. 리눅스 준비
sudo systemctl stop irqbalance
echo 2048 | sudo tee /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
sudo mkdir -p /dev/hugepages
sudo mount -t hugetlbfs none /dev/hugepages

# 2. VFIO 바인딩
sudo modprobe vfio-pci
sudo dpdk-devbind --bind=vfio-pci 0000:18:00.0 0000:18:00.1
dpdk-devbind --status

# 3. startup.conf 예시
cat <<'EOF' | sudo tee /etc/vpp/startup.conf
unix {
    cli-listen /run/vpp/cli.sock
    log /var/log/vpp/vpp.log
}
cpu {
    main-core 2
    corelist-workers 4-7
}
dpdk {
    dev 0000:18:00.0 {
        num-rx-queues 4
        num-tx-queues 4
        num-rx-desc 1024
        num-tx-desc 1024
    }
    dev 0000:18:00.1 {
        num-rx-queues 4
        num-tx-queues 4
    }
}
buffers {
    buffers-per-numa 65536
    default data-size 2048
}
EOF

# 4. VPP 기동 후 즉시 확인
sudo systemctl restart vpp
vppctl show threads
vppctl show hardware-interfaces detail
vppctl show interface rx-placement
vppctl show runtime
vppctl show buffers
점검 지점정상 징후이상이면 먼저 볼 것
VFIO 바인딩dpdk-devbind --status에서 NIC가 vfio-pci에 연결됩니다.BIOS IOMMU 설정, 커널 부팅 옵션(intel_iommu=on 등), 기존 커널 드라이버 재점유 여부를 확인합니다.
스레드 배치show threads에서 메인 코어와 워커 코어가 겹치지 않습니다.main-core, corelist-workers, 호스트 측 CPU 격리 설정을 다시 봅니다.
큐 배치show interface rx-placement에서 큐마다 워커가 배정됩니다.NIC RSS 큐 수와 num-rx-queues가 부족한지, 워커 수보다 큐 수가 적은지 확인합니다.
벡터 효율show runtime에서 dpdk-input, ethernet-inputVectors/Call이 꾸준히 올라갑니다.트래픽이 한 큐에 몰리는지, RSS 해시와 Flow Director 규칙이 비대칭인지 확인합니다.
버퍼 여유show buffers에 여유 버퍼가 충분히 남습니다.Hugepage 부족, buffers-per-numa 과소 설정, 점보 프레임으로 인한 다중 세그먼트 사용을 의심합니다.

DPDK Crypto 디바이스 연동

VPP는 DPDK의 Cryptodev API를 통해 하드웨어/소프트웨어 암호화(Encryption) 가속을 지원합니다. IPsec의 AES-GCM, ChaCha20-Poly1305 등의 암호화 연산을 전용 하드웨어(QAT, Mellanox ConnectX 등)로 오프로드하거나, 소프트웨어 라이브러리(IPSec-MB, OpenSSL)를 사용합니다.

/* startup.conf — Crypto 디바이스 설정 */
dpdk {
    dev 0000:03:00.0             /* 네트워크 NIC */
    dev 0000:0b:00.0 {           /* Intel QAT 디바이스 */
        num-rx-queues 2
    }
    uio-driver vfio-pci
}

/* VPP crypto 엔진 설정 */
vpp# set crypto handler aes-256-gcm ipsecmb
vpp# show crypto handlers
크립토 엔진유형지원 알고리즘성능 (1500B 기준)비고
nativeSW (VPP 내장)AES-GCM (AES-NI)~15 Gbps/코어x86 AES-NI 필수
ipsecmbSW (Intel)AES-CBC/GCM, SHA, ChaCha20~12 Gbps/코어Intel 최적화 라이브러리
opensslSW모든 OpenSSL 알고리즘~8 Gbps/코어범용, 호환성 우수
dpdk-cryptodevHW디바이스 의존~40+ GbpsQAT, Mellanox 등
HW vs SW 크립토: 소규모 패킷(64~256B)에서는 HW 오프로드의 DMA 전송 지연이 SW 처리보다 느릴 수 있습니다. show crypto engines로 현재 엔진별 처리량을 비교하고, show ipsec sa에서 SA별 바이트/패킷 카운터로 실제 성능을 측정하세요.

하드웨어 오프로드 심화 — Checksum·TSO·LRO/GRO·RSS·Flow Director·SR-IOV·DDP·Inline Crypto

VPP는 DPDK PMD를 통해 현대 NIC가 제공하는 대부분의 오프로드 기능을 드러냅니다. 오프로드는 CPU 사이클 절약만을 위한 것이 아니라, 특정 워크로드에서 지연(latency)을 반 이하로 줄이거나 처리량의 상한을 수 배로 올리는 결정적 수단입니다. 이 절은 주요 오프로드를 기능별로 나누어 동작 원리, VPP 설정, 실전 함정, 성능 측정 포인트를 정리합니다. 아래 표는 빠른 참조용 치트시트입니다.

오프로드계층주요 NICVPP 관여도활성화 지점
Checksum (IP/UDP/TCP/IPv6)L3/L4거의 모든 NICvlib_buffer flag기본 on, per-buffer
VLAN strip/insertL2거의 모든 NICvnet_hw_interface인터페이스 단위
TSO (TCP Segmentation Offload)L4대부분gso 플래그 + segment size버퍼 플래그 + MTU
GSO (Generic SO)L4virtio/vhost-uservhost-user 협상게스트 ↔ 호스트
LRO (Large Receive Offload)L4Intel 700 시리즈+PMD 설정enable-lro
RSS (Receive Side Scaling)해시 기반 큐 분배대부분PMD 설정 + 워커 매핑num-rx-queues
Flow Director / RTE Flow정확 매칭 큐 분배Intel/Mellanox/BroadcomVPP flow 플러그인flow add
DDP (Dynamic Device Personalization)프로파일 기반 파싱Intel E810PMD 초기화DPDK PMD 옵션(드라이버별)
SR-IOV VF하드웨어 가상화(Virtualization)대부분부팅 시 PF/VF 분할커널 sysfs
Crypto 오프로드암호화 엔진QAT, Nitrox, Mellanoxcryptodevdpdk { cryptodev ... }
IPsec inlineESP 처리까지Mellanox ConnectX-6, ChelsioIPsec security sessionSA 생성 시 플래그
TLS inline (kTLS HW)TLS 레코드 처리Mellanox ConnectX-7, Chelsio T6실험적, tls 플러그인빌드 옵션

체크섬 오프로드 — 모든 경로의 기본

체크섬 오프로드는 수신 시 NIC가 IP/TCP/UDP 체크섬을 검증해 결과를 디스크립터 플래그로 전달하고, 송신 시에는 VPP가 체크섬 계산을 건너뛰고 NIC에게 위임하는 기능입니다. VPP는 vlib_buffer_t의 플래그로 이 위임을 표현합니다.

/* 송신 경로에서 체크섬 오프로드를 요청하는 버퍼 플래그 */
b->flags |= VNET_BUFFER_F_OFFLOAD;
vnet_buffer (b)->oflags |= VNET_BUFFER_OFFLOAD_F_IP_CKSUM
                         | VNET_BUFFER_OFFLOAD_F_TCP_CKSUM;

/* L4 헤더 오프셋과 L3 오프셋도 같이 채워야 NIC가 체크섬 필드 위치를 압니다 */
vnet_buffer (b)->l3_hdr_offset = ip_start - b->data;
vnet_buffer (b)->l4_hdr_offset = tcp_start - b->data;

확인 포인트는 두 가지입니다. 첫째, show hardware-interfaces verbose에서 해당 NIC가 rx-checksum, tx-checksum 플래그를 지원한다고 보고해야 합니다. 둘째, show errors에서 ip4-input: bad checksum 카운터가 0인지 확인하시기 바랍니다. 오프로드가 잘못 설정되면 이 카운터가 급증합니다. 특히 VLAN 또는 QinQ 태그가 붙어 있을 때 오프셋 계산을 틀리기 쉽습니다.

TSO/LRO — 대형 세그먼트 병합과 분할

TSO는 VPP(혹은 스택)가 MSS보다 훨씬 큰 64 KiB 단위의 TCP 페이로드를 NIC에 넘기고, NIC가 이를 MTU에 맞는 세그먼트로 나누어 송출하게 하는 기능입니다. 핸드셰이크 완료 후 대용량 데이터 전송 구간에서 CPU 사용량을 절반 이하로 떨어뜨립니다.

# VPP GSO는 인터페이스 생성 시 gso 플래그를 주거나
# startup.conf의 해당 PMD 블록에서 활성화합니다.
# 전용 'set interface feature gso' CLI는 존재하지 않습니다.
vpp# set interface mtu 9000 GigabitEthernet0/8/0

# 확인 — capabilities 줄에 tx-offload-tso/gso가 나오면 됩니다
vpp# show hardware-interfaces GigabitEthernet0/8/0 | grep -iE 'tso|gso|capabilit'

LRO는 수신 시 NIC가 같은 흐름에 속하는 여러 TCP 세그먼트를 하나의 큰 세그먼트로 병합해 VPP에 전달합니다. 서버 쪽 다운로드 워크로드에 특히 효과적입니다. 대신 포워딩 장비(라우터·로드 밸런서)에서 LRO를 켜면 원래 패킷 경계를 잃어 재전송 타이머·PMTUD·ICMP 처리에 문제가 생길 수 있으므로 꺼 두는 것이 원칙입니다.

포워딩 장비의 LRO는 꺼 두시기 바랍니다. 다른 호스트로 전달해야 할 세그먼트가 병합되면 원래 MSS와 MTU의 관계가 깨지고 경로의 다음 홉에서 MTU 초과 드롭이 발생합니다. 종단(서버)에서만 LRO를 켜고, VPP가 라우터·프록시 역할이라면 enable-lro를 끄거나 per-interface lro off로 명시하시기 바랍니다.

RSS 해시와 큐 분배 — 처리량의 출발점

RSS는 NIC가 패킷의 5-tuple에 토플리츠(Toeplitz) 해시를 적용해 수신 큐를 결정하는 기능입니다. 큐는 곧 VPP 워커 스레드에 매핑되므로, RSS가 망가지면 한 워커에 트래픽이 몰려 전체 처리량이 무너집니다.

# startup.conf에서 4큐, 4워커 구성
dpdk { dev 0000:81:00.0 { num-rx-queues 4 } }
cpu  { main-core 0 corelist-workers 1-4 }

# RSS 큐 매핑 (실존 명령)
vpp# set interface rss queues GigabitEthernet0/8/0 list 0 1 2 3

# 배포 확인 — 초당 벡터/콜이 워커별로 고르게 나와야 합니다
vpp# show runtime | grep -E '(dpdk-input|ip4-input)'

RSS 해시 필드와 Toeplitz 키 조작은 NIC PMD 수준 설정입니다. VPP는 set interface rss queues로 어떤 큐가 RSS 대상인지 지정하는 CLI만 제공하며, 해시에 포함할 필드(ipv4-tcp/ipv4-udp)·해시 키 자체는 startup.conf의 dpdk { dev ... { rss ... } } 하위 옵션 또는 DPDK PMD 빌드/드라이버 수준에서 바꿉니다. DNS 서버처럼 단일 IP로 트래픽이 몰리는 환경에서 UDP 해시가 2-tuple이면 한 큐에 편중되므로, 사용 중인 PMD 문서에서 심볼릭 RSS hash type을 4-tuple로 바꾸는 방법을 확인하시기 바랍니다. 대칭 RSS 키(클라이언트·서버 방향을 같은 워커로 귀속)도 같은 경로에서 설정합니다.

Flow Director / RTE Flow — 정확 매칭 큐 조종

RSS가 해시 기반 분산이라면, Flow Director(Intel)나 RTE Flow API는 정확한 5-tuple 또는 임의 헤더 패턴에 따라 특정 큐로 라우팅합니다. 관리 트래픽 분리, 하이 프라이어리티 큐로 보내기, DDoS 완화 시 블랙홀 큐로 포워딩 등에 씁니다.

# 모든 BGP 트래픽(포트 179)을 큐 0(제어 전용 워커)으로
vpp# flow add GigabitEthernet0/8/0 ipv4 proto tcp \
        dst-port 179 redirect-to-queue 0

# 특정 공격자 IP의 트래픽을 드롭 큐(큐 7)로 강제
vpp# flow add GigabitEthernet0/8/0 ipv4 src 203.0.113.50/32 redirect-to-queue 7
vpp# set interface rx-placement GigabitEthernet0/8/0 queue 7 worker 3

Flow Director 규칙 수는 NIC별로 제한이 있습니다. Intel 82599는 ~8 K, X710/X810은 수만 건까지 가능하며 ConnectX-6는 수십만 건입니다. 규칙이 한계를 넘으면 조용히 실패하는 PMD가 많으므로 설치 후 반드시 show flow로 성공 여부를 확인하시기 바랍니다.

DDP — 새 프로토콜을 NIC에 가르치기

Intel E810 이상은 Dynamic Device Personalization(DDP) 패키지를 업로드해 NIC 파서에 GTP-U·PPPoE·GRE over UDP·MPLSoUDP 같은 프로토콜 지식을 주입할 수 있습니다. DDP가 적용되면 RSS 해시 필드에 inner header 도 포함되어 5G UPF·모바일 게이트웨이 워크로드의 큐 분배가 균형을 잡습니다.

# DDP 패키지는 VPP의 일체형 CLI가 아닌 PMD(드라이버) 경로로 로드됩니다.
# 두 가지 접근:
#   (A) startup.conf의 dpdk { dev ... { ddp profile <path> } } 같은
#       PMD-specific 옵션 (Intel ice/i40e PMD에서 지원)
#   (B) DPDK testpmd 또는 별도 도구로 로드한 뒤 VPP가 인식

# 실제 로드 여부는 DPDK PMD 레벨 로그와 NIC feature를 통해 확인합니다.
vpp# show hardware-interfaces HundredGigabitEthernet6/0/0 verbose

DDP 패키지는 NIC 펌웨어(Firmware)에 로드되므로 VPP 재시작 시에도 유지됩니다. 업그레이드 시 기존 패키지 언로드를 먼저 수행하시기 바랍니다. 잘못된 패키지는 NIC 전체 링크를 떨어뜨릴 수 있어 운영 중 교체는 유지보수 창에서만 하시기 바랍니다. 실제 명령은 사용 중인 DPDK 릴리스(예: drivers/net/ice)의 DDP 지원 문서를 확인하시기 바랍니다 — VPP에 전용 ddp add CLI는 존재하지 않습니다.

SR-IOV — 가상화 환경의 라인레이트 해법

SR-IOV는 하나의 PF를 여러 VF로 나누어 각 VF를 게스트(VM)나 컨테이너에 전용으로 할당합니다. VF는 PF와 비슷한 오프로드 기능을 대부분 제공하며, 하이퍼바이저(Hypervisor) 우회로 라인레이트를 달성합니다. VPP는 VF를 직접 바인딩해 DPDK로 열거나, VF 위에서 DPDK 포트로 인식합니다.

# 호스트에서 VF 생성 (예: 8개)
echo 8 > /sys/class/net/ens5f0/device/sriov_numvfs

# VPP startup.conf에 VF PCI 주소 지정
dpdk {
  dev 0000:81:10.1 { name VF1 num-rx-queues 2 }
  dev 0000:81:10.2 { name VF2 num-rx-queues 2 }
}

# 게스트 VM 안에서 VPP가 VF를 다시 DPDK로 잡는 것도 가능

VF는 대개 PF가 켜 둔 오프로드만 사용 가능합니다. PF에서 LRO가 꺼져 있으면 VF에서도 쓸 수 없습니다. 또한 VLAN 태그 매핑·MAC anti-spoofing 같은 PF 정책이 VF에 강제되므로, 처음 구성할 때는 PF 관리자가 어떤 제약을 걸었는지 확인하시기 바랍니다.

Crypto 오프로드 — QAT·Nitrox·DPDK cryptodev

암호화 연산은 Intel QAT, Marvell Nitrox, Mellanox EIP 등 전용 하드웨어로 오프로드할 수 있습니다. VPP는 DPDK cryptodev 인터페이스로 이들을 통합하며, IPsec SA 생성 시 async 플래그를 주어 비동기 crypto 경로를 탑니다. 엔진 선택은 SA 플래그가 아니라 런타임 핸들러 등록으로 이루어집니다. set crypto handler CLI로 알고리즘별 기본 엔진을 바꾸거나, set ipsec async mode on으로 전체 IPsec 경로를 비동기 cryptodev로 전환합니다.

# QAT 바인딩
modprobe qat_c62x
echo 0000:b3:00.0 > /sys/bus/pci/drivers/c6xx/bind

# VPP startup.conf — DPDK plugin에 cryptodev 등록
dpdk {
  uio-driver vfio-pci
  cryptodev {
    num-mbufs 32768
  }
}

# 런타임 — 엔진 목록과 핸들러 교체
vpp# show crypto engines
vpp# set crypto handler aes-128-gcm dpdk_cryptodev
vpp# set crypto handler aes-256-gcm dpdk_cryptodev

# IPsec 비동기 모드로 전체 경로 전환
vpp# set ipsec async mode on

# SA 생성 — async 플래그를 주면 등록된 비동기 엔진을 사용
vpp# ipsec sa add 10 spi 1001 esp crypto-alg aes-gcm-128 \
        crypto-key 6162636465666768696a6b6c6d6e6f70 \
        inbound async

주의할 점은 배치 크기(batch)입니다. cryptodev는 디스크립터를 모아서 처리할 때 효율이 최대이며, 작은 패킷을 하나씩 보내면 DMA 오버헤드가 SW 처리보다 비쌉니다. VPP는 벡터 처리 덕분에 자연스럽게 배치가 형성되지만, 트래픽이 낮을 때는 CPU 엔진이 오히려 빠를 수 있습니다. show crypto engines로 현재 엔진별 처리량을 비교하시기 바랍니다.

🔄 26.02 변경 — IPsec ESP의 crypto+HMAC 단일 op 통합: v26.02 릴리스 노트에 "Unify crypto+HMAC in single op for ESP"가 포함되어, 암호화와 HMAC 계산을 하나의 cryptodev operation으로 묶어 DMA 왕복과 디스크립터 사용량을 줄입니다. 25.02에서는 두 단계로 나누어 보냈기 때문에 배치당 디스크립터가 2배 필요했습니다. 작은 패킷이 많은 워크로드에서 체감 효과가 가장 큽니다. show crypto engines의 "pending" 카운터가 25.02 대비 크게 줄면 통합 경로가 제대로 타고 있는 신호입니다. 또한 25.06에는 AES-CBC HMAC 지원이 추가되어, 레거시 cipher를 쓰는 환경의 오프로드 범위가 넓어졌습니다.

IPsec Inline — 패킷이 NIC 밖을 나가기도 전에 암호화

Inline IPsec은 NIC 하드웨어가 ESP 헤더 삽입·암호화·시퀀스 번호 관리까지 모두 수행하는 모드입니다. VPP는 SA를 설정한 뒤 평문을 송신하고, NIC가 실제 ESP 패킷으로 변환해 송출합니다. 수신 경로도 마찬가지로 NIC가 복호화해 평문을 VPP에 전달합니다. 지원 NIC는 Mellanox ConnectX-6 Dx, Chelsio T6, 일부 Intel E810 계열 등이며, 동작 경로는 (1) DPDK rte_security PMD, (2) VPP의 Mellanox rdma-core 네이티브 PMD 두 가지로 나뉩니다.

벤더별 CLI는 릴리스마다 달라집니다. VPP 본 문서에서 inline IPsec을 켜는 단일 CLI 명령을 제시하지 않습니다. 일반적으로는 (a) DPDK 플러그인이 rte_security capability를 보고 SA를 자동으로 오프로드하거나, (b) 드라이버 빌드 옵션과 startup.conf의 enable-inline-ipsec 류 플래그로 활성화합니다. 여러분이 쓰시는 VPP 버전(show version)과 NIC PMD 문서를 교차 확인하신 뒤 실제 명령을 적용하시기 바랍니다. 이 절은 기능이 어떤 형태로 존재하는가를 설명하는 개념 가이드로 참고하시면 됩니다.
# 일반적인 확인 명령 — 실제 활성화 명령은 PMD 문서 기준
vpp# show hardware-interfaces HundredGigabitEthernet6/0/0 | grep -i offload
vpp# show ipsec sa 20
# SA 플래그에 "hw offload" 또는 "inline-protect"가 보이면 적용된 상태입니다
inline vs lookaside 선택 기준: 패킷당 2~3 µs가 크게 느껴지는 저지연(금융·텔레콤) 환경이거나, 패킷 크기가 커서 DMA 왕복 시간이 계산 시간보다 많아지는 경우 inline이 유리합니다. 반대로 SA 수가 수만 개이고 플로우별로 작은 패킷이 다수라면 lookaside(cryptodev) 쪽이 캐시와 스케줄링에서 이득입니다.

오프로드가 실제로 동작하고 있는지 확인하는 법

설정을 켠 것과 실제 오프로드가 일어나는 것은 다릅니다. 다음 4단계를 항상 확인하시기 바랍니다.

  1. NIC 역량 확인: show hardware-interfaces verbosecapabilities 줄에 해당 플래그가 있는가.
  2. VPP 활성 확인: show interface features 또는 기능별 명령(show flow, show ipsec sa)에 오프로드 플래그가 보이는가.
  3. 카운터 확인: show errors에서 해당 경로의 bad-checksum, software-csum, crypto-fallback 카운터가 증가하지 않는가.
  4. CPU 분포: 예상되는 워커에서 show runtime의 per-vector clocks가 SW 구현 대비 실제로 줄었는지 확인합니다.
# 4단계 요약 스크립트 예
vpp# show hardware-interfaces verbose | grep -E 'capabilit|flag'
vpp# show interface features GigabitEthernet0/8/0
vpp# show errors | grep -iE 'checksum|crypto-fallback'
vpp# show runtime | sort -k5 -n | tail

오프로드 함정 요약

함정증상해결
VLAN QinQ 환경에서 체크섬 오프셋 오류bad checksum 카운터 증가L3/L4 오프셋을 outer 태그 수에 맞춰 다시 계산
포워딩 장비에서 LRO on다음 홉에서 MTU 초과 드롭LRO off, TSO만 유지
UDP RSS 2-tupleDNS 리졸버 트래픽이 한 워커에 몰림hash-types ipv4-udp 지정
Flow Director 규칙 한계 초과마지막에 추가한 규칙이 무시설치 후 show flow로 확인, NIC 한계 문서 확인
cryptodev 배치 부족CPU 엔진보다 느림트래픽 낮은 구간에서는 SW, 고부하에서만 HW
SR-IOV VF가 PF 정책에 막힘특정 VLAN·MAC 통신 불가PF 관리자에게 트러스트 플래그 요청
DDP 패키지 버전 불일치링크 다운 또는 부팅 실패펌웨어와 호환되는 패키지만 사용, 유지보수 창에서 교체
inline IPsec anti-replay 오작동간헐 드롭, replay 카운터 증가윈도우 크기 확대, 시간 동기화 점검
연계 절: 이 절에서 다룬 오프로드를 실제 운영 경로에서 모니터링하고 튜닝하는 방법은 성능 최적화를, IPsec crypto 엔진의 세부 구현은 IPsec 암호화 엔진 상세를 참고하시기 바랍니다.

커널 인터페이스

VPP는 유저스페이스에서 동작하지만, 커널 네트워크 스택과의 연동이 필수적인 경우가 많습니다. VPP는 다양한 커널 인터페이스를 통해 커널과 패킷을 교환합니다.

커널 네트워크 스택 vs VPP 데이터패스 커널 공간 (Kernel Space) 커널 네트워크 스택 NIC 드라이버 (ixgbe...) TAP/TUN vhost AF_PACKET AF_XDP UIO / VFIO (커널 bypass) 유저 공간 (User Space) VPP Process 그래프 노드 엔진 DPDK PMD 플러그인 Binary API CLI (vppctl) Shared Memory Physical NIC (10GbE / 25GbE / 100GbE)

TAP/TUN 인터페이스

TAP 인터페이스는 VPP와 커널 네트워크 스택 간 L2/L3 패킷을 교환하는 가장 일반적인 방법입니다. 커널의 drivers/net/tun.c가 구현합니다.

/* VPP CLI: TAP 인터페이스 생성 */
vpp# create tap id 0 host-if-name vpp-tap0 host-ip4-addr 192.168.1.1/24

/* 호스트에서 확인 */
$ ip addr show vpp-tap0
vpp-tap0: <BROADCAST,MULTICAST,UP> mtu 1500 ...
    inet 192.168.1.1/24 scope global vpp-tap0

/* VPP 측 IP 할당 */
vpp# set interface ip address tap0 192.168.1.2/24
vpp# set interface state tap0 up
virtio 기반 TAP: VPP의 TAP은 내부적으로 virtio 링을 사용합니다. /dev/net/tun + IFF_VNET_HDR 플래그로 virtio 헤더를 포함하여 체크섬 오프로드와 GSO를 지원합니다. 이는 전통적인 TAP보다 성능이 우수합니다.

vhost-user / virtio

vhost-user는 VM과 VPP 간 고성능 패킷 교환을 위한 메커니즘입니다. 공유 메모리와 virtio 링을 사용하여 데이터 복사 없이 패킷을 전달합니다. 커널의 drivers/vhost/ 서브시스템과 유사한 개념이지만, VPP는 유저스페이스에서 vhost 백엔드를 직접 구현합니다.

/* VPP: vhost-user 인터페이스 생성 (서버 모드) */
vpp# create vhost-user socket /var/run/vpp/sock0.sock server
vpp# set interface state VirtualEthernet0/0/0 up

/* QEMU VM 연결 */
$ qemu-system-x86_64 \
    -chardev socket,id=char0,path=/var/run/vpp/sock0.sock \
    -netdev vhost-user,id=net0,chardev=char0 \
    -device virtio-net-pci,netdev=net0

vhost-user vring 메모리 매핑과 구조체 상세

vhost-user는 "유저스페이스에서 virtio 디바이스 후단(backend)을 구현한다"는 단순한 아이디어이지만, 내부적으로는 세 가지 공유 자원이 정밀하게 맞물려야 동작합니다. ① 게스트 메모리 영역(memory regions)을 호스트 주소 공간(Address Space)에 mmap, ② 가상 링(virtio ring) 자체가 그 메모리 안에 위치, ③ 이벤트 파일 디스크립터(call/kick eventfd)로 양쪽이 깨어납니다. 이 셋 중 하나라도 어긋나면 패킷은 단 한 건도 흐르지 않습니다.

/* virtio split ring 레이아웃 — 게스트와 VPP가 공유 */
struct vring_desc {
  u64 addr;      /* 게스트 물리 주소(GPA) — VPP는 GPA→HVA 변환 필요 */
  u32 len;
  u16 flags;     /* NEXT | WRITE | INDIRECT */
  u16 next;
};

struct vring_avail {
  u16 flags;
  u16 idx;       /* 게스트가 다음 기록할 위치 */
  u16 ring[QUEUE_SIZE];
  u16 used_event; /* VIRTIO_RING_F_EVENT_IDX */
};

struct vring_used {
  u16 flags;
  u16 idx;       /* VPP가 다음 기록할 위치 */
  struct { u32 id; u32 len; } ring[QUEUE_SIZE];
  u16 avail_event;
};

/* VPP vhost-user 백엔드 — 메모리 영역 테이블 */
typedef struct {
  u64 guest_phys_addr;   /* GPA 시작 */
  u64 memory_size;       /* 영역 크기 */
  u64 userspace_addr;    /* 호스트 가상 주소 (QEMU 프로세스 관점) */
  u64 mmap_offset;       /* fd 내 오프셋 */
  int fd;                /* SCM_RIGHTS로 전달된 fd (memfd) */
  void *mmap_addr;       /* VPP 주소 공간 mmap 결과 */
} vhost_user_memory_region_t;

/* GPA → VPP 주소 변환 — 인라인 핫패스 */
static inline void *
vhost_user_gpa_to_vva (vhost_user_intf_t *vui, u64 gpa)
{
  for (int i = 0; i < vui->nregions; i++) {
    vhost_user_memory_region_t *r = &vui->regions[i];
    if (gpa >= r->guest_phys_addr &&
        gpa < r->guest_phys_addr + r->memory_size)
      return (u8 *) r->mmap_addr + (gpa - r->guest_phys_addr);
  }
  return NULL;  /* 게스트가 잘못된 주소를 넣었거나 hotplug 중 */
}

핵심 포인트vhost_user_gpa_to_vva모든 디스크립터 접근마다 호출된다는 점입니다. 영역 수가 많을수록 선형 탐색 비용이 누적되므로, QEMU의 메모리 핫플러그(Hotplug)·NUMA 분할로 영역이 10개를 넘어가면 눈에 띄는 성능 저하가 발생합니다. VPP 최신 트리는 8개 이하일 때 배열 인라인, 그 이상은 해시 기반 조회로 자동 전환합니다. 또한 디스크립터의 addr은 GPA이므로 매 접근마다 변환이 필요하며, 이는 캐시 미스의 주요 원인입니다. Packed ring(VIRTIO 1.1)은 avail/used를 하나의 링으로 통합해 캐시 라인 접근을 줄입니다.

# vhost-user 디버깅 — 메모리 영역·링 상태 덤프
vpp# show vhost-user VirtualEthernet0/0/0
  Virtio vring 0  avail.idx 42  used.idx 42  last_used_idx 42
  Virtio vring 1  avail.idx 98  used.idx 98  last_used_idx 98
  Memory regions: 4
    [0] gpa=0x00000000 size=  4MB  fd=32  mmap=0x7f0120000000
    [1] gpa=0x00100000 size=128MB  fd=32  mmap=0x7f0120400000
    [2] gpa=0x40000000 size=  2GB  fd=33  mmap=0x7f0200000000
    [3] gpa=0xC0000000 size=  1GB  fd=33  mmap=0x7f0280000000

vpp# show errors | grep vhost
  VirtualEthernet0/0/0 vhost-user-input   12  no available descriptors
  VirtualEthernet0/0/0 vhost-user-input    3  gpa translation failed

가상 인터페이스 비교표: vhost-user · AF_PACKET · AF_XDP · memif · TAP

항목vhost-userAF_PACKETAF_XDPmemifTAP/virtio
목적VM↔VPP커널 NIC 공유커널 NIC zero-copy프로세스↔프로세스커널 스택 연동
복사 횟수0 (공유 메모리)1 (mmap ring)0 (UMEM)0 (공유 메모리)1~2
링 자료구조virtio split/packedPACKET_MMAP v3AF_XDP desc ringmemif ringvirtio + /dev/tun
메모리 평면게스트 RAM 매핑커널→유저 mmapUMEM (유저 할당)POSIX shm커널 skb 경유
제어 채널Unix 소켓 + SCM_RIGHTSsyscallnetlink + XSK bindUnix 소켓tun ioctl
이벤트kick/call eventfdpoll/epollwakeup flaginterrupt-eventfdpoll
NIC 독점N/A아니오예 (해당 큐)N/AN/A
zero-copy 조건항상불가(복사 1회)드라이버 XDP_ZEROCOPY항상불가
전형적 10GbE 성능14 Mpps1.2 Mpps11 Mpps20+ Mpps0.8 Mpps
대표 사용처OpenStack, K8s CNI개발/테스트XDP 가속 데몬서비스 체이닝관리·제어 평면
주요 함정메모리 영역 수, GPA 변환PACKET_FANOUT 필요드라이버 지원 편차양측 ring-size 일치GSO/체크섬 협상

선택 가이드: (a) 게스트 VM이 소비자라면 vhost-user가 사실상 표준입니다. (b) 같은 호스트 내 다른 VPP·유저 프로세스 간 서비스 체이닝은 memif가 가장 빠릅니다 — 실전 구성 예제는 오버레이 — memif 서비스 체이닝을 참고합니다. (c) 기존 커널 스택과 공존이 필요하면 AF_XDP를 먼저 고려하고, 드라이버 ZC 지원이 없을 때만 AF_PACKET으로 후퇴합니다. (d) TAP/virtio는 오로지 제어·관리용이며, 데이터 평면에 넣으면 안 됩니다.

AF_PACKET

AF_PACKET은 커널의 기존 네트워크 인터페이스를 통해 raw 패킷을 송수신하는 소켓 인터페이스입니다(net/packet/af_packet.c). DPDK처럼 NIC를 독점하지 않으므로 기존 커널 스택과 공존 가능합니다.

/* VPP: AF_PACKET 인터페이스 (커널의 eth1에 연결) */
vpp# create host-interface name eth1
vpp# set interface state host-eth1 up
vpp# set interface ip address host-eth1 10.0.0.1/24
용도: AF_PACKET은 DPDK보다 성능이 낮지만(커널 경유), NIC를 커널에서 분리할 필요가 없어 개발/테스트 환경에 적합합니다. PACKET_MMAP 링 버퍼(Ring Buffer)를 사용하여 시스템 콜(System Call) 오버헤드를 줄입니다.

VPP의 linux-cp(Linux Control Plane) 플러그인은 Netlink를 통해 커널 라우팅 테이블, ARP 엔트리, 인터페이스 상태를 VPP와 동기화합니다. 이를 통해 커널의 ip route, ip neigh 등의 명령이 VPP에도 반영됩니다.

/* linux-cp 플러그인 활성화 (startup.conf) */
plugins {
    plugin linux_cp_plugin.so { enable }
    plugin linux_cp_unittest_plugin.so { enable }
}

linux-cp {
    default netns dataplane
}

/* VPP CLI: 리눅스 인터페이스 미러링 */
vpp# lcp create tap0 host-if lcp-tap0

linux-cp 플러그인 내부 구현

linux-cp 플러그인의 핵심 개념은 미러링(Mirroring)입니다. VPP의 각 하드웨어 인터페이스에 대응하는 TAP 디바이스를 커널 네트워크 네임스페이스(Namespace)에 생성하고, Netlink 메시지를 통해 양방향으로 상태를 동기화합니다. 이를 통해 VPP가 커널의 라우팅 데몬(FRR, BIRD 등)과 투명하게 연동할 수 있습니다.

linux-cp 플러그인 — 양방향 동기화 아키텍처 VPP (User Space) Linux Kernel HW Interfaces FIB (ip4/ip6-fib) Neighbor Table (ARP/ND) LCP Pairs DB linux-cp plugin lcp_itf_pair_t TAP Mirror Interfaces Routing Table (FIB) ARP / Neighbor Cache FRR / BIRD RTM_NEWROUTE RTM_NEWROUTE TAP create/delete RTM_NEWNEIGH Data Path: dpdk-input → ip4-lookup → ... Host Punt: TAP → kernel stack punt → TAP inject VPP → Kernel 동기화 Kernel → VPP 학습 인터페이스 미러링

linux-cp의 핵심 자료구조는 lcp_itf_pair_t입니다. 이 구조체는 VPP 하드웨어 인터페이스(sw_if_index)와 커널 TAP 인터페이스(host_if_index)의 매핑을 유지합니다. 플러그인이 활성화되면 VPP는 다음 두 방향으로 동기화를 수행합니다:

linux-cp 설정과 운영

linux-cp 플러그인을 사용하려면 먼저 startup.conf에서 플러그인을 활성화한 후, VPP CLI에서 각 인터페이스에 대한 LCP 쌍(Pair)을 생성해야 합니다.

# 1. startup.conf에 linux-cp 플러그인 활성화
plugins {
    plugin linux_cp_plugin.so { enable }
    plugin linux_cp_unittest_plugin.so { enable }
}

linux-cp {
    default netns dataplane    # LCP 인터페이스가 생성될 네임스페이스
    lcp-sync                   # 커널 → VPP 동기화 활성화
    lcp-auto-subint            # 서브인터페이스 자동 미러링
}

# 2. VPP 시작 후 LCP 쌍 생성
vpp# lcp create GigabitEthernet0/8/0 host-if eth0
vpp# lcp create GigabitEthernet0/9/0 host-if eth1

# 3. 생성된 LCP 쌍 확인
vpp# show lcp
 lcp-pair: [0] GigabitEthernet0/8/0 phy-sw-if-idx 1 host-if eth0 lip-sw-if-idx 3
 lcp-pair: [1] GigabitEthernet0/9/0 phy-sw-if-idx 2 host-if eth1 lip-sw-if-idx 4

# 4. 커널에서 미러 인터페이스 확인
$ ip -n dataplane link show
3: eth0: <BROADCAST,MULTICAST,UP> mtu 1500 ...
4: eth1: <BROADCAST,MULTICAST,UP> mtu 1500 ...

# 5. 커널에서 IP 주소 설정 → VPP에 자동 반영
$ ip -n dataplane addr add 10.0.1.1/24 dev eth0
vpp# show interface addr GigabitEthernet0/8/0
GigabitEthernet0/8/0: 10.0.1.1/24
동기화 대상VPP → KernelKernel → VPPNetlink 메시지비고
인터페이스 상태 (UP/DOWN)OORTM_NEWLINK양방향 즉시 반영
IP 주소OORTM_NEWADDRlcp-sync 필요
IPv4/IPv6 경로OORTM_NEWROUTElcp-sync 필요
ARP/ND 이웃OORTM_NEWNEIGH양방향 즉시 반영
VLAN 서브인터페이스ORTM_NEWLINKlcp-auto-subint 필요
MTUOORTM_NEWLINK양방향 즉시 반영
ACL / Policy RouteXXVPP 전용, 커널에 반영 불가
NAT / CGNXXVPP 전용 기능

Netlink 메시지 처리의 내부 흐름은 다음과 같습니다:

/* linux-cp Netlink 수신 처리 의사 코드 */
static void
lcp_nl_route_add (struct nl_msg *msg)
{
    struct rtmsg *rtm = nlmsg_data (nlmsg_hdr (msg));
    fib_prefix_t pfx;
    fib_route_path_t *rpaths = NULL;

    /* 1. Netlink 메시지에서 prefix, gateway, oif 파싱 */
    lcp_nl_mk_prefix (rtm, msg, &pfx);
    lcp_nl_mk_paths (rtm, msg, &rpaths);

    /* 2. host-if index → VPP sw_if_index 변환
     *    LCP 쌍 DB에서 매핑 조회 */
    lcp_nl_remap_paths (rpaths);

    /* 3. VPP FIB에 경로 설치 */
    fib_table_entry_path_add2 (
        fib_index, &pfx,
        FIB_SOURCE_NL,     /* Netlink 소스 표시 */
        FIB_ENTRY_FLAG_NONE,
        rpaths);

    vec_free (rpaths);
}
코드 설명 보기

구조 커널에서 Netlink 소켓으로 RTM_NEWROUTE 메시지가 도착하면, lcp_nl_route_add()가 호출됩니다. 이 함수는 메시지에서 목적지 프리픽스(Prefix)와 경로 정보를 파싱합니다.

핵심 lcp_nl_remap_paths()가 핵심입니다. 커널이 보내는 인터페이스 인덱스(oif)는 TAP 디바이스를 가리키지만, VPP FIB에 설치할 때는 실제 하드웨어 인터페이스의 sw_if_index가 필요합니다. LCP 쌍 DB를 조회하여 이 변환을 수행합니다.

출처 FIB_SOURCE_NL로 표시된 경로는 Netlink에서 학습한 것임을 나타냅니다. VPP 자체가 설치한 경로(FIB_SOURCE_API)와 구분되어, 경로 충돌 시 우선순위(Priority) 판단에 사용됩니다.

linux-cp 제한 사항:
  • VPP의 고급 기능(ACL, NAT, Policer, Segment Routing 등)은 커널에 대응하는 기능이 없으므로 동기화되지 않습니다.
  • Netlink 메시지 처리는 메인 스레드에서 수행되므로, 대량의 경로 변경(예: BGP full table 수렴)이 발생하면 VPP 제어 평면 지연이 발생할 수 있습니다.
  • TAP 인터페이스를 통한 호스트 통신(ping 등)은 VPP 데이터 플레인을 경유하므로, punt 경로 설정이 필요합니다.
  • lcp-sync 옵션 없이 실행하면 Kernel → VPP 방향 동기화가 비활성화됩니다. FRR 연동 시 반드시 활성화하세요.

linux-cp 사용 시나리오

linux-cp 플러그인이 필요한 대표적인 상황은 VPP 라우터에서 표준 리눅스 라우팅 데몬을 실행하는 경우입니다. FRR(Free Range Routing)이나 BIRD 같은 라우팅 소프트웨어는 커널 네트워크 인터페이스와 라우팅 테이블을 통해 동작하므로, VPP의 인터페이스와 경로를 커널에 미러링해야 합니다.

VPP + FRR 통합 아키텍처 (linux-cp 기반) BGP Peer A BGP Peer B NIC (eth0) NIC (eth1) VPP Data Plane dpdk-input ip4-lookup ip4-rewrite lcp-punt linux-cp (Netlink sync) TAP eth0 / eth1 FRR Suite zebra (RIB) bgpd ospfd staticd punt TCP 179 Netlink RTM_NEWROUTE → VPP FIB sync Data Path Control Path Host Path

위 구성에서 패킷 흐름은 두 가지 경로로 나뉩니다:

FRR 연동 모범 사례: FRR의 zebra가 VPP FIB와 충돌 없이 동작하려면, FRR에서 ip nht resolve-via-default를 설정하여 기본 경로를 통한 next-hop 해석을 허용하세요. 또한 lcp-synclcp-auto-subint를 함께 활성화하면 VLAN 인터페이스까지 자동으로 미러링됩니다.

host-interface (AF_XDP)

AF_XDP(net/xdp/xsk.c)는 커널 5.4+에서 사용 가능한 고성능 패킷 I/O 인터페이스입니다. eBPF/XDP 프로그램과 연계하여 커널 네트워크 스택을 우회하면서도 NIC 드라이버를 교체할 필요가 없습니다.

/* VPP: AF_XDP 인터페이스 생성 */
vpp# create interface af_xdp host-if eth0 num-rx-queues 4
vpp# set interface state af_xdp-eth0 up
DPDK vs AF_XDP: DPDK는 NIC를 커널에서 완전히 분리(UIO/VFIO)하여 최고 성능을 제공하지만 커널 스택과 공존이 불가합니다. AF_XDP는 NIC 드라이버를 유지하면서 XDP 후킹으로 VPP에 패킷을 전달하므로, 일부 트래픽은 커널 스택이 처리하고 특정 트래픽만 VPP로 보낼 수 있습니다.

memif 내부 구현 상세

memif(Memory Interface)는 VPP 인스턴스 간 또는 VPP와 사용자 공간(User Space) 애플리케이션 간의 고성능 패킷 교환을 위해 설계된 공유 메모리 기반 인터페이스입니다. 커널을 완전히 우회하며, 공유 메모리 링 버퍼를 통해 제로 카피(Zero-copy) 방식으로 패킷을 전달합니다.

VPP 인스턴스 A (Master) memif TX 핸들러 memif RX 핸들러 VPP 인스턴스 B (Slave) memif RX 핸들러 memif TX 핸들러 공유 메모리 영역 (/dev/shm/memif-*) Ring 0 (A→B 방향) desc[0] desc[1] ... desc[n] head/tail Ring 1 (B→A 방향) desc[0] desc[1] ... desc[n] 패킷 버퍼 풀 (packet_buffer[0 .. buffer_size-1]) enqueue dequeue enqueue dequeue 디스크립터는 버퍼 인덱스만 교환 → 제로 카피 head/tail volatile 포인터로 lock-free 동기화
/* memif 공유 메모리 링 디스크립터 구조 */
typedef struct {
  uint16_t flags;          /* 디스크립터 플래그 (NEXT 체인 등) */
  uint16_t region;         /* 버퍼가 위치한 공유 메모리 리전 인덱스 */
  uint32_t length;         /* 패킷 데이터 길이 (바이트) */
  uint64_t offset;         /* 리전 시작부터 버퍼까지의 오프셋 */
  uint32_t metadata;       /* 사용자 정의 메타데이터 */
} memif_desc_t;

/* 공유 메모리 링 구조 */
typedef struct {
  volatile uint16_t head;  /* 생산자가 증가 (다음 쓰기 위치) */
  volatile uint16_t tail;  /* 소비자가 증가 (다음 읽기 위치) */
  uint16_t cookie;         /* 버전 검증용 매직 값 (0xD00D) */
  uint16_t flags;          /* 링 플래그 (인터럽트 모드 등) */
  memif_desc_t desc[0];    /* 가변 길이 디스크립터 배열 */
} memif_ring_t;
코드 설명
  • memif_desc_t각 디스크립터는 24바이트 크기이며, regionoffset으로 패킷 데이터 위치를 지정합니다. flags의 NEXT 비트로 점보 프레임을 분할 전송할 수 있습니다.
  • head / tail생산자-소비자 패턴의 핵심입니다. 생산자(TX)는 head를, 소비자(RX)는 tail을 증가시킵니다. head == tail이면 링이 비어 있습니다.
  • volatile서로 다른 프로세스가 동시에 접근하므로, 컴파일러 최적화(Compiler Optimization)에 의한 캐싱을 방지합니다.
필드크기역할접근 주체
head2바이트다음 쓰기 위치 (생산자 포인터)생산자(TX) 쓰기 / 소비자(RX) 읽기
tail2바이트다음 읽기 위치 (소비자 포인터)소비자(RX) 쓰기 / 생산자(TX) 읽기
cookie2바이트memif 프로토콜 버전 매직 값 (0xD00D)초기화 시 검증
desc[]24B × N패킷 위치를 가리키는 디스크립터 배열생산자 쓰기 / 소비자 읽기
buffer[]가변실제 패킷 데이터가 저장되는 버퍼 영역양측 직접 접근 (제로 카피)

memif 연결 설정과 링 초기화

memif 연결은 마스터/슬레이브(Master/Slave) 모델을 따릅니다. 마스터가 유닉스 도메인 소켓에서 대기하고, 슬레이브가 연결을 시도합니다. 연결이 수립되면 마스터가 공유 메모리 파일(/dev/shm/memif-*)을 생성하고 파일 디스크립터(File Descriptor)를 SCM_RIGHTS 앵커리 메시지로 슬레이브에게 전달합니다.

# VPP 인스턴스 A (Master)
vpp# create memif socket id 1 filename /run/vpp/memif.sock
vpp# create interface memif id 0 socket-id 1 master \
    ring-size 1024 buffer-size 2048 rx-queues 2 tx-queues 2
vpp# set interface state memif1/0 up
vpp# set interface ip address memif1/0 10.0.0.1/24

# VPP 인스턴스 B (Slave)
vpp# create memif socket id 1 filename /run/vpp/memif.sock
vpp# create interface memif id 0 socket-id 1 slave \
    ring-size 1024 buffer-size 2048 rx-queues 2 tx-queues 2
vpp# set interface state memif1/0 up
vpp# set interface ip address memif1/0 10.0.0.2/24

# 연결 상태 확인
vpp# show memif
링 크기 최적화: memif의 기본 링 크기는 1024개 디스크립터입니다. 높은 처리량이 필요한 환경에서는 ring-size 2048 또는 ring-size 4096으로 증가시킬 수 있습니다. 단, 링 크기는 2의 거듭제곱이어야 합니다. 다중 큐 설정(rx-queues, tx-queues)은 멀티코어 환경에서 RSS와 유사한 병렬 처리를 제공합니다.

AF_XDP 내부 동작 상세

AF_XDP(Address Family XDP)는 리눅스 커널 4.18에서 도입된 소켓 인터페이스로, XDP 프로그램과 사용자 공간 애플리케이션 사이의 고성능 패킷 교환 채널을 제공합니다. 커널 네트워크 스택을 부분적으로 우회하면서도, 커널이 여전히 NIC 드라이버와 하드웨어를 관리하므로 DPDK와 달리 커널의 보안 모델과 자원 관리를 유지합니다.

AF_XDP의 핵심은 4개의 링 버퍼와 1개의 공유 메모리 영역(UMEM)으로 구성된 아키텍처입니다:

커널 공간 NIC 드라이버 XDP 프로그램 (XDP_REDIRECT) FILL Ring 빈 버퍼 주소 공급 COMPLETION Ring TX 완료 통보 사용자 공간 (VPP) af_xdp_device_input() / output() RX Ring 수신 패킷 정보 TX Ring 송신 패킷 정보 UMEM (공유 메모리 영역) frame[0] frame[1] ... frame[n] 각 프레임 = 4KB (XDP_UMEM_MIN_CHUNK_SIZE) ① 빈 버퍼 주소 공급 (FILL) ③ RX 디스크립터 전달 모든 링은 UMEM 프레임의 주소(오프셋)만 교환합니다 — 패킷 데이터 복사 없음

AF_XDP vs DPDK 비교

비교 항목AF_XDPDPDK
커널 우회 수준부분 우회 (NIC 드라이버는 커널 관리)완전 우회 (UIO/VFIO로 NIC 분리)
NIC 공유가능 (커널 스택과 공유)불가능 (NIC 독점)
성능 (64B)~24 Mpps (단일 코어)~37 Mpps (단일 코어)
성능 (1518B)~14.8 Mpps (라인 레이트)~14.8 Mpps (라인 레이트)
보안 모델커널 보안 프레임워크 유지커널 보안 우회
컨테이너(Container) 환경우수 (표준 소켓 인터페이스)복잡 (VFIO passthrough 필요)
드라이버표준 커널 드라이버 (XDP 지원 필요)전용 PMD 필요
실무 권장: 10GbE 환경에서 대부분의 워크로드는 AF_XDP 제로 카피 모드로 라인 레이트에 도달할 수 있습니다. DPDK 대비 성능 격차는 주로 64바이트 소형 패킷에서 나타나며, 실제 트래픽에서 이러한 극단적 조건은 드뭅니다. 운영 편의성과 보안이 중요한 환경에서는 AF_XDP를, 극한 성능이 요구되는 전용 네트워크 어플라이언스에서는 DPDK를 권장합니다.

커널 인터페이스 선택 가이드

인터페이스성능 등급주요 사용 사례제로 카피NIC 공유
TAP/TUN낮음 (~1 Gbps)호스트 스택 연동, 디버깅미지원해당 없음
AF_PACKET낮음 (~3 Gbps)기존 NIC 공유, 레거시PACKET_MMAP (부분)가능
vhost-user중간 (~10 Gbps)VM-VPP 연동 (QEMU/KVM)지원 (virtio)해당 없음
memif높음 (~40 Gbps)VPP 인스턴스 간, 컨테이너 간완전 지원해당 없음
AF_XDP높음 (~25 Gbps)물리 NIC 고성능, 클라우드XDP_ZEROCOPY가능
DPDK매우 높음 (~100 Gbps)전용 네트워크 어플라이언스완전 지원불가능

L2 브릿징/스위칭

VPP는 커널의 net/bridge/와 유사한 L2 브릿지 도메인을 제공합니다. MAC 학습, 플러딩, BUM(Broadcast/Unknown unicast/Multicast) 트래픽 처리를 지원합니다.

브릿지 도메인 내부적으로 VPP는 해시 기반 MAC 학습 테이블을 유지하며, 각 엔트리는 MAC 주소와 수신 인터페이스(sw_if_index)의 매핑으로 구성됩니다. 학습된 MAC 엔트리에는 에이징 타이머(aging timer)가 적용되어 기본 300초 동안 트래픽이 없으면 자동으로 삭제됩니다. 목적지 MAC이 학습 테이블에 없는 유니캐스트(Unknown Unicast), 브로드캐스트, 멀티캐스트를 통칭하는 BUM 트래픽은 브릿지 도메인 내 모든 멤버 포트로 플러딩(flooding)됩니다. 이때 스플릿 호라이즌 그룹(Split-Horizon Group)을 설정하면 동일 그룹에 속한 포트 간에는 플러딩이 억제되어 루프 방지 및 불필요한 트래픽 전파를 차단할 수 있습니다. BVI(Bridge Virtual Interface)는 브릿지 도메인에 L3 라우팅 기능을 연결하는 가상 인터페이스로, BVI에 IP 주소를 할당하면 해당 브릿지 도메인의 트래픽이 VPP의 라우팅 테이블로 전달됩니다. 이를 통해 동일 브릿지 도메인 내에서 L2 스위칭과 L3 라우팅을 동시에 수행하는 IRB(Integrated Routing and Bridging) 구성이 가능합니다.

브릿지 도메인 (Bridge Domain 1) GigE0/8/0 (포트1) GigE0/9/0 (포트2) tap0 (포트3) BVI (L3 Gateway) IP: 192.168.1.1/24 MAC 학습 테이블 (Hash Table) MAC 주소 포트 aa:bb:cc:00:01:01 GigE0/8/0 aa:bb:cc:00:02:02 GigE0/9/0 aa:bb:cc:00:03:03 tap0 에이징 타이머 기본값: 300초 만료 시 엔트리 자동 삭제 BUM 트래픽 플러딩 경로 Broadcast → 모든 멤버 포트로 복제 전송 Unknown Unicast → MAC 미학습 시 플러딩 Multicast → IGMP 스누핑 미적용 시 플러딩 Split-Horizon 같은 그룹 간 플러딩 억제 L3 라우팅 테이블 IRB: L2 스위칭 + L3 라우팅 동시 수행 ip4-lookup 노드 연결
/* 브릿지 도메인 생성 및 인터페이스 추가 */
vpp# create bridge-domain 1 learn 1 forward 1 flood 1
vpp# set interface l2 bridge GigabitEthernet0/8/0 1
vpp# set interface l2 bridge GigabitEthernet0/9/0 1
vpp# set interface l2 bridge tap0 1 bvi
vpp# show bridge-domain 1 detail

l2-learn 노드 내부 구현

VPP의 L2 MAC 학습은 l2-learn 노드에서 수행됩니다. 이 노드는 수신 패킷의 소스 MAC 주소(Source MAC)를 추출하여 L2 FIB 해시 테이블에 등록하거나 갱신합니다. quad-loop 패턴을 통해 파이프라인 효율을 극대화합니다.

에이징 메커니즘: VPP는 l2fib_mac_age_scanner_process() 프로세스가 주기적으로 실행되어 현재 시각과 각 엔트리의 타임스탬프를 비교합니다. 브릿지 도메인별로 설정된 mac_age 값(분 단위)을 초과한 엔트리는 자동으로 삭제됩니다.
/* src/vnet/l2/l2_learn.c - l2learn_node_inline() 간소화 의사 코드 */
static_always_inline uword
l2learn_node_inline (vlib_main_t *vm,
                      vlib_node_runtime_t *node,
                      vlib_frame_t *frame,
                      int do_trace)
{
  u32 n_left, *from;
  l2learn_main_t *msm = &l2learn_main;
  f64 timestamp = vlib_time_now (vm);

  from = vlib_frame_vector_args (frame);
  n_left = frame->n_vectors;

  /* quad-loop: 4개 패킷 동시 처리 */
  while (n_left >= 8)
    {
      vlib_prefetch_buffer_header (b[4], LOAD);
      vlib_prefetch_buffer_header (b[5], LOAD);
      vlib_prefetch_buffer_header (b[6], LOAD);
      vlib_prefetch_buffer_header (b[7], LOAD);

      for (int i = 0; i < 4; i++)
        {
          ethernet_header_t *eh = vlib_buffer_get_current (b[i]);
          u32 bd_index = vnet_buffer(b[i])->l2.bd_index;
          u32 sw_if_index = vnet_buffer(b[i])->sw_if_index[VLIB_RX];

          /* 소스 MAC + BD 인덱스로 해시 키 생성 */
          l2fib_entry_key_t key;
          l2fib_make_key (eh->src_address, bd_index, &key);

          /* L2 FIB 해시 테이블에서 조회 */
          l2fib_entry_result_t result;
          u32 bucket = l2fib_lookup (msm->mac_table, &key, &result);

          if (PREDICT_FALSE (result.fields.age_not == 0))
            {
              /* 엔트리 없음 → 신규 MAC 학습 */
              l2fib_add_entry (key.raw, bd_index, sw_if_index,
                               L2FIB_ENTRY_RESULT_FLAG_NONE, timestamp);
            }
          else
            {
              result.fields.timestamp = timestamp;
              /* MAC 이동 감지: 포트가 변경된 경우 업데이트 */
              if (PREDICT_FALSE (
                    result.fields.sw_if_index != sw_if_index))
                {
                  result.fields.sw_if_index = sw_if_index;
                  if (result.fields.static_mac)
                    goto skip_update;
                }
              l2fib_update_entry (bucket, &result);
            }
          skip_update:
          vnet_buffer(b[i])->l2.feature_bitmap &= ~L2INPUT_FEAT_LEARN;
        }
      b += 4;
      n_left -= 4;
    }
  /* single-loop: 나머지 1~3개 패킷 처리 */
  while (n_left > 0) { /* ... 동일 로직 ... */ n_left--; }
  return frame->n_vectors;
}
코드 설명
  • quad-loop4개 패킷을 동시에 처리하며, 다음 4개를 미리 캐시에 적재(prefetch)하여 메모리 지연를 숨깁니다.
  • l2fib_make_key()6바이트 MAC + BD 인덱스 2바이트를 하나의 u64(8바이트 키)로 패킹합니다.
  • l2fib_lookup()clib_bihash 해시 테이블에서 lock-free 조회를 수행합니다.
  • MAC 이동 감지동일 MAC이 다른 포트에서 수신되면 sw_if_index를 갱신합니다. 정적 엔트리는 변경하지 않습니다.
  • PREDICT_FALSE신규 MAC 학습은 드물게 발생하므로, 분기 예측 힌트로 fast path를 최적화합니다.

l2-fwd 노드 내부 구현

MAC 학습이 완료된 패킷은 l2-fwd 노드로 전달됩니다. 이 노드는 목적지 MAC 주소를 L2 FIB에서 조회하여 출력 인터페이스를 결정합니다. BUM(Broadcast, Unknown unicast, Multicast) 트래픽은 브릿지 도메인 내 모든 포트로 플러딩됩니다.

/* src/vnet/l2/l2_fwd.c - l2fwd_node_inline() 간소화 의사 코드 */
static_always_inline uword
l2fwd_node_inline (vlib_main_t *vm,
                    vlib_node_runtime_t *node,
                    vlib_frame_t *frame)
{
  u32 n_left, *from, next_index;
  from = vlib_frame_vector_args (frame);
  n_left = frame->n_vectors;

  while (n_left > 0)
    {
      vlib_buffer_t *b0 = vlib_get_buffer (vm, from[0]);
      ethernet_header_t *eh = vlib_buffer_get_current (b0);
      u32 bd_index = vnet_buffer(b0)->l2.bd_index;

      /* 목적지 MAC + BD 인덱스로 L2 FIB 조회 */
      l2fib_entry_key_t key;
      l2fib_make_key (eh->dst_address, bd_index, &key);
      l2fib_entry_result_t result;
      int hit = l2fib_lookup (msm->mac_table, &key, &result);

      if (PREDICT_TRUE (hit))
        {
          if (result.fields.bvi)
            {
              /* BVI 포트 → L3 라우팅으로 전환 (IRB) */
              next_index = L2FWD_NEXT_L2_INPUT_VTR;
            }
          else
            {
              /* 일반 L2 포워딩 */
              next_index = L2FWD_NEXT_L2_OUTPUT;
            }
          vnet_buffer(b0)->sw_if_index[VLIB_TX] =
              result.fields.sw_if_index;

          /* Split-horizon 검사: 동일 그룹이면 드롭 */
          u32 rx_shg = vnet_buffer(b0)->l2.shg;
          if (PREDICT_FALSE (rx_shg != 0 &&
                rx_shg == result.fields.shg))
            next_index = L2FWD_NEXT_DROP;
        }
      else
        {
          /* FIB 미스: BUM 트래픽 → 플러딩 */
          if (ethernet_address_is_multicast (eh->dst_address)
              || ethernet_address_is_broadcast (eh->dst_address))
            next_index = L2FWD_NEXT_FLOOD;
          else
            {
              /* Unknown unicast: BD 설정에 따라 플러딩 또는 드롭 */
              l2_bridge_domain_t *bd =
                  vec_elt_at_index (l2input_main.bd_configs, bd_index);
              if (bd->feature_bitmap & L2INPUT_FEAT_UU_FLOOD)
                next_index = L2FWD_NEXT_FLOOD;
              else
                next_index = L2FWD_NEXT_DROP;
            }
        }
      vlib_validate_buffer_enqueue_x1 (vm, node, next_index,
          to_next, n_left_to_next, from[0], next_index);
      from++;
      n_left--;
    }
  return frame->n_vectors;
}
코드 설명
  • FIB 히트목적지 MAC이 테이블에 존재하면 sw_if_index로 유니캐스트 전달합니다. BVI 포트이면 L3 경로로 전환됩니다.
  • Split-Horizon수신 포트와 송신 포트가 동일 SHG에 속하면 드롭합니다. VPLS 등 루프 방지에 필수적입니다.
  • BUM 플러딩FIB 미스 시 l2-flood 노드로 전달되어 브릿지 도메인 내 모든 포트로 복제 전송됩니다.
  • BVI 분기BVI 포트로 향하는 패킷은 l2-input-vtr을 거쳐 ip4-input으로 전달되어 IRB가 가능합니다.

L2 FIB 해시 테이블 구현

VPP의 L2 FIB는 clib_bihash_8_8(Bounded-index Extensible Hashing) 자료구조를 기반으로 구현됩니다. lock-free 조회를 지원하며 수백만 개의 MAC 엔트리를 관리할 수 있습니다.

L2 FIB 해시 테이블 구조 (clib_bihash_8_8) 키 생성 (l2fib_make_key) MAC 6B + BD-ID 2B clib_bihash 8_8 해시 버킷 배열 (각 버킷 4 KV 쌍) Bucket 0 Bucket N Bucket M l2fib_entry_result_t (값: 8바이트) sw_if_index (32b) timestamp (8b) SHG (8b) flags (8b) filter age_not / lrn_evt 에이징 프로세스 (l2fib_mac_age_scanner_process) 주기적 스캔 now - timestamp > mac_age? 엔트리 삭제
필드크기설명
sw_if_index32비트패킷을 전달할 출력 인터페이스 인덱스
timestamp8비트마지막 학습/갱신 시각 (분 단위, 에이징 비교용)
shg8비트Split-Horizon Group 번호 (0이면 비활성)
static_mac1비트정적 MAC 엔트리 여부 (에이징·이동 불가)
bvi1비트BVI 포트 엔트리 여부 (L3 라우팅 전환)
filter1비트MAC 필터링 엔트리 (매칭 시 드롭)
age_not1비트엔트리 존재 여부 (0이면 빈 슬롯)
lrn_evt1비트학습 이벤트 알림 플래그 (API 통지용)

L2 패킷 처리 노드 체인

L2 패킷 처리는 노드 그래프 기반으로 동작합니다. BVI를 통한 라우팅 트래픽은 l2-fwd 노드에서 L3 경로로 분기됩니다.

L2 브릿지 도메인 패킷 처리 노드 그래프 L2 포워딩 경로 ethernet-input l2-input l2-input-classify l2-learn l2-fwd l2-output interface-output 프레임 파싱 BD 매핑 ACL/분류 소스 MAC 학습 목적지 조회 VLAN/리라이트 물리 전송 BVI 분기 (L3 라우팅 전환) l2-input-vtr ip4-input ip4-lookup BUM 플러딩 경로 l2-flood 패킷 복제 + 전체 포트 전송 (SHG 필터 적용)

브릿지 도메인 실전 활용 예제

다음은 L2 브릿지 도메인을 생성하고 IRB(Integrated Routing and Bridging)를 구성하는 전체 워크플로우입니다.

# 1. 브릿지 도메인 생성 (BD-ID 100, MAC 에이징 5분)
vpp# create bridge-domain 100 learn 1 forward 1 flood 1 uu-flood 1 mac-age 5

# 2. 물리 인터페이스 추가 (SHG 0)
vpp# set interface l2 bridge GigabitEthernet0/8/0 100 0
vpp# set interface l2 bridge GigabitEthernet0/9/0 100 0

# 3. VXLAN 터널 추가 (SHG 1 — 동일 SHG 간 플러딩 차단)
vpp# create vxlan tunnel src 10.0.0.1 dst 10.0.0.2 vni 1000
vpp# set interface l2 bridge vxlan_tunnel0 100 1

# 4. BVI 인터페이스 생성 (IRB용 L3 게이트웨이)
vpp# bvi create instance 100
vpp# set interface state bvi100 up
vpp# set interface ip address bvi100 192.168.100.1/24
vpp# set interface l2 bridge bvi100 100 bvi

# 5. 인터페이스 활성화 후 확인
vpp# set interface state GigabitEthernet0/8/0 up
vpp# set interface state GigabitEthernet0/9/0 up
vpp# show bridge-domain 100 detail
vpp# show l2fib all

# 6. 정적 MAC / MAC 필터 설정
vpp# l2fib add 00:aa:bb:cc:dd:ee 100 GigabitEthernet0/8/0 static
vpp# l2fib add 00:de:ad:be:ef:00 100 filter
MAC 학습 제한: 대규모 환경에서는 set bridge-domain learn-limit <bd_id> <count> 명령으로 브릿지 도메인별 학습 상한을 설정하여 메모리 소모를 방지해야 합니다.

L3 라우팅 (FIB)

VPP의 FIB(Forwarding Information Base)는 mtrie(multi-way trie) 구조를 사용하여 O(1) 시간에 longest-prefix match를 수행합니다. 8-16-8 stride 구조로 최대 3회의 메모리 접근으로 lookup이 완료됩니다.

VRF 기초 — 다중 라우팅 테이블 격리

VRF(Virtual Routing and Forwarding)는 한 VPP 인스턴스 안에서 여러 개의 독립된 라우팅 테이블을 운영하기 위한 기본 단위입니다. 멀티테넌트 게이트웨이, MPLS L3VPN의 PE 라우터, 같은 사설 대역(예: 10.0.0.0/8)을 쓰는 고객의 트래픽 분리에 필수입니다. VPP에서 VRF는 FIB 테이블 ID(fib_index)로 식별되며, IPv4와 IPv6는 서로 다른 ID 공간을 가집니다.

# IPv4 VRF 10번 테이블 생성
vpp# ip table add 10
vpp# ip6 table add 10

# 인터페이스를 VRF 10에 바인딩 (이전 VRF의 모든 주소가 제거됨)
vpp# set interface ip table GigabitEthernet0/8/0 10
vpp# set interface ip address GigabitEthernet0/8/0 192.168.10.1/24

# VRF 10에 라우트 추가
vpp# ip route add table 10 0.0.0.0/0 via 192.168.10.254

# 확인
vpp# show ip fib table 10
vpp# show ip fib summary

VRF 내부 모델

각 VRF는 독립된 fib_table_t를 가지고, 그 안에 자체 ip4_fib_t(mtrie)와 mfib를 보유합니다. 인터페이스를 VRF에 바인딩하면 해당 인터페이스로 들어온 패킷이 ip4-input에서 vnet_buffer(b)->sw_if_index[VLIB_RX]로부터 fib_index를 얻고, 이 인덱스로 FIB lookup을 수행합니다. 따라서 같은 IP 주소가 여러 VRF에 존재해도 충돌하지 않습니다.

VRF 간 라우트 리킹

두 VRF가 일부 경로를 공유해야 할 때(예: 공유 인터넷 게이트웨이) route leaking을 사용합니다. VPP는 두 가지 방법을 제공합니다:

VRF 트러블슈팅 — 자주 만나는 함정:
  • 인터페이스를 VRF에 옮긴 뒤 IP 주소가 사라짐set interface ip table은 의도된 동작입니다. 바인딩 후 주소를 다시 설정해야 합니다.
  • 다른 VRF로 라우팅 안 됨 — 두 VRF는 기본적으로 완전히 격리됩니다. lookup DPO 또는 leaking 설정 없이는 통신할 수 없습니다.
  • NAT44가 다른 VRF로 변환 안 됨 — NAT44 인터페이스 모드가 fib_index를 따라가는지 확인합니다. 보안 — NAT44의 ED 모드는 VRF 인식이 가능합니다.
  • BGP/OSPF가 VRF에서 동작 안 함 — Linux CP를 통해 FRR/BIRD를 연동할 때, 라우팅 데몬이 어느 VRF의 경로를 읽는지 명시해야 합니다.
FIB mtrie 룩업 흐름 (8-16-8 stride) Dest IP 10.1.2.3 Ply 0 8비트 (256 엔트리) index = 10 Ply 1 16비트 (65536 엔트리) index = 1.2 (0x0102) Ply 2 8비트 (256 엔트리) index = 3 Load Balance → Adjacency → ip4-rewrite mtrie 경로: 첫 8비트 → 다음 16비트 → 마지막 8비트 = 최대 3회 메모리 접근 결과: load-balance 객체 → adjacency → ip4-rewrite 노드에서 MAC 헤더 재작성 FIB 엔트리 유형: attached | attached-host | connected | local | drop | receive | special | deag (재귀 룩업) ECMP: load-balance 객체가 여러 adjacency를 해시 기반으로 분산
/* IP 라우팅 설정 */
vpp# ip route add 10.0.0.0/8 via 192.168.1.1
vpp# ip route add 0.0.0.0/0 via 192.168.1.1

/* ECMP (Equal-Cost Multi-Path) */
vpp# ip route add 10.0.0.0/8 via 192.168.1.1 via 192.168.2.1

/* FIB 테이블 확인 */
vpp# show ip fib
vpp# show ip fib table 0 summary

DPO (Data-Path Object) 체인 구현

1. DPO란 무엇이고 왜 도입되었는가

DPO(Data-Path Object)는 VPP의 포워딩 평면에서 "패킷이 다음에 무엇을 해야 하는가"를 표현하는 추상화된 작은 객체입니다. 타입(type)과 인덱스(index)만 들고 다니는 16바이트 디스크립터 한 장이 실제 동작(rewrite, drop, receive, classify, lookup, replicate 등)과 다음 그래프 노드(next node)를 간접적으로 지정합니다. 즉 DPO는 컨트롤 플레인(FIB, CLI, 플러그인)이 생산하고 데이터 플레인(그래프 노드)이 소비하는 포워딩 의도(intent)의 최소 단위라고 볼 수 있습니다.

DPO가 도입되기 전, 초창기 VPP의 IP 포워딩은 adjacency 구조체에 next-node 인덱스와 rewrite 헤더를 직접 묶어 두는 단순 구조였습니다. 그러나 MPLS, SRv6, GRE/VXLAN, NAT, ECMP, 재귀 라우팅, 정책 기반 라우팅(PBR), 멀티캐스트 복제 등 포워딩 변형이 늘어나면서 "FIB 엔트리가 직접 최종 동작을 기술한다"는 모델은 확장성과 재사용성 모두에서 한계에 부딪혔습니다. DPO는 이 문제를 다음 세 가지 원칙으로 해결합니다.

비유로 이해하기. DPO 체인은 유닉스 파이프라인과 닮았습니다. cat file | grep foo | sort | uniq에서 각 프로그램은 독립적으로 존재하고 스트림으로 연결됩니다. VPP에서 패킷은 "스트림"이고, 각 DPO는 "프로그램"에 해당하며, dpoi_next_node가 파이프 연결을 담당합니다. 그래서 DPO 체인이 바뀌면 파이프라인을 재조립할 뿐, 패킷 처리 코드 자체는 전혀 손대지 않아도 됩니다.
2. dpo_id_t — 16바이트 포워딩 디스크립터

DPO 그 자체는 특정 타입의 객체(예: load_balance_t, ip_adjacency_t)이지만, 이를 "가리키는 포인터" 역할을 하는 것이 dpo_id_t입니다. FIB 엔트리, 그래프 노드의 opaque 필드, load-balance의 버킷 등 거의 모든 곳에서 DPO는 이 디스크립터 형태로 전달됩니다.

/* src/vnet/dpo/dpo.h — 디스크립터는 정확히 16바이트, 캐시라인 친화적 */
typedef struct dpo_id_t_ {
    dpo_type_t  dpoi_type;       /* 1B: 타입 ID (DPO_LOAD_BALANCE, DPO_ADJACENCY, ...) */
    dpo_proto_t dpoi_proto;      /* 1B: 프로토콜 (IP4/IP6/MPLS/ETHERNET/NSH) */
    u16         dpoi_next_node;  /* 2B: 소비 노드의 next index (dispatch 전용) */
    index_t     dpoi_index;      /* 4B: 타입별 풀(pool)에서의 오브젝트 인덱스 */
} dpo_id_t;                       /* 총 8B + padding, 로드/저장 1회로 복사 가능 */

/* 포인터가 아니라 index를 쓰는 이유:
 *  - 포인터(8B) 대신 4B index를 써서 디스크립터 크기를 절반으로
 *  - DPDK-style 풀(pool) 재할당 시 기존 참조가 깨지지 않음
 *  - 워커 스레드가 공유하는 구조체에 원자적(Atomic) store가 가능 */
코드 설명
  • dpoi_type컴파일 타임에 고정된 built-in 타입(예: DPO_ADJACENCY)뿐 아니라, 플러그인이 런타임에 dpo_register_new_type()으로 등록한 동적 타입도 담을 수 있습니다. 타입 값은 그대로 vtable 배열의 인덱스로 사용됩니다.
  • dpoi_next_node"이 DPO를 만났을 때 어느 그래프 노드로 점프해야 하는가"를 나타냅니다. 같은 타입이라도 부모 노드가 무엇이냐(ip4-lookup인지 ip6-lookup인지)에 따라 다른 값이 채워지므로, DPO 등록 시 부모 노드별로 next index를 사전 계산하여 저장합니다. 런타임에 vlib_get_next_node()를 호출하지 않고 바로 분기할 수 있어 hot path에서 branch 한 번이면 끝납니다.
  • dpoi_index타입별 오브젝트 풀의 배열 인덱스입니다. 예를 들어 dpoi_type == DPO_ADJACENCY이면 adj_pool[dpoi_index]가 실제 ip_adjacency_t입니다. 포인터를 쓰지 않아 pool grow(realloc) 시에도 참조가 안전합니다.
3. DPO 체인 구조 한눈에 보기

FIB 엔트리는 직접 패킷 처리 동작을 수행하지 않습니다. 대신 DPO 체인을 통해 패킷의 다음 처리 경로를 결정합니다. FIB 엔트리는 항상 load-balance DPO를 "진입점"으로 가리키며, ECMP(Equal-Cost Multi-Path) 라우팅의 경우 내부에 여러 버킷(Bucket)이 존재하여 각각 서로 다른 하위 DPO를 가리킵니다. 버킷이 하나뿐인 단일 경로인 경우에도 load-balance는 항상 존재하며(이때는 단순 wrapper 역할), 이렇게 해서 hot path 코드가 항상 "FIB 엔트리 → load-balance → 자식 DPO"라는 동일한 3단 접근을 가정할 수 있게 됩니다.

DPO 체인 구조와 ECMP 경로 선택 FIB Entry 10.0.1.0/24 load-balance buckets: 2, hash: src,dst 5-tuple flow hash 기반 bucket 0 bucket 1 adjacency (adj-idx 24) → GigE0/0/0 adjacency (adj-idx 25) → GigE0/0/1 rewrite L2 header rewrite L2 header 기타 DPO 타입: receive drop classify punt lookup replicate ECMP: 패킷의 5-tuple 해시값을 bucket 수로 나눈 나머지로 경로 선택 → 동일 플로우는 항상 같은 경로
DPO 타입설명사용 예시
adjacencyL2 rewrite 후 전달일반 IP 포워딩 (next-hop이 직접 연결 네트워크)
receive로컬 호스트로 패킷 수신VPP 자체 인터페이스 IP 주소로의 패킷
drop패킷 폐기블랙홀 라우트, unreachable 경로
classify분류 테이블에 따라 다음 DPO 결정PBR(Policy-Based Routing)
punt슬로 패스로 전달컨트롤 플레인 처리가 필요한 패킷
lookup다른 FIB 테이블에서 재조회VRF 간 라우트 리킹(Leaking)
replicate패킷 복제하여 여러 경로 전달멀티캐스트 포워딩
ip-nullNULL 경로로 폐기 + ICMP 응답unreachable / prohibit 라우트
4. DPO vtable — 타입별 동작 디스패치

DPO의 핵심은 "공통 디스크립터 + 타입별 vtable"이라는 객체지향적 구조입니다. 각 DPO 타입은 생성/참조/해제/덤프(Dump)/체인 연결 등에 사용되는 함수 포인터 집합을 dpo_vft_t로 등록하며, FIB 코어는 이 vtable을 타입 인덱스로 바로 끌어 씁니다. 새 포워딩 방식을 추가하는 일은 "새 vtable 하나를 구현하고 등록하는 일"로 축소됩니다.

/* src/vnet/dpo/dpo.h — DPO 타입이 구현해야 할 함수 테이블 */
typedef struct dpo_vft_t_ {
    /* 참조 카운팅: 이 DPO를 참조하는 상위 객체가 생길 때/사라질 때 호출 */
    void (*dv_lock)  (dpo_id_t *dpo);
    void (*dv_unlock)(dpo_id_t *dpo);

    /* CLI/debug용: "show ..." 명령이 DPO를 문자열로 출력할 때 */
    u8 * (*dv_format)(index_t index, u8 *s);

    /* 그래프 노드 연결: 어느 부모 노드에서 이 DPO로 점프할 수 있는지 등록 */
    void (*dv_mem_show)(void);
    dpo_mem_show_t dv_get_next_node;

    /* 체인 연결 확인: 하위 DPO의 타입이 내 뒤에 올 수 있는지 검사 */
    int (*dv_is_drop)(const dpo_id_t *dpo);
} dpo_vft_t;

/* 타입 등록 — 보통 플러그인/모듈 init에서 1회 호출 */
dpo_register(DPO_LOAD_BALANCE, &load_balance_vft, load_balance_nodes);
dpo_register(DPO_ADJACENCY,    &adj_nbr_dpo_vft, ip4_nodes);
dpo_register(DPO_DROP,         &drop_vft,         ip4_nodes);

/* 플러그인이 동적으로 새 타입을 받아갈 수도 있음 */
dpo_type_t my_custom_type = dpo_register_new_type(&my_vft, my_nodes);
코드 설명
  • dv_lock / dv_unlockDPO는 참조 카운팅으로 수명이 관리됩니다. 상위 객체가 dpo_stack()으로 자식 DPO를 연결할 때 lock이 호출되고, dpo_reset() 시 unlock이 호출됩니다. 카운트가 0이 되면 풀에서 제거됩니다. 이 덕분에 동일 next-hop을 공유하는 수십만 경로가 adjacency 객체 1개를 안전하게 재사용할 수 있습니다. 이때 각 DPO는 참조 카운트(Reference Count)를 유지합니다.
  • dv_formatshow ip fib 같은 CLI 출력에서 DPO 체인을 펼쳐 보여줄 때 사용됩니다. 각 타입이 자기 자신을 어떻게 렌더링할지 알고 있으므로 CLI 코어는 타입을 몰라도 출력할 수 있습니다.
  • dpo_register_new_typeNAT, SRv6 localsid, IOAM 같은 플러그인은 built-in 타입 상수를 쓰지 않고 런타임에 새 타입 ID를 할당받습니다. 그래서 플러그인을 끄면 그 타입은 존재하지 않은 것처럼 FIB 코어에서 사라집니다 — 바이너리 호환성을 깨지 않고 포워딩을 확장할 수 있는 핵심 메커니즘입니다.
5. 재귀(Recursive) DPO 체인 — BGP 경로가 풀리는 방식

DPO 체인의 가장 강력한 응용이 재귀 해석(recursive resolution)입니다. 예를 들어 BGP가 10.0.0.0/8 via 203.0.113.1을 설치했을 때 VPP는 "203.0.113.1이 누구인가"를 즉시 알지 못합니다. 이때는 또 다른 FIB 엔트리(203.0.113.0/24 via 192.168.1.254 GigE0/0/0)를 거쳐야 최종 adjacency가 나옵니다. 컨트롤 플레인이 이를 미리 펼쳐서 "BGP 경로 → GigE adjacency"로 직결해 버리면 IGP가 갱신될 때마다 모든 BGP 경로를 다시 계산해야 합니다. DPO는 이 문제를 중간 단계를 그대로 두는 지연 해석으로 해결합니다.

재귀 DPO 체인: BGP 경로의 간접 해석 BGP FIB entry 10.0.0.0/8 via 203.0.113.1 (재귀) load-balance (lb#41) bucket[0] → via-fib#7 (진입점, 버킷=1) IGP FIB entry 203.0.113.0/24 (부모 해석 결과) load-balance (lb#12) bucket[0] → adj#24 adjacency (adj#24) → GigE0/0/0 dst MAC 00:5e:...:01 이 구조가 주는 이점 ① IGP가 next-hop을 다른 인터페이스로 옮기면 오른쪽 load-balance만 교체되고, 왼쪽 BGP 엔트리는 손대지 않아도 됩니다. ② 같은 next-hop을 공유하는 100만 개 BGP 경로가 중간 IGP FIB 엔트리 하나를 통해 간접 참조하므로 메모리/갱신 비용이 선형이 아닌 상수입니다. ③ IGP 수렴 시 "자식 DPO의 dpoi_index" 한 필드만 원자적(Atomic)으로 갱신하면 BGP 전체가 새 경로로 즉시 전환됩니다 (PIC Core).

컨트롤 플레인 관점에서 이것은 FIB 엔트리의 트래킹(tracking)으로 구현됩니다. "BGP 10.0.0.0/8"은 "IGP 203.0.113.0/24"를 부모 엔트리로 등록하고, 부모가 바뀔 때마다 콜백으로 통보받아 자기 load-balance의 자식 DPO만 바꿔 끼웁니다. 이 패턴은 Cisco/Juniper에서 PIC(Prefix-Independent Convergence) Core라 부르는 기능에 해당하며, VPP에서는 DPO 체인 그 자체가 곧 PIC 구현입니다.

6. DPO 수명주기 — stack / reset / lock / unlock

DPO 체인은 고정된 자료구조가 아니라 끊임없이 재구성됩니다. ARP가 풀리고, 인터페이스가 다운되고, IGP가 수렴할 때마다 체인의 일부가 교체됩니다. 이 갱신을 안전하게 만드는 네 개의 기본 연산이 dpo_stack, dpo_reset, 그리고 각 타입이 구현하는 lock/unlock입니다.

/* src/vnet/dpo/dpo.c — 체인 연결의 핵심 프리미티브 */

/* 1) 상위 DPO(parent)의 자식 슬롯(dpo)에 new_child를 연결.
 *    기존 자식은 unlock, 새 자식은 lock → 참조 카운팅이 맞춰진다. */
void dpo_stack (dpo_type_t parent_type,
                 dpo_proto_t parent_proto,
                 dpo_id_t   *dpo,            /* inout: 자식 슬롯 */
                 const dpo_id_t *new_child)
{
    dpo_id_t tmp = *dpo;         /* 기존 자식을 복사해 둠 */
    dpo_copy(dpo, new_child);   /* 슬롯을 한 번에 교체 */
    dpo_lock(dpo);              /* 새 자식의 lock() 호출 */
    dpo_reset(&tmp);            /* 기존 자식의 unlock() 호출 */
    /* next_node는 parent_type으로부터 사전 계산된 값으로 채워짐 */
}

/* 2) 슬롯을 빈 상태로 되돌림. unlock → 참조 카운트 감소 → 0이면 풀 반환 */
void dpo_reset (dpo_id_t *dpo)
{
    dpo_unlock(dpo);
    *dpo = (dpo_id_t) DPO_INVALID;
}

/* 3) 타입별 vtable로 우회 호출 */
static_always_inline void
dpo_lock (dpo_id_t *dpo)
{
    if (!dpo_id_is_valid(dpo)) return;
    dpo_vfts[dpo->dpoi_type].dv_lock(dpo);
}
코드 설명
  • dpo_stack체인 교체를 단일 store(원자적(Atomic) 갱신)로 만드는 것이 관건입니다. 먼저 기존 자식을 stack에 저장해 두고, 슬롯을 새 자식으로 덮어쓴 뒤, 새 자식에 lock을 걸고 나서야 옛 자식을 unlock합니다. 순서가 중요한 이유는, 만약 unlock을 먼저 했다가 참조 카운트가 0이 되어 객체가 해제되면 hot path에서 방금 해제된 메모리로 점프할 수 있기 때문입니다.
  • dpo_reset슬롯을 DPO_INVALID로 되돌리면서 참조 카운트를 감소시킵니다. FIB 엔트리를 삭제할 때 root에서 leaf 방향으로 reset이 전파되며, 참조 카운트가 0이 된 DPO는 각자의 dv_unlock에서 풀로 반환됩니다.
  • dpo_lockvtable을 거치는 한 번의 간접 호출입니다. hot path에서는 호출되지 않고 오직 컨트롤 플레인 갱신 경로에서만 동작하므로, 간접 호출 비용이 포워딩 성능에 영향을 주지 않습니다.

Adjacency 기초 — 무엇이고 왜 필요한가

1. 한 문장 정의

Adjacency란 "L3 next-hop 하나에 대해 패킷을 실제로 어떻게 송출할지 미리 계산해 둔 기록"입니다. 조금 풀어 쓰면 "출력 인터페이스(sw_if_index) + 완성된 L2 rewrite 헤더(예: Ethernet DA/SA/EtherType) + 해당 상태를 이끈 ARP/ND 해석 결과"의 묶음입니다. IP 라우팅이 끝나고 "패킷을 어디로, 어떤 이더넷 헤더로 쏠지"가 결정되는 순간, 그 결정을 번번이 다시 하지 않고 단 한 번의 메모리 로드로 재사용할 수 있게 해 주는 구조가 adjacency입니다.

어원. "인접(adjacent)"은 L3 홉 단위로 바로 옆에 있는 이웃을 뜻하며, 원래 Cisco IOS CEF(Cisco Express Forwarding)에서 "adjacency table"이라는 이름으로 굳어졌습니다. VPP의 adj 서브시스템도 같은 전통을 이어받아, FIB 엔트리와 "실제 송출"을 잇는 고정 길이 송출 레코드를 adjacency라고 부릅니다.
2. 왜 별도 객체로 뽑아냈나 — 세 가지 동기
Adjacency가 놓이는 위치 — "L3 결정"과 "L2 송출" 사이 수많은 FIB 엔트리 10.0.0.0/8 10.1.0.0/16 172.16.0.0/12 ... (100만 개) 모두 같은 next-hop load-balance DPO bucket[0] → adj#24 (공유됨) adjacency (adj#24) next-hop: 192.168.1.254 sw_if_index: GigE0/0/0 rewrite: 005e00000101 dead... 0800 interface TX DPDK / memif / ... NIC 송출 한 장으로 보는 계약 ① FIB/DPO는 "누구에게(next-hop)"까지만 안다 — 어떤 MAC으로 어떻게 쏠지는 모른다. ② adjacency는 "누구에게 + 어떻게(L2 rewrite)"를 함께 가진다 — ARP/ND가 풀리면 이 객체 하나만 갱신된다. ③ hot path(ip4-rewrite)는 adj_index 한 개로 인터페이스·MTU·rewrite 버퍼까지 캐시라인 한 장에서 꺼낸다.
3. 핵심 자료구조 ip_adjacency_t — 캐시라인 한 장의 설계

adjacency는 hot path에서 읽히는 구조체이므로, 필드 배치가 64바이트 캐시라인 한 장에 가장 자주 접근되는 것들을 몰아넣도록 설계되어 있습니다. rewrite_header가 구조체 앞쪽에 오는 이유도 "버퍼에 헤더를 prepend할 때 같은 캐시라인에서 바로 복사하기 위함"입니다.

/* src/vnet/adj/adj.h — 핵심 필드만 추려낸 형태 */
typedef struct ip_adjacency_t_ {
    CLIB_CACHE_LINE_ALIGN_MARK(cacheline0);

    /* --- hot-path (cacheline 0) --- */
    u32 rewrite_header[0];       /* 가변 길이 L2 rewrite 헤더의 시작 */
    vnet_rewrite_data_t rewrite_data[VLIB_BUFFER_PRE_DATA_SIZE];
    u16 max_packet_bytes;          /* 이 인터페이스의 MTU */
    u32 sw_if_index;               /* 출력 인터페이스 */
    vnet_link_t ia_link;           /* IP4/IP6/MPLS/... */
    adj_nbr_flags_t ia_flags;

    CLIB_CACHE_LINE_ALIGN_MARK(cacheline1);

    /* --- control-plane (cacheline 1) --- */
    ip46_address_t sub.nbr.next_hop; /* 이웃의 L3 주소 */
    union {
        struct { /* adj-nbr */
            adj_next_hop_fn_t *next_fn; /* 해석 완료 시 콜백 */
        } nbr;
        struct { /* adj-glean */
            ip46_address_t rx_nh_addr;
        } glean;
        struct { /* adj-midchain */
            u32 fei;                  /* 터널이 타고 갈 부모 FIB 엔트리 */
            dpo_id_t next_dpo;         /* 자식 DPO: 다음 단계로 이어지는 체인 */
            adj_midchain_fixup_t fixup_func;
        } midchain;
    } sub_type;

    u32 ia_node_index;             /* 부모 그래프 노드 (ip4-rewrite 등) */
    u32 ia_cfg_index;              /* feature-arc 설정 인덱스 */
    u32 ia_locks;                  /* 참조 카운트 */
} ip_adjacency_t;
코드 설명
  • rewrite_header[0]C의 유연 배열 멤버(flexible array member)를 이용해 "구조체의 시작 지점부터 rewrite 바이트가 시작된다"는 의미를 부여합니다. 실제 rewrite 바이트는 그 뒤 rewrite_data 영역에 저장되며, 인터페이스별로 길이가 다릅니다(일반 Ethernet=14, VLAN=18, QinQ=22). hot path의 ip4-rewrite 노드는 이 포인터를 받아 버퍼 앞쪽에 그대로 복사합니다.
  • max_packet_bytesMTU 체크가 adjacency 수준에서 이뤄지는 이유입니다. 인터페이스 구조체를 다시 조회할 필요 없이, 캐시라인 0에 이미 올라와 있는 값으로 판단합니다 — hot path에서 조건 분기 한 번이면 끝.
  • CLIB_CACHE_LINE_ALIGN_MARK(cacheline1)두 번째 캐시라인은 컨트롤 플레인만 건드리는 필드들입니다. 이렇게 나눠 두면 ARP가 갱신될 때 cacheline 0을 건드리지 않고 cacheline 1의 next-hop 정보만 수정되는 케이스도 있어, 동시 포워딩 중인 워커들의 캐시 라인 바운싱(cache-line bouncing)을 줄일 수 있습니다.
  • sub_type 유니언하나의 ip_adjacency_t가 nbr/glean/midchain 중 어떤 하위 타입으로 동작할지 결정합니다. midchain은 특히 중요한데, 자식 DPO(next_dpo)를 내부에 품고 있어 "이 adjacency를 타면 외부 터널로 캡슐화되고, 그 뒤 다시 다른 adjacency로 이어진다"는 재귀적 체인이 가능합니다.
  • ia_locksDPO 체인과 동일한 참조 카운팅입니다. 동일 next-hop을 공유하는 경로가 늘어나면 adj_nbr_add_or_lock()이 이 값을 증가시키고, 경로가 사라지면 감소시킵니다. 0이 되면 풀로 반환됩니다.

Adjacency 타입별 구현

Adjacency는 패킷의 L2 rewrite와 출력 인터페이스를 결정하는 핵심 객체입니다. ARP/ND 해석 상태와 하는 일(이웃 전달/직결 해석/터널 캡슐화/멀티캐스트)에 따라 여러 하위 타입으로 분류됩니다.

Adjacency 타입설명상태
adj-rewrite완전히 해석된 adjacency로, L2 rewrite 헤더가 채워져 있습니다활성
adj-incompleteARP/ND 해석이 완료되지 않은 adjacency로, 패킷 도착 시 ARP를 트리거합니다미완성
adj-glean직접 연결 서브넷에 대한 adjacency로, 목적지 IP로 ARP 요청을 보냅니다활성
adj-midchain터널 캡슐화(Encapsulation) 등 중간 체인 처리가 필요한 adjacency (GRE, VXLAN, IPsec)활성
adj-mcast멀티캐스트 MAC 주소로의 rewrite를 수행합니다활성
/* DPO 구조체 (src/vnet/dpo/dpo.h) */
typedef struct dpo_id_t_ {
    dpo_type_t dpoi_type;       /* DPO 타입 (adjacency, drop 등) */
    dpo_proto_t dpoi_proto;     /* 프로토콜 (IPv4, IPv6, MPLS) */
    u32 dpoi_index;              /* 타입별 오브젝트 인덱스 */
    u32 dpoi_next_node;          /* 그래프 노드에서의 next index */
} dpo_id_t;

/* Adjacency 구조체 (src/vnet/adj/adj.h) */
typedef struct ip_adjacency_t_ {
    CLIB_CACHE_LINE_ALIGN_MARK(cacheline0);
    u8 rewrite_header[64];     /* L2 rewrite 헤더 (Ethernet 등) */
    u32 sw_if_index;             /* 출력 인터페이스 sw_if_index */
    adj_type_t lookup_next_index; /* adjacency 타입 */
    ip46_address_t next_hop;     /* next-hop IP 주소 */
    u32 n_adj;                   /* 참조 카운트 */
} ip_adjacency_t;
코드 설명
  • dpo_id_tDPO 체인의 기본 단위로, 타입과 인덱스로 실제 오브젝트를 참조합니다. dpoi_next_node는 이 DPO를 처리할 다음 그래프 노드를 가리킵니다.
  • ip_adjacency_t캐시 라인(Cache Line)에 정렬되어 있으며, rewrite_header에 미리 계산된 L2 헤더가 저장됩니다. 패킷 전송 시 이 헤더를 prepend하여 별도의 헤더 조립 과정 없이 전송합니다.
Adjacency 상태 머신 — incomplete → complete

새 next-hop이 처음 등장하면 adjacency는 incomplete(=rewrite 헤더가 아직 비어 있고, 전용 arp-request 노드가 next로 설정된 상태)로 생성됩니다. 첫 패킷이 이 adjacency를 타는 순간 ARP 요청이 트리거되고, 응답이 도착하면 adj_nbr_update_rewrite()가 rewrite 헤더를 채우고 next 노드를 ip4-rewrite로 바꿉니다. 이 한 번의 갱신이 수많은 FIB 경로를 동시에 "사용 가능" 상태로 전환합니다.

Adjacency 상태 전이 (미존재) 첫 경로 설치 전 adj_nbr_add_or_lock() incomplete next_node = arp-request rewrite = 비어 있음 ARP reply 수신 update_rewrite() complete next_node = ip4-rewrite rewrite = DA/SA/0x0800 ARP 재해석 타임아웃 (probe / refresh) stale 아직 포워딩 가능 reply 도착 → refresh 재시도 초과 → incomplete 복귀 locks=0 → 풀로 반환
4. 생성과 공유 — adj_nbr_add_or_lock()

adjacency를 만드는 함수는 본질적으로 "해시 테이블에서 찾아보고, 있으면 lock만, 없으면 생성"이라는 단순한 패턴입니다. 공유의 핵심이 바로 이 "찾아보고 있으면 lock만"이라는 한 문장에 있습니다.

/* src/vnet/adj/adj_nbr.c — 발췌(요점만 추렸고 일부 단순화) */
adj_index_t
adj_nbr_add_or_lock (fib_protocol_t nh_proto,
                     vnet_link_t link_type,
                     const ip46_address_t *nh_addr,
                     u32 sw_if_index)
{
    adj_index_t ai;

    /* 1) (인터페이스, next-hop) 키로 해시에서 기존 adjacency를 찾는다 */
    ai = adj_nbr_find(nh_proto, link_type, nh_addr, sw_if_index);

    if (ADJ_INDEX_INVALID == ai)
    {
        /* 2) 없으면 풀에서 새로 뽑고 incomplete 상태로 초기화 */
        ip_adjacency_t *adj = adj_alloc(nh_proto);

        ai = adj - adj_pool_get(0);
        adj->sub_type.nbr.next_hop = *nh_addr;
        adj->ia_link               = link_type;
        adj->sw_if_index            = sw_if_index;
        adj->ia_node_index          = adj_get_rewrite_node(link_type);
        adj_nbr_insert(nh_proto, link_type, nh_addr, sw_if_index, ai);

        /* 3) incomplete: arp-request / ip6-discover-neighbor로 next 설정 */
        adj_nbr_update_rewrite(
            ai, ADJ_NBR_REWRITE_FLAG_INCOMPLETE, NULL);
    }

    /* 4) 기존이든 새로 만든 것이든 lock 카운트 증가 */
    adj_lock(ai);
    return ai;
}
코드 설명
  • adj_nbr_find(next-hop IP, sw_if_index, link type) 튜플을 키로 쓰는 해시 테이블 조회입니다. 동일 next-hop을 쓰는 서로 다른 FIB 엔트리가 이 함수를 거치면 항상 같은 adj_index를 받게 되며, 이것이 adjacency 공유의 구현입니다. 키에 sw_if_index가 포함된 것에 주목하세요 — 같은 IP라도 다른 인터페이스로 나간다면 L2 환경이 다르므로 rewrite 헤더가 달라야 하고, 따라서 별개의 adjacency가 됩니다.
  • adj_allocadjacency는 공용 풀에서 할당되며, 인덱스 = (adj 주소 - 풀 베이스)로 계산됩니다. 이 인덱스가 hot path에서 adj_index_t로 유통되는 그 값입니다. 포인터 대신 인덱스를 쓰는 이유는 DPO와 동일합니다(풀 realloc 안전성, 컴팩트한 저장).
  • adj_nbr_update_rewrite(..., FLAG_INCOMPLETE, NULL)NULL rewrite 버퍼를 건네면서 INCOMPLETE 플래그를 주면, 이 adjacency는 "패킷이 오면 ip4-rewrite가 아니라 ip4-arp 노드로 점프"하도록 설정됩니다. ip4-arp 노드는 해당 패킷을 보관/드롭하면서 동시에 ARP 요청을 컨트롤 플레인에 트리거합니다. 이 한 줄이 "먼저 온 패킷이 ARP 해석을 유발한다"는 설계의 구현입니다.
  • adj_lock이미 존재하던 adjacency든 방금 만든 adjacency든, 새 참조자(=호출한 FIB 경로)가 추가된 것이므로 무조건 lock 카운트를 1 증가시킵니다. 이 함수를 호출한 쪽은 나중에 반드시 adj_unlock()으로 짝을 맞춰야 합니다 — 짝이 맞지 않으면 adjacency가 영원히 풀에 남는 리크가 발생합니다.
5. hot path — ip4-rewrite 노드가 adjacency를 소비하는 방식

컨트롤 플레인이 만들어 둔 adjacency가 실제로 쓰이는 지점은 ip4-rewrite(또는 ip6-rewrite, mpls-output) 노드입니다. 이 노드의 hot loop는 "패킷당 메모리 접근을 최소화"하는 방향으로 극단적으로 다듬어져 있습니다.

/* src/vnet/ip/ip4_forward.c — ip4-rewrite 노드 핵심만 추림 */
while (n_left_from > 0)
{
    vlib_buffer_t *b0 = vlib_get_buffer(vm, from[0]);
    ip4_header_t  *ip0 = vlib_buffer_get_current(b0);

    /* 1) 이전 노드(ip4-lookup)가 심어둔 adj_index 꺼내기 */
    u32 adj_index0 = vnet_buffer(b0)->ip.adj_index[VLIB_TX];
    ip_adjacency_t *adj0 = adj_get(adj_index0);

    /* 2) 다음 루프 반복을 위해 미리 prefetch — 메모리 대기 시간을 숨김 */
    CLIB_PREFETCH(
        adj_get(vnet_buffer(b1)->ip.adj_index[VLIB_TX]),
        CLIB_CACHE_LINE_BYTES, LOAD);

    /* 3) MTU 검사 — adjacency 캐시라인 0에서 바로 읽음 */
    if (PREDICT_FALSE(vlib_buffer_length_in_chain(vm, b0) >
                      adj0->max_packet_bytes))
        ip4_mtu_exceeded(b0);   /* → fragment / ICMP */

    /* 4) TTL 감소 + 체크섬 incremental update */
    u32 checksum = ip0->checksum + clib_host_to_net_u16(0x0100);
    checksum += checksum >= 0xffff;
    ip0->checksum = checksum;
    ip0->ttl -= 1;

    /* 5) L2 rewrite 적용 — 이 한 줄이 패킷 송출을 결정 */
    vnet_rewrite_one_header(adj0[0], ip0, sizeof(ethernet_header_t));
    vlib_buffer_advance(b0, -(word) adj0->rewrite_header.data_bytes);

    /* 6) 다음 노드 = adjacency가 가리키는 노드 (대개 interface-output) */
    next0 = adj0->rewrite_header.next_index;
    vnet_buffer(b0)->sw_if_index[VLIB_TX] = adj0->rewrite_header.sw_if_index;

    from++; n_left_from--;
}
코드 설명
  • 1) adj_index 꺼내기ip4-lookup이 FIB 트리(mtrie)를 탐색한 뒤, 최종 load-balance 버킷이 가리키는 adjacency 인덱스를 버퍼의 opaque 영역에 심어 둡니다. ip4-rewrite는 이 값을 "이미 결정되어 있는 TX 정보"로 간주하고 한 번의 배열 접근(adj_get)으로 ip_adjacency_t를 얻습니다.
  • 2) CLIB_PREFETCH다음 반복에서 필요할 adjacency를 미리 L1 캐시로 끌어오는 소프트웨어 프리페치입니다. 현재 반복이 계산하는 동안 DRAM 대기 시간(Latency)이 병렬로 진행되므로, 포워딩 속도가 캐시 미스에 지배되지 않도록 합니다. 이것이 바로 VPP가 "배치 기반(batch-based)" 벡터 처리를 택한 진짜 이유입니다 — 프리페치할 "다음 항목"이 항상 존재하기 때문입니다.
  • 3) MTU 검사adjacency 안에 MTU가 들어 있기 때문에 인터페이스 테이블을 다시 참조할 필요가 없습니다. 이는 구조체 레이아웃 설계와 hot path 성능이 얼마나 밀접하게 묶여 있는지 보여주는 전형적인 예입니다.
  • 5) vnet_rewrite_one_header내부적으로는 "b0의 현재 데이터 직전 영역에 adj0의 rewrite 바이트를 복사"하는 매크로(Macro)입니다. 크기는 컴파일 타임에 결정된 최대치(예: 14바이트)로 복사되므로 분기 없는 상수 길이 memcpy이고, 대부분의 아키텍처에서 단일 SIMD 로드/스토어로 축소됩니다.
  • 6) 다음 노드 = adjacency가 가리키는 노드adjacency가 "다음에 갈 그래프 노드"까지 가지고 있기 때문에, ip4-rewrite는 패킷이 GigE 인터페이스로 가야 할지 GRE midchain으로 가야 할지 자기 자신이 결정하지 않습니다. adjacency에 기록된 next_index를 그대로 따라갈 뿐입니다. 이 덕분에 같은 ip4-rewrite 노드가 일반 포워딩과 터널 캡슐화 경로 양쪽을 모두 처리할 수 있습니다.
6. adj-midchain — 터널 캡슐화가 붙는 방식

GRE, VXLAN, IPIP, IPsec 같은 터널은 "패킷을 먼저 외부 헤더로 감싸고, 그 외부 패킷을 다시 정상적인 IP 포워딩으로 보내야" 합니다. VPP는 이를 위해 별도의 "터널 포워딩 엔진"을 만들지 않고, 대신 adjacency의 한 변종adj-midchain으로 해결합니다.

adj-midchain: GRE 터널 포워딩 체인 inner FIB entry 10.200.0.0/16 via gre0 adj-midchain (gre0) rewrite = GRE+outer IP 헤더 fixup_func: src IP 업데이트 next_dpo → 부모 FIB의 LB (외부 경로 재해석) outer FIB entry 198.51.100.0/24 (터널 엔드포인트) adj-nbr (GigE0/0/0) rewrite = Ethernet → NIC 송출 핵심 포인트 • adj-midchain은 "캡슐화 rewrite + 자식 DPO"를 함께 가진다 → 패킷은 외부 헤더가 붙은 뒤 다시 그래프의 앞쪽으로 돌아간다. • 외부 경로가 IGP로 바뀌면 midchain의 next_dpo만 원자적으로 재스택(restack)되어, 내부 FIB 100만 개가 그대로 유지된다.
/* src/vnet/adj/adj_midchain.c — 터널 생성 시 호출되는 핵심 경로 */
void adj_nbr_midchain_update_rewrite (adj_index_t ai,
                                       adj_midchain_fixup_t fixup,
                                       const void *fixup_data,
                                       adj_flags_t flags,
                                       u8 *rewrite)
{
    ip_adjacency_t *adj = adj_get(ai);

    /* 1) 외부 헤더(예: IP+GRE) 전체를 rewrite 바이트로 등록 */
    vnet_rewrite_set_data(adj->rewrite_header, rewrite, vec_len(rewrite));

    /* 2) fixup: 전송 직전 패킷별로 바꿔야 할 바이트 (예: outer src IP, IPsec SPI/SEQ) */
    adj->sub_type.midchain.fixup_func      = fixup;
    adj->sub_type.midchain.fixup_data      = fixup_data;

    /* 3) next 노드를 tunnel의 midchain 노드(예: gre-midchain-tx)로 바꾼다 */
    adj->rewrite_header.next_index = vlib_node_get_next(
        vm, ip4_rewrite_node.index, gre_midchain_node.index);

    /* 4) 외부 경로를 트래킹하고, 그 결과 DPO를 midchain.next_dpo에 stack */
    adj_midchain_setup(ai, fixup, fixup_data, flags);
}
코드 설명
  • 1) rewrite 바이트 등록일반 adjacency가 Ethernet 14바이트를 갖는다면, midchain은 "outer IP(20) + GRE(4) + (optional) Ethernet(14)" 같은 수십 바이트짜리 합성 헤더를 갖습니다. hot path는 이 차이를 모릅니다 — 그저 rewrite_header.data_bytes만큼 prepend할 뿐입니다.
  • 2) fixup_func패킷별로 달라져야 하는 필드(outer IP length, checksum, IPsec sequence number 등)는 rewrite 버퍼에 고정해 둘 수 없습니다. 이때 midchain은 "이 패킷의 외부 헤더에 이러이러한 수정을 가하라"는 콜백을 저장해 두고, 노드가 패킷마다 호출합니다. 분기 없이 함수 포인터 한 번으로 터널별 변형이 처리됩니다.
  • 4) adj_midchain_setup → next_dpo 트래킹midchain이 터널 엔드포인트로 가는 외부 경로를 FIB 엔트리로 등록(track)하고, 그 결과 DPO(= 외부 load-balance)를 midchain.next_dpo에 stack합니다. 외부 IGP가 엔드포인트 경로를 바꾸면 이 한 슬롯만 교체되어 — 터널을 타고 있는 내부 경로 전체가 즉시 새 외부 경로를 따라갑니다. 이것이 "수십만 VPN 프리픽스의 백홀이 한순간에 다른 회선으로 옮겨가는" 기능의 구현입니다.
7. adj-glean · adj-mcast — 남은 두 하위 타입

FIB 경로 해석 실전 예제

# ECMP 경로 추가
vpp# ip route add 10.0.100.0/24 via 192.168.1.254 GigabitEthernet0/0/0 \
                                via 192.168.2.254 GigabitEthernet0/0/1

# FIB 엔트리 확인 — DPO 체인 표시
vpp# show ip fib 10.0.100.0/24
  10.0.100.0/24
    unicast-ip4-chain
    [@0]: dpo-load-balance: [proto:ip4 index:22 buckets:2 uRPF:28]
      [0] [@5]: ipv4 via 192.168.1.254 GigE0/0/0: rewrite-adj-idx:24
      [1] [@5]: ipv4 via 192.168.2.254 GigE0/0/1: rewrite-adj-idx:25

# Adjacency 상세 확인
vpp# show adjacency 24
  [@24] ipv4 via 192.168.1.254 GigE0/0/0:
    rewrite: [dst:00:00:5e:00:01:01 src:de:ad:be:ef:00:01 ethertype:0800]
    flags: rewrite  share-count: 1

# Adjacency incomplete 진단 (ARP 미해석)
vpp# show ip fib 10.0.200.0/24
  10.0.200.0/24
      [0] [@0]: ipv4 via 192.168.3.254 GigE0/0/2: ** INCOMPLETE **

# 수동 ARP 엔트리 추가로 해결
vpp# set ip neighbor GigabitEthernet0/0/2 192.168.3.254 00:11:22:33:44:55
Adjacency 공유(Sharing): 동일한 next-hop IP와 출력 인터페이스를 가진 경로들은 하나의 adjacency 객체를 공유합니다. ARP 갱신 시 한 번의 rewrite 업데이트로 모든 관련 경로에 즉시 반영됩니다.

DPO 트레이싱과 트러블슈팅

DPO 체인은 컨트롤 플레인의 산출물이므로, 문제가 생겼을 때는 "내가 의도한 체인이 실제로 설치되었는가"를 먼저 확인하는 것이 원칙입니다. VPP는 체인을 단계별로 펼쳐 보는 CLI와, hot path가 어느 DPO를 소비했는지 기록하는 패킷 트레이서를 제공합니다.

# 1) FIB 엔트리를 "상세(detail)"로 펼치면 체인이 계층적으로 표시됩니다.
vpp# show ip fib 10.0.0.0/8 detail
  10.0.0.0/8 fib:0 index:42 locks:2
    CLI refs:1 src-flags:added,contributing,active,
      path-list:[17] locks:6 flags:shared, uRPF-list:28
        path:[23] pl-index:17 ip4 weight=1 pref=0 recursive:
          via 203.0.113.1 in fib-index:0, fib:0 index:7
            forwarding:   unicast-ip4-chain
            [@0]: dpo-load-balance: [proto:ip4 index:12 buckets:1]
              [0] [@5]: ipv4 via 192.168.1.254 GigE0/0/0: mtu:9000
                  rewrite: 005e00000101deadbeef00010800

    forwarding:   unicast-ip4-chain
    [@0]: dpo-load-balance: [proto:ip4 index:41 buckets:1]
      [0] [@12]: dpo-receive: 203.0.113.1 on GigE0/0/0
                 (재귀 진입점 — 내부적으로 lb#12를 참조)

# 2) 특정 DPO 인덱스만 집중해서 덤프
vpp# show dpo load-balance 41
vpp# show dpo adjacency 24

# 3) 등록된 DPO 타입과 각 타입이 보유한 오브젝트 수 확인
vpp# show dpo memory
  DPO           allocs        frees
  load-balance  12034         11802
  adjacency     287           154
  ...

# 4) 그래프 트레이서로 실제 패킷이 본 DPO 확인
vpp# trace add dpdk-input 50
# ... 트래픽 발생 ...
vpp# show trace
  01: dpdk-input: GigE0/0/0 rx queue 0
  02: ip4-input: TTL=64 src=... dst=10.0.0.5
  03: ip4-lookup: fib 0 dpo-idx 42 flow hash: 0x1b2c  ← load-balance index
  04: ip4-rewrite: tx_sw_if_index 1 adj-idx 24        ← 최종 adjacency
  05: GigE0/0/0-output: ...
자주 마주치는 증상과 원인
  • ** INCOMPLETE ** 표시 + 패킷 드롭 — ARP/ND 해석이 끝나지 않은 상태입니다. glean adjacency가 ARP를 쏘고 있는지 확인하고, 실패한다면 L2 연결성/MAC 필터를 점검합니다. 임시로 set ip neighbor로 고정 ARP를 넣어 DPO 체인이 완성되는지 검증해 볼 수 있습니다.
  • 트래픽은 흐르는데 한 경로로만 쏠림 — load-balance 버킷은 5-tuple 해시 기반이라 동일 플로우는 항상 같은 버킷에 맞습니다. 테스트 트래픽의 src/dst 포트를 다양화하거나 set ip flow-hash로 해시 입력 필드를 조정해야 합니다.
  • dpo-drop으로 귀결되는 라우트 — uRPF 실패, 블랙홀 경로, fib-source 우선순위 역전(Priority Inversion)(LISP/BIER 플러그인이 같은 prefix를 더 높은 우선순위로 덮어썼을 때)이 흔한 원인입니다. show ip fib <prefix> detail에서 src-flags와 path-list 소스를 확인합니다.
  • 체인은 올바른데 패킷이 엉뚱한 노드로 점프 — DPO 등록 시 dpoi_next_node가 부모 노드별로 다른 값을 쓰는 것을 놓친 경우입니다. 플러그인을 직접 작성 중이라면 dpo_register()의 두 번째 인자(노드 리스트)에 부모 노드를 전부 등록했는지 확인하세요.
DPO 한 줄 요약

DPO = "작은 디스크립터(dpo_id_t) + 타입별 vtable + 참조 카운팅된 체인"입니다. FIB 엔트리는 항상 load-balance를 진입점으로 하는 체인을 가리키며, 각 단계는 독립적으로 공유·교체될 수 있습니다. 포워딩 확장은 새 DPO 타입을 등록하는 일로 수렴되고, 컨트롤 플레인 수렴은 체인의 한 슬롯을 교체하는 일로 수렴됩니다. 이 단일 추상화가 VPP가 ECMP·재귀 라우팅·터널 캡슐화·NAT·SRv6·멀티캐스트 복제를 동일한 그래프 엔진 위에서 모두 고속 처리할 수 있는 근본 이유입니다.

추가 디바이스 드라이버 — RDMA · vmxnet3 · Netmap · Pipe

앞의 DPDK 통합커널 인터페이스 섹션이 VPP의 주된 I/O 경로를 다뤘다면, 이 절은 특정 환경에서 유용한 보조 드라이버 네 개를 간단히 정리합니다.

RDMA 네이티브 드라이버 (mlx5)

VPP의 rdma 플러그인은 Mellanox(현 NVIDIA) ConnectX-4/5/6 NIC을 DPDK 없이 직접 제어합니다. libibverbs를 통해 하드웨어 큐에 바로 접근하므로, DPDK PMD보다 빌드·배포가 단순하고 커널 측 ibverbs 스택과 공존합니다.

vpp# create interface rdma host-if mlx5_0 name rdma-0
vpp# set interface state rdma-0 up
vpp# show interface rdma-0

장점은 DPDK 전체 스택을 피할 수 있다는 점입니다. mlx5 NIC 전용 시스템이라면 DPDK mlx5 PMD 대신 이 드라이버가 배포가 더 간단합니다. 단점은 다른 벤더 NIC에는 쓸 수 없습니다.

vmxnet3 — VMware 게스트

VPP를 VMware ESXi 게스트로 돌릴 때 권장되는 인터페이스입니다. VMware 가상 NIC(VMXNET3)을 VPP가 직접 제어해, E1000 에뮬레이션보다 훨씬 높은 처리량을 냅니다.

vpp# create interface vmxnet3 0000:0b:00.0
vpp# set interface state vmxnet3-0/b/0/0 up

VMware 위의 VPP 테스트랩·POC 환경에서 최우선 선택지이며, DPDK도 지원하지만 독립 vmxnet3 드라이버가 가볍고 설정이 단순합니다.

Netmap — FreeBSD 호환 고속 I/O

Netmap은 Luigi Rizzo의 고속 패킷 I/O 프레임워크로, 리눅스와 FreeBSD 양쪽에서 동작합니다. VPP의 netmap 플러그인은 netmap 백엔드를 통해 NIC 또는 가상 인터페이스(VALE 스위치 포트)를 연결합니다. DPDK가 없는 환경이나 FreeBSD 호환성이 필요한 특수 케이스에 쓰입니다.

Pipe 디바이스 — 내부 연결

Pipe는 두 개의 가상 인터페이스를 서로 직접 연결하는 내부 장치입니다. 하나의 VPP 인스턴스 안에서 두 개의 그래프 경로를 독립 테스트할 때 유용합니다. 물리 NIC 없이 송신이 바로 수신으로 들어가는 루프백을 만들 수 있어, CI 테스트·플러그인 개발·트레이스 훈련에 편리합니다.

vpp# create pipe instance 0
vpp# show interface pipe0.0
vpp# show interface pipe0.1

L2 인프라에서 VPP는 단순 브릿징을 넘어 링크 집선(bonding), LACP 동적 협상, LLDP 이웃 탐지를 지원합니다. 데이터센터 ToR와 서버 NIC 사이의 표준적 링크 관리 프로토콜입니다.

Bonding (Link Aggregation)

여러 물리 포트를 하나의 논리 인터페이스로 묶어 대역폭 합산장애 시 자동 fail-over를 제공합니다. VPP는 Linux 커널 bonding과 유사한 5개 모드(round-robin, active-backup, xor, broadcast, lacp)를 지원합니다.

vpp# create bond mode lacp load-balance l34
BondEthernet0
vpp# bond add BondEthernet0 TenGigabitEthernet0/0/0
vpp# bond add BondEthernet0 TenGigabitEthernet0/0/1
vpp# set interface state BondEthernet0 up
vpp# show bond

LACP — Link Aggregation Control Protocol

LACP(IEEE 802.3ad)는 링크 집선 멤버가 서로 살아있고 같은 집선 그룹에 속하는지를 동적으로 협상합니다. 스위치와 VPP 양쪽이 LACPDU를 초 단위로 교환하며, 응답이 끊기면 해당 멤버를 자동으로 떨어뜨립니다.

vpp# show lacp
vpp# show lacp details

운영 시 확인 포인트는 partner state입니다. Sync·Aggregation·Collecting·Distributing 네 비트가 모두 서야 해당 멤버가 active입니다.

LLDP — Link Layer Discovery Protocol

LLDP(IEEE 802.1AB)는 인접 장비의 시스템명, 포트 ID, 관리 주소, VLAN 정보를 주기적으로 광고합니다. 데이터센터 케이블 연결 오류 검증에 특히 유용합니다.

vpp# set lldp system-name vpp-dc-01 tx-hold 4 tx-interval 30
vpp# set interface lldp TenGigabitEthernet0/0/0 port-desc "uplink-to-tor-01"
vpp# show lldp
vpp# show lldp neighbors detail

BFD — 빠른 장애 감지

BFD(RFC 5880, Bidirectional Forwarding Detection)는 인접 장비 간 경로 상태를 밀리초 단위로 확인하는 경량 프로토콜입니다. BGP·OSPF 같은 라우팅 프로토콜이 수 초~수십 초에 감지하는 장애를 수십 ms 내에 포착해 빠른 라우팅 전환을 가능케 합니다.

BFD 동작 모드

CLI 구성

vpp# bfd udp session add interface TenGigabitEthernet0/0/0      local-addr 10.0.0.1 peer-addr 10.0.0.2      desired-min-tx 100000 required-min-rx 100000 detect-mult 3

# 100ms × 3 = 300ms 안에 장애 감지
vpp# show bfd sessions
vpp# show bfd sessions verbose

FIB 통합과 Fast Reroute

BFD 세션은 FIB의 next-hop 상태와 직접 연결됩니다. 세션이 Down으로 전이되는 즉시 VPP는 해당 next-hop을 가리키는 모든 경로를 ECMP 내 다른 멤버로 재라우팅합니다. 이는 제어 평면 개입 없이 데이터 평면에서 즉시 수행되므로 서브-밀리초 fail-over가 가능합니다.

DHCP — 클라이언트 · 서버 · 프록시

VPP의 dhcp 플러그인은 세 가지 역할을 모두 지원합니다. 엣지 라우터 역할로는 DHCP 프록시/릴레이가 가장 자주 쓰입니다.

DHCP 클라이언트

인터페이스가 공인 IPv4를 받아야 할 때 사용합니다. WAN 인터페이스가 ISP로부터 동적 할당을 받는 케이스입니다.

vpp# set dhcp client intfc GigabitEthernet0/0/0 hostname vpp-wan set-broadcast-flag
vpp# show dhcp client

DHCP Relay / Proxy

서로 다른 서브넷에 있는 DHCP 클라이언트 요청을 외부 DHCP 서버로 릴레이합니다. Layer 2 브로드캐스트로 제한되는 DHCP를 Layer 3 경계를 넘어 전달합니다.

vpp# set dhcp proxy server 192.0.2.10 src-address 10.0.1.1
vpp# set interface ip address GigabitEthernet0/0/1 10.0.1.1/24
vpp# show dhcp proxy

DHCPv6

IPv6도 동일한 플러그인에서 dhcp6 client, dhcp6 pd (Prefix Delegation) 명령으로 지원합니다. 가입자망에서 ISP가 IPv6 프리픽스를 위임할 때 DHCPv6 PD가 핵심 메커니즘입니다.

vpp# dhcp6 client enable intfc GigabitEthernet0/0/0
vpp# dhcp6 pd client enable intfc GigabitEthernet0/0/0

FIB load-balance와 재귀 DPO 스태킹

VPP FIB의 내부 객체 그래프는 L3 라우팅 (FIB)에서 본 사용자 관점 동작의 아래쪽에서 실제 데이터 평면을 구성합니다. VPP 라우팅이 수백만 프리픽스에서도 O(1)에 가까운 갱신 시간을 유지하는 비결은 경로(Path)의 공유재귀 DPO(Data Path Object) 스태킹에 있습니다.

객체 그래프 — fib_entry · fib_path_list · load_balance · adjacency

FIB의 제어 평면은 네 종류의 객체가 삼각형처럼 연결된 구조를 가집니다. fib_entry_t는 프리픽스 단위 소유자입니다. 같은 프리픽스에 여러 source(API, CLI, LISP, BGP, 인터페이스 주소, IGMP 등)가 기여할 수 있고, 각 source는 자신의 경로 집합을 나타내는 fib_path_list_t를 가집니다. FIB_SOURCE_* 열거 순서에 따라 가장 우선되는 source가 active가 되며, 이때만 실제로 DPO(Data Path Object)가 mtrie 또는 radix 트리에 설치됩니다.

/* fib_entry_t 축약: 프리픽스 단위 소유자 */
typedef struct fib_entry_t_ {
    fib_prefix_t        fe_prefix;
    fib_node_index_t    fe_parent;       /* 커버(covering) 엔트리 */
    fib_entry_src_t    *fe_srcs;          /* 소스별 상태 (vec) */
    fib_entry_delegate_t *fe_delegates;
    dpo_id_t            fe_lb;            /* 설치된 load_balance DPO */
    dpo_id_t            fe_lb_urpf;       /* uRPF 검증용 */
    u32                 fe_import_rr_fixup;
} fib_entry_t;

fib_path_list_t공유 가능한(shared) 경로 집합입니다. 수천 개의 프리픽스가 동일한 next-hop 집합을 쓸 때, VPP는 경로 리스트를 fib_path_list_db(bihash)에서 조회하여 이미 존재하면 인덱스를 재사용합니다. 네이버 하나가 다운되면 이 공유 경로 리스트 하나를 수정하는 것만으로 그것을 참조하는 모든 엔트리가 O(1)에 일관성을 회복합니다.

ip_adjacency_t는 인터페이스 + 목적지 MAC + VLAN 태그로 구성된 L2 rewrite를 캐시합니다. 아래 네 가지 중 하나의 타입을 가집니다.

DPO — 2-word 다형 객체

dpo_id_t단 두 개의 워드(dpoi_type, dpoi_index)로 데이터 평면의 "다음 할 일"을 표현하는 다형(polymorphic) 객체입니다. 각 타입은 vft(dpo_vft_t)를 등록하여 lock/unlock/format 인터페이스를 제공합니다.

/* DPO 핵심 타입 (src/vnet/dpo/dpo.h) */
typedef enum dpo_type_t_ {
    DPO_FIRST,
    DPO_DROP,                    /* 무조건 드롭 */
    DPO_PUNT,                    /* 제어 평면으로 */
    DPO_RECEIVE,                 /* 자신 주소 수신 */
    DPO_LOOKUP,                  /* 다른 테이블에서 재룩업 */
    DPO_LOAD_BALANCE,            /* ECMP 해시 분배 */
    DPO_REPLICATE,               /* 멀티캐스트 복제 */
    DPO_ADJACENCY,               /* L2 rewrite (확정) */
    DPO_ADJACENCY_INCOMPLETE,    /* Glean */
    DPO_ADJACENCY_MIDCHAIN,      /* 터널 인캡 */
    DPO_ADJACENCY_GLEAN,
    DPO_ADJACENCY_MCAST,
    DPO_MPLS_LABEL,              /* MPLS 푸시 */
    DPO_CLASSIFY,                /* vnet_classify 체인 */
    DPO_L3_PROXY,
    DPO_BIER_TABLE, DPO_BIER_FMASK,
    DPO_LAST,
} dpo_type_t;

/* 타입별 vft 등록 */
void dpo_register(dpo_type_t type,
                  const dpo_vft_t *vft,
                  const char *const *const *nodes);

DPO 등록 시 nodes 인자는 프로토콜별 다음 노드 이름의 2차원 배열입니다. 예를 들어 DPO_ADJACENCY는 IPv4 경로에서 ip4-rewrite, IPv6 경로에서 ip6-rewrite, MPLS에서 mpls-output을 다음 노드로 지정합니다. dpo_stack(parent_type, proto, &parent_dpo, &child_dpo) 호출은 부모 DPO의 dpoi_next_node를 자식 DPO 타입에 맞는 노드 인덱스로 설정하여, 룩업 노드가 다음에 실행할 그래프 노드를 컴파일 시점에 결정할 수 있게 합니다.

load_balance_create 내부

load_balance_t는 ECMP 또는 단일 NH 경로를 해시 기반으로 분배하는 DPO입니다. 핵심은 버킷 수를 항상 2의 제곱으로 강제하여 해시에서 모듈로 연산 대신 AND 마스크를 사용하는 점입니다.

/* src/vnet/dpo/load_balance.c 축약 */
typedef struct load_balance_t_ {
    /* 첫 캐시라인 — 데이터 평면 핫 */
    u16         lb_n_buckets;
    u16         lb_n_buckets_minus_1;   /* & 마스크용 */
    dpo_proto_t lb_proto;
    u8          lb_flags;
    u8          lb_fib_entry_flags;
    u32         lb_hash_config;
    dpo_id_t    lb_buckets_inline[4];   /* n<=4 inline */
    dpo_id_t   *lb_buckets;             /* n>4 heap */
    /* 이후 콜드 필드 */
    u32         lb_locks;
    index_t     lb_urpf;
    vlib_combined_counter_main_t *lb_counters;
} load_balance_t;

index_t
load_balance_create(u32 n_buckets, dpo_proto_t lb_proto,
                     flow_hash_config_t fhc)
{
    load_balance_t *lb;
    index_t lbi;

    pool_get_aligned(load_balance_pool, lb, CLIB_CACHE_LINE_BYTES);
    lbi = lb - load_balance_pool;
    clib_memset(lb, 0, sizeof(*lb));

    /* 2^n 반올림 */
    lb->lb_n_buckets = ip_multipath_normalize_next_hop_bucket_count(n_buckets);
    lb->lb_n_buckets_minus_1 = lb->lb_n_buckets - 1;
    lb->lb_proto = lb_proto;
    lb->lb_hash_config = fhc;
    lb->lb_locks = 1;

    /* 버킷이 4개 이하면 인라인 배열, 아니면 힙 할당 */
    if (lb->lb_n_buckets > LB_NUM_INLINE_BUCKETS)
        vec_validate_aligned(lb->lb_buckets,
                              lb->lb_n_buckets - 1,
                              CLIB_CACHE_LINE_BYTES);

    vlib_validate_combined_counter(&(load_balance_main.lbm_to_counters), lbi);
    vlib_zero_combined_counter(&(load_balance_main.lbm_to_counters), lbi);

    return lbi;
}

n_buckets가 4 이하일 때는 lb_buckets_inline 배열을 사용하여 추가 캐시 미스 없이 버킷을 첫 캐시라인 안에서 읽습니다. 단일 NH 라우트가 대다수인 실환경에서 이 최적화는 룩업 핫패스를 상당 폭 단축합니다.

fib_table_entry_path_add 전체 콜그래프

실제로 라우트 하나가 추가될 때 호출되는 함수 체인입니다. 각 단계는 공유 재사용과 원자 교체 원칙을 지킵니다.

fib_table_entry_path_add(fib_index, prefix, src, flags, proto, nh, sw_if_index, ...)
│
├─ fib_table_lookup_exact_match         # 기존 엔트리 있으면 재사용
│   └─ (없으면) fib_entry_create        # pool_get으로 fib_entry_t 할당
│
├─ fib_entry_path_add                   # 엔트리에 경로 추가
│   │
│   ├─ fib_entry_src_find_or_create     # 해당 source 상태 획득
│   │   └─ vft->fesv_init               # 첫 추가면 source 초기화
│   │
│   ├─ vft->fesv_path_add               # 새 path-list 후보 생성
│   │   └─ fib_path_list_copy_and_path_add
│   │       │
│   │       ├─ 기존 path-list 복사 + 새 path 추가
│   │       └─ fib_path_list_db_find_or_insert  # bihash 중복 제거!
│   │           └─ 동일 NH 시퀀스면 기존 인덱스 반환
│   │
│   └─ fib_entry_src_action_reactivate  # best source 재선정
│       │
│       └─ (best가 바뀌었으면)
│           fib_entry_src_action_install
│           │
│           ├─ fib_entry_get_dpo_for_source
│           │   └─ fib_path_list_contribute_forwarding
│           │       └─ load_balance_multipath_update
│           │           └─ load_balance_set_bucket(i, &adj_dpo)
│           │
│           └─ fib_table_fwding_dpo_update
│               └─ ip4_fib_table_fwding_dpo_update
│                   └─ ip4_fib_mtrie_route_add   # mtrie에 원자 교체 설치

핵심은 fib_path_list_db_find_or_insert 단계의 bihash 기반 중복 제거입니다. BGP full table이 동일 업스트림을 공유하는 경우가 많기 때문에, 수십만 개의 엔트리가 실제로는 한두 개의 path-list 인스턴스만 참조합니다. 네이버 장애 시 단 한 번의 load_balance_multipath_update가 모든 엔트리를 일관되게 갱신합니다.

재귀 DPO 스태킹 — GRE over IPv4 예시

가장 흥미로운 부분은 재귀 해결(recursive resolution)입니다. GRE 터널 패킷은 두 번의 IPv4 룩업을 거칩니다. (1) 인너 패킷이 터널 목적지로 라우트되고, (2) 터널 목적지가 실제 언더레이 next-hop으로 다시 라우트됩니다. VPP는 이를 midchain adjacency와 DPO 스택 추적으로 구현합니다.

[인너 라우트 10.0.0.0/24 via 203.0.113.1 (GRE 터널)]
         │
         ▼
    fib_entry (10.0.0.0/24)
         │
         └──▶ load_balance (1 bucket)
                 │
                 └──▶ midchain_adjacency (GRE 터널 인캡)
                         │ rewrite = [GRE header + outer IP header]
                         │
                         └──▶ (스택 추적)
                              outer_fib_entry (203.0.113.1/32)
                                      │
                                      └──▶ load_balance (ECMP 3-way)
                                              ├─ adj(REWRITE) → eth0 MAC_A
                                              ├─ adj(REWRITE) → eth1 MAC_B
                                              └─ adj(REWRITE) → eth2 MAC_C

midchain adjacency는 adj_midchain_stack(adj_index, &parent_dpo)로 자신의 "부모"를 언더레이 FIB 엔트리로 설정합니다. 이 호출은 단순 포인터 복사가 아니라 FIB tracking 메커니즘에 등록됩니다. 언더레이 엔트리의 load_balance가 갱신되면(예: 언더레이 ECMP 재분배), tracker 콜백이 midchain의 dpoi_next_node를 자동으로 재계산합니다. 데이터 평면 코드는 이 모든 재귀를 인식하지 못하고, 단순히 gre_encap 노드 실행 후 다음 버퍼를 next_index(이미 새 load_balance 노드로 설정됨)로 보냅니다.

왜 이렇게 복잡한가: 이 구조의 대가는 라우트 하나를 사람이 머릿속에서 따라가기 어렵다는 점이지만, 대가로 얻는 것은 네이버 플랩 시 O(1) 전파입니다. 전통적인 라우팅 테이블은 터널 목적지가 바뀌면 해당 터널을 사용하는 모든 라우트를 개별적으로 재작성해야 하지만, VPP는 스택된 midchain 하나만 업데이트하면 됩니다. BGP PIC(Prefix Independent Convergence)이 VPP에서 자연스럽게 구현되는 이유입니다.

데이터 평면 순회 — ip4-rewrite의 4-way 언롤

이 모든 제어 평면 구조가 데이터 평면에서 어떻게 "사라지는가"를 보여주는 것이 ip4_rewrite_inline입니다. 이 함수는 프레임의 버퍼 인덱스에서 adjacency 인덱스를 꺼내 rewrite를 적용하고 다음 노드로 enqueue합니다. 4-way 언롤링 + 2-ahead 프리페치가 핵심입니다.

/* ip4_rewrite 핫루프 (src/vnet/ip/ip4_forward.c 축약) */
while (n_left_from >= 8) {
    vlib_buffer_t *b0, *b1, *b2, *b3;
    ip_adjacency_t *adj0, *adj1, *adj2, *adj3;
    u32 adj_index0, adj_index1, adj_index2, adj_index3;

    /* 4-ahead 프리페치: 다음 다음 캐시라인 확보 */
    vlib_prefetch_buffer_header(b[4], LOAD);
    vlib_prefetch_buffer_header(b[5], LOAD);
    vlib_prefetch_buffer_header(b[6], LOAD);
    vlib_prefetch_buffer_header(b[7], LOAD);
    CLIB_PREFETCH(b[4]->data, CLIB_CACHE_LINE_BYTES, LOAD);
    CLIB_PREFETCH(b[5]->data, CLIB_CACHE_LINE_BYTES, LOAD);

    b0 = b[0]; b1 = b[1]; b2 = b[2]; b3 = b[3];

    /* adjacency 인덱스는 이전 노드(ip4-lookup)가 vnet_buffer에 저장 */
    adj_index0 = vnet_buffer(b0)->ip.adj_index[VLIB_TX];
    adj_index1 = vnet_buffer(b1)->ip.adj_index[VLIB_TX];
    adj_index2 = vnet_buffer(b2)->ip.adj_index[VLIB_TX];
    adj_index3 = vnet_buffer(b3)->ip.adj_index[VLIB_TX];

    adj0 = adj_get(adj_index0);
    adj1 = adj_get(adj_index1);
    adj2 = adj_get(adj_index2);
    adj3 = adj_get(adj_index3);

    /* TTL 감소 + 체크섬 증분 */
    ip0 = vlib_buffer_get_current(b0);
    checksum0 = ip0->checksum + clib_host_to_net_u16(0x0100);
    checksum0 += checksum0 >= 0xffff;
    ip0->checksum = checksum0;
    ip0->ttl -= 1;

    /* rewrite 헤드룸 확보 */
    vlib_buffer_advance(b0, -(word)adj0->rewrite_header.data_bytes);

    /* rewrite 바이트 복사 (SIMD 16B 단위) */
    u8 *dst0 = vlib_buffer_get_current(b0);
    clib_memcpy_fast(dst0,
                     adj0->rewrite_data,
                     adj0->rewrite_header.data_bytes);

    /* 다음 노드 = adj의 next_index (interface-output 또는 midchain) */
    next0 = adj0->rewrite_header.next_index;

    /* per-adjacency 카운터 */
    vlib_increment_combined_counter(
        &adjacency_counters, thread_index, adj_index0, 1,
        vlib_buffer_length_in_chain(vm, b0));

    /* b1, b2, b3 동일 패턴 (언롤) ... */

    b += 4; n_left_from -= 4;
}

이 루프에서 제어 평면의 path-list·DPO·스택 정보는 전혀 보이지 않습니다. 단지 adjacency 인덱스 하나를 읽고, rewrite 바이트를 복사하고, 다음 노드 인덱스를 꺼낼 뿐입니다. 제어 평면이 하는 모든 일은 이 adjacency의 rewrite 바이트와 next_index를 올바르게 채워두는 것입니다. 데이터 평면이 이토록 단순하기 때문에 패킷당 처리 사이클이 수십 사이클 수준으로 유지됩니다.

관찰 명령어

# 라우트의 DPO 스택 펼쳐 보기
vpp# show ip fib 10.0.0.0/24
ipv4-VRF:0, fib_index:0, flow hash:[src dst sport dport proto flowlabel ] epoch:0 flags:none locks:[CLI:1, ]
10.0.0.0/24 fib:0 index:12 locks:2
  CLI refs:1 src-flags:added,contributing,active,
    path-list:[19] locks:2 flags:shared, uPRF-list:17 len:1 itfs:[1, ]
      path:[24] pl-index:19 ip4 weight=1 pref=0 attached-nexthop:  oper-flags:resolved,
        203.0.113.1 GigabitEthernet0/0/0
      [@0]: ipv4 via 203.0.113.1 GigabitEthernet0/0/0: mtu:9000 next:3 flags:[]
            0800 0800deadbeef...

 forwarding:   unicast-ip4-chain
  [@0]: dpo-load-balance: [proto:ip4 index:15 buckets:1 uRPF:17 to:[0:0]]
    [0] [@5]: ipv4 via 203.0.113.1 GigabitEthernet0/0/0: mtu:9000 next:3 flags:[]

# path-list 공유 관계 확인
vpp# show fib path-list 19
path-list:[19] locks:2 flags:shared
  refcount:2 uPRF-list:17

# adjacency 풀 전체
vpp# show adj
  0: glean: loop0-mcast
  1: glean: GigabitEthernet0/0/0-mcast
  2: arp-ipv4: via 0.0.0.0 GigabitEthernet0/0/0
  3: ipv4 via 203.0.113.1 GigabitEthernet0/0/0: mtu:9000 ...

show ip fib 출력의 forwarding: 블록이 실제로 mtrie에 설치된 DPO 체인입니다. 대괄호 숫자([@0], [@5])는 DPO 타입의 다음 노드 인덱스이며, 이것이 데이터 평면의 next_index로 그대로 사용됩니다.

참고자료

이 문서는 VPP 26.02 기준 L2~L4 데이터 평면을 다루고 있으며, 하드웨어 오프로드·터널·FIB·SRv6 영역은 DPDK와 NIC 벤더 문서를 함께 읽어야 정확합니다. 아래 링크는 각 주제별로 1차 자료와 실무 가이드를 구분해 정리했습니다.

VPP 데이터 평면 공식 문서

DPDK 및 하드웨어 오프로드

관련 표준 (RFC)

성능 및 벤치마크

커널 소스 교차 참조

이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하세요.

오픈소스 코드 인용 고지

라이선스 고지: 이 문서의 코드 예제에는 아래 오픈소스 프로젝트의 소스 코드에서 발췌·간략화한 내용이 포함되어 있습니다. 해당 코드 블록에는 원본 프로젝트의 라이선스가 그대로 적용되며, 본 사이트의 CC BY-NC-SA 4.0 라이선스 대상에서 제외됩니다. 이들 코드의 포함은 한국 저작권법 제28조 및 제35조의5에 근거한 교육 목적의 공정 이용에 해당합니다.
프로젝트저작권자라이선스공식 저장소
VPP (Vector Packet Processing) FD.io contributors Apache License 2.0 github.com/FDio/vpp
DPDK (Data Plane Development Kit) DPDK contributors BSD 3-Clause License github.com/DPDK/dpdk

코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.