데이터 경로 (L2~L4)
VPP 데이터 경로: L2~L4 노드 심화, DPDK 통합, 커널 인터페이스, L2 브릿징, L3 라우팅, SRv6, QoS, VXLAN, L3 NAT44 및 memif 서비스 체이닝 실전을 다룹니다.
핵심 요약
- 데이터 경로 전체 흐름 —
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) 마킹 같은 오버레이 네트워크 기능을 그래프 노드로 구현하여 물리 경로 위에 가상 경로를 얹습니다.
단계별 이해
- NIC 바인딩과 DPDK 준비
lspci | grep Ethernet으로 장치를 확인하고,dpdk-devbind.py -b vfio-pci <PCI>로 DPDK PMD에 바인딩합니다.isolcpus로 CPU 격리(Isolation)를 선행합니다. - startup.conf 기본 구성
워커 수·버퍼(Buffer) 풀·DPDK 디바이스 목록·NUMA 배치를 정의합니다. Hugepage 2MB × 1024 이상을 확보하고 워커를 NUMA 로컬 코어에 핀닝합니다. - 인터페이스 생성
물리 NIC은 DPDK 바인딩으로 자동 인식되며,create host-interface로 AF_PACKET,create memif socket로 memif 소켓,create tap로 커널 TAP을 만듭니다. - 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를 확인합니다. - NAT/ACL 정책 적용
nat44 add interface address로 외부 인터페이스를 지정하고,nat44 add static mapping으로 고정 매핑(Mapping)을,classify table+set interface acl로 ACL 정책을 각각 적용합니다. NAT44 ED 모드·세션 타임아웃·CGNAT 튜닝은 보안/터널링 — NAT44/NAT64에 정리되어 있습니다. - 실전 검증
show runtime으로 그래프 노드별 벡터 크기·클럭 사이클을,show errors로 에러 카운터를,show interface로 패킷/바이트 통계를 확인하여 구성이 기대대로 동작하는지 검증합니다.
L2 ~ L4 핵심 노드 심화 가이드
앞 절들에서 개별 노드를 따로 살펴보았다면, 이 절은 OSI 계층별로 묶어 기초 개념 → 헤더 구조 → VPP 그래프 노드 책임 → 내부 구현 → 실전 예시의 순서로 다시 정리합니다. 커널 네트워크 스택 경험은 있지만 VPP 내부는 처음 보는 독자를 염두에 두고, 동일한 프레임이 L2 → L3 → L4를 통과할 때 어느 노드가 어떤 필드를 건드리는지를 하나씩 따라갈 수 있게 구성했습니다.
arp-input, l2-output, l2-flood, ip4-local, ip6-input, udp-local, session-queue 등을 새로 파고듭니다.
네트워크 계층 기초 — VPP가 바라보는 프레임·패킷·세그먼트
네트워크 프로토콜은 데이터 앞에 계층별 헤더를 양파처럼 감싸는 구조로 전송됩니다. 수신 측은 바깥쪽(L2)부터 한 겹씩 벗겨내며 각 헤더를 해석하는데, VPP의 그래프 노드는 정확히 이 "한 겹 벗기기" 한 단계씩에 대응하도록 설계되어 있습니다. 즉 특정 노드가 어느 계층을 다루는지를 알면, 해당 노드에서 읽고 쓰는 필드가 자연스럽게 결정됩니다.
세 계층은 각자의 "관심사"가 뚜렷이 분리되어 있어, 실무 디버깅에서도 "이 문제는 L2 문제인가, L3 문제인가, L4 문제인가"라는 질문을 먼저 던지는 편이 빠릅니다. 아래 표는 각 계층이 답해야 하는 질문과 VPP에서 그 답을 내는 노드를 요약합니다.
| 계층 | 핵심 질문 | 다루는 식별자 | 주요 VPP 노드 | 실패 시 증상 |
|---|---|---|---|---|
| L2 | 이 프레임은 어느 이웃에서 왔고, 다음으로 어느 포트에 넣을까? | MAC, VLAN, EtherType | ethernet-input, l2-input, l2-learn, l2-fwd, l2-flood, l2-output, arp-input | 브릿지 ping 불가, MAC 미학습, VLAN 태그 문제 |
| L3 | 이 패킷을 어느 next-hop으로 보내야 하고, TTL을 얼마나 깎을까? | IP 주소, TTL, Protocol | ip4-input, ip4-lookup, ip4-rewrite, ip4-local, ip4-punt, ip6-input | 라우팅 루프, 블랙홀, TTL 만료 |
| L4 | 이 세그먼트는 어느 연결·어느 포트에 속하며, 순서·신뢰성은 어떻게 보장할까? | Port, Seq/Ack, Flags | udp-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-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-match | TCI의 VID가 100과 일치하는 프레임만 해당 subif로 진입, 태그 pop | VLAN 태그 제거됨 |
| Default subinterface | set 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를 참조하도록 설계되어 있습니다.
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_index는l2-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-horizon | show 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 헤더 비트 필드 상세
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-localfeature를 끄면 스킵되며, 그러면 성능은 약간 올라가지만 보안상 위험합니다. - 44~47행L4 체크섬은 NIC 오프로드가 있다면 여기서 검증하지 않고, 오프로드 플래그를 그대로 다음 노드로 전달합니다. 다음 노드(tcp/udp)는 플래그가 세팅되어 있으면 체크섬 계산을 건너뛰고, 없으면 직접 계산합니다.
ip4-local에서 SRC_LOOKUP_MISS 카운터가 올라가는 형태로 나타납니다. 원인은 대부분 ① 송신 측 IP가 VPP FIB에 없는 private range여서 uRPF가 drop하거나, ② RPF 모드가 strict인데 asymmetric 경로가 있거나, ③ 송신 측이 스푸프된 src IP를 쓰는 경우입니다. show errors | include ip4-local과 trace 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-input | ip6-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는 (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)되어야 하며, 그래서 이벤트 큐 경유로 처리됩니다.
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 중이라고 가정합니다.
| # | 노드 | 계층 | 주요 동작 | 버퍼에 남는 변화 |
|---|---|---|---|---|
| 1 | dpdk-input | L1 | NIC RX 링에서 64바이트 mbuf burst 획득, vlib_buffer_t 초기화 | sw_if_index[RX]=1, current_data=0 (Ethernet 시작) |
| 2 | ethernet-input | L2 | DMAC 확인, EtherType 0x0800 → sparse vector lookup → next=ip4-input, vlib_buffer_advance(14) | current_data=14 (IP 시작), l2.bd_index 미설정 |
| 3 | ip4-input | L3 | Version=4, IHL=5, TTL=64, checksum 검증 OK → ip4-lookup | FIB 인덱스 결정됨 (기본 0) |
| 4 | ip4-lookup | L3 | mtrie로 10.0.0.1/32 검색 → RECEIVE DPO 히트 → ip4-local | adj_index에 RECEIVE 인덱스 |
| 5 | ip4-local | L3/L4 경계 | Protocol=6(TCP), uRPF로 src 10.0.0.5 경로 확인 OK → next=tcp4-input-nolookup | TCP 헤더 시작 오프셋이 opaque에 기록 |
| 6 | tcp4-input-nolookup | L4 | 4-tuple 해시 lookup → LISTEN 상태의 tcp_connection_t 발견 → next=tcp4-listen | tcp.connection_index 세팅 |
| 7 | tcp4-listen | L4 | SYN 플래그 확인, 새 tcp_connection_t(SYN_RCVD 상태) 생성, ISN 선택, SYN+ACK 응답 생성 | 새 세션 레코드 생성, 원본 버퍼는 소비 |
| 8 | tcp-output | L4 | SYN+ACK 세그먼트 빌드, pseudo-header 체크섬 계산 (HW offload 없으면) | 새 버퍼에 TCP+IP 헤더 prepend |
| 9 | ip4-output | L3 | adjacency 조회로 next-hop MAC 획득, MAC rewrite, TTL=64 세팅, IP 체크섬 계산 | L2 헤더까지 완성 |
| 10 | interface-output | L2 | 출력 sw_if_index의 TX 노드로 디스패치(Patch) (dpdk-tx) | TX 큐 인덱스 세팅 |
| 11 | dpdk-tx | L1 | NIC 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 불통 (같은 서브넷) | L2 | arp-input, l2-fwd, ethernet-input | show ip neighbors, show l2fib |
| ping 불통 (다른 서브넷) | L3 | ip4-input, ip4-lookup drop 카운터 | show ip fib, show errors | include ip4- |
| 특정 포트만 안 됨 | L4 | udp-local, tcp4-input, Feature Arc ACL | show session verbose, show acl-plugin acl |
| 성능 급락 | 전 계층 | 노드별 vectors/call | show runtime |
| 무작위 드롭 | L3 uRPF / L4 체크섬 | ip4-local SRC_LOOKUP_MISS, tcp4-input bad-csum | show errors |
| brige 외부로 새지 않음 | L2 | BD 설정, split-horizon | show bridge-domain <bd> detail |
이 치트시트가 의미하는 바는 단순합니다. "먼저 L2를 확인하고, 막히면 L3로 올라가고, 그다음에 L4를 본다"는 원칙입니다. VPP는 계층별 노드 분리가 명확하므로 이 순서만 지켜도 대부분의 디버깅에서 원인 범위를 빠르게 좁힐 수 있습니다.
DPDK 통합
v26.02(2026-02-25 릴리스)를 기준선으로 작성되어 있으며, 구버전 차이는 "25.02 대비 변경" 박스로 표기합니다. 페이지 간 통일된 변경 요약은 Host Stack 문서의 변경 요약을 참고하시기 바랍니다.
- 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
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
멀티큐와 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 */
}
RSS 워커 친화성과 해시 키 설계
RSS는 NIC 하드웨어가 수신 패킷의 튜플(기본: src/dst IP + src/dst port)을 해싱해 RX 큐에 분배하는 기법입니다. VPP 워커는 각 RX 큐를 독점 폴링하므로, RSS 해시 키 설계가 바로 워커 부하 균형(balance)을 결정합니다. 대부분의 장애는 "워커는 많이 띄웠는데 단일 워커만 100%에 달하고 나머지는 유휴"인 형태로 나타나며, 원인은 거의 항상 RSS 키 부적합입니다.
네 가지 전형적인 오구성:
- 단일 플로우 편중 — 동일 5-튜플의 단일 TCP 세션이 대역폭(Bandwidth)을 지배하는 경우(예: iperf 단일 스트림). RSS는 이 플로우 전체를 단일 큐에 고정하므로 워커 분산이 불가능합니다. 해결: 애플리케이션 층 병렬화(다중 세션) 또는 Flow Director로 수동 분산.
- 터널 외부 헤더 해싱 — VXLAN/GENEVE 캡슐화 트래픽은 외부 IP가 고정(예: 터널 양 끝 두 IP)되어 내부 플로우가 모두 같은 큐에 몰립니다. 해결: NIC이 내부(inner) 헤더 해싱을 지원하면(
ethtool -N ... rx-flow-hash tcp4 sdfn) inner 5-tuple 해싱 활성화, 미지원이면 VXLAN source port 엔트로피(RFC 7348)를 제대로 활용하도록 터널 측에서 보정. - IPsec ESP 해싱 — ESP는 L4 포트가 없으므로 기본 RSS는 2-튜플(src/dst IP)로만 해싱합니다. 동일 터널의 트래픽 전체가 단일 큐에 고정됩니다. 해결: SPI(ESP Security Parameter Index)를 해시에 포함하는 NIC(Intel 82599+)에서
rx-flow-hash esp4 sdn활성화. - 워커 수 ≠ 큐 수 — 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개를 기준으로 한 전형적인 기동 순서입니다.
# 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-input의 Vectors/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 기준) | 비고 |
|---|---|---|---|---|
| native | SW (VPP 내장) | AES-GCM (AES-NI) | ~15 Gbps/코어 | x86 AES-NI 필수 |
| ipsecmb | SW (Intel) | AES-CBC/GCM, SHA, ChaCha20 | ~12 Gbps/코어 | Intel 최적화 라이브러리 |
| openssl | SW | 모든 OpenSSL 알고리즘 | ~8 Gbps/코어 | 범용, 호환성 우수 |
| dpdk-cryptodev | HW | 디바이스 의존 | ~40+ Gbps | QAT, Mellanox 등 |
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 설정, 실전 함정, 성능 측정 포인트를 정리합니다. 아래 표는 빠른 참조용 치트시트입니다.
| 오프로드 | 계층 | 주요 NIC | VPP 관여도 | 활성화 지점 |
|---|---|---|---|---|
| Checksum (IP/UDP/TCP/IPv6) | L3/L4 | 거의 모든 NIC | vlib_buffer flag | 기본 on, per-buffer |
| VLAN strip/insert | L2 | 거의 모든 NIC | vnet_hw_interface | 인터페이스 단위 |
| TSO (TCP Segmentation Offload) | L4 | 대부분 | gso 플래그 + segment size | 버퍼 플래그 + MTU |
| GSO (Generic SO) | L4 | virtio/vhost-user | vhost-user 협상 | 게스트 ↔ 호스트 |
| LRO (Large Receive Offload) | L4 | Intel 700 시리즈+ | PMD 설정 | enable-lro |
| RSS (Receive Side Scaling) | 해시 기반 큐 분배 | 대부분 | PMD 설정 + 워커 매핑 | num-rx-queues |
| Flow Director / RTE Flow | 정확 매칭 큐 분배 | Intel/Mellanox/Broadcom | VPP flow 플러그인 | flow add |
| DDP (Dynamic Device Personalization) | 프로파일 기반 파싱 | Intel E810 | PMD 초기화 | DPDK PMD 옵션(드라이버별) |
| SR-IOV VF | 하드웨어 가상화(Virtualization) | 대부분 | 부팅 시 PF/VF 분할 | 커널 sysfs |
| Crypto 오프로드 | 암호화 엔진 | QAT, Nitrox, Mellanox | cryptodev | dpdk { cryptodev ... } |
| IPsec inline | ESP 처리까지 | Mellanox ConnectX-6, Chelsio | IPsec security session | SA 생성 시 플래그 |
| 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 처리에 문제가 생길 수 있으므로 꺼 두는 것이 원칙입니다.
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로 현재 엔진별 처리량을 비교하시기 바랍니다.
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 두 가지로 나뉩니다.
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"가 보이면 적용된 상태입니다
오프로드가 실제로 동작하고 있는지 확인하는 법
설정을 켠 것과 실제 오프로드가 일어나는 것은 다릅니다. 다음 4단계를 항상 확인하시기 바랍니다.
- NIC 역량 확인:
show hardware-interfaces verbose의capabilities줄에 해당 플래그가 있는가. - VPP 활성 확인:
show interface features또는 기능별 명령(show flow,show ipsec sa)에 오프로드 플래그가 보이는가. - 카운터 확인:
show errors에서 해당 경로의bad-checksum,software-csum,crypto-fallback카운터가 증가하지 않는가. - 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-tuple | DNS 리졸버 트래픽이 한 워커에 몰림 | 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 카운터 증가 | 윈도우 크기 확대, 시간 동기화 점검 |
커널 인터페이스
VPP는 유저스페이스에서 동작하지만, 커널 네트워크 스택과의 연동이 필수적인 경우가 많습니다. VPP는 다양한 커널 인터페이스를 통해 커널과 패킷을 교환합니다.
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 링을 사용합니다. /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-user | AF_PACKET | AF_XDP | memif | TAP/virtio |
|---|---|---|---|---|---|
| 목적 | VM↔VPP | 커널 NIC 공유 | 커널 NIC zero-copy | 프로세스↔프로세스 | 커널 스택 연동 |
| 복사 횟수 | 0 (공유 메모리) | 1 (mmap ring) | 0 (UMEM) | 0 (공유 메모리) | 1~2 |
| 링 자료구조 | virtio split/packed | PACKET_MMAP v3 | AF_XDP desc ring | memif ring | virtio + /dev/tun |
| 메모리 평면 | 게스트 RAM 매핑 | 커널→유저 mmap | UMEM (유저 할당) | POSIX shm | 커널 skb 경유 |
| 제어 채널 | Unix 소켓 + SCM_RIGHTS | syscall | netlink + XSK bind | Unix 소켓 | tun ioctl |
| 이벤트 | kick/call eventfd | poll/epoll | wakeup flag | interrupt-eventfd | poll |
| NIC 독점 | N/A | 아니오 | 예 (해당 큐) | N/A | N/A |
| zero-copy 조건 | 항상 | 불가(복사 1회) | 드라이버 XDP_ZEROCOPY | 항상 | 불가 |
| 전형적 10GbE 성능 | 14 Mpps | 1.2 Mpps | 11 Mpps | 20+ Mpps | 0.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
PACKET_MMAP 링 버퍼(Ring Buffer)를 사용하여 시스템 콜(System Call) 오버헤드를 줄입니다.
Netlink 연동
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의 핵심 자료구조는 lcp_itf_pair_t입니다. 이 구조체는 VPP 하드웨어 인터페이스(sw_if_index)와 커널 TAP 인터페이스(host_if_index)의 매핑을 유지합니다. 플러그인이 활성화되면 VPP는 다음 두 방향으로 동기화를 수행합니다:
- VPP → Kernel: VPP의 FIB에 경로가 설치되면 Netlink
RTM_NEWROUTE메시지로 커널 라우팅 테이블에 반영합니다. 이를 통해ip route show로 VPP 경로를 확인할 수 있습니다. - Kernel → VPP: 커널에서 FRR 같은 라우팅 데몬이 경로를 설치하면 Netlink 알림을 수신하여 VPP FIB에 반영합니다. ARP/ND 엔트리도 동일하게 동기화됩니다.
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 → Kernel | Kernel → VPP | Netlink 메시지 | 비고 |
|---|---|---|---|---|
| 인터페이스 상태 (UP/DOWN) | O | O | RTM_NEWLINK | 양방향 즉시 반영 |
| IP 주소 | O | O | RTM_NEWADDR | lcp-sync 필요 |
| IPv4/IPv6 경로 | O | O | RTM_NEWROUTE | lcp-sync 필요 |
| ARP/ND 이웃 | O | O | RTM_NEWNEIGH | 양방향 즉시 반영 |
| VLAN 서브인터페이스 | O | △ | RTM_NEWLINK | lcp-auto-subint 필요 |
| MTU | O | O | RTM_NEWLINK | 양방향 즉시 반영 |
| ACL / Policy Route | X | X | — | VPP 전용, 커널에 반영 불가 |
| NAT / CGN | X | X | — | VPP 전용 기능 |
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) 판단에 사용됩니다.
- 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의 인터페이스와 경로를 커널에 미러링해야 합니다.
- BGP 라우터: FRR의 bgpd가 BGP 피어와 세션을 맺고, 학습한 경로를 커널 FIB에 설치 → linux-cp가 VPP FIB에 동기화
- OSPF 라우터: FRR의 ospfd가 OSPF 인접 관계를 형성하고 SPF 결과를 커널에 반영 → VPP로 전파
- 관리 평면:
ping,traceroute,ssh등 표준 도구로 VPP 인터페이스 진단 - 모니터링: SNMP, NetFlow 에이전트 등 커널 인터페이스 통계를 읽는 도구 활용
위 구성에서 패킷 흐름은 두 가지 경로로 나뉩니다:
- 데이터 플레인 경로: NIC → dpdk-input → ip4-lookup → ip4-rewrite → NIC (VPP 노드 그래프 처리, 고속)
- 제어 플레인 경로: BGP 패킷(TCP 179)은 lcp-punt 노드에서 TAP으로 전달 → FRR의 bgpd가 수신 → zebra가 경로를 커널 FIB에 설치 → linux-cp가 Netlink로 감지 → VPP FIB에 동기화
ip nht resolve-via-default를 설정하여 기본 경로를 통한 next-hop 해석을 허용하세요. 또한 lcp-sync와 lcp-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
memif 내부 구현 상세
memif(Memory Interface)는 VPP 인스턴스 간 또는 VPP와 사용자 공간(User Space) 애플리케이션 간의 고성능 패킷 교환을 위해 설계된 공유 메모리 기반 인터페이스입니다. 커널을 완전히 우회하며, 공유 메모리 링 버퍼를 통해 제로 카피(Zero-copy) 방식으로 패킷을 전달합니다.
/* 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바이트 크기이며,
region과offset으로 패킷 데이터 위치를 지정합니다.flags의 NEXT 비트로 점보 프레임을 분할 전송할 수 있습니다. - head / tail생산자-소비자 패턴의 핵심입니다. 생산자(TX)는 head를, 소비자(RX)는 tail을 증가시킵니다.
head == tail이면 링이 비어 있습니다. - volatile서로 다른 프로세스가 동시에 접근하므로, 컴파일러 최적화(Compiler Optimization)에 의한 캐싱을 방지합니다.
| 필드 | 크기 | 역할 | 접근 주체 |
|---|---|---|---|
head | 2바이트 | 다음 쓰기 위치 (생산자 포인터) | 생산자(TX) 쓰기 / 소비자(RX) 읽기 |
tail | 2바이트 | 다음 읽기 위치 (소비자 포인터) | 소비자(RX) 쓰기 / 생산자(TX) 읽기 |
cookie | 2바이트 | 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
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)으로 구성된 아키텍처입니다:
- FILL Ring: 사용자 공간이 커널에게 "이 UMEM 프레임에 수신 패킷을 써도 됩니다"라고 알려주는 링입니다
- RX Ring: 커널이 사용자 공간에게 "이 UMEM 프레임에 패킷이 도착했습니다"라고 알려주는 링입니다
- TX Ring: 사용자 공간이 커널에게 "이 UMEM 프레임의 패킷을 전송해 주세요"라고 요청하는 링입니다
- COMPLETION Ring: 커널이 사용자 공간에게 "TX 전송이 완료되었으니 이 UMEM 프레임을 재사용해도 됩니다"라고 알려주는 링입니다
AF_XDP vs DPDK 비교
| 비교 항목 | AF_XDP | DPDK |
|---|---|---|
| 커널 우회 수준 | 부분 우회 (NIC 드라이버는 커널 관리) | 완전 우회 (UIO/VFIO로 NIC 분리) |
| NIC 공유 | 가능 (커널 스택과 공유) | 불가능 (NIC 독점) |
| 성능 (64B) | ~24 Mpps (단일 코어) | ~37 Mpps (단일 코어) |
| 성능 (1518B) | ~14.8 Mpps (라인 레이트) | ~14.8 Mpps (라인 레이트) |
| 보안 모델 | 커널 보안 프레임워크 유지 | 커널 보안 우회 |
| 컨테이너(Container) 환경 | 우수 (표준 소켓 인터페이스) | 복잡 (VFIO passthrough 필요) |
| 드라이버 | 표준 커널 드라이버 (XDP 지원 필요) | 전용 PMD 필요 |
커널 인터페이스 선택 가이드
| 인터페이스 | 성능 등급 | 주요 사용 사례 | 제로 카피 | 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) 구성이 가능합니다.
/* 브릿지 도메인 생성 및 인터페이스 추가 */
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 패턴을 통해 파이프라인 효율을 극대화합니다.
- 해시 계산: 소스 MAC 주소와 브릿지 도메인 ID를 결합하여 해시 키를 생성합니다
- 엔트리 조회: 해시 테이블에서 기존 엔트리를 검색합니다
- 신규 등록: 엔트리가 없으면 새로 생성하고
sw_if_index, 타임스탬프를 기록합니다 - 갱신: 기존 엔트리가 있으면 타임스탬프를 갱신하고, 포트가 변경되었으면
sw_if_index를 업데이트합니다 - 에이징(Aging): 주기적으로 타임스탬프를 비교하여 일정 시간 동안 갱신되지 않은 엔트리를 삭제합니다
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 엔트리를 관리할 수 있습니다.
| 필드 | 크기 | 설명 |
|---|---|---|
sw_if_index | 32비트 | 패킷을 전달할 출력 인터페이스 인덱스 |
timestamp | 8비트 | 마지막 학습/갱신 시각 (분 단위, 에이징 비교용) |
shg | 8비트 | Split-Horizon Group 번호 (0이면 비활성) |
static_mac | 1비트 | 정적 MAC 엔트리 여부 (에이징·이동 불가) |
bvi | 1비트 | BVI 포트 엔트리 여부 (L3 라우팅 전환) |
filter | 1비트 | MAC 필터링 엔트리 (매칭 시 드롭) |
age_not | 1비트 | 엔트리 존재 여부 (0이면 빈 슬롯) |
lrn_evt | 1비트 | 학습 이벤트 알림 플래그 (API 통지용) |
L2 패킷 처리 노드 체인
L2 패킷 처리는 노드 그래프 기반으로 동작합니다. BVI를 통한 라우팅 트래픽은 l2-fwd 노드에서 L3 경로로 분기됩니다.
브릿지 도메인 실전 활용 예제
다음은 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
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는 두 가지 방법을 제공합니다:
- 정적 lookup DPO — 한 VRF의 경로 next-hop을 다른 VRF의 lookup으로 지정합니다.
ip route add table 10 8.8.8.8/32 via lookup-in-table 0처럼 작성하면 매칭 패킷이 fib 0으로 재조회됩니다. - 인터셉트 인터페이스 — 두 VRF 사이에 가상 인터페이스(예: loopback) 한 쌍을 두고 정상 라우팅으로 연결합니다. 단순하지만 hop이 하나 추가됩니다.
- 인터페이스를 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의 경로를 읽는지 명시해야 합니다.
/* 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는 이 문제를 다음 세 가지 원칙으로 해결합니다.
- 다형성(polymorphism): FIB 엔트리는 "어떤 DPO를 가리키는지"만 알면 되고, 실제 처리 로직은 DPO 타입별 vtable이 제공합니다. 새 포워딩 방식을 추가할 때 FIB 코어를 수정할 필요가 없습니다.
- 공유(sharing): 동일한 동작을 하는 DPO는 단 하나의 인스턴스로 관리되며 여러 FIB 엔트리가 참조 카운팅으로 공유합니다. 100만 개의 BGP 경로가 같은 next-hop을 쓰면 adjacency 객체는 1개면 충분합니다.
- 연쇄(chaining): DPO는 다른 DPO를 자식으로 가리킬 수 있습니다.
load-balance → adjacency → rewrite처럼 각 단계가 독립된 객체로 구성되므로, 중간 단계만 부분 갱신할 수 있습니다(예: ARP 해석이 완료되면 rewrite 헤더만 바뀌고 load-balance는 그대로).
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 타입 | 설명 | 사용 예시 |
|---|---|---|
adjacency | L2 rewrite 후 전달 | 일반 IP 포워딩 (next-hop이 직접 연결 네트워크) |
receive | 로컬 호스트로 패킷 수신 | VPP 자체 인터페이스 IP 주소로의 패킷 |
drop | 패킷 폐기 | 블랙홀 라우트, unreachable 경로 |
classify | 분류 테이블에 따라 다음 DPO 결정 | PBR(Policy-Based Routing) |
punt | 슬로 패스로 전달 | 컨트롤 플레인 처리가 필요한 패킷 |
lookup | 다른 FIB 테이블에서 재조회 | VRF 간 라우트 리킹(Leaking) |
replicate | 패킷 복제하여 여러 경로 전달 | 멀티캐스트 포워딩 |
ip-null | NULL 경로로 폐기 + 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_format
show 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는 이 문제를 중간 단계를 그대로 두는 지연 해석으로 해결합니다.
컨트롤 플레인 관점에서 이것은 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입니다.
adj 서브시스템도 같은 전통을 이어받아, FIB 엔트리와 "실제 송출"을 잇는 고정 길이 송출 레코드를 adjacency라고 부릅니다.
2. 왜 별도 객체로 뽑아냈나 — 세 가지 동기
- 성능(미리 계산한 rewrite): 패킷마다 ARP 테이블을 찾고 Ethernet 헤더를 조립하는 것이 아니라, 이미 완성된 14바이트(VLAN이면 18바이트)를
vlib_buffer_advance(-rewrite_len)후 memcpy 한 번으로 prepend 합니다. hot path에서 L2 헤더 "조립"이라는 개념이 사라집니다. - 공유(sharing): "via 192.168.1.254 GigE0/0/0"을 공통 next-hop으로 쓰는 경로가 100만 개 있어도 adjacency 객체는 1개면 충분합니다. FIB 엔트리/load-balance 버킷은
adj_index하나만 들고 다닙니다. - 원자적 업데이트: ARP가 새로 풀리거나 이웃 MAC이 바뀌면 adjacency 하나의 rewrite 버퍼만 갱신하면 이를 참조하는 모든 경로가 즉시 반영됩니다. FIB 스캔이 필요 없습니다(이 성질이 대규모 BGP 수렴 시간을 사실상 상수로 만듭니다).
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-incomplete | ARP/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 경로를 동시에 "사용 가능" 상태로 전환합니다.
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으로 해결합니다.
/* 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 — 남은 두 하위 타입
- adj-glean (글린 adjacency): "직접 연결 서브넷에 있는 임의의 호스트"를 위한 자리 표시자입니다. 예컨대
192.168.1.0/24가 인터페이스에 설정되면 그 서브넷 전체를 덮는 하나의 glean adjacency가 생성되고, 여기로 들어온 패킷은 "목적지 IP 기준으로 ARP를 쏘라"는 노드로 갑니다. 개별 호스트마다 incomplete adjacency를 미리 만들지 않아도 되므로 /24 하나에 대해 객체 수백 개를 절약합니다. 첫 패킷이 도착한 호스트에 대해서만 실제 neighbor adjacency가 lazy하게 생성됩니다. - adj-mcast (멀티캐스트 adjacency): 목적지 IP 멀티캐스트 주소를 Ethernet 멀티캐스트 MAC으로 변환하는 rewrite를 갖습니다. IPv4는
01:00:5e접두사 + IP 하위 23비트로 MAC이 결정되므로, 실제 rewrite 바이트는 패킷마다 하위 바이트를 계산해 채워 넣는 부분 rewrite가 됩니다. 이 미묘한 차이 때문에 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
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 해석이 끝나지 않은 상태입니다.gleanadjacency가 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 링크 레이어 제어 — Bonding · LACP · LLDP
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 동작 모드
- Asynchronous mode — 양쪽이 주기적으로 BFD control 패킷을 서로에게 보냄. 기본 모드.
- Echo mode — 로컬이 보낸 패킷을 상대가 되돌려 주기만 함. 상대 CPU 부담이 적어 더 공격적인 타이머(수십 ms)가 가능.
- Demand mode — 필요할 때만 polling. 저전력 환경용.
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를 캐시합니다. 아래 네 가지 중 하나의 타입을 가집니다.
IP_LOOKUP_NEXT_REWRITE— 확정된 네이버.ip4-rewrite노드가 그대로 복사합니다.IP_LOOKUP_NEXT_ARP— Glean. 미해결 상태로, 트리거 패킷이 ARP/ND 요청을 유발합니다.IP_LOOKUP_NEXT_MIDCHAIN— 터널 캡슐화용. 인너 rewrite 후 outer 헤더를 재귀 룩업합니다.IP_LOOKUP_NEXT_MCAST/IP_LOOKUP_NEXT_BCAST— 멀티캐스트/브로드캐스트 전용.
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 노드로 설정됨)로 보냅니다.
데이터 평면 순회 — 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 데이터 평면 공식 문서
- VPP 26.02 공식 문서 — 최신 데이터 평면 기능 트리
- VPP Configuration Reference —
dpdk,cpu,buffers섹션 - VPP CLI 사용 가이드 —
show/set계열 명령 패턴 - src/vnet/fib/ — FIB/Adjacency/LFIB 구현
- src/vnet/l2/ — L2 브릿지·BD·BVI
- src/vnet/srv6/ — SRv6 Endpoint/Policy 노드
DPDK 및 하드웨어 오프로드
- DPDK 공식 프로그래머 가이드 — mbuf, PMD, 메모리 풀
- rte_flow API — Flow Director의 표준 인터페이스
- rte_security (Inline Crypto/IPsec) — NIC 오프로드 추상화
- DPDK NIC Poll Mode Drivers — 벤더별 기능 매트릭스
- XDP Project — AF_XDP 배경 이해
관련 표준 (RFC)
- RFC 7348 — VXLAN
- RFC 8926 — Geneve
- RFC 8402 — Segment Routing Architecture
- RFC 8754 — IPv6 Segment Routing Header (SRH)
- RFC 8986 — SRv6 Network Programming
- RFC 7665 — Service Function Chaining Architecture
- RFC 2474 — DiffServ DSCP
- RFC 2475 — Differentiated Services 아키텍처
성능 및 벤치마크
- CSIT Performance Report — NDR/PDR/MRR 공식 결과
- CSIT Trending — 릴리스 간 성능 추이
- TRex Traffic Generator — 라인레이트 트래픽 생성
커널 소스 교차 참조
drivers/net/tun.c— TAP 인터페이스drivers/vhost/net.c— vhost-user 백엔드net/packet/af_packet.c— AF_PACKET (host-interface)net/xdp/xsk.c— AF_XDPdrivers/uio/uio_pci_generic.c,drivers/vfio/pci/vfio_pci_core.c— DPDK 바인딩mm/hugetlb.c,fs/hugetlbfs/— Hugepage 메모리 풀
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하세요.
오픈소스 코드 인용 고지
| 프로젝트 | 저작권자 | 라이선스 | 공식 저장소 |
|---|---|---|---|
| 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 |
코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.