vppinfra 엔드투엔드 패턴
vppinfra API를 실제 VPP 코드에서 조합해 쓰는 엔드투엔드 패턴과 전형적인 사용 사례 모음.
앞 절의 clib 인프라 라이브러리 상세 분석에서는 개별 API의 시그니처와 내부 동작을 살폈습니다. 이 절은 그 API들이 실제 플러그인/노드/제어 평면 코드에서 어떻게 결합되어 쓰이는지 보여 줍니다. 데이터 평면에서 자주 등장하는 8가지 패턴을 골라, 완전한 코드 스니펫과 단계별 설명, 엣지 케이스, 성능 고려 사항을 함께 실었습니다. 그대로 복사해 플러그인 스켈레톤에 붙여넣어도 동작하도록 자기 완결적인 예시로 구성했습니다.
각 패턴이 어떤 API 절과 연결되는지 먼저 짚어 두겠습니다. 아래 지도를 보고 낯선 API가 있으면 해당 링크로 돌아가 기본 시그니처부터 확인하신 뒤 다시 읽으시면 이해가 훨씬 빨라집니다.
| 예시 | 주로 쓰는 API (앵커) |
|---|---|
| 1. 플로우 테이블 | clib_pool + clib_bihash + clib_vec |
| 2. 인터페이스 집합 | clib_vec 기반 clib_bitmap_* |
| 3. format/unformat | clib format 프레임워크 (clib 인프라) |
| 4. clib_error 체이닝 | clib_error |
| 5. 링 버퍼 FIFO | clib_atomic + 캐시 라인 정렬 매크로 |
| 6. elog 이벤트 로깅 | elog 매크로 계열 |
| 7. 타이머 휠 | clib_time + tw_timer_wheel 템플릿 |
| 8. 배리어 재구성 | 동기화 프리미티브 + vlib_worker_thread_barrier_* |
예시 1 — 플로우 테이블: pool + bihash + vec 결합
모든 L4 상태 추적(NAT, 방화벽(Firewall), 로드 밸런서, IDS)은 5-tuple → 세션 엔트리 매핑이 핵심입니다. VPP에서는 bihash로 키→인덱스를 잡고, 실제 엔트리는 pool에 저장하는 패턴이 사실상 표준입니다. 해시 테이블에 큰 구조체를 직접 담으면 리사이즈 비용이 커지고 캐시 효율이 떨어지기 때문입니다.
typedef struct {
/* 5-tuple은 키이므로 pool 엔트리 내부에도 복사해 두어야
bihash가 리해시할 때 역참조할 수 있습니다. */
ip4_address_t src, dst;
u16 sport, dport;
u8 proto;
/* 실제 세션 상태 */
u64 bytes_in, bytes_out;
u64 last_seen_ns;
u32 flags;
u32 thread_index; /* owner worker */
} flow_entry_t;
typedef struct {
flow_entry_t *pool; /* pool_get / pool_put */
clib_bihash_16_8_t table; /* key=16B tuple, value=pool index */
u32 *lru_vec; /* vec_add1 / vec_del1 로 LRU 관리 */
clib_spinlock_t lock; /* 제어 평면 접근 시에만 */
} flow_table_t;
static u32
flow_insert (flow_table_t *ft, flow_key_t *k, u64 now)
{
flow_entry_t *e;
pool_get_zero (ft->pool, e); /* ① pool에서 슬롯 할당 */
e->src = k->src; e->dst = k->dst;
e->sport = k->sport; e->dport = k->dport;
e->proto = k->proto;
e->last_seen_ns = now;
clib_bihash_kv_16_8_t kv;
clib_memcpy_fast (&kv.key, k, sizeof (*k));
kv.value = e - ft->pool; /* ② 인덱스만 저장 */
clib_bihash_add_del_16_8 (&ft->table, &kv, 1 /* add */);
vec_add1 (ft->lru_vec, e - ft->pool); /* ③ LRU 꼬리에 추가 */
return e - ft->pool;
}
static always_inline flow_entry_t *
flow_lookup (flow_table_t *ft, flow_key_t *k)
{
clib_bihash_kv_16_8_t kv, v;
clib_memcpy_fast (&kv.key, k, sizeof (*k));
if (PREDICT_FALSE (clib_bihash_search_16_8 (&ft->table, &kv, &v)))
return 0; /* miss → 슬로우 패스 */
return pool_elt_at_index (ft->pool, v.value);
}
왜 이 구조가 필요한가
- ①
pool_get_zero는 free list에서 슬롯을 꺼내거나 비어 있으면 vector를 확장합니다. 할당/해제에 malloc 수준 비용이 들지 않으면서도 인덱스가 안정적입니다. 리사이즈가 일어나도pool_elt_at_index는 정확한 포인터를 돌려 줍니다. - ②bihash 값에는
flow_entry_t를 직접 담지 않고 pool 인덱스만 저장합니다. 해시 리사이즈 비용을 O(엔트리 크기)에서 O(8 바이트)로 낮춥니다. - ③LRU 벡터로 만료 순서를 관리하면 타임아웃 스캔이
vec_foreach한 줄로 끝납니다. 단,vec_del1은 O(1) 이지만 순서를 보장하지 않으므로, 엄격한 시간 순서가 필요하면dlist(doubly-linked list) 또는 타임 휠을 추가하시기 바랍니다.
pool_put 직후 같은 인덱스가 재사용될 수 있습니다. 데이터 평면이 아직 lookup 중일 수 있으므로, 삭제는 배리어 동기화 구간에서만 수행하거나 에폭 기반 해제(RCU-like 패턴)를 사용해야 합니다. bihash 자체는 read-mostly 시나리오에서 락 없이 안전합니다.
예시 2 — 인터페이스 집합 관리: clib_bitmap
ACL에서 "이 규칙이 적용되는 인터페이스 집합"을 표현하거나, feature arc에서 "enable된 스레드 집합"을 관리할 때 비트맵이 가장 효율적입니다. clib_bitmap_t는 uword 단위 벡터로 자동 확장되며, vec_validate와 같은 메모리 관리 관례를 그대로 따릅니다.
uword *sw_if_index_bitmap = 0; /* 빈 비트맵 */
/* 인터페이스 enable */
sw_if_index_bitmap = clib_bitmap_set (sw_if_index_bitmap, sw_if_index, 1);
/* 특정 인터페이스가 집합에 속하는지 확인 — O(1) */
if (clib_bitmap_get (sw_if_index_bitmap, sw_if_index))
vnet_feature_enable_disable ("ip4-unicast", "my-filter",
sw_if_index, 1, 0, 0);
/* 집합 순회 — 활성화된 인덱스만 돕니다 */
u32 i;
clib_bitmap_foreach (i, sw_if_index_bitmap)
{
vnet_sw_interface_t *si = vnet_get_sw_interface (vnm, i);
vlib_cli_output (vm, " %U", format_vnet_sw_interface_name, vnm, si);
}
/* 두 집합의 교집합 (예: 허용 IF ∩ 활성 IF) */
uword *intersect = clib_bitmap_dup_and (allow_bm, active_bm);
/* ... 사용 ... */
vec_free (intersect);
비트맵은 희소(sparse)한 집합에는 비효율적입니다. 예를 들어 sw_if_index가 100만까지 갈 수 있는 환경에서 한두 개만 쓴다면 uword * 대신 u32 * 벡터 또는 bihash를 쓰시기 바랍니다. 반대로 수백~수만 개 인터페이스를 다루는 에지 라우터에서는 비트맵이 캐시 친화적이고 논리 연산(AND/OR/XOR)까지 상수 시간에 가능하여 이상적입니다.
예시 3 — format/unformat: 사용자 정의 출력·파서
VPP의 모든 CLI 출력과 설정 파싱은 format/unformat 프레임워크를 거칩니다. printf류와 달리 사용자 함수를 포맷 지시자로 주입할 수 있어, 복잡한 구조체를 한 줄로 출력할 수 있습니다. 이 관례를 따르지 않으면 show 명령의 일관성이 깨집니다.
/* 포맷 함수: 5-tuple을 "src:sport -> dst:dport proto" 형태로 출력 */
u8 *
format_flow_key (u8 *s, va_list *args)
{
flow_key_t *k = va_arg (*args, flow_key_t *);
s = format (s, "%U:%u -> %U:%u %U",
format_ip4_address, &k->src, clib_net_to_host_u16 (k->sport),
format_ip4_address, &k->dst, clib_net_to_host_u16 (k->dport),
format_ip_protocol, k->proto);
return s;
}
/* 언포맷 함수: "1.2.3.4:80 -> 5.6.7.8:443 tcp" 를 파싱 */
uword
unformat_flow_key (unformat_input_t *input, va_list *args)
{
flow_key_t *k = va_arg (*args, flow_key_t *);
u32 sp, dp;
if (!unformat (input, "%U:%u -> %U:%u %U",
unformat_ip4_address, &k->src, &sp,
unformat_ip4_address, &k->dst, &dp,
unformat_ip_protocol, &k->proto))
return 0;
k->sport = clib_host_to_net_u16 (sp);
k->dport = clib_host_to_net_u16 (dp);
return 1;
}
/* CLI 핸들러에서 사용 */
static clib_error_t *
show_flow_cmd (vlib_main_t *vm, unformat_input_t *input, vlib_cli_command_t *cmd)
{
flow_table_t *ft = &my_plugin_main.flows;
flow_entry_t *e;
pool_foreach (e, ft->pool)
{
vlib_cli_output (vm, "%U bytes=%llu/%llu age=%llu ms",
format_flow_key, (flow_key_t *) &e->src,
e->bytes_in, e->bytes_out,
(now - e->last_seen_ns) / 1000000);
}
return 0;
}
format은 동적 벡터 u8 *를 반환하며, 호출자가 vec_free로 해제해야 합니다. vlib_cli_output은 내부적으로 해제를 처리하므로 직접 걱정할 일이 거의 없지만, 장기 저장이 필요하면 format (0, ...)로 새 벡터를 만들고 직접 관리하시기 바랍니다.
예시 4 — clib_error 체이닝과 전파
초기화 함수(VLIB_INIT_FUNCTION)·설정 함수(VLIB_CONFIG_FUNCTION)는 오류 시 clib_error_t *를 반환해야 합니다. 원인을 잃지 않고 상위로 전달하려면 체이닝이 필수입니다.
static clib_error_t *
my_plugin_configure (vlib_main_t *vm, flow_table_t *ft, u32 n_buckets, u32 mem_mb)
{
if (n_buckets & (n_buckets - 1))
return clib_error_return (0, "n-buckets (%u)는 2의 거듭제곱이어야 합니다", n_buckets);
clib_bihash_init_16_8 (&ft->table, "my-flow-table", n_buckets, mem_mb << 20);
clib_error_t *e = my_plugin_api_init (vm);
if (e)
return clib_error_return (e, "API 채널 초기화 실패"); /* 원인 체인 보존 */
return 0;
}
clib_error_return (e, ...)의 첫 인자에 기존 clib_error_t *를 넘기면 원인이 포함된 새 에러가 만들어집니다. show init-function errors CLI로 초기화 실패를 재추적할 때 이 체인이 결정적입니다. 경고 수준은 clib_warning()으로 stderr에 찍고, 치명 오류는 clib_error_report()로 VPP 로그에 남기시기 바랍니다.
예시 5 — 벡터로 구현하는 링 버퍼 FIFO
워커 스레드에서 제어 평면으로 이벤트를 전달할 때, 글로벌 락 없는 SPSC 링 버퍼가 필요합니다. VPP에는 svm_queue 같은 고수준 API도 있지만, 플러그인 내부에서 가볍게 쓰고 싶다면 벡터 + 원자 인덱스로 충분합니다.
typedef struct {
my_event_t *ring; /* vec_validate로 고정 크기 미리 할당 */
u32 mask; /* 크기 - 1 (2의 거듭제곱) */
CLIB_CACHE_LINE_ALIGN_MARK (a);
volatile u32 head; /* producer (워커) */
CLIB_CACHE_LINE_ALIGN_MARK (b);
volatile u32 tail; /* consumer (제어 스레드) */
} event_ring_t;
static always_inline int
event_ring_push (event_ring_t *r, my_event_t *ev)
{
u32 h = r->head;
u32 t = clib_atomic_load_acq_n (&r->tail);
if (PREDICT_FALSE (((h + 1) & r->mask) == (t & r->mask)))
return -1; /* full → drop or backpressure */
r->ring[h & r->mask] = *ev;
clib_atomic_store_rel_n (&r->head, h + 1);
return 0;
}
static u32
event_ring_drain (event_ring_t *r, my_event_t *out, u32 max)
{
u32 t = r->tail;
u32 h = clib_atomic_load_acq_n (&r->head);
u32 n = clib_min (h - t, max);
for (u32 i = 0; i < n; i++)
out[i] = r->ring[(t + i) & r->mask];
clib_atomic_store_rel_n (&r->tail, t + n);
return n;
}
두 인덱스를 다른 캐시 라인(CLIB_CACHE_LINE_ALIGN_MARK)에 두는 것이 핵심입니다. 그렇지 않으면 producer/consumer가 서로의 캐시 라인을 계속 무효화(Invalidation)하여(false sharing) 링 버퍼의 의미가 사라집니다. acq/rel 배리어는 인덱스와 데이터 사이 순서를 보장하기 위해 반드시 필요합니다.
예시 6 — elog: 나노초 정밀도 이벤트 로깅
elog는 VPP의 경량 바이너리 이벤트 로거입니다. 각 이벤트가 16~24바이트이며 printf 포맷팅을 후처리(show event-logger)로 미룹니다. 나노초 단위 타임스탬프가 자동 기록되어, 슬로우 패스의 순서 문제를 추적할 때 trace보다 훨씬 가볍습니다.
/* 정적 이벤트 정의 — 포맷 문자열은 한 번만 등록 */
ELOG_TYPE_DECLARE (e_session_new) = {
.format = "session new: idx %d proto %d dport %d",
.format_args = "i4i1i2",
};
/* 기록 — 워커 스레드에서도 락 없이 안전 */
{
ELOG_TYPE_DECLARE (e_session_new);
struct { u32 idx; u8 proto; u16 dport; } *ed;
ed = ELOG_DATA (&vlib_global_main.elog_main, e_session_new);
ed->idx = session_index;
ed->proto = k->proto;
ed->dport = clib_net_to_host_u16 (k->dport);
}
CLI 한 줄로 덤프(Dump)하고 흐름을 재구성할 수 있습니다.
vpp# event-logger resize 1000000
vpp# event-logger restart
# ... 재현 시나리오 실행 ...
vpp# show event-logger
vpp# event-logger save vpp-elog.clib
# 파일은 /tmp/ 아래에 저장됩니다. 이후 wireshark/Perfetto 풍 시각화가
# 필요하면 별도 변환 도구(c2cpel, elog-json 등)로 CLIB 바이너리를 변환합니다.
패킷 경로의 핫 패스에는 넣지 마시기 바랍니다. 이벤트당 수십 ns이 추가되므로 10 Mpps 워커에서는 눈에 띕니다. 반대로 초당 수만 건 이하의 제어 이벤트에는 최고의 도구입니다.
예시 7 — 타이머 휠과 clib_time 결합
세션 만료, keepalive, 재전송 스케줄링에는 tw_timer_wheel_* API를 사용합니다. VPP는 2초 버킷, 100ms 버킷 등 여러 해상도의 템플릿을 제공합니다. 핵심은 타이머 발사 시점의 현재 시각을 VPP 메인 루프 시계에 맞추는 것입니다.
/* 초기화 — 2초 해상도, 최대 32분 타임아웃 */
tw_timer_wheel_2t_1w_2048sl_t tw;
tw_timer_wheel_init_2t_1w_2048sl (&tw, expired_timer_cb,
2.0 /* seconds per tick */, ~0);
/* 세션 만료 타이머 등록 — user_id는 pool 인덱스 */
u32 handle = tw_timer_start_2t_1w_2048sl (&tw, session_index, 0 /* timer_id */,
900 /* ticks → 30분 */);
session->timer_handle = handle;
/* 메인 루프 프로세스 노드에서 주기적으로 expire */
static uword
session_timer_process (vlib_main_t *vm, vlib_node_runtime_t *rt, vlib_frame_t *f)
{
f64 now = vlib_time_now (vm);
while (1) {
tw_timer_expire_timers_2t_1w_2048sl (&tw, now);
vlib_process_suspend (vm, 1.0); /* 1초 슬립(Sleep) */
now = vlib_time_now (vm);
}
return 0;
}
/* 만료 콜백 — user_id 벡터가 넘어옵니다 */
static void
expired_timer_cb (u32 *expired_indices)
{
u32 i;
for (i = 0; i < vec_len (expired_indices); i++)
flow_evict (&fm->flows, expired_indices[i]);
}
vlib_time_now()는 clib_cpu_time_now()를 기반으로 한 단조 증가 시계입니다. 벽시계(unix_time_now)와는 다르며, NTP 교정이나 suspend/resume에 영향을 받지 않습니다. 타이머 정확도는 process_suspend의 슬립(Sleep) 주기에 좌우되므로, 서브초 정밀도가 필요하면 슬립을 100 ms 이하로 줄이고 tw_timer_wheel_16t_2w_512sl처럼 해상도가 높은 템플릿을 선택하시기 바랍니다.
예시 8 — 배리어 기반 원자적 재구성
제어 평면이 포인터를 바꿔 데이터 평면 동작을 교체해야 할 때(예: FIB 포인터 swap), 워커 배리어가 가장 간단한 해답입니다. 모든 워커가 안전 지점에 모이도록 강제한 뒤 수정합니다.
/* 제어 스레드(main)에서 호출 */
vlib_worker_thread_barrier_sync (vm);
/* 이 구간에서는 워커가 전혀 패킷을 처리하지 않습니다 — 안전 */
fm->active_table = fm->candidate_table; /* 포인터 교체 */
clib_bihash_free_16_8 (&fm->old_table);
vlib_worker_thread_barrier_release (vm);
종합: vppinfra 사용 시 반복되는 함정
| 함정 | 증상 | 해결 |
|---|---|---|
vec_add 후 기존 포인터 사용 | 간헐적 SEGV, UBSan use-after-free | 리사이즈가 가능한 벡터는 항상 인덱스로 참조. 핫 경로에서는 미리 vec_validate로 크기 확보. |
pool_put 직후 lookup | 같은 인덱스가 재할당되어 다른 세션으로 oops | 배리어 구간 또는 에폭 카운터로 삭제를 지연. |
| bihash 값에 포인터 저장 | 리사이즈 시 포인터가 허상 | pool 인덱스만 저장. pool은 리사이즈해도 인덱스가 불변. |
clib_bitmap_set 반환값 무시 | 확장된 비트맵 포인터 손실 → 덮어쓰기 충돌 | 반드시 bm = clib_bitmap_set (bm, ...) 패턴으로 대입. |
format 결과 벡터 누수 | show 명령 반복 시 RSS 증가 | vlib_cli_output을 쓰지 않는 경우 vec_free (s)를 호출. |
워커 간 vlib_main_t 공유 자료에 락 없이 쓰기 | 간헐적 카운터 뒤섞임 | 워커별 per-thread 벡터로 분리하거나 clib_atomic_* 사용. |
| elog를 핫 패스에 남발 | pps 급락 | 슬로우 패스·슬라이드 다운 이벤트에만 사용. |
CLIB_DEBUG=1로 빌드하면 vec_validate/pool_get 경계 검사가 활성화됩니다. CI에서 최소 한 번은 이 모드로 단위 테스트를 돌리고, 릴리스 빌드는 성능 수치를 별도로 재측정하시기 바랍니다.