호스트 스택 개요
VPP 호스트 스택 (VPP 25.02 기준): VCL/VLS 세션 레이어, LD_PRELOAD, TCP 호스트 스택, TLS 아키텍처/인증서, QUIC 프로토콜, TLS 성능 최적화, Host Stack 현황, TLS/QUIC 보안 모범 사례를 다룹니다.
tlspicotls·QUIC·OpenSSL async·AF_XDP 등은 여전히 experimental로 분류되므로 프로덕션 채택 전 최신 릴리스 노트를 반드시 확인하세요.
핵심 요약
- VCL (VPP Communications Library) — VPP 호스트 스택(Host Stack)을 직접 사용하는 네이티브 애플리케이션용 API입니다. 신규 프로그램을 작성할 때 가장 단순하고 예측 가능한 선택지입니다.
- VLS (VCL Locked Sessions) — 스레드 안전성이 없는 VCL 위에 잠금 계층을 덧씌운 래퍼(Wrapper)입니다. 멀티스레드 앱이나 POSIX 소켓 호환 계층에서 세션 공유를 안전하게 만들 때 사용합니다.
- LD_PRELOAD 경로 —
libvcl_ldpreload.so가 기존 소켓 API를 가로채어 내부적으로 VLS 함수를 호출합니다. 기존 nginx, iperf3, curl 같은 프로그램을 빠르게 붙일 때 유용합니다. - 세션 레이어(Session Layer) — TCP, TLS, QUIC, UDP와 애플리케이션 사이에 위치한 공용 추상 계층입니다. 공유 메모리 FIFO와 이벤트 큐를 사용해 syscall 없이 데이터를 교환합니다.
- TCP의 역할 — 연결 수립, 재전송, 혼잡 제어, 순서 보장을 담당합니다. TLS는 그 위의 바이트 스트림(Byte Stream)을 받아 암복호화만 수행합니다.
- TLS의 역할 — 인증서 검증, 핸드셰이크, 레코드(Record) 경계, 암복호화, 세션 재개를 담당합니다. 손실 복구나 패킷(Packet) 재전송은 TLS가 아니라 TCP 책임입니다.
- QUIC의 차이점 — QUIC은 TLS 1.3을 내부에 통합하고 TCP 기능 일부를 UDP 위로 끌어올립니다. 그래서 손실 복구와 혼잡 제어도 QUIC 엔진이 직접 처리합니다.
단계별 이해
- 애플리케이션 진입점(Entry Point) 선택
새 프로그램이면vppcom_*또는 VCL 네이티브 API부터 시작하고, 기존 소켓 프로그램이면 LD_PRELOAD 또는 VLS 적합성을 먼저 판단합니다. - 세션 생성과 워커 소유권 확인
세션은 생성 순간 특정 워커 스레드에 귀속됩니다. 이후 같은 흐름의 TCP 세그먼트와 FIFO 접근은 이 워커 소유권을 기준으로 진행됩니다. - TCP 연결 수립 이해
session_open()또는accept()흐름에서 half-open 세션, 연결 완료 이벤트, FIFO 할당, READY 상태 전환 순서를 추적해야 합니다. - TLS 핸드셰이크와 데이터 경로 분리
핸드셰이크는 인증서와 키 교환을 마치기 위한 제어 단계이고, 그 후에는 App FIFO의 평문이 TLS 엔진을 거쳐 TCP FIFO의 암호문으로 변환됩니다. - 운영 병목(Bottleneck)을 계층별로 구분
연결 폭증은 주로 TCP half-open과 TLS 핸드셰이크에서, 대역폭(Bandwidth) 병목은 FIFO 크기와 암호화(Encryption) 엔진에서, 지연(Latency) 증가는 워커 불균형과 재전송에서 나타납니다.
호스트 스택, VCL, VLS 개요
VPP를 단순한 L2/L3 포워더로만 이해하면 실제 배치 구조를 절반만 본 셈입니다. 최근 VPP는 호스트 스택(Host Stack)을 통해 TCP, TLS, QUIC 같은 연결 지향 워크로드까지 유저스페이스에서 처리하며, 이때 애플리케이션은 커널 소켓 대신 VCL(VPP Communications Library) 또는 VLS(VCL Locked Sessions) 경로로 VPP에 들어옵니다.
실무에서 중요한 질문은 "VPP가 TCP를 지원하는가"보다 "내 애플리케이션이 어떤 진입점(Entry Point)으로 VPP 세션 레이어에 붙는가"입니다. 신규 코드라면 직접 VCL이 가장 단순하고, 기존 멀티스레드 소켓 프로그램이라면 VLS 또는 LD_PRELOAD가 전환 비용을 줄여 줍니다. 대신 이 경로는 잠금(Lock), 워커 간 RPC, 세션 소유권, FIFO 공유 방식까지 함께 이해해야 정확한 성능 분석이 가능합니다.
직접 VCL, VLS, LD_PRELOAD의 차이
| 방식 | 핵심 인터페이스 | 장점 | 주의점 | 적합한 경우 |
|---|---|---|---|---|
| 직접 VCL | vppcom_session_* | 함수 호출 경로가 가장 짧고 세션, FIFO, 이벤트 큐를 세밀하게 제어할 수 있습니다 | 애플리케이션 수정량이 가장 큽니다 | 신규 TCP/TLS 프록시, 전용 보안 장비, 벤치마크 도구 |
| VLS | vls_create(), vls_epoll_wait() | 기존 멀티스레드 구조를 유지하면서 세션 공유를 단계적으로 이전할 수 있습니다 | 잠금 비용과 경합(Contention)이 늘 수 있습니다 | 자체 C/C++ 서버, 레거시 프록시, 단계적 전환 프로젝트 |
| LD_PRELOAD | 기존 POSIX 소켓 API | 기존 프로그램을 거의 수정하지 않고 파일럿을 만들 수 있습니다 | sendmsg(), 일부 소켓 옵션, 파일 디스크립터(File Descriptor) 전달은 제약을 점검해야 합니다 | nginx, Envoy, HAProxy, 사내 TCP 서비스의 빠른 적합성 검증 |
TCP/TLS 데이터 경로를 읽는 순서
- 진입점을 먼저 식별합니다 — 직접 VCL인지, VLS인지, LD_PRELOAD인지에 따라 성능 병목 위치가 달라집니다. 직접 VCL은 애플리케이션 로직과 세션 소유권이 가장 명확하고, VLS/LD_PRELOAD는 호환성이 좋은 대신 잠금 경로가 추가됩니다.
- 세션 소유권과 FIFO를 확인합니다 — TCP/TLS 세션은 생성 시점에 특정 워커에 바인딩되며, 이후 송수신은 공유 메모리 FIFO를 통해 이루어집니다. 그래서 워커 불균형이나 큐 편향이 생기면 애플리케이션 문제가 아니라 세션 배치 문제일 수 있습니다.
- TCP 책임을 분리합니다 — 연결 수립, ACK 처리, 혼잡 제어(Congestion Control), 재전송(Retransmission), 순서 보장(Ordering)은 TCP가 담당합니다. TLS는 이 위의 바이트 스트림(Byte Stream)을 받아 암복호화할 뿐입니다.
- TLS 책임을 따로 봅니다 — 인증서 검증, 핸드셰이크, 세션 재개, 레코드(Record) 경계, 암호화 엔진 선택은 TLS 계층의 문제입니다. CPS가 무너질 때는 RSA/ECDSA 서명, 인증서 캐시(Cache), 비동기 엔진, QAT 가속 여부를 따로 보아야 합니다. TLS 핸드셰이크 타임라인과 엔진 콜백 흐름은 TLS — 핸드셰이크 상세 타임라인에서, 운영 중 디버깅 절차는 프록시 — TLS 디버깅에서 다룹니다.
- 파일럿과 운영 아키텍처를 구분합니다 — LD_PRELOAD는 빠른 검증에 유리하지만, 운영에서는 직접 VCL 또는 memif 분리형 구조가 더 예측 가능한 경우가 많습니다. 특히 세션 수가 커질수록 잠금 경로와 워커 간 RPC 비용이 더 선명하게 드러납니다.
# 기존 소켓 프로그램을 VPP 호스트 스택 위에서 빠르게 검증하는 최소 파일럿
$ export VCL_CONFIG=/etc/vpp/vcl.conf
$ export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so
# 기존 설정은 유지한 채 기능 적합성을 먼저 확인합니다
$ curl https://198.51.100.10:8443/
# VPP 쪽에서는 세션과 워커 편향을 반드시 함께 봅니다
vpp# show session verbose
vpp# show runtime
vpp# show errors | grep -iE 'tls|session'
VCL과 세션 레이어
VPP 세션 레이어
VPP는 L4 전송 프로토콜(TCP, UDP, QUIC)을 유저스페이스에서 직접 구현합니다. 세션 레이어가 소켓(Socket)과 유사한 추상화를 제공하며, 애플리케이션은 VCL(VPP Communications Library)을 통해 접근합니다.
| 항목 | 커널 소켓 API | VPP 세션 API |
|---|---|---|
| API | socket(), bind(), listen(), accept() | 공유 메모리 기반 세션 큐 |
| 데이터 전달 | send()/recv() (커널 복사) | 공유 메모리 FIFO (zero-copy) |
| 이벤트 | epoll/select | VPP 이벤트 큐 (eventfd) |
| 멀티플렉싱 | epoll_wait() | vcl_epoll_wait() |
| 성능 | ~200K conn/s | ~1M+ conn/s |
| 호환성 | 모든 애플리케이션 | VCL 또는 LD_PRELOAD 필요 |
/* startup.conf — 세션 레이어 활성화 */
session {
evt_qs_memfd_seg /* memfd 기반 이벤트 큐 */
event-queue-length 100000 /* 이벤트 큐 크기 */
}
/* 세션 활성화 확인 */
vpp# session enable
vpp# show session verbose
세션 레이어 아키텍처 상세
VPP 세션 레이어는 커널의 소켓 서브시스템에 해당하는 유저스페이스 구현체입니다. src/vnet/session/ 디렉터리에 위치하며, 전송 프로토콜(TCP, UDP, TLS, QUIC)과 애플리케이션 사이의 추상화 계층을 제공합니다.
session_state_t — 세션 상태 머신
세션은 생성부터 종료까지 다음과 같은 상태를 거칩니다.
| 상태 | 값 | 설명 |
|---|---|---|
SESSION_STATE_CREATED | 0 | 세션 구조체(Struct) 할당 완료, 아직 연결되지 않은 상태입니다 |
SESSION_STATE_LISTENING | 1 | session_listen() 호출 후 수신 대기 중인 리스너 세션입니다 |
SESSION_STATE_CONNECTING | 2 | session_open() 호출 후 비동기 연결 진행 중입니다 |
SESSION_STATE_ACCEPTING | 3 | 전송 계층에서 SYN을 수신하여 수락 처리 중입니다 |
SESSION_STATE_READY | 4 | 연결이 완료되어 데이터 송수신이 가능한 상태입니다 |
SESSION_STATE_TRANSPORT_CLOSING | 5 | 전송 계층에서 종료를 시작했습니다 (원격 FIN 수신) |
SESSION_STATE_CLOSING | 6 | 애플리케이션이 session_close()를 호출하여 종료 진행 중입니다 |
SESSION_STATE_CLOSED | 7 | 세션이 완전히 종료되어 리소스 해제 대기 중입니다 |
session_t 핵심 필드
session_t는 VPP 세션 레이어의 중심 데이터 구조체입니다. 하나의 세션은 하나의 전송 연결(TCP 커넥션, UDP 바인딩 등)에 대응합니다.
/* src/vnet/session/session_types.h — session_t 핵심 필드 (간략화) */
typedef struct session_ {
/** 세션 풀 내 인덱스 — 세션 식별에 사용됩니다 */
u32 session_index;
/** 이 세션을 소유한 워커 스레드 인덱스 */
u32 thread_index;
/** 세션 유형: 전송 프로토콜 + FIB 프로토콜 인코딩 */
session_type_t session_type;
/** 현재 세션 상태 (CREATED → READY → CLOSED) */
volatile session_state_t session_state;
/** 소유 애플리케이션의 워커 인덱스 */
u32 app_wrk_index;
/** 전송 계층 연결 인덱스 (TCP/UDP connection) */
u32 connection_index;
/** 공유 메모리 FIFO — 수신/송신 데이터 버퍼(Buffer) */
svm_fifo_t *rx_fifo;
svm_fifo_t *tx_fifo;
/** 세션 플래그 (EVT_Q_SHM, IS_DGRAM 등) */
session_flags_t flags;
/** 리스너 세션인 경우 수락된 세션 수 추적 */
u32 n_accepted;
} session_t;
코드 설명
-
3~5행
session_index와thread_index의 조합이 세션의 고유 식별자입니다. 워커별 세션 풀을 분리하여 잠금(Lock) 없이 세션을 할당/해제할 수 있습니다. -
7~8행
session_type은 전송 프로토콜(TCP/UDP/TLS/QUIC)과 FIB 프로토콜(IPv4/IPv6)을 하나의 값으로 인코딩합니다. 세션 검색 시 프로토콜 조합을 단일 비교로 판별할 수 있습니다. -
14~15행
rx_fifo/tx_fifo는 공유 메모리(SVM) 위의 FIFO입니다. VPP와 애플리케이션이 mmap으로 동일 메모리를 공유하여 데이터 복사 없이 제로 카피 통신을 수행합니다. -
10행
session_state에volatile한정자가 붙어 있어 워커 스레드(Thread)와 애플리케이션 스레드 간 상태 변경이 즉시 가시적입니다.
session_type_t 인코딩
session_type_t는 전송 프로토콜과 네트워크 프로토콜을 하나의 값으로 인코딩합니다. 상위 비트에 transport_proto_t(TCP, UDP, TLS, QUIC 등)를, 하위 비트에 fib_protocol_t(IP4, IP6)를 저장합니다.
/* session_type_t 인코딩 — 전송 프로토콜 × FIB 프로토콜 */
static inline session_type_t
session_type_from_proto_and_ip (transport_proto_t proto,
u8 is_ip4)
{
return (proto << 1 | is_ip4);
}
/* 디코딩 예시 */
transport_proto_t tp = session_type >> 1; /* TCP=0, UDP=1, TLS=2, QUIC=3 */
u8 is_ip4 = session_type & 0x1; /* 0=IPv6, 1=IPv4 */
/* 예: TCP+IPv4 = (0 << 1 | 1) = 1
* UDP+IPv6 = (1 << 1 | 0) = 2
* QUIC+IPv4 = (3 << 1 | 1) = 7 */
세션 생명주기
세션은 소켓 API와 유사한 생명주기를 따르지만, 내부적으로는 비동기·이벤트 기반으로 동작합니다.
| 단계 | 함수 | 동작 |
|---|---|---|
| Listen | session_listen() | transport_start_listen()을 호출하여 리스너 세션을 생성합니다. 리스너는 SESSION_STATE_LISTENING 상태로 전환됩니다 |
| Connect | session_open() | transport_connect()를 호출하여 비동기 연결을 시작합니다. 세션은 CONNECTING 상태가 되며, 연결 완료 시 SESSION_CTRL_EVT_CONNECTED 이벤트가 발생합니다 |
| Accept | session_stream_accept() | 전송 계층에서 SYN을 수신하면 새 세션을 할당하고 FIFO를 생성한 뒤, 애플리케이션에 SESSION_CTRL_EVT_ACCEPTED 이벤트를 전달합니다 |
| Data | session_enqueue_stream_connection() | 수신 데이터를 rx_fifo에 enqueue하고, SESSION_IO_EVT_RX 이벤트로 애플리케이션에 알립니다 |
| Close | session_close() | session_transport_close()를 통해 전송 계층에 종료를 요청합니다. FIN 교환 후 세션이 CLOSED 상태로 전환됩니다 |
/* session_open() — 비동기 연결 흐름 (간략화) */
int
session_open (session_endpoint_cfg_t *rmt, u32 opaque)
{
transport_proto_t tp = rmt->transport_proto;
transport_connection_t *tc;
session_t *s;
int rv;
/* 1. 전송 계층에 연결 요청 (TCP SYN 전송 등) */
rv = transport_connect (tp, rmt);
if (rv < 0)
return rv;
/* 2. 전송 연결 객체 조회 */
tc = transport_get_half_open (tp, rv);
/* 3. 세션 할당 및 초기화 */
s = session_alloc_for_connection (tc);
s->session_state = SESSION_STATE_CONNECTING;
s->app_wrk_index = rmt->app_wrk_index;
s->opaque = opaque;
/* 4. 연결 완료는 비동기 — transport에서 콜백으로 알림
* → session_connected_callback()
* → SESSION_CTRL_EVT_CONNECTED 이벤트 발생 */
return 0;
}
/* 연결 완료 콜백 — transport에서 호출됩니다 */
static void
session_connected_callback (u32 app_wrk_index,
u32 opaque,
transport_connection_t *tc,
session_error_t err)
{
session_t *s = session_get (tc->s_index, tc->thread_index);
/* FIFO 할당 */
session_alloc_fifos (s);
/* 상태 전환: CONNECTING → READY */
s->session_state = SESSION_STATE_READY;
/* 애플리케이션에 연결 완료 이벤트 전달 */
app_worker_connect_notify (s->app_wrk_index, s, err);
}
세션 FIFO 메커니즘
VPP 세션 레이어의 핵심 성능 비결은 공유 메모리 FIFO입니다. 커널 소켓이 send()/recv()마다 커널-유저 복사를 수행하는 것과 달리, VPP는 애플리케이션과 동일한 FIFO를 공유하여 zero-copy 데이터 전달을 달성합니다.
svm_fifo_t 구조체
svm_fifo_t는 공유 메모리 위에 구현된 원형 버퍼(Buffer)입니다. Lock-free 단일 생산자/단일 소비자(SPSC) 설계로, VPP 워커 스레드(생산자)와 애플리케이션 스레드(소비자)가 잠금 없이 동시에 접근할 수 있습니다.
/* src/svm/svm_fifo.h — svm_fifo_t 핵심 필드 (간략화) */
typedef struct svm_fifo_ {
/** 공유 구조체 — VPP와 app이 mmap으로 공유합니다 */
svm_fifo_shared_t *shr;
/** FIFO 최대 크기 (바이트) */
u32 nitems;
/** 현재 저장된 데이터 크기 */
u32 cursize;
/** 읽기 위치 — 소비자(app)가 갱신합니다 */
u32 head;
/** 쓰기 위치 — 생산자(VPP)가 갱신합니다 */
u32 tail;
/** 소유 워커 스레드 인덱스 */
u32 master_thread_index;
/** 소유 세션 인덱스 */
u32 master_session_index;
/** 세그먼트 관리자 — 다중 청크 지원 */
svm_fifo_chunk_t *start_chunk;
svm_fifo_chunk_t *end_chunk;
} svm_fifo_t;
FIFO 크기 설정과 Backpressure
FIFO 크기는 startup.conf의 session 섹션에서 설정합니다. FIFO가 가득 차면 VPP가 전송 계층에 backpressure를 적용하여 TCP 수신 윈도우를 축소합니다.
/* startup.conf — FIFO 크기 설정 */
session {
rx-fifo-size 64K /* 수신 FIFO 기본 크기 */
tx-fifo-size 64K /* 송신 FIFO 기본 크기 */
evt_qs_memfd_seg /* memfd 기반 이벤트 큐 */
event-queue-length 100000
preallocated-sessions 1024 /* 세션 사전 할당 */
}
/* 런타임 FIFO 크기 확인 */
vpp# show session [verbose]
vpp# show session fifo trace
FIFO 기반 backpressure 흐름은 다음과 같습니다.
- 데이터 수신 →
svm_fifo_enqueue()로rx_fifo에 저장합니다 svm_fifo_max_enqueue()로 남은 공간을 확인합니다- 남은 공간이 임계값 이하이면, TCP 윈도우 크기를 축소하여 송신 측에 감속을 요청합니다
- 애플리케이션이
svm_fifo_dequeue()로 데이터를 소비하면 윈도우가 다시 확장됩니다
주요 FIFO API
| 함수 | 용도 |
|---|---|
svm_fifo_enqueue() | FIFO에 데이터를 기록합니다 (VPP → app 방향) |
svm_fifo_dequeue() | FIFO에서 데이터를 읽고 소비합니다 (app → VPP 방향) |
svm_fifo_peek() | 데이터를 소비하지 않고 읽습니다 (head 이동 없음) |
svm_fifo_dequeue_drop() | 데이터를 읽지 않고 소비합니다 (skip 용도) |
svm_fifo_max_enqueue() | FIFO의 남은 쓰기 공간을 반환합니다 |
svm_fifo_max_dequeue() | FIFO에서 읽을 수 있는 데이터 크기를 반환합니다 |
svm_fifo_segments() | zero-copy 직접 포인터 접근 (wrap-around 시 2개 세그먼트 반환) |
/* FIFO enqueue/dequeue 기본 패턴 */
/* 생산자 (VPP 워커) — 수신 데이터를 rx_fifo에 저장 */
int
session_enqueue_stream_connection (session_t *s,
vlib_buffer_t *b)
{
u32 enqueued;
u32 max_enq = svm_fifo_max_enqueue (s->rx_fifo);
if (max_enq == 0)
return 0; /* FIFO 가득 참 — backpressure */
enqueued = svm_fifo_enqueue (s->rx_fifo,
vlib_buffer_length_in_chain (vm, b),
vlib_buffer_get_current (b));
/* 애플리케이션에 수신 이벤트 알림 */
if (enqueued > 0)
session_send_io_evt_to_thread (s->rx_fifo,
SESSION_IO_EVT_RX);
return enqueued;
}
/* 소비자 (애플리케이션/VCL) — rx_fifo에서 데이터 읽기 */
int
app_recv_stream (session_t *s, u8 *buf, u32 len)
{
u32 max_deq = svm_fifo_max_dequeue (s->rx_fifo);
u32 to_read = clib_min (max_deq, len);
if (to_read == 0)
return 0;
svm_fifo_dequeue (s->rx_fifo, to_read, buf);
return to_read;
}
/* zero-copy 패턴 — 데이터 복사 없이 직접 접근 */
svm_fifo_seg_t segs[2];
u32 n_segs = 2;
/* wrap-around 시 최대 2개 세그먼트로 분할됩니다 */
svm_fifo_segments (s->rx_fifo, segs, &n_segs);
for (int i = 0; i < n_segs; i++)
process_data (segs[i].data, segs[i].len);
/* 처리 완료 후 소비 확정 */
svm_fifo_dequeue_drop (s->rx_fifo, total_len);
세션 이벤트 큐와 애플리케이션 통신
VPP와 애플리케이션 사이의 이벤트 전달은 공유 메모리 메시지 큐(svm_msg_q_t)를 통해 이루어집니다. 커널의 epoll 메커니즘에 대응하지만, 시스템 콜(System Call) 오버헤드(Overhead) 없이 동작합니다.
session_event_t 이벤트 유형
| 이벤트 | 방향 | 설명 |
|---|---|---|
SESSION_IO_EVT_RX | VPP → App | 수신 데이터가 rx_fifo에 도착했음을 알립니다. 애플리케이션은 svm_fifo_dequeue()로 데이터를 읽습니다 |
SESSION_IO_EVT_TX | App → VPP | 애플리케이션이 tx_fifo에 데이터를 기록했음을 알립니다. VPP가 전송을 시작합니다 |
SESSION_CTRL_EVT_ACCEPTED | VPP → App | 리스너에 새 연결이 수락되었습니다. 애플리케이션이 accept 응답을 해야 합니다 |
SESSION_CTRL_EVT_CONNECTED | VPP → App | 비동기 session_open()의 연결이 완료(또는 실패)되었습니다 |
SESSION_CTRL_EVT_DISCONNECTED | VPP → App | 원격 측에서 연결을 정상 종료했습니다 (FIN 수신) |
SESSION_CTRL_EVT_RESET | VPP → App | 연결이 비정상 리셋되었습니다 (RST 수신) |
svm_msg_q_t — 공유 메모리 메시지 큐
VPP는 각 애플리케이션 워커마다 별도의 svm_msg_q_t 이벤트 큐를 할당합니다. 이 큐는 공유 메모리 세그먼트 위에 위치하며, lock-free ring buffer로 구현되어 있습니다.
- VPP → App 방향: VPP 워커가
app_send_io_evt_rx()또는app_worker_send_event()를 호출하면, 이벤트가 애플리케이션의 메시지 큐에 enqueue됩니다 - App → VPP 방향: 애플리케이션이
tx_fifo에 데이터를 기록한 후app_send_io_evt_to_vpp()를 호출하여 VPP에 전송을 요청합니다 - eventfd 기반 깨우기(Wakeup): 애플리케이션이 이벤트를 기다릴 때,
epoll_wait()에 eventfd를 등록하여 블로킹 대기합니다. VPP가 이벤트를 enqueue한 후 eventfd에 write하여 스레드를 깨웁니다
/* VCL 애플리케이션의 이벤트 처리 루프 (간략화) */
while (1) {
svm_msg_q_msg_t msg;
session_event_t *evt;
/* 이벤트 큐에서 대기 — eventfd로 블로킹 */
svm_msg_q_wait (app_mq, SVM_MQ_WAIT_EMPTY);
while (svm_msg_q_sub (app_mq, &msg, SVM_Q_NOWAIT, 0) == 0) {
evt = (session_event_t *) svm_msg_q_msg_data (app_mq, &msg);
switch (evt->event_type) {
case SESSION_IO_EVT_RX:
/* 수신 데이터 처리 */
s = session_get_from_handle (evt->session_handle);
n = svm_fifo_dequeue (s->rx_fifo, buf_sz, buf);
handle_rx_data (s, buf, n);
break;
case SESSION_IO_EVT_TX:
/* 송신 공간 확보 — 추가 데이터 전송 가능 */
s = session_get_from_handle (evt->session_handle);
resume_sending (s);
break;
case SESSION_CTRL_EVT_ACCEPTED:
/* 새 연결 수락 */
accepted_msg = (session_accepted_msg_t *) evt->data;
handle_accept (accepted_msg);
break;
case SESSION_CTRL_EVT_CONNECTED:
/* 연결 완료 */
connected_msg = (session_connected_msg_t *) evt->data;
if (connected_msg->retval == 0)
handle_connected (connected_msg);
else
handle_connect_failed (connected_msg);
break;
case SESSION_CTRL_EVT_DISCONNECTED:
/* 원격 종료 — 정리 후 disconnect reply 전송 */
handle_disconnect (evt);
break;
case SESSION_CTRL_EVT_RESET:
/* 비정상 리셋 — 즉시 세션 정리 */
handle_reset (evt);
break;
}
/* 메시지 소비 완료 — 슬롯 반환 */
svm_msg_q_free_msg (app_mq, &msg);
}
}
이 이벤트 기반 모델의 핵심 장점은 시스템 콜 횟수의 최소화입니다. 커널 소켓에서는 epoll_wait() + recv()마다 2회의 시스템 콜이 필요하지만, VPP 세션 레이어에서는 eventfd read() 1회로 여러 이벤트를 배치 처리할 수 있습니다. 고부하 상황에서는 eventfd 없이 공유 메모리를 직접 폴링(Polling)하여 시스템 콜을 완전히 제거할 수도 있습니다.
LD_PRELOAD 투명 가속
VCL의 LD_PRELOAD 기능은 기존 POSIX 소켓 기반 애플리케이션을 수정 없이 VPP 세션 레이어로 가속합니다. libvcl_ldpreload.so가 libc의 소켓 함수를 가로채어 VPP와 통신합니다.
# iperf3에 VCL LD_PRELOAD 적용
$ VCL_CONFIG=/etc/vpp/vcl.conf \
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
iperf3 -s
# nginx에 적용
$ VCL_CONFIG=/etc/vpp/vcl.conf \
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
nginx -c /etc/nginx/nginx.conf
# vcl.conf 예제
vcl {
rx-fifo-size 4000000 /* RX FIFO 4MB */
tx-fifo-size 4000000 /* TX FIFO 4MB */
app-scope-global /* 글로벌 세션 네임스페이스 */
api-socket-name /run/vpp/api.sock
}
| POSIX 소켓 함수 | VCL 대응 | 지원 수준 |
|---|---|---|
| socket() | vls_create() | TCP/UDP/TLS |
| bind() | vls_bind() | 완전 |
| listen() | vls_listen() | 완전 |
| accept() | vls_accept() | 완전 |
| connect() | vls_connect() | 완전 |
| send()/write() | vls_write() | 완전 |
| recv()/read() | vls_read() | 완전 |
| epoll_*() | vls_epoll_*() | 완전 |
| select()/poll() | vls_select() | 부분 (epoll 권장) |
| sendmsg()/recvmsg() | — | 미지원 |
VCL, VLS, LD_PRELOAD의 역할 분담
실무에서는 VCL, VLS, LD_PRELOAD를 하나의 묶음으로 부르지만, 내부 역할은 분명히 다릅니다. VCL은 네이티브 API이고, VLS는 그 위에 잠금과 세션 공유 정책을 얹은 호환 레이어이며, LD_PRELOAD는 기존 POSIX 소켓 호출을 VLS 쪽으로 우회시키는 진입 방법입니다.
| 방식 | 핵심 API | 장점 | 주의점 | 적합한 상황 |
|---|---|---|---|---|
| 직접 VCL | vppcom_session_* | 오버헤드가 가장 낮고 제어 범위가 넓습니다 | 애플리케이션 수정이 필요합니다 | 신규 TCP/TLS/QUIC 서버, 전용 프록시, 벤치마크 도구 |
| VLS | vls_create(), vls_epoll_wait() | 멀티스레드 세션 공유를 안전하게 만들 수 있습니다 | 잠금과 워커 간 RPC 비용이 추가됩니다 | 기존 구조를 유지하면서 VPP 호스트 스택으로 옮길 때 |
| LD_PRELOAD | POSIX 소켓 API 그대로 사용 | 기존 프로그램을 거의 수정 없이 붙일 수 있습니다 | 지원되지 않는 소켓 옵션이나 sendmsg() 계열은 제약이 있습니다 | nginx, iperf3, curl, 사내 프록시의 빠른 파일럿 전환 |
VLS 잠금 모드와 스레드 모델
현재 VPP 소스의 src/vcl/vcl_locked.c 주석에는 VLS가 세 가지 동작 모드를 가진다고 명시되어 있습니다. 이 구분을 이해하지 못하면, 왜 어떤 애플리케이션은 잘 붙고 어떤 애플리케이션은 갑자기 락 경합(Contention)과 워커 간 RPC 때문에 성능이 떨어지는지 설명할 수 없습니다.
- 프로세스별 워커(per-process workers) —
fork()된 자식 프로세스(Process)를 새로운 VCL 워커로 간주합니다. 공유 세션은 명시적으로 잠그고, 한 시점에 한 프로세스만 세션을 만질 수 있게 합니다. - 스레드별 워커(per-thread workers) — 새
pthread를 새로운 워커로 등록합니다. 다른 스레드가 소유하지 않은 세션에 접근하면 clone-and-share RPC를 보내어 세션을 해당 워커에도 매핑(Mapping)합니다. - 단일 워커 멀티스레드(single-worker multi-thread) — VCL 워커를 추가로 만들지 않고, 하나의 워커를 여러 스레드가 공유하는 공격적 잠금 모드입니다. 구현은 단순하지만 락 비용이 가장 큽니다.
실전에서는 다음 기준으로 판단하면 됩니다. 새 코드를 쓸 수 있으면 직접 VCL, 기존 멀티스레드 소켓 프로그램을 크게 안 바꾸고 싶으면 VLS/LD_PRELOAD, 프로세스 모델이 명확하고 연결 수가 많지 않으면 프로세스별 워커가 보통 안전합니다.
/* 멀티스레드 서버에서 VLS를 명시적으로 쓰는 최소 패턴 */
#include <vcl/vcl_locked.h>
#include <pthread.h>
static void *
worker_loop (void *arg)
{
vls_handle_t epfd = *(vls_handle_t *) arg;
struct epoll_event events[64];
/* 현재 스레드를 VCL/VLS 워커로 등록합니다 */
vls_register_vcl_worker ();
for (;;) {
int n = vls_epoll_wait (epfd, events, 64, 1.0);
for (int i = 0; i < n; i++) {
char buf[4096];
int fd = events[i].data.fd;
int nr = vls_read (fd, buf, sizeof(buf));
if (nr > 0)
vls_write (fd, buf, nr);
}
}
}
int
main (void)
{
vls_handle_t lfd, epfd;
pthread_t tid;
vls_app_create ("vls-echo");
lfd = vls_create (VPPCOM_PROTO_TCP, 0);
epfd = vls_epoll_create ();
/* bind/listen/accept 등록 후 워커 스레드에 epoll을 넘깁니다 */
pthread_create (&tid, 0, worker_loop, &epfd);
pthread_join (tid, 0);
return 0;
}
SO_REUSEPORT, sendmsg(), 파일 디스크립터 전달처럼 POSIX 소켓의 구석 기능에 강하게 의존하는 프로그램은 VLS/LD_PRELOAD 파일럿을 먼저 돌려 호환성을 확인한 뒤 전환해야 합니다. 반대로 단순 TCP/TLS 리버스(Bus) 프록시나 에코 서버는 VLS로도 비교적 안정적으로 옮길 수 있습니다.
VLS 잠금 경합(Lock Contention) 측정과 병목 회피 패턴
VLS는 POSIX 멀티스레드 소켓 프로그램을 거의 그대로 VPP 호스트 스택에 옮기기 위한 래퍼지만, 내부적으로 세션 테이블 단위의 rwlock과 워커 간 세션 이전 RPC가 동작합니다. 저연결·고대역 워크로드에서는 경합이 미미하지만, 단시간에 수만 개의 accept/close가 반복되는 고 CPS 워크로드에서는 rwlock write lock이 병목이 됩니다. 병목은 CPU 사용률이 한계에 도달하지 않았는데 CPS가 선형으로 증가하지 않는 형태로 나타납니다.
# VLS 잠금 경합 진단 — perf lock-contention
$ sudo perf lock contention -a -F vls_rwlock_*
contended total wait max wait avg wait type caller
128472 3.842 s 18.42 ms 29.9 us rwlock vls_session_get
42018 1.211 s 11.15 ms 28.8 us rwlock vls_mt_add
18432 0.589 s 9.02 ms 31.9 us rwlock vls_handle_pending_wrk_cleanup
# VPP 측 워커별 RPC 카운터
vpp# show session verbose 2 | head
Thread 1: workers 128472 rx-events, 42018 tx-events, 892 cross-worker RPCs
Thread 2: workers 130192 rx-events, 41028 tx-events, 875 cross-worker RPCs
# 워커별 상주 세션 수 — 불균형 확인
vpp# show session summary
Thread 1: 4821 sessions (58%) ← 편중
Thread 2: 1823 sessions (22%)
Thread 3: 1021 sessions (12%)
Thread 4: 612 sessions ( 8%)
완화 패턴 4가지:
- listener per thread — 하나의 listening 소켓을 여러 스레드가 공유하는 대신,
SO_REUSEPORT로 각 스레드가 독립 리스너를 갖습니다. VLS는 이를 워커별 독립 세션 풀로 매핑하여 write lock 발생 지점을 제거합니다. - accept 스레드 분리 — accept만 담당하는 스레드를 1~2개 두고, 수락된 fd를 해시(Hash) 기반으로 워커 큐에 분배합니다. VLS는 이 이전 과정에서 한 번만 lock을 잡으므로 경합이 O(connection) → O(accept/batch)로 감소합니다.
- batch-close —
close()를 즉시 호출하지 않고 짧은 지연 후 일괄 처리합니다. close는 내부적으로 세션 테이블 write lock을 요구하므로, 배치 처리만으로도 경합을 대폭 줄일 수 있습니다. - 작업자 핀닝 —
pthread_setaffinity_np로 애플리케이션 스레드를 특정 코어에 고정하고, 동일 코어의 VPP 워커와 짝을 이루게 합니다. cross-worker RPC가 극적으로 감소합니다.
| 패턴 | CPS 개선 | 코드 변경 비용 | 호환성 |
|---|---|---|---|
| 기본(공유 listener) | 기준 1.0× | — | — |
| SO_REUSEPORT per-thread | 3.2× | 적음 | nginx·envoy 동일 패턴 |
| accept 스레드 분리 | 2.4× | 중간 | 일반적 M:N 모델 |
| batch-close | 1.5× | 적음 | 응답성 약간 희생 |
| 조합 (1+4) | 4.8× | 중간 | 권장 |
측정 기반 권장: 단일 커넥션당 5KB 이하의 짧은 요청-응답(예: API 게이트웨이) 워크로드에서는 조합 (1+4)가 거의 항상 최선입니다. 반대로 장기 연결·대용량 스트리밍(WebSocket, 파일 전송)은 경합이 적어 기본 모드로도 충분합니다.
VCL 구현 심화: API 레퍼런스와 최소 예제
VCL을 실제로 사용하려면 vppcom_* 함수군이 POSIX 소켓의 어느 호출에 대응하는지, 각 함수가 요구하는 호출 순서는 무엇인지, 오류 코드는 어떻게 해석하는지를 먼저 이해해야 합니다. 이 절에서는 핵심 API를 단계별로 나누고, 단일 스레드 TCP 에코 서버를 처음부터 끝까지 따라가면서 VCL 네이티브 API의 호출 흐름을 짚어봅니다.
vppcom_app_create 이전에 다른 vppcom_*를 부르지 않기, (2) 포트는 반드시 htons()로 네트워크 바이트 순서 변환, (3) epoll 타임아웃은 정수 밀리초가 아닌 double 초, (4) VPPCOM_EAGAIN(-11)을 에러가 아닌 "나중에 다시" 신호로 처리, (5) ckpair 인덱스는 세션 close와 함께 반드시 해제. 이 다섯 가지가 VCL 입문 시 가장 자주 틀리는 지점입니다.
핵심 vppcom_* API 레퍼런스
VCL의 네이티브 API는 모두 vcl/vppcom.h에 선언되어 있습니다. 함수군은 앱 수명주기, 세션 제어, I/O, 이벤트 멀티플렉싱, 세션 속성의 다섯 덩어리로 나눌 수 있으며, 실제 구현에서 가장 자주 쓰이는 항목만 정리하면 다음과 같습니다.
| 분류 | 함수 | POSIX 대응 | 역할 |
|---|---|---|---|
| 앱 수명주기 | vppcom_app_create(name) | — | VCL 앱 등록, 공유 메모리 세그먼트 attach, 앱 워커 생성 |
vppcom_app_destroy() | — | 세션 정리, 메모리 detach, 앱 unregister | |
| 세션 제어 | vppcom_session_create(proto, is_nonblocking) | socket() | TCP/UDP/TLS/QUIC 세션 핸들 할당 (proto에 VPPCOM_PROTO_TCP/TLS/QUIC) |
vppcom_session_bind(sh, endpt) | bind() | 로컬 IP/포트 바인딩, vppcom_endpt_t 사용 | |
vppcom_session_listen(sh, q_len) | listen() | accept 큐 길이 지정, TLS의 경우 핸드셰이크 이후에만 accept | |
vppcom_session_accept(sh, ep, flags) | accept() | 새 세션 핸들 반환, O_NONBLOCK 시 VPPCOM_EAGAIN | |
vppcom_session_connect(sh, endpt) | connect() | 원격지 연결, half-open 상태에서 CONNECTED 이벤트 대기 | |
| I/O | vppcom_session_read(sh, buf, n) | read() | RX FIFO에서 복사, TLS면 자동 복호화(Decryption) |
vppcom_session_write(sh, buf, n) | write() | TX FIFO로 복사, TLS면 자동 암호화 | |
vppcom_session_read_segments() | — | zero-copy 경로, FIFO 세그먼트 포인터만 반환 | |
vppcom_session_free_segments() | — | zero-copy 소비 완료 알림 | |
| 이벤트 | vppcom_epoll_create() | epoll_create() | VCL 전용 epoll 핸들, 커널 fd 아님 |
vppcom_epoll_ctl(ep, op, sh, ev) | epoll_ctl() | 세션 핸들을 epoll에 등록 (fd 아닌 VCL 핸들 사용) | |
vppcom_epoll_wait(ep, evs, max, to) | epoll_wait() | 세션 이벤트 대기, timeout은 double(초 단위) | |
| 세션 속성 | vppcom_session_attr(sh, op, buf, len) | getsockopt/setsockopt | TLS 인증서 설정, SNI, 플래그 제어 등 |
vppcom_session_close(sh) | close() | FIN 전송, FIFO 반환, 세션 해제 |
vppcom_session_create의 반환값)은 커널 파일 디스크립터가 아닙니다. 커널 select/poll/epoll, fcntl, dup, /proc/self/fd 등과 함께 쓸 수 없으며, 반드시 vppcom_epoll_* 군을 사용해야 합니다. LD_PRELOAD 경로에서는 내부적으로 가짜 fd를 반환해 호환성을 맞춥니다.
단계별 TCP 에코 서버 (단일 스레드)
아래는 VCL 네이티브 API만으로 작성한 논블로킹 TCP 에코 서버입니다. vppcom_app_create로 시작해 vppcom_epoll_wait 루프로 끝나는 전형적인 골격을 보여주며, 실제로 빌드·실행이 가능한 형태입니다.
/* vcl-echo-server.c — VCL 네이티브 TCP 에코 서버 */
#include <vcl/vppcom.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define LISTEN_PORT 7000
#define MAX_EVENTS 128
#define BUF_SZ 16384
static int
make_listener (void)
{
vppcom_endpt_t endpt = { 0 };
uint8_t ip[4] = { 0, 0, 0, 0 };
int sh;
/* 1) 논블로킹 TCP 세션 핸들 생성 */
sh = vppcom_session_create (VPPCOM_PROTO_TCP, 1 /* nonblocking */);
if (sh < 0) {
fprintf (stderr, "session_create: %d\n", sh);
return sh;
}
/* 2) 바인드: vppcom_endpt_t에 IP와 네트워크 바이트 순서 포트 */
endpt.is_ip4 = 1;
endpt.ip = ip;
endpt.port = htons (LISTEN_PORT);
vppcom_session_bind (sh, &endpt);
/* 3) 리슨: q_len은 accept 큐 길이 */
vppcom_session_listen (sh, 1024);
return sh;
}
int
main (void)
{
int lsh, epfd, rv;
struct epoll_event ev, events[MAX_EVENTS];
char buf[BUF_SZ];
/* A) VCL 앱 등록 — VCL_CONFIG 환경변수로 vcl.conf 로드 */
if (vppcom_app_create ("vcl-echo") < 0) {
fprintf (stderr, "vppcom_app_create failed\n");
return 1;
}
lsh = make_listener ();
epfd = vppcom_epoll_create ();
/* B) 리스너를 VCL epoll에 등록 (LT 모드, 수신 이벤트만) */
ev.events = EPOLLIN;
ev.data.u32 = lsh;
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, lsh, &ev);
for (;;) {
/* C) 이벤트 대기. timeout은 double(초) — 1.0 = 1초 */
rv = vppcom_epoll_wait (epfd, events, MAX_EVENTS, 1.0);
if (rv < 0) break;
for (int i = 0; i < rv; i++) {
uint32_t sh = events[i].data.u32;
uint32_t e = events[i].events;
if (sh == (uint32_t) lsh) {
/* D) 새 연결: accept는 새 세션 핸들을 반환 */
vppcom_endpt_t peer = { 0 };
uint8_t pip[16];
peer.ip = pip;
int csh = vppcom_session_accept (lsh, &peer, 0);
if (csh < 0) continue;
ev.events = EPOLLIN | EPOLLRDHUP;
ev.data.u32 = csh;
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, csh, &ev);
continue;
}
if (e & (EPOLLHUP | EPOLLRDHUP | EPOLLERR)) {
/* E) 원격 종료/오류 */
vppcom_epoll_ctl (epfd, EPOLL_CTL_DEL, sh, NULL);
vppcom_session_close (sh);
continue;
}
if (e & EPOLLIN) {
/* F) 읽고 그대로 되돌려보내기 */
int n = vppcom_session_read (sh, buf, sizeof(buf));
if (n <= 0) {
vppcom_epoll_ctl (epfd, EPOLL_CTL_DEL, sh, NULL);
vppcom_session_close (sh);
continue;
}
int off = 0;
while (off < n) {
int w = vppcom_session_write (sh, buf + off, n - off);
if (w == VPPCOM_EAGAIN) continue;
if (w < 0) break;
off += w;
}
}
}
}
vppcom_session_close (lsh);
vppcom_app_destroy ();
return 0;
}
코드 설명
-
A) vppcom_app_create
VCL 공유 메모리 세그먼트를 attach하고 앱 워커를 등록합니다. 이 호출 이전에 다른
vppcom_*함수를 쓰면 즉시 실패하며, 반드시 VPP 프로세스가 먼저 실행 중이고VCL_CONFIG가 가리키는vcl.conf가 존재해야 합니다. -
1) vppcom_session_create 두 번째 인자
두 번째 인자는 논블로킹 플래그입니다.
1을 주면vppcom_session_read가 데이터가 없을 때VPPCOM_EAGAIN(= -11)을 반환하고,0이면 이벤트가 올 때까지 블로킹됩니다. epoll 루프를 쓸 때는 항상 논블로킹으로 설정합니다. -
2) vppcom_endpt_t 구조
IP 바이트는
ip포인터가 가리키는 외부 버퍼에 담고, 포트는 네트워크 바이트 순서(Byte Order)(htons)로 넣어야 합니다.is_ip4 = 1이면 버퍼 4바이트,0이면 16바이트를 봅니다. 이 부분은 POSIX sockaddr과 다르게 VCL만의 관례이므로 주의가 필요합니다. -
C) epoll_wait timeout
커널
epoll_wait는 밀리초 정수지만,vppcom_epoll_wait의 타임아웃은double초 단위입니다.0.0이면 즉시 리턴, 음수면 무한 대기,1.0이면 1초 대기입니다. 이 시그니처 차이 때문에 LD_PRELOAD 대신 네이티브 API를 쓸 때 가장 많이 실수하는 지점입니다. -
F) 쓰기 루프와 VPPCOM_EAGAIN
TX FIFO가 가득 찼을 때는
VPPCOM_EAGAIN이 반환됩니다. 프로덕션 코드에서는 즉시 재시도하지 말고EPOLLOUT을 등록하여 FIFO가 비면 다시 쓰도록 해야 합니다. 위 예제는 간결성을 위해 바쁜 대기(Busy Wait)로 단순화했습니다.
# 빌드
$ gcc -O2 -o vcl-echo vcl-echo-server.c \
-I/usr/include/vpp -lvppcom -lvlibmemoryclient -lsvm -lpthread
# 최소 vcl.conf
$ cat /etc/vpp/vcl.conf
vcl {
rx-fifo-size 400000
tx-fifo-size 400000
app-scope-local
app-scope-global
api-socket-name /run/vpp/api.sock
use-mq-eventfd
}
# 실행 — VPP는 이미 동작 중이어야 합니다
$ VCL_CONFIG=/etc/vpp/vcl.conf ./vcl-echo &
# 동작 확인
$ echo "hello vcl" | nc 127.0.0.1 7000
hello vcl
VCL 네이티브 TLS 클라이언트 예제
VCL에서 TLS는 별도의 라이브러리가 아니라 세션 생성 시 프로토콜만 VPPCOM_PROTO_TLS로 바꾸면 됩니다. 인증서·개인키·SNI 같은 설정은 vppcom_session_attr()를 통해 전달하며, 핸드셰이크는 vppcom_session_connect()가 완료하기까지 내부에서 자동으로 수행됩니다.
/* VCL 네이티브 TLS 클라이언트 핵심 부분 */
int sh = vppcom_session_create (VPPCOM_PROTO_TLS, 0 /* blocking */);
/* SNI 설정: 서버 호스트네임을 ClientHello에 실어 보냅니다 */
const char *sni = "api.example.com";
uint32_t sni_len = strlen (sni);
vppcom_session_attr (sh, VPPCOM_ATTR_SET_SNI_HOSTNAME,
(void *) sni, &sni_len);
/* 연결: 핸드셰이크가 끝날 때까지 블로킹 */
vppcom_endpt_t ep = { 0 };
uint8_t ip[4] = { 93, 184, 216, 34 };
ep.is_ip4 = 1;
ep.ip = ip;
ep.port = htons (443);
if (vppcom_session_connect (sh, &ep) < 0) {
fprintf (stderr, "TLS connect 실패\n");
vppcom_session_close (sh);
return 1;
}
/* 이 시점부터 write/read는 평문만 다룹니다 — 암호화는 VCL이 담당 */
const char *req = "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n";
vppcom_session_write (sh, (void *) req, strlen (req));
char buf[8192];
int n = vppcom_session_read (sh, buf, sizeof(buf));
fwrite (buf, 1, n, stdout);
vppcom_session_close (sh);
VPPCOM_ATTR_SET_CKPAIR로 인증서-키 쌍 인덱스를 전달합니다. 이 인덱스는 VPP CLI tls cert-key-pair add cert <file> key <file>로 등록한 뒤 반환되는 값이며, 원시 PEM 바이트를 매번 복사하지 않고 VPP 내부 엔진이 관리하는 자격증명을 참조하기 위한 구조입니다.
VCL에서 QUIC 스트림 다루기
QUIC은 연결과 스트림이 분리되어 있어, VCL도 connection session과 stream session이라는 두 종류의 핸들을 사용합니다. 애플리케이션은 먼저 connection 세션을 만들고, 거기에서 스트림 세션을 파생시킨 뒤 스트림 세션으로만 read/write를 수행합니다. 아래 예제는 클라이언트 쪽 흐름입니다.
/* QUIC 클라이언트 — connection 세션에서 스트림을 만듭니다 */
int qc = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_endpt_t ep = { 0 };
uint8_t ip[4] = { 10, 0, 0, 1 };
ep.is_ip4 = 1; ep.ip = ip; ep.port = htons (4433);
/* connection 핸드셰이크 완료까지 블로킹 */
vppcom_session_connect (qc, &ep);
/* 같은 connection 위에 양방향 스트림을 추가로 "생성"합니다.
VCL은 스트림 개설을 새로운 세션 create + 부모 connection 속성 설정으로 표현합니다. */
int s1 = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
uint32_t parent = (uint32_t) qc;
uint32_t plen = sizeof (parent);
vppcom_session_attr (s1, VPPCOM_ATTR_SET_PARENT_HANDLE, &parent, &plen);
/* 이후 read/write는 s1(스트림 세션)에 대해서만 수행 */
vppcom_session_write (s1, "PING", 4);
char buf[64];
vppcom_session_read (s1, buf, sizeof(buf));
vppcom_session_close (s1); /* 스트림만 닫음 */
vppcom_session_close (qc); /* connection 종료 → CONNECTION_CLOSE 송신 */
CONNECTION_CLOSE 프레임이 송신됩니다. 멀티플렉싱 서버를 만들 때는 스트림 accept 이벤트를 connection 세션 쪽 epoll에서 받아야 한다는 점을 기억해야 합니다.
자주 쓰는 vcl.conf 파라미터
vcl.conf는 VCL이 VPP에 attach할 때 참조하는 유일한 설정 파일입니다. 다음 파라미터는 실제 성능과 호환성에 직접 영향을 주므로 초기 세팅 단계에서 명시적으로 지정해 두는 편이 안전합니다.
| 파라미터 | 의미 | 권장값 (출발점) |
|---|---|---|
rx-fifo-size / tx-fifo-size | 세션당 RX/TX FIFO 크기(바이트) | 단일 연결 대역폭이 필요하면 4000000 이상, 다수 연결이면 400000 수준 |
app-scope-local / app-scope-global | 같은 호스트 안의 앱 간 local 최적화 경로와 타 호스트/다른 네임스페이스(Namespace)와의 global 경로 활성화 | 일반적으로 둘 다 활성화 |
api-socket-name | VPP binary API 소켓 경로 | /run/vpp/api.sock |
use-mq-eventfd | 메시지 큐 이벤트 통지에 eventfd 사용 | 켜는 편이 지연이 낮고 epoll 통합이 자연스러움 |
namespace-id / namespace-secret | VPP 세션 네임스페이스 식별자와 비밀값 | 멀티테넌시 분리 필요 시 VPP 쪽 app namespace add와 일치시켜야 함 |
event-queue-size | 앱 워커 이벤트 큐 깊이 | 고연결 환경에서는 256 이상 |
app ns add id X secret Y sw_if_index N으로 네임스페이스를 정의했는데 vcl.conf의 namespace-id/secret이 일치하지 않으면, vppcom_app_create는 성공하지만 bind/connect가 조용히 실패하며 패킷이 어디로도 나가지 않습니다. 이 증상은 VPP 로그에 거의 단서가 남지 않으므로 초기 구성 단계에서 양쪽 값을 반드시 문서화해 두어야 합니다.
VCL 오류 코드와 해석
VCL API는 음수 반환값으로 오류를 알려주며, 각 코드는 vcl/vppcom.h에 VPPCOM_E* 매크로(Macro)로 정의되어 있습니다. 실전에서 가장 자주 만나는 항목은 다음과 같습니다.
| 코드 | 값 | 의미 | 전형적인 원인 |
|---|---|---|---|
VPPCOM_EAGAIN | -11 | 지금은 완료 불가 | RX FIFO 비어있음, TX FIFO 가득 참 — epoll 이벤트로 재진입 |
VPPCOM_EINPROGRESS | -115 | 비동기 연결 진행 중 | 논블로킹 connect 후 CONNECTED 이벤트 대기 |
VPPCOM_ECONNRESET | -104 | 연결 리셋됨 | 원격이 RST 전송 또는 TLS alert |
VPPCOM_ENOTCONN | -107 | 연결되지 않음 | 연결 완료 전 read/write 시도, 이미 닫힘 |
VPPCOM_ETIMEDOUT | -110 | 타임아웃 | TCP 재전송 한계 초과, TLS 핸드셰이크 지연 |
VPPCOM_EBADFD | -77 | 잘못된 세션 핸들 | 이미 close된 핸들을 다시 사용, 다른 앱 워커의 핸들 접근 |
show session verbose, show app, show app ns를 함께 확인하면 애플리케이션 측 오류가 세션 상태와 어떻게 대응되는지 추적할 수 있습니다. 특히 show session verbose의 상태 열에서 CLOSED_WAITING이나 TRANSPORT_CLOSING이 오래 남아 있다면, 앱이 vppcom_session_close를 빠뜨린 경우가 대부분입니다.
VCL/VLS API 실전 사용 예시 — 엔드투엔드 레시피
앞 절들은 VCL의 수명주기와 API 이름, VLS의 잠금 모드를 개별적으로 다뤘습니다. 여기서는 실제 서비스에서 자주 복사-붙여넣어 쓰는 완결형 레시피 8개를 제시합니다. 각 레시피는 컴파일 가능한 C 코드와 함께, 왜 이 구조를 선택했는지 그리고 피해야 할 실수는 무엇인지 함께 설명합니다. POSIX 소켓에 이미 익숙한 분이라면 vppcom_*/vls_* 대응 부분만 빠르게 훑으시면 됩니다.
- 프로세스당 한 번:
vppcom_app_create(name)— 메인 스레드에서 단 한 번만 호출합니다. 여러 번 부르면 VCL이 중복 앱으로 인식합니다. - VLS 사용 시 한 번 더:
vls_app_create(name)— VLS 래퍼를 쓰는 프로세스에서만, 역시 한 번만 호출합니다. - pthread당 한 번: 추가 워커 스레드에서는 최초 진입 시
vls_register_vcl_worker()를 호출해 자신의 VCL 워커를 등록합니다. 순수 VCL(단일 스레드) 애플리케이션에서는 이 호출이 필요하지 않습니다.
레시피 1 — 단일 스레드 TCP 에코 서버 (VCL 네이티브)
가장 기본적인 서버 루프입니다. vppcom_app_create → vppcom_session_create → bind/listen → epoll_wait → accept/recv/send 순서를 익히는 데 최적입니다.
/* gcc -o vcl-echo vcl-echo.c -lvppcom */
#include <vcl/vppcom.h>
#include <string.h>
#include <stdio.h>
int main (void)
{
int rv;
if ((rv = vppcom_app_create ("vcl-echo")) < 0) {
fprintf (stderr, "vppcom_app_create: %s\n", vppcom_retval_str (rv));
return 1;
}
int lfd = vppcom_session_create (VPPCOM_PROTO_TCP, 0 /* non-blocking */);
vppcom_endpt_t ep = {
.is_ip4 = 1,
.ip = (u8[]){ 0, 0, 0, 0 },
.port = htons (7777),
};
vppcom_session_bind (lfd, &ep);
vppcom_session_listen (lfd, 32);
int epfd = vppcom_epoll_create ();
struct epoll_event ev = { .events = EPOLLIN, .data.u32 = lfd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[64];
char buf[4096];
for (;;) {
int n = vppcom_epoll_wait (epfd, events, 64, 10.0 /* seconds */);
for (int i = 0; i < n; i++) {
int fd = events[i].data.u32;
if (fd == lfd) { /* ① accept */
vppcom_endpt_t peer = { .ip = (u8[16]){0} };
int cfd = vppcom_session_accept (lfd, &peer, 0);
struct epoll_event cev = { .events = EPOLLIN|EPOLLRDHUP, .data.u32 = cfd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, cfd, &cev);
} else { /* ② echo */
int r = vppcom_session_read (fd, buf, sizeof buf);
if (r <= 0) {
vppcom_epoll_ctl (epfd, EPOLL_CTL_DEL, fd, 0);
vppcom_session_close (fd);
continue;
}
int off = 0;
while (off < r) { /* ③ 부분 쓰기 처리 */
int w = vppcom_session_write (fd, buf + off, r - off);
if (w == VPPCOM_EWOULDBLOCK) {
/* TX FIFO 가득 — EPOLLOUT 재등록 후 이벤트 루프에 맡깁니다 */
struct epoll_event ev = { .events = EPOLLOUT | EPOLLRDHUP, .data.u32 = fd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_MOD, fd, &ev);
break;
}
if (w < 0) break;
off += w;
}
}
}
}
vppcom_app_destroy ();
}
포인트 해설
- ①
vppcom_session_accept는 완료된 연결만 돌려주므로 half-open 상태에서는VPPCOM_EAGAIN이 반환됩니다. 드롭하지 말고 epoll 재진입에 맡기시기 바랍니다. - ②
vppcom_session_read는 FIFO에서 직접 데이터를 꺼내는 zero-copy 경로입니다. 내부적으로는svm_fifo_dequeue가 호출됩니다. - ③TX FIFO가 가득 찼을 때
EWOULDBLOCK이 나오면,EPOLLOUT을 추가 등록해 쓰기 준비 이벤트를 기다려야 합니다. 에코 서버의 경우 수신 속도가 송신 속도보다 빠르면 불가피하게 backpressure가 발생합니다.
레시피 2 — 타임아웃·재시도 가능한 TCP 클라이언트
클라이언트는 connect 결과를 기다리는 방식이 서버보다 까다롭습니다. 블로킹 모드에서는 vppcom_session_connect가 완료까지 막히고, 논블로킹 모드에서는 VPPCOM_EINPROGRESS를 반환한 뒤 epoll에서 EPOLLOUT이 올라올 때 연결 수립으로 간주합니다. 에러가 있으면 같은 이벤트에 EPOLLERR이 함께 올라오므로 두 플래그만 확인하면 충분합니다.
static int
connect_with_timeout (const char *ip4, u16 port, f64 timeout_s)
{
int fd = vppcom_session_create (VPPCOM_PROTO_TCP, 0 /* nonblocking */);
vppcom_endpt_t ep = { .is_ip4 = 1, .port = htons (port) };
inet_pton (AF_INET, ip4, ep.ip);
int rv = vppcom_session_connect (fd, &ep);
if (rv != VPPCOM_OK && rv != VPPCOM_EINPROGRESS) {
vppcom_session_close (fd);
return -1;
}
/* 완료 대기 — epoll로 EPOLLOUT(연결 성공) 또는 EPOLLERR(실패) */
int epfd = vppcom_epoll_create ();
struct epoll_event ev = { .events = EPOLLOUT | EPOLLERR, .data.u32 = fd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, fd, &ev);
struct epoll_event out[1];
int n = vppcom_epoll_wait (epfd, out, 1, timeout_s);
vppcom_epoll_ctl (epfd, EPOLL_CTL_DEL, fd, 0);
vppcom_session_close (epfd); /* VCL epoll 핸들 닫기 */
if (n <= 0 || (out[0].events & (EPOLLERR | EPOLLHUP))) {
vppcom_session_close (fd);
return -1; /* 타임아웃 또는 RST */
}
return fd;
}
재시도 시에는 지수 백오프를 권장합니다. VPP 호스트 스택은 빠르게 응답하지만, 대상 서버가 실제로 다운된 경우 kernel TCP와 동일하게 ETIMEDOUT이 올 때까지 수십 초가 걸릴 수 있습니다. 짧은 초기 타임아웃(예: 500 ms)으로 실패를 빠르게 확인한 뒤 1 s → 2 s → 4 s로 늘리는 식이 바람직합니다.
레시피 3 — TLS 클라이언트 (CKPAIR·SNI·ALPN 설정 포함)
VCL은 TLS도 같은 vppcom_session_* API로 다룹니다. 다른 점은 세션 생성 시 프로토콜을 VPPCOM_PROTO_TLS로 주고, 연결 전에 (1) 인증서 쌍(CKPAIR) 인덱스와 (2) SNI·ALPN 같은 암호화 확장 설정을 붙이는 것뿐입니다. 데이터 경로는 이후 동일합니다.
SNI·ALPN은 전용 VPPCOM_ATTR_*가 따로 있지 않고, 세션 레이어의 공용 채널인 VPPCOM_ATTR_SET_ENDPT_EXT_CFG로 transport_endpt_ext_cfg_t를 넘깁니다. 그 안의 crypto 유니언 멤버(transport_endpt_crypto_cfg_t)에 hostname[256]과 alpn_protos[4] 배열을 채웁니다. alpn_protos는 vppcom_proto_t 값이 아니라 내부 ALPN ID(예: TLS_ALPN_PROTO_HTTP_2)를 0으로 종단된 리스트로 담는 4바이트 필드입니다.
alpn_protos)와 TLS 플러그인 단의 ALPN 협상 지원은 v25.06에서 추가되었습니다(변경 요약 참조). v25.02 기준 환경에서는 이 필드를 채워도 플러그인이 무시합니다. 25.02를 쓰신다면 ALPN은 서버 측 OpenSSL 설정에서 고정하고, 클라이언트는 hostname만 지정하시기 바랍니다. 이 문서의 예시는 25.06 이상에서 동작하도록 작성되어 있습니다.
int fd = vppcom_session_create (VPPCOM_PROTO_TLS, 0);
/* CKPAIR 인덱스는 VPP CLI나 API로 미리 등록된 값입니다 (0 = 기본 자체서명) */
u32 ckpair_index = my_ckpair_index;
vppcom_session_attr (fd, VPPCOM_ATTR_SET_CKPAIR, &ckpair_index, sizeof ckpair_index);
/* SNI와 ALPN — 세션 확장 설정으로 한 번에 전달 */
struct {
transport_endpt_ext_cfg_t hdr;
/* transport_endpt_ext_cfg_t.crypto 멤버가 가변 길이여서 바로 뒤에 이어 담습니다 */
} cfg;
clib_memset (&cfg, 0, sizeof cfg);
cfg.hdr.type = TRANSPORT_ENDPT_EXT_CFG_CRYPTO;
cfg.hdr.len = sizeof (transport_endpt_crypto_cfg_t);
cfg.hdr.crypto.ckpair_index = ckpair_index;
cfg.hdr.crypto.crypto_engine = CRYPTO_ENGINE_OPENSSL;
strncpy ((char *) cfg.hdr.crypto.hostname, "api.example.com",
sizeof cfg.hdr.crypto.hostname - 1);
cfg.hdr.crypto.alpn_protos[0] = TLS_ALPN_PROTO_HTTP_2;
cfg.hdr.crypto.alpn_protos[1] = TLS_ALPN_PROTO_HTTP_1_1;
vppcom_session_attr (fd, VPPCOM_ATTR_SET_ENDPT_EXT_CFG, &cfg,
sizeof (cfg.hdr));
/* 이후 connect / read / write는 TCP와 동일 — TLS 레코드는 투명하게 처리됩니다 */
vppcom_session_connect (fd, &ep);
vppcom_session_write (fd, "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n", 42);
int n = vppcom_session_read (fd, buf, sizeof buf);
CKPAIR 등록은 런타임에도 바이너리 API로 추가할 수 있습니다. 컨트롤 평면에서 vnet_app_add_cert_key_pair를 호출하거나, CLI의 test app attach ... certkey ... 같은 테스트 경로로도 등록됩니다. 클라이언트 인증(mTLS)이 필요하면 같은 CKPAIR에 클라이언트 인증서와 키를 담고, 서버가 CertificateRequest를 보낼 때 자동으로 제시됩니다.
alpn_protos 필드와 TLS 플러그인의 ALPN 협상 지원은 VPP 24.x 이후에 합류했습니다. 구형 릴리스(예: 22.02)에서는 hostname만 채우고 ALPN은 서버 설정으로 고정하시기 바랍니다. 필드 레이아웃은 src/vnet/session/transport_types.h에서 확인 가능합니다.
vppcom_session_attr 치트 시트
세션 하나에 설정할 수 있는 속성이 수십 개 있습니다. 실전에서 자주 쓰는 것만 정리했습니다.
| 속성 | 용도 | 버퍼 타입 |
|---|---|---|
GET_NREAD | 수신 FIFO에 쌓인 바이트 수 질의 — ioctl(FIONREAD) 대응 | u32 * |
GET_NWRITE | 송신 FIFO의 남은 공간 — backpressure 판단에 사용 | u32 * |
SET_FLAGS / GET_FLAGS | 논블로킹 전환 (O_NONBLOCK) | int * |
GET_LCL_ADDR / GET_PEER_ADDR | 로컬·피어 엔드포인트 조회 — getsockname/getpeername 대응 | vppcom_endpt_t * |
SET_CKPAIR | TLS 인증서 쌍 결합 | u32 * |
SET_ENDPT_EXT_CFG | SNI·ALPN·crypto 엔진 등 확장 설정 — transport_endpt_ext_cfg_t 전달 | void * (가변) |
SET_CONNECTED | accept 이후 애플리케이션이 완전한 소유권을 확보했음을 알림 | NULL |
GET_PROTOCOL / GET_LISTEN / GET_ERROR | 세션 메타정보 조회 — half-open 판별은 별도 속성이 아니라 epoll 이벤트로 합니다 | int * |
SET_TCP_KEEPIDLE / SET_TCP_KEEPINTVL | TCP Keepalive 파라미터 | u32 * |
SET_DSCP | DSCP 마킹 — QoS 구간과 연동 | u8 * |
fcntl(fd, F_SETFL, O_NONBLOCK) 대신 int flag = 1; vppcom_session_attr (fd, VPPCOM_ATTR_SET_FLAGS, &flag, sizeof flag);를 사용합니다. 생성 시점에 비블로킹 플래그를 넘기면 이 호출을 생략할 수 있습니다.
오류 코드 해석
POSIX의 errno를 쓰지 않고 VPP 고유 코드를 반환합니다. 반드시 vppcom_retval_str()로 변환해 로그에 남기시기 바랍니다.
| 반환 코드 | 의미 | POSIX 대응 |
|---|---|---|
VPPCOM_EAGAIN / EWOULDBLOCK | 지금 할 수 있는 작업이 없음 — epoll 재진입 | EAGAIN |
VPPCOM_EINPROGRESS | 연결 수립 중 — EPOLLOUT 기다림 | EINPROGRESS |
VPPCOM_ECONNRESET | 상대가 RST — FIFO 정리 후 close | ECONNRESET |
VPPCOM_ENOTCONN | 아직 connect 완료 전에 read/write 시도 | ENOTCONN |
VPPCOM_EBADFD | 이미 close된 세션 핸들에 접근 | EBADF |
VPPCOM_ETIMEDOUT | TCP keepalive 또는 connect 타임아웃 | ETIMEDOUT |
레시피 4 — VLS 기반 멀티 스레드 서버 (워커 풀 모델)
VLS는 여러 pthread가 같은 listen 핸들을 공유하면서 각자 accept/epoll을 돌릴 수 있게 해 줍니다. VCL 단독으로는 각 스레드마다 독립 세션이 되므로 레거시 멀티 스레드 서버를 포팅할 때 VLS가 필수입니다.
#include <vcl/vcl_locked.h>
#include <pthread.h>
static vls_handle_t g_listen;
static void *
worker_main (void *arg)
{
vls_register_vcl_worker (); /* ① 각 워커는 자기 VCL 워커를 등록 */
vls_handle_t epfd = vls_epoll_create ();
struct epoll_event ev = { .events = EPOLLIN, .data.u64 = g_listen };
vls_epoll_ctl (epfd, EPOLL_CTL_ADD, g_listen, &ev);
struct epoll_event events[64];
char buf[4096];
for (;;) {
int n = vls_epoll_wait (epfd, events, 64, 1.0);
for (int i = 0; i < n; i++) {
vls_handle_t h = events[i].data.u64;
if (h == g_listen) {
vppcom_endpt_t peer = { .ip = (u8[16]){0} };
vls_handle_t ch = vls_accept (g_listen, &peer, 0); /* ② */
struct epoll_event ce = { .events = EPOLLIN, .data.u64 = ch };
vls_epoll_ctl (epfd, EPOLL_CTL_ADD, ch, &ce);
} else {
int r = vls_read (h, buf, sizeof buf);
if (r <= 0) { vls_close (h); continue; }
vls_write (h, buf, r);
}
}
}
return 0;
}
int main (void)
{
vppcom_app_create ("vls-pool");
vls_app_create ("vls-pool");
g_listen = vls_create (VPPCOM_PROTO_TCP, 0);
vppcom_endpt_t ep = { .is_ip4 = 1, .port = htons (8080), .ip = (u8[]){0,0,0,0} };
vls_bind (g_listen, &ep);
vls_listen (g_listen, 128);
pthread_t thr[4];
for (int i = 0; i < 4; i++)
pthread_create (&thr[i], 0, worker_main, 0);
for (int i = 0; i < 4; i++)
pthread_join (thr[i], 0);
}
VLS 워커 등록의 의미
- ①
vls_register_vcl_worker는 해당 pthread를 VPP 호스트 스택의 독립 VCL 워커로 등록합니다. 이 호출이 빠지면 세션 이벤트가 mainthread로만 전달되어 경합이 발생합니다. - ②
vls_accept는 같은 listen 핸들에 대해 thread-safe합니다. VLS 내부에서 rwlock으로 listener 테이블을 보호하고, 수락된 연결은 호출한 워커에 귀속됩니다. 이후 read/write는 해당 워커에서만 하시기 바랍니다.
vls_session_get의 rwlock 경합이 병목이 됩니다. VLS 잠금 모드에서 설명했듯 MT 모드(vls-attach-detach=off)로 세션 이동을 금지하거나, 워커당 독립 리스너를 만들어 SO_REUSEPORT 풍의 분산을 하시기 바랍니다.
레시피 5 — VLS 핸들을 여러 스레드에서 안전하게 공유
작업 큐(Workqueue) 패턴(한 스레드에서 accept, 다른 스레드에서 처리)을 구현하려면 수락된 세션 핸들을 다른 pthread에 넘겨야 합니다. VLS는 동일한 vlsh(VLS 핸들)를 여러 워커에서 참조할 수 있도록 내부 rwlock으로 보호합니다. 별도의 attach/detach API가 아니라 단순히 핸들을 넘겨도 된다는 것이 VLS의 존재 이유입니다.
/* Producer (accept) 스레드에서 */
vls_handle_t ch = vls_accept (g_listen, &peer, 0);
job_queue_push (&jq, ch); /* 핸들을 그대로 전달 */
/* Consumer (worker) 스레드에서 */
vls_register_vcl_worker (); /* 최초 진입 시 1회 */
vls_handle_t ch = job_queue_pop (&jq);
/* 이제 read/write/close 가능 — VLS가 내부적으로 락을 잡아 줍니다 */
u8 buf[4096]; vls_read (ch, buf, sizeof buf);
사용 시 주의할 점 두 가지가 있습니다. 첫째, consumer 스레드는 최초 진입 시 반드시 vls_register_vcl_worker를 호출해 자신의 VCL 워커 컨텍스트를 등록해야 합니다. 등록 없이 vls_* API를 호출하면 현재 워커 탐색에 실패합니다. 둘째, 같은 핸들에 두 스레드가 동시에 read/write를 돌리면 데이터 순서가 섞일 수 있으므로, 핸들 하나는 한 번에 한 워커만 다루도록 잡 큐 설계로 보장하시기 바랍니다. VLS의 락은 자료 구조 무결성(Integrity)만 보장하며 애플리케이션 바이트 순서는 보장하지 않습니다.
vls_mt_wrk_supported 모드를 고려하거나, 워커별 독립 리스너 + SO_REUSEPORT 스타일로 자연 분산을 유도하시기 바랍니다.
레시피 6 — 제로 카피 읽기 (peek/segment 패턴)
프록시처럼 데이터를 그대로 전달만 하는 워크로드는 vppcom_session_read로 사용자 버퍼에 복사하지 말고 vppcom_session_read_segments를 써서 FIFO의 세그먼트 포인터를 직접 받는 편이 빠릅니다.
vppcom_data_segment_t segs[4];
int n = vppcom_session_read_segments (fd, segs, 4, 65536);
if (n > 0) {
int total = 0;
for (int i = 0; i < n; i++) {
/* segs[i].data / segs[i].len 을 곧바로 상대 세션으로 write */
vppcom_session_write (peer_fd, segs[i].data, segs[i].len);
total += segs[i].len;
}
vppcom_session_free_segments (fd, total); /* FIFO 소비 확정 */
}
성능상의 이득이 큽니다. 일반 read/write 조합은 바이트당 최소 두 번의 memcpy(FIFO→사용자 버퍼→다른 FIFO)를 수행하지만, 세그먼트 API는 사용자 공간(User Space)을 거치지 않습니다. 대신 같은 세그먼트를 두 번 읽지 않도록 free_segments를 꼭 호출해야 합니다. 실수로 빠뜨리면 FIFO가 차올라 backpressure가 발생합니다.
레시피 7 — UDP 데이터그램 송수신
UDP는 커넥션이 없지만 VCL에서는 여전히 vppcom_session_* API를 씁니다. 차이는 VPPCOM_PROTO_UDP로 생성하고, sendmsg/recvmsg에 해당하는 vppcom_session_sendto/recvfrom를 쓴다는 점입니다.
int fd = vppcom_session_create (VPPCOM_PROTO_UDP, 0);
vppcom_endpt_t local = { .is_ip4 = 1, .port = htons (5300), .ip = (u8[]){0,0,0,0} };
vppcom_session_bind (fd, &local);
char buf[2048];
vppcom_endpt_t peer = { .ip = (u8[16]){0} };
int n = vppcom_session_recvfrom (fd, buf, sizeof buf, 0, &peer);
/* 같은 소스로 응답 */
vppcom_session_sendto (fd, buf, n, 0, &peer);
DNS 리졸버, QUIC 애플리케이션, SIP·STUN 같은 프로토콜을 VPP 호스트 스택 위에 올릴 때 이 패턴이 기본 골격입니다. UDP는 FIFO 대신 메시지 큐로 동작하므로 하나의 recvfrom은 정확히 한 데이터그램을 반환합니다.
레시피 8 — 시그널(Signal)·종료 처리와 리소스 정리
장시간 실행되는 서버에서 Ctrl-C나 SIGTERM에 안전하게 반응하려면, 시그널 핸들러(Handler)가 플래그만 세팅하고 본 루프가 이를 확인해 정리하도록 합니다. VCL은 시그널 안전 함수가 아니므로 핸들러에서 직접 vppcom_*를 호출하지 마시기 바랍니다.
static volatile sig_atomic_t g_stop;
static void on_sig (int sig) { g_stop = 1; }
int main (void) {
signal (SIGINT, on_sig);
signal (SIGTERM, on_sig);
vppcom_app_create ("graceful");
/* ... setup ... */
while (!g_stop) {
int n = vppcom_epoll_wait (epfd, events, 64, 0.5);
/* ... process events ... */
}
/* 정리: 모든 세션 닫고 앱 종료 */
for (int fd = 0; fd < max_fd; fd++)
if (active[fd]) vppcom_session_close (fd);
vppcom_session_close (epfd);
vppcom_app_destroy ();
return 0;
}
vppcom_app_destroy를 생략하고 프로세스가 죽으면 VPP 측에 세션이 CLOSED_WAITING 상태로 남을 수 있습니다. show session verbose에 좀비 세션이 쌓이면 재시작(Reboot) 시 포트 바인드가 실패할 수 있으니, 반드시 정상 경로로 정리하시기 바랍니다.
VCL/VLS 공통 함정 요약
| 함정 | 증상 | 해결 |
|---|---|---|
POSIX epoll_*를 VCL 세션과 혼용 | 이벤트가 전혀 오지 않음 | 반드시 vppcom_epoll_*/vls_epoll_* 사용. 커널 fd는 따로 관리. |
vppcom_app_create를 pthread마다 호출 | 워커 등록 실패, 경고 로그 | 프로세스당 한 번만 호출. 워커는 vls_register_vcl_worker. |
| TLS 세션에 CKPAIR 미설정 | 핸드셰이크 실패, 자체 서명 경고 | CKPAIR 등록 후 VPPCOM_ATTR_SET_CKPAIR. |
블로킹 connect 중 시그널 | 프로세스 멈춤 | 논블로킹 소켓 + epoll로 전환. |
| read/write 부분 처리 무시 | 애플리케이션이 데이터를 잃음 | 루프에서 반환 바이트 수 누적 확인. |
| VLS 세션을 다른 워커에서 close | 간헐적 SEGV | close는 소유 워커에서만. 잡 큐 이동 시 detach/attach. |
EWOULDBLOCK을 에러로 처리 | 연결이 갑자기 끊김 | epoll 재진입 신호로 해석. |
VCL 네이티브 구현 예시 심화 — 프로토콜·상태 머신·운영 패턴
앞 절 레시피 모음은 각 API를 "한 가지씩" 보여주는 데 초점을 맞췄습니다. 이 절은 실제 서비스에서 필요한 완결된 구현을 다룹니다. VLS나 LD_PRELOAD를 쓰지 않고 vppcom_* API만으로 HTTP/1.1 파서, 커넥션 풀 클라이언트, 양방향 프록시 릴레이, mTLS 서버, 제어 이벤트 상태 머신, 우아한 종료(graceful shutdown)까지 처음부터 끝까지 구현합니다. 바탕은 모두 단일 VCL 워커 + epoll 이벤트 루프(Event Loop)입니다.
VCL 런타임 설정 — VCL_CONFIG와 초기화 옵션
VCL 기반 애플리케이션을 실행하기 전에 VPP와 연결되는 파라미터를 /etc/vpp/vcl.conf(또는 VCL_CONFIG 환경변수가 가리키는 경로)에 지정합니다. VPP 호스트 스택은 이 파일 또는 환경변수를 통해 애플리케이션에 대한 FIFO 크기·세그먼트 크기·폴링 전략을 전달합니다.
# /etc/vpp/vcl.conf — 고처리량 프록시용 권장 프로파일
vcl {
rx-fifo-size 4000000 # 4 MiB — 버스트 수용
tx-fifo-size 4000000
app-scope-local # per-app private namespace
app-scope-global
api-socket-name /run/vpp/api.sock
event-queue-size 100000 # 세션 이벤트 큐
use-mq-eventfd # eventfd 기반 알림 — epoll 연동 개선
app-socket-api /run/vpp/app_ns_sockets/default # 앱 API 소켓
namespace-id my-app
namespace-secret 0xDEADBEEF
}
# 환경변수로 우선 지정 가능 — 컨테이너 배포 시 편리
export VCL_CONFIG=/etc/vpp/vcl.conf
export VCL_APP_SCOPE_LOCAL=1
./my-vcl-server
rx-fifo-size/tx-fifo-size는 애플리케이션이 vppcom_app_create 시점에 VPP에 통보하는 값입니다. 너무 크면 메모리가 낭비되고, 너무 작으면 backpressure가 자주 발생합니다. 기본 64 KiB로 시작해 실제 show session verbose에서 tx-fifo usage가 80 %를 넘나들 때만 키우시기 바랍니다.
완결 예시 — 단일 스레드 HTTP/1.1 서버 (VCL 네이티브)
HTTP/1.1 요청을 파싱하고 간단한 응답을 돌려주는 400줄 규모 예시입니다. 실제 프로덕션에 쓸 수준은 아니지만, VCL로 애플리케이션 프로토콜을 얹는 전형적인 구조를 보여 줍니다. 상태 머신은 READ_REQLINE → READ_HEADERS → READ_BODY → WRITE_RESP 순서입니다.
#include <vcl/vppcom.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
enum { ST_REQLINE, ST_HEADERS, ST_BODY, ST_WRITE, ST_DONE };
typedef struct {
int state;
u8 rbuf[16384]; u32 rlen;
u8 wbuf[16384]; u32 wlen, woff;
u32 content_length; u8 keepalive;
} conn_t;
static conn_t *conns[65536]; /* fd → state */
static int
parse_request_line (conn_t *c)
{
u8 *end = memchr (c->rbuf, '\n', c->rlen);
if (!end) return 0; /* 더 읽어야 함 */
/* 예: "GET /hello HTTP/1.1\r\n" 만 검증 */
c->keepalive = 1; /* HTTP/1.1 기본 */
u32 consumed = end - c->rbuf + 1;
memmove (c->rbuf, end + 1, c->rlen - consumed);
c->rlen -= consumed;
c->state = ST_HEADERS;
return 1;
}
static int
parse_headers (conn_t *c)
{
for (;;) {
u8 *end = memchr (c->rbuf, '\n', c->rlen);
if (!end) return 0;
u32 line = end - c->rbuf;
if (line == 0 || (line == 1 && c->rbuf[0] == '\r')) {
/* 헤더 종료 */
memmove (c->rbuf, end + 1, c->rlen - (line + 1));
c->rlen -= line + 1;
c->state = c->content_length ? ST_BODY : ST_WRITE;
return 1;
}
if (line > 14 && !memcmp (c->rbuf, "Content-Length", 14))
c->content_length = atoi ((char *)(c->rbuf + 16));
if (line > 10 && !memcmp (c->rbuf, "Connection", 10)
&& strcasestr ((char *)(c->rbuf + 12), "close"))
c->keepalive = 0;
memmove (c->rbuf, end + 1, c->rlen - (line + 1));
c->rlen -= line + 1;
}
}
static void
build_response (conn_t *c)
{
static const char body[] = "Hello from VPP VCL\n";
c->wlen = snprintf ((char *) c->wbuf, sizeof c->wbuf,
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: %zu\r\n"
"Connection: %s\r\n"
"\r\n%s",
sizeof body - 1, c->keepalive ? "keep-alive" : "close", body);
c->woff = 0;
c->state = ST_WRITE;
}
static void
conn_drive (int epfd, int fd)
{
conn_t *c = conns[fd];
for (;;) {
switch (c->state) {
case ST_REQLINE:
if (!parse_request_line (c)) return;
break;
case ST_HEADERS:
if (!parse_headers (c)) return;
if (c->state == ST_WRITE) build_response (c);
break;
case ST_BODY:
if (c->rlen < c->content_length) return;
c->rlen -= c->content_length; /* 본문은 버림 */
memmove (c->rbuf, c->rbuf + c->content_length, c->rlen);
build_response (c);
break;
case ST_WRITE: {
while (c->woff < c->wlen) {
int w = vppcom_session_write (fd, c->wbuf + c->woff, c->wlen - c->woff);
if (w == VPPCOM_EWOULDBLOCK) { /* ① TX FIFO full */
struct epoll_event ev = { .events = EPOLLOUT | EPOLLRDHUP, .data.u32 = fd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_MOD, fd, &ev);
return;
}
if (w < 0) { c->state = ST_DONE; return; }
c->woff += w;
}
c->state = c->keepalive ? ST_REQLINE : ST_DONE;
struct epoll_event ev = { .events = EPOLLIN | EPOLLRDHUP, .data.u32 = fd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_MOD, fd, &ev);
break;
}
case ST_DONE:
vppcom_epoll_ctl (epfd, EPOLL_CTL_DEL, fd, 0);
vppcom_session_close (fd);
free (conns[fd]); conns[fd] = 0;
return;
}
}
}
int main (void)
{
vppcom_app_create ("vcl-http");
int lfd = vppcom_session_create (VPPCOM_PROTO_TCP, 0);
vppcom_endpt_t ep = { .is_ip4 = 1, .port = htons (8080), .ip = (u8[]){0,0,0,0} };
vppcom_session_bind (lfd, &ep);
vppcom_session_listen (lfd, 128);
int epfd = vppcom_epoll_create ();
struct epoll_event ev = { .events = EPOLLIN, .data.u32 = lfd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[256];
for (;;) {
int n = vppcom_epoll_wait (epfd, events, 256, 1.0);
for (int i = 0; i < n; i++) {
int fd = events[i].data.u32;
if (fd == lfd) {
vppcom_endpt_t peer = { .ip = (u8[16]){0} };
int c = vppcom_session_accept (lfd, &peer, 0);
conns[c] = calloc (1, sizeof (conn_t));
conns[c]->state = ST_REQLINE;
struct epoll_event ce = { .events = EPOLLIN | EPOLLRDHUP, .data.u32 = c };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, c, &ce);
continue;
}
if (events[i].events & EPOLLIN) {
conn_t *c = conns[fd];
int r = vppcom_session_read (fd, c->rbuf + c->rlen, sizeof c->rbuf - c->rlen);
if (r <= 0 && r != VPPCOM_EWOULDBLOCK) { c->state = ST_DONE; }
else if (r > 0) { c->rlen += r; }
}
conn_drive (epfd, fd);
}
}
}
설계 포인트 해설
- ①응답 쓰기 중 TX FIFO가 가득 차면
EPOLLOUT으로 전환해 쓸 수 있게 되는 순간을 기다립니다. 이 패턴이 빠지면 정적 파일 다운로드처럼 응답이 큰 경우에 데이터가 잘립니다. - ②상태 머신은
conn_drive안에서 반복 실행해 한 이벤트로 최대한 많은 바이트를 소모합니다. 그렇지 않으면 같은 데이터에 대해 epoll이 여러 번 깨어나 오버헤드가 커집니다. - ③
conns[fd]배열은 VCL의 세션 인덱스가 작고 단조 증가한다는 성질을 이용한 O(1) 매핑입니다. 인덱스가 수만 개를 넘는 워크로드에서는pool_get(vppinfra) 또는 해시 테이블(Hash Table)로 바꾸시기 바랍니다. - ④Keep-Alive가 false면 상태를
DONE으로 이동해 다음 드라이브 호출에서 정상 종료합니다. 반쪽 닫기가 필요하면vppcom_session_shutdown (fd, SHUT_WR)을 호출하지만, HTTP/1.1에서는 전체 close가 명확하므로 이 예제는 단순히 닫습니다.
완결 예시 — mTLS 서버(클라이언트 인증 요구)
클라이언트 인증서까지 검증하는 TLS 서버입니다. 데이터 경로는 평문 TCP와 완전히 같고, 초기화 단계에서만 차이가 납니다. 핵심은 (1) 서버용 CKPAIR 등록, (2) 클라이언트 CA 체인을 같은 CKPAIR에 포함시키거나 컨트롤 플레인이 별도로 신뢰 목록을 주입하는 것입니다.
tls ckpair add 같은 CLI가 등장하지만, 공식 tlsopenssl 플러그인에 그런 명령은 없습니다. CKPAIR 등록은 반드시 바이너리 API app_add_cert_key_pair(GoVPP/PAPI/vpp_echo 모두 같은 경로)로 수행합니다. 아래 예시는 vpp_echo의 BAPI 호출을 간략히 나타낸 것입니다.
/* 바이너리 API로 CKPAIR 등록 — 반환되는 index를 VCL에 넘깁니다 */
vl_api_app_add_cert_key_pair_t *mp;
mp = vl_msg_api_alloc (sizeof *mp + cert_len + key_len);
mp->_vl_msg_id = ntohs (msg_id_add);
mp->cert_len = htons (cert_len);
mp->certkey_len = htons (cert_len + key_len);
clib_memcpy (mp->certkey, cert_pem, cert_len);
clib_memcpy (mp->certkey + cert_len, key_pem, key_len);
vl_msg_api_send_shmem (wrk->vl_input_queue, (u8 *)&mp);
/* reply 핸들러에서 .index 필드를 받아 ckpair_index로 사용 */
mTLS(클라이언트 인증) 요구는 VCL API만으로는 스위치 불가능합니다. 실제 강제는 tlsopenssl 플러그인이 애플리케이션 scope를 확인해 핸드셰이크 시 SSL_VERIFY_PEER|SSL_VERIFY_FAIL_IF_NO_PEER_CERT를 설정하는 경로에서 이루어집니다. 컨트롤 플레인에서 해당 동작을 켜려면 플러그인 옵션(빌드·설정) 또는 별도 바이너리 API를 사용해야 하며, 릴리스마다 경로가 다르니 실제 사용 중인 VPP의 src/plugins/tlsopenssl 소스를 확인하시기 바랍니다.
static int
tls_listen (u16 port, u32 ckpair_index)
{
int lfd = vppcom_session_create (VPPCOM_PROTO_TLS, 0);
/* CKPAIR + ALPN을 확장 설정으로 한 번에 전달 */
struct { transport_endpt_ext_cfg_t hdr; } cfg;
clib_memset (&cfg, 0, sizeof cfg);
cfg.hdr.type = TRANSPORT_ENDPT_EXT_CFG_CRYPTO;
cfg.hdr.len = sizeof (transport_endpt_crypto_cfg_t);
cfg.hdr.crypto.ckpair_index = ckpair_index;
cfg.hdr.crypto.crypto_engine = CRYPTO_ENGINE_OPENSSL;
cfg.hdr.crypto.alpn_protos[0] = TLS_ALPN_PROTO_HTTP_2;
cfg.hdr.crypto.alpn_protos[1] = TLS_ALPN_PROTO_HTTP_1_1;
vppcom_session_attr (lfd, VPPCOM_ATTR_SET_ENDPT_EXT_CFG, &cfg,
sizeof (cfg.hdr));
vppcom_endpt_t ep = { .is_ip4 = 1, .port = htons (port), .ip = (u8[]){0,0,0,0} };
vppcom_session_bind (lfd, &ep);
vppcom_session_listen (lfd, 128);
return lfd;
}
/* accept 후 첫 이벤트 — 핸드셰이크가 완료된 시점에만 EPOLLIN이 옵니다 */
static void
on_new_tls_session (int epfd, int fd)
{
fprintf (stderr, "new TLS session fd=%d\n", fd);
struct epoll_event ev = { .events = EPOLLIN | EPOLLRDHUP, .data.u32 = fd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, fd, &ev);
}
X-Forwarded-Client-Cert 같은 우회 헤더가 필요함" — 이 26.02부터 해소됩니다. 정확한 속성 이름·시그니처는 master 브랜치에서 빠르게 확정되는 중이므로, 실제 적용 전에 src/plugins/tlsopenssl와 src/vnet/session/application_interface.h 최신 소스를 확인하시기 바랍니다.
vppcom_session_attr로 조회할 수 없습니다. 필요하면 (1) VPP 측 플러그인에서 세션 레이어 콜백을 이용해 메타데이터를 바이너리 API로 푸시하거나, (2) 애플리케이션 레이어 헤더(예: HTTP X-Forwarded-Client-Cert)에 상위 레이어가 직접 실어 보내는 우회책을 쓰시기 바랍니다. 이 제약을 프로토콜 설계 단계에서 미리 반영하시는 편이 안전합니다.
완결 예시 — 양방향 TCP 릴레이 프록시
클라이언트 ↔ 업스트림을 바이트 단위로 중계하는 프록시입니다. 단일 VCL 워커 기준이며, 각 세션 쌍(pair_t)이 상태를 공유합니다. 한쪽이 EAGAIN이면 상대 쪽의 EPOLLIN을 잠시 막아 backpressure를 전파합니다.
typedef struct {
int a, b; /* a=client, b=upstream */
int a_read_on, b_read_on;
} pair_t;
static pair_t *pairs[65536];
static void
set_read (int epfd, int fd, int on)
{
struct epoll_event ev = { .data.u32 = fd,
.events = (on ? EPOLLIN : 0) | EPOLLRDHUP };
vppcom_epoll_ctl (epfd, EPOLL_CTL_MOD, fd, &ev);
}
static void
relay_one (int epfd, int src, int dst, int *src_on)
{
u8 buf[16384];
int r = vppcom_session_read (src, buf, sizeof buf);
if (r <= 0) return;
int off = 0;
while (off < r) {
int w = vppcom_session_write (dst, buf + off, r - off);
if (w == VPPCOM_EWOULDBLOCK) {
/* backpressure: 상대 방향의 read 일시 정지 */
*src_on = 0;
set_read (epfd, src, 0);
struct epoll_event ev = { .events = EPOLLOUT | EPOLLRDHUP, .data.u32 = dst };
vppcom_epoll_ctl (epfd, EPOLL_CTL_MOD, dst, &ev);
return;
}
if (w < 0) return;
off += w;
}
}
static void
on_writable (int epfd, int fd)
{
/* TX 여유 → 상대 방향의 read 재개 */
pair_t *p = pairs[fd];
int peer = (fd == p->a) ? p->b : p->a;
int *peer_on = (fd == p->a) ? &p->b_read_on : &p->a_read_on;
*peer_on = 1;
set_read (epfd, peer, 1);
struct epoll_event ev = { .events = EPOLLIN | EPOLLRDHUP, .data.u32 = fd };
vppcom_epoll_ctl (epfd, EPOLL_CTL_MOD, fd, &ev);
}
이 패턴은 단순하지만 실전에서 가장 자주 쓰입니다. HTTP CONNECT 터널, L4 로드 밸런서, SOCKS5 등 모두 이 골격에 기반합니다. 확장 포인트는 (1) 세션 생성·종료 이벤트 관찰과 로깅, (2) vppcom_session_read_segments로 zero-copy 경로 전환, (3) 연결 타임아웃 관리입니다. 타임아웃은 별도 주기적 타이머(Timer)(예: epoll_wait의 timeout 활용)로 구현할 수 있습니다.
완결 예시 — Keep-Alive 커넥션 풀 클라이언트
마이크로서비스·API 게이트웨이에서 흔히 필요한 패턴입니다. 업스트림마다 사전 연결을 N개 열어 두고, 요청이 들어오면 풀에서 꺼내 재사용합니다. 논블로킹 특성상 사용 중·대기 중 플래그를 풀 안에 유지하고, 요청이 완료되면 다시 반납합니다.
typedef struct {
int fd;
int in_use;
u64 last_used_ns;
} pool_slot_t;
typedef struct {
vppcom_endpt_t upstream;
pool_slot_t slots[64];
u32 n_slots;
u32 max_idle_ns;
} conn_pool_t;
static int
pool_acquire (conn_pool_t *p)
{
for (u32 i = 0; i < p->n_slots; i++) {
if (p->slots[i].fd > 0 && !p->slots[i].in_use) {
p->slots[i].in_use = 1;
return p->slots[i].fd;
}
}
/* 전부 사용 중 — 새 연결을 만듭니다 */
int fd = vppcom_session_create (VPPCOM_PROTO_TCP, 0);
int rv = vppcom_session_connect (fd, &p->upstream);
if (rv != VPPCOM_OK && rv != VPPCOM_EINPROGRESS) { vppcom_session_close (fd); return -1; }
/* 슬롯에 등록 */
for (u32 i = 0; i < sizeof p->slots / sizeof p->slots[0]; i++)
if (p->slots[i].fd <= 0) { p->slots[i].fd = fd; p->slots[i].in_use = 1; break; }
return fd;
}
static void
pool_release (conn_pool_t *p, int fd, u64 now)
{
for (u32 i = 0; i < p->n_slots; i++)
if (p->slots[i].fd == fd) {
p->slots[i].in_use = 0;
p->slots[i].last_used_ns = now;
return;
}
}
static void
pool_reap_idle (conn_pool_t *p, u64 now)
{
for (u32 i = 0; i < p->n_slots; i++) {
pool_slot_t *s = &p->slots[i];
if (s->fd > 0 && !s->in_use && (now - s->last_used_ns) > p->max_idle_ns) {
vppcom_session_close (s->fd);
s->fd = 0;
}
}
}
커넥션 풀의 가장 흔한 버그는 헤어진 세션을 다시 꺼내 쓰는 것입니다. 업스트림이 idle timeout으로 RST를 보내면 풀은 여전히 "사용 가능"으로 믿고 다음 요청을 올립니다. 이를 방지하려면 (1) pool_reap_idle을 주기적으로 호출해 오래된 세션을 선제적으로 닫고, (2) 첫 write가 ECONNRESET으로 실패하면 풀에서 즉시 제거한 후 새 세션으로 재시도하시기 바랍니다. 이 "단 한 번의 재시도"가 안정성을 크게 올립니다.
VCL 세션 이벤트 — 애플리케이션이 직접 추적하는 상태 머신
VPP 내부의 session_state_t(서버 측 열거형(Enum))는 VCL 공개 API로 노출되지 않습니다. 애플리케이션은 그 대신 자신이 관리하는 상태를 epoll 이벤트와 반환 코드로 전이시킵니다. 다음 표는 권장 상태 집합과 각 상태에서 반응해야 할 이벤트입니다.
| 앱 상태 (사용자 정의) | 진입 조건 | 관찰되는 이벤트 | 권장 처리 |
|---|---|---|---|
APP_INIT | 세션 생성 직후 | 없음 | 속성 설정(CKPAIR, ENDPT_EXT_CFG 등) 후 connect/listen |
APP_CONNECTING | connect 호출, EINPROGRESS 수신 | EPOLLOUT 또는 EPOLLERR 대기 | 성공 시 APP_READY로, 실패 시 제거 |
APP_LISTEN | listen 성공 | EPOLLIN(accept 준비) | vppcom_session_accept 호출 |
APP_READY | accept 세션 또는 connect 완료 | EPOLLIN/EPOLLOUT | 프로토콜 상태 머신 드라이브 |
APP_PEER_CLOSED | read 반환 0 또는 EPOLLRDHUP | EPOLLRDHUP | 쓰기 큐 소진 후 close |
APP_ERROR | 시스템콜이 음수 반환(EAGAIN 제외) | EPOLLERR | 로그 기록 후 close, 풀에서 제거 |
APP_CLOSED | vppcom_session_close 호출 직후 | 없음 | 자원 정리 완료 |
typedef enum {
APP_INIT, APP_CONNECTING, APP_LISTEN, APP_READY,
APP_PEER_CLOSED, APP_ERROR, APP_CLOSED,
} app_state_t;
static const char *
app_state_str (app_state_t s)
{
static const char *n[] = {
"INIT", "CONNECTING", "LISTEN", "READY",
"PEER_CLOSED", "ERROR", "CLOSED",
};
return n[s];
}
/* epoll 이벤트를 받아 상태를 전이하는 전형적 핸들러 */
static void
drive (int fd, app_state_t *st, u32 events)
{
if (events & EPOLLERR) { *st = APP_ERROR; return; }
if (events & EPOLLRDHUP){ *st = APP_PEER_CLOSED; return; }
if (*st == APP_CONNECTING && (events & EPOLLOUT))
*st = APP_READY;
/* ... 이후 APP_READY 상태에서 프로토콜 로직 실행 ... */
}
getsockopt로 조회하던 습관과 달리, VCL은 상태를 이벤트 스트림으로 전달합니다. 이벤트를 놓치지 않는 것이 애플리케이션의 책임이며, 상태를 얻기 위해 별도 API를 호출하는 설계는 VCL과 맞지 않습니다.
우아한 종료와 드레인(Drain)
장시간 실행되는 서버가 배포 롤오버나 수평 축소 시 신규 요청을 끊고 진행 중 요청은 모두 완료시킨 뒤 종료해야 합니다. VCL 네이티브 루프에서는 세 단계로 달성합니다.
static volatile sig_atomic_t g_draining;
static void on_term (int s) { g_draining = 1; }
int main (void)
{
signal (SIGTERM, on_term);
vppcom_app_create ("vcl-graceful");
int lfd = start_listener (8080);
int epfd = vppcom_epoll_create ();
/* ... ADD lfd ... */
u32 active = 0; /* 진행 중인 요청 수 */
while (1) {
if (g_draining && lfd != -1) { /* ① 리스너만 먼저 닫음 */
vppcom_epoll_ctl (epfd, EPOLL_CTL_DEL, lfd, 0);
vppcom_session_close (lfd);
lfd = -1;
}
if (g_draining && active == 0) break; /* ② 남은 세션이 모두 정리됨 */
struct epoll_event events[256];
int n = vppcom_epoll_wait (epfd, events, 256, 0.5);
/* ... 이벤트 처리 — close 시 active-- ... */
}
vppcom_app_destroy (); /* ③ 앱 컨텍스트 정리 */
}
(1) 리스너 먼저 닫기 → 신규 연결이 TCP 레벨에서 거부되므로 로드 밸런서가 빠르게 감지합니다. (2) 기존 세션이 자연 종료될 때까지 기다리되, 설정 가능한 상한(예: 30초)을 두어 무한 대기를 막으시기 바랍니다. (3) vppcom_app_destroy가 빠지면 VPP 측에 좀비 앱이 남아 재시작 시 혼선이 생깁니다.
디버깅과 관찰 가능성(Observability)
VCL 프로세스의 문제를 조사할 때 쓸 수 있는 5가지 도구와 명령입니다.
| 도구 | 용도 | 예시 |
|---|---|---|
VPP show session verbose | VPP가 보는 세션 상태, FIFO 사용량 | vppctl show session verbose 3 |
VPP show app | 등록된 애플리케이션, 네임스페이스 | vppctl show app |
VCL_DEBUG 환경변수 | VCL 내부 로그를 stderr로 | VCL_DEBUG=3 ./my-app |
| perf record | VCL 함수별 CPU 분포 | perf record -g -p $(pidof my-app) |
| strace | 커널 syscall — VCL이 어디서 polling하는지 | strace -p $(pidof my-app) -e epoll_wait,eventfd |
# 세션 하나의 전체 수명을 추적
$ VCL_DEBUG=3 ./my-vcl-server 2> vcl.log &
$ vppctl show session verbose 3
$ vppctl show errors | grep -iE 'session|tls|tcp'
# FIFO가 차올라 backpressure가 일어난 세션만 추리기
$ vppctl show session verbose 3 | awk '/tx-fifo.*full/ {print}'
VCL 애플리케이션 성능 튜닝 체크
- FIFO 크기를 실제 메시지 크기에 맞춰 조정. RPC류는 64 KiB 기본으로 충분하고, 파일 서빙은 1~4 MiB 권장.
- epoll 배치 크기를 256 이상으로 두어 시스템콜 빈도를 낮추기. 기본 64로는 수만 pps에서 CPU 상승.
- 논블로킹 고정. 블로킹 경로가 섞이면 epoll 만으로 드라이브가 불가능해지고 지연이 튑니다.
- per-fd 버퍼 할당 전략. 요청/응답마다 malloc을 하면 1백만 연결에서는 RSS가 폭발합니다. 슬랩 또는 vppinfra
pool적용. - 세션 재사용. 외부 TLS를 열 때마다 핸드셰이크를 하면 성능이 무너집니다. 세션 티켓 또는 커넥션 풀로 해소.
- 로그.
fprintf (stderr, ...)를 핫 패스에 두면write(2)syscall이 모든 이점을 상쇄합니다. elog로 돌리거나 샘플링.
show errors의 session-queue/app-rx-full 카운터입니다. 이 값이 증가한다면 애플리케이션이 세션 이벤트를 너무 느리게 소비 중입니다. 둘째는 show session verbose에 나타나는 FIFO full 비율. 셋째는 perf top에서 vppcom_session_read·vppcom_session_write의 인라인 여부. 세 가지 순서로 확인하면 90 %의 성능 이슈는 원인이 드러납니다.
TCP 세션 생명주기와 세션 이벤트 매핑
VPP 호스트 스택에서 TCP는 단순히 패킷을 주고받는 계층이 아닙니다. 세션 레이어는 TCP의 half-open, 연결 완료, 수신 가능, 송신 가능, 종료 진행 상태를 애플리케이션 이벤트로 변환합니다. 이 매핑을 이해해야 CONNECTED 이벤트가 왜 늦게 오는지, FIFO는 이미 비어 있는데 왜 쓰기가 막히는지 같은 문제를 분석할 수 있습니다.
- 클라이언트 connect 경로 —
session_open()이 먼저 half-open 세션을 만들고 TCP 연결 요청을 전송합니다. 이 단계에서는 아직 애플리케이션 FIFO가 없습니다. - 서버 accept 경로 — SYN이 들어오면 TCP가 연결 컨텍스트를 만들고, 세션 레이어가 새 세션과 FIFO를 붙인 뒤
SESSION_CTRL_EVT_ACCEPTED를 큐에 넣습니다. - READY 전환 — 3-way handshake가 끝나야
SESSION_STATE_READY가 됩니다. 애플리케이션이 connect 호출 직후 곧바로 읽고 쓸 수 있다고 가정하면 안 됩니다. - 수신 경로 —
tcp_input이 순서 보장과 재조립을 마친 뒤에만 세션 FIFO에 데이터를 넣습니다. 그래서 애플리케이션은 재정렬된 바이트 스트림만 보게 됩니다. - 송신 경로 — 애플리케이션은 TX FIFO에 쓰기만 하고 실제 세그멘트화, ACK 처리, 재전송, 혼잡 윈도우 조정은 TCP 계층이 담당합니다.
| 애플리케이션에서 보이는 현상 | 실제 원인 계층 | 관찰 포인트 |
|---|---|---|
| connect가 늦게 완료됩니다 | TCP handshake 지연 또는 half-open 정체 | show session verbose에서 CONNECTING 세션과 half-open 테이블을 봅니다 |
| read는 없는데 CPU는 높습니다 | 재전송, out-of-order 처리, ACK 폭증 | TCP worker 런타임과 trace를 함께 봅니다 |
| write가 자주 0 또는 짧게 반환됩니다 | TX FIFO 여유 부족 또는 원격 윈도우 축소 | VPPCOM_ATTR_GET_NWRITEQ, FIFO 사용률, RTT 증가를 확인합니다 |
| 세션 종료가 깔끔하지 않습니다 | FIN과 애플리케이션 close 타이밍 불일치 | DISCONNECTED, RESET, CLOSING 이벤트 순서를 확인합니다 |
TCP 백프레셔와 FIFO의 상호작용
VPP 호스트 스택을 이해할 때 가장 중요한 운영 포인트는 백프레셔(backpressure)가 TCP 윈도우와 FIFO 여유 공간이 만나는 지점에서 발생한다는 사실입니다. 애플리케이션이 데이터를 늦게 소비하면 RX FIFO가 차고, 세션 레이어는 TCP에 더 적은 윈도우를 광고하게 됩니다. 반대로 송신 측이 너무 빨리 쓰면 TX FIFO가 포화되어 애플리케이션 쓰기가 짧게 반환되거나 재시도가 필요해집니다.
| 상황 | 내부 동작 | 결과 | 조치 |
|---|---|---|---|
| RX FIFO 포화 | 애플리케이션 소비 속도가 수신 속도보다 느립니다 | TCP advertised window가 줄고 원격 송신 속도가 낮아집니다 | 애플리케이션 소비 스레드 수를 늘리거나 rx-fifo-size를 키웁니다 |
| TX FIFO 포화 | 앱이 너무 빨리 쓰지만 ACK와 congestion window가 따라오지 못합니다 | write()가 짧게 반환되거나 재시도 루프가 필요합니다 | tx-fifo-size, 송신 batching, epoll 재개 로직을 조정합니다 |
| 워커 불균형 | 특정 워커에 연결이 몰려 FIFO와 이벤트 큐가 편향됩니다 | 일부 연결만 지연과 드롭이 늘어납니다 | RSS, handoff, 애플리케이션 워커 배치를 조정합니다 |
| TLS 추가 | 평문 FIFO와 암호문 FIFO가 모두 생겨 메모리 압력이 커집니다 | 같은 세션 수라도 메모리와 CPU 여유가 더 빨리 줄어듭니다 | TLS 워크로드는 FIFO 크기와 preallocated 세션 수를 별도로 산정합니다 |
2026년 기준 Host Stack 현재 상태
v26.02 (2026-02-25 릴리스, 최신 stable). 필요 시 과거 기준점이었던 v25.02와의 차이를 본문 곳곳의 "25.02 대비 변경" 박스로 명시합니다. 릴리스 타임라인은 v25.02 → v25.06(2025-06) → v25.10(2025-10) → v26.02(2026-02) 순서이며, 각 릴리스 노트는 FDio/vpp docs/aboutvpp/releasenotes에서 확인 가능합니다.
v25.02 → v26.02 주요 변경 요약
현재 문서가 기준으로 삼는 26.02는 25.02 대비 3개 릴리스(25.06, 25.10, 26.02)의 변경을 누적한 상태입니다. 본 표에 정리한 항목은 이후 본문에서 인라인 "25.02 대비 변경" 박스로 다시 참조됩니다.
| 영역 | v25.02 | v26.02 | 도입 릴리스 |
|---|---|---|---|
| TLS ALPN 협상 | 지원 없음 — 서버 설정으로만 고정 | VCL 애플리케이션이 alpn_protos[4]로 클라이언트 우선순위(Priority) 지정 가능 | 25.06 |
| 서버측 mTLS (VCL 차원) | 플러그인 내부 경로만, VCL API 미노출 | server side mtls support, peer cert retrieval 지원 | 26.02 |
| QUIC 엔진 API | quicly 직결 | QUIC engine API(플러그형) 공식 출시 | 25.06 |
| HTTP/2 전체 구현 | 부분적 | HPACK·프레임·흐름제어·멀티플렉싱·클라이언트 포함 완성 | 25.06 |
| HTTP/2 CONNECT/PUT/UDP 터널 | 없음 | extended CONNECT, UDP over HTTP/2 터널 지원 | 25.10 |
| HTTP/3 (QUIC 위) | 없음 | H3 framing + core + 클라이언트 구현, QPACK 인코딩/디코딩 | 26.02 |
| Session 이벤팅 인프라 | 기본 이벤트 큐만 | 애플리케이션용 session eventing 인프라 | 25.06 |
| Session trusted CA 설정 | 없음(플러그인 내부) | 세션 레이어에서 trusted CA 구성 가능 | 25.10 |
| Session FIFO 최대 메모리 | 없음 | per-FIFO max memory 설정 | 25.10 |
| Proxy write early data 콜백 | 없음 | proxy_write_early_data 훅 추가 | 25.10 |
| HTTP connect proxy 클라이언트 | 없음 | HTTP connect proxy client 내장 | 26.02 |
| IPsec AES-CBC HMAC | 없음 | 지원 추가 | 25.06 |
| IPv6 IPsec bypass/discard | 제한적 | IPv6 bypass/discard 정책 + UDP 캡슐화(Encapsulation)(policy 모드) | 25.06 |
| IPsec unified crypto+HMAC | 분리 연산 | ESP에서 crypto+HMAC 단일 op로 통합 | 26.02 |
| DPDK 버전 | DPDK 24.x | DPDK 25.11 (+ rdma-core 60.0) | 26.02 |
| DPDK 중간 버전 | — | 25.10에서 DPDK 25.07 + rdma-core 58.0 경유 | 25.10 |
| Intel Gigabit 네이티브 드라이버 | DPDK 경유만 | i211/i225/i226 네이티브 PMD | 26.02 |
| GRE 키 지원 | 없음 | GRE 플러그인 키 필드 지원 | 25.10 |
| Virtio/TAP 이름 지정 | 없음 | 인터페이스 생성 시 이름 옵션 | 25.10 |
| 새 플러그인 | — | NPol(Network Policies), Selog(Shared Elog), Soft RSS, SFDP(StateFul Data Plane Services), SASC(Session-Aware Service Chaining) | 26.02 |
| 제거된 API | — | avf_create/avf_delete 메시지 제거 | 26.02 |
| Deprecated API | — | tap_create_v2 (26.02), pg_create_interface_v2 (25.10) | 25.10/26.02 |
2026년 4월 기준 공식 기능 목록을 보면 Session Layer, VPP Comms Library (VCL), TCP, TLS 프레임워크는 production으로 분류됩니다. 반면 TLS OpenSSL 엔진, QUIC, QUICLY는 experimental입니다. 따라서 운영 설계는 "호스트 스택 전체가 불안정합니다"가 아니라, 세션 추상화는 주력으로 쓰되 엔진과 프로토콜 확장은 경계를 나눠 배치해야 합니다라는 식으로 읽어야 정확합니다.
| 구성 요소 | 공식 분류 | 최근 변화 | 실무 해석 |
|---|---|---|---|
| Session Layer | production | 25.06에서 app용 session eventing 인프라 추가 | 이벤트 기반 애플리케이션과 모니터링의 중심 계층으로 보는 편이 맞습니다. |
| VCL | production | 25.02에서 transport attributes 강화 | 신규 TCP/TLS 프록시는 직접 VCL을 기준으로 설계하는 편이 예측 가능성이 높습니다. |
| TCP | production | 호스트 스택의 안정 축 | 대량 연결, 투명 프록시, TLS 종단의 기본 운반 계층으로 적합합니다. |
| TLS 프레임워크 | production | 플러그형 엔진 구조 유지 | TLS 자체보다 어떤 엔진과 신뢰 체인(Chain of Trust)을 선택하느냐가 더 중요한 문제입니다. |
| TLS OpenSSL | experimental | 25.06 ALPN, 25.10 configurable trusted CAs, early data 프록시 훅 | 기능은 강력하지만, 전체 트래픽에 일괄 적용하기보다 역할을 좁혀 검증해야 합니다. |
| QUIC / QUICLY | experimental | 25.06 QUIC engine API | 파일럿, 특정 서비스, 별도 티어(Tier)로는 좋지만 핵심 검사 경로와 강결합시키지 않는 편이 안전합니다. |
25.02~25.10 변화가 TLS/QUIC 설계에 주는 의미
| 릴리스 | 핵심 변화 | 설계에 미치는 영향 |
|---|---|---|
| 25.02 | vpp_papi asyncio 지원, VCL transport attributes, auto SDL, TLS 비동기 처리 |
제어 평면을 동기식 폴링 루프로 유지할 이유가 줄었습니다. 세션 자동 차단과 비동기 제어 루프를 함께 설계할 수 있습니다. |
| 25.06 | session eventing, ALPN, QUIC engine API, HTTP/2 코어/다중화(Multiplexing)/흐름 제어(Flow Control), host stack 성능 시험 인프라 | 단순 TCP 종단만이 아니라, L7 프록시와 프로토콜 선택(ALPN), 이벤트 기반 오케스트레이션까지 VPP 내부에서 직접 다룰 수 있게 되었습니다. |
| 25.10 | FIFO 최대 메모리 제한, proxy_write_early_data, configurable trusted CAs, HTTP/2 CONNECT/Extended CONNECT, HTTP/2 기반 UDP 터널링 |
메모리 상한과 신뢰 체인을 운영 정책으로 걸 수 있고, CONNECT류 터널과 Early Data를 인지하는 프록시 설계가 더 현실적인 주제가 되었습니다. |
현재 시점 배치 권장안
운영 환경에서는 신뢰 경계, 메모리 경계, 프로토콜 경계를 먼저 정하고 들어가는 편이 좋습니다. 25.10에서 trusted CA·early data 관련 훅이 추가되고 26.02에서 서버측 mTLS와 peer cert 조회가 정식으로 들어오면서, "VPP가 어디까지 믿고 어디서 끊을 것인가"를 설정과 API 양쪽에서 표현할 수 있는 기반이 갖춰졌습니다. 25.02에 머물러 있던 조직은 mTLS 요구사항을 애플리케이션 레이어로 떠넘기는 우회 설계를 써 왔을 텐데, 26.02로 올리면 그 우회를 걷어낼 수 있습니다.
# 보수적 운영 출발점
tls {
ca-cert-path /etc/ssl/certs/ca-certificates.crt
}
session {
evt_qs_memfd_seg
event-queue-length 131072
}
| 워크로드 | 권장 경로 | 권장 이유 | 주의점 |
|---|---|---|---|
| 대량 TCP/TLS 종단 | 직접 VCL + TLS | 세션 소유권과 FIFO 병목을 가장 명확하게 볼 수 있습니다. | TLS 엔진은 별도 랩 검증 후 단계적으로 확장해야 합니다. |
| SSL Inspection | 이중 세션 + 역할별 CA 번들 | 신뢰 CA와 클라이언트용 CA를 분리해야 운영 사고가 줄어듭니다. | 0-RTT와 certificate pinning은 기본 차단 또는 바이패스가 안전합니다. |
| QUIC 파일럿 | 별도 서비스 티어 또는 우회 정책 | QUIC과 QUICLY가 experimental이므로 장애 반경을 줄일 수 있습니다. | 핵심 검사 경로와 강결합시키면 운영 복잡도가 급격히 커집니다. |
| 오케스트레이션/자동화 | 이벤트 기반 Binary API 클라이언트 | session eventing과 asyncio 지원으로 반응형 제어 루프 구성이 쉬워졌습니다. | 데이터 경로와 제어 경로의 재연결 실패 처리 분리가 필요합니다. |
HTTP 프로토콜과 builtin HTTP 서버
VCL/세션 레이어와 TLS/QUIC가 전송 계층의 그릇을 마련해도, 실제 애플리케이션이 주고받는 메시지는 HTTP입니다. FD.io VPP는 25.10 기준으로 src/plugins/http/(HTTP/1.1·HTTP/2·CONNECT)와 src/plugins/http_static/(정적 파일 서버) 두 플러그인으로 builtin HTTP 스택을 제공합니다. 이 절은 HTTP 프로토콜 표준(RFC 9110~9114)과 VPP가 이를 세션 레이어 위에 어떻게 얹는지를 함께 정리합니다.
HTTP 메시지 구조 — 요청/응답 라인과 헤더
HTTP/1.1 요청·응답은 텍스트 기반이며 다음 4부분으로 구성됩니다. start-line + header field* + 빈 줄(CRLF) + 선택적 message body입니다.
GET /api/v1/version HTTP/1.1
Host: vpp.example.com
User-Agent: curl/8.5.0
Accept: application/json
Connection: keep-alive
HTTP/1.1 200 OK
Server: vpp_http_static
Content-Type: application/json
Content-Length: 47
Connection: keep-alive
{"vpp":"25.10-release","build":"2025-10-30"}
HTTP/2부터는 동일한 의미를 바이너리 프레임으로 직렬화(Serialization)합니다. 메서드·경로·헤더는 HEADERS 프레임의 HPACK 블록에 담기고, 본문은 DATA 프레임으로 다중 스트림에 분산됩니다. VPP의 http2/ 구현은 RFC 9113의 SETTINGS·WINDOW_UPDATE·PRIORITY_UPDATE 프레임을 모두 지원합니다.
builtin http_static 플러그인 처리 흐름
다음 다이어그램은 클라이언트 요청이 NIC RX에서 시작해 VPP 세션 레이어를 거쳐 http_static 응답이 TX 큐로 나가기까지의 전체 경로를 정리합니다.
핵심은 요청 데이터가 SVM(Shared VM) FIFO를 통해 zero-copy로 앱에 전달된다는 점입니다. 외부 HTTP 서버라면 커널 → 사용자 공간 복사가 발생하지만, builtin 앱은 같은 워커 스레드 안에서 FIFO를 직접 읽기 때문에 컨텍스트 스위치가 0회입니다.
http_static 코드 분석 — 세션 콜백과 응답 작성
다음은 src/plugins/http_static/static_server.c의 핵심 콜백(Callback)을 단순화한 형태입니다. 실제 코드는 동기 파서·캐시·MIME 매핑까지 포함하지만, 흐름 파악에는 세 함수면 충분합니다.
/* 세션 accept: 새 연결이 들어왔을 때 호출 */
static int
hss_session_accept_callback (session_t *s)
{
http_session_t *hs;
hs = hss_session_alloc (s->thread_index);
hs->vpp_session_handle = session_handle (s);
hs->session_state = HSS_SESSION_STATE_HTTP_START;
s->opaque = hs->session_index;
s->session_state = SESSION_STATE_READY;
return 0;
}
/* RX 콜백: rx_fifo에 요청 바이트가 도착했을 때 호출 */
static int
hss_session_rx_callback (session_t *s)
{
http_session_t *hs = hss_session_get (s->opaque, s->thread_index);
u32 max_deq = svm_fifo_max_dequeue_cons (s->rx_fifo);
u8 *req = 0;
vec_validate (req, max_deq - 1);
svm_fifo_dequeue (s->rx_fifo, max_deq, req);
/* HTTP/1.1 요청 라인 파싱: "GET /path HTTP/1.1\r\n" */
if (http1_parse_request_line (req, &hs->method, &hs->path) < 0)
return hss_send_error (s, HTTP_STATUS_BAD_REQUEST);
/* path → 파일 매핑 후 응답 송신 */
return hss_serve_file (s, hs->path);
}
/* 응답 송신: tx_fifo에 enqueue → tcp_output 노드가 픽업 */
static int
hss_send_response (session_t *s, u8 *body, u32 body_len, char *ct)
{
u8 *resp = format (0,
"HTTP/1.1 200 OK\r\n"
"Server: vpp_http_static\r\n"
"Content-Type: %s\r\n"
"Content-Length: %u\r\n"
"Connection: keep-alive\r\n\r\n",
ct, body_len);
svm_fifo_enqueue (s->tx_fifo, vec_len (resp), resp);
svm_fifo_enqueue (s->tx_fifo, body_len, body);
session_send_io_evt_to_thread (s->tx_fifo, SESSION_IO_EVT_TX);
vec_free (resp);
return 0;
}
session_send_io_evt_to_thread 호출이 핵심입니다. 이 함수는 같은 워커 스레드의 이벤트 큐에 TX 이벤트를 넣고, 메인 루프가 다음 디스패치(Dispatch) 사이클에 tcp_output을 호출하게 합니다. 따라서 요청 수신 → 응답 송신이 같은 워커 안에서 큐 1회로 끝나며, 록 경합·코어 간 캐시 라인(Cache Line) 이동이 발생하지 않습니다.
실전 CLI 예시 — http_static 활성화와 부하 테스트
# 1) 플러그인 로드 확인
vppctl show plugins | grep http_static
# 2) 정적 파일 루트 등록
vppctl http static server www-root /var/www uri tcp://0.0.0.0/8080 \
cache-size 16m fifo-size 64k
# 3) 동작 확인
curl -v http://192.0.2.10:8080/index.html
# 4) wrk로 부하 측정 (단일 워커, keep-alive 100 conn)
wrk -t1 -c100 -d30s http://192.0.2.10:8080/index.html
# 5) 세션 통계 관찰
vppctl show session verbose
vppctl show http static sessions
http_static은 작은 정적 객체(≤4KB)에서 250K~400K req/s를 처리합니다(2025년 기준 FD.io CSIT 리포트). nginx 단일 워커 대비 약 3~4배입니다. 다만 이는 캐시 적중·zero-copy 전제이며, 동적 콘텐츠 생성이 들어가면 이점이 빠르게 사라집니다.
HTTP/2 멀티플렉싱과 세션 매핑
HTTP/1.1은 한 TCP 연결당 한 번에 한 요청만 처리하지만, HTTP/2는 한 연결 안에서 여러 스트림을 동시에 다중화합니다. VPP의 http2/ 구현은 각 스트림을 세션 레이어의 가상 세션으로 매핑해 기존 builtin 앱 콜백 모델을 그대로 재사용합니다.
| HTTP/2 개념 | VPP 매핑 | 구현 파일 |
|---|---|---|
| Connection | session_t (TCP/TLS 위) | http2_conn.c |
| Stream | http2_stream_t (논리 세션) | http2_stream.c |
| HEADERS frame | HPACK 디코더 → http_msg_t | http2_hpack.c |
| DATA frame | 스트림별 sub-FIFO | http2_stream.c |
| WINDOW_UPDATE | FIFO 가용 공간 신호 | http2_flow.c |
스트림 단위로 흐름 제어가 분리되어 있어, 큰 응답 1개가 작은 응답 99개를 막는 HOL(Head-of-Line) 블로킹이 완화됩니다. 다만 TCP 위 HTTP/2는 전송 계층 HOL은 여전히 남아 있고, 이를 제거하려면 QUIC 위 HTTP/3로 가야 합니다(이 장 후반의 QUIC 절 참고).
Common Pitfalls
- Content-Length 누락 — http_static의 helper를 거치지 않고 직접 응답 문자열을 만들 때 자주 누락됩니다. HTTP/1.1은 길이 지정이 없으면 연결 종료까지 본문으로 간주해 keep-alive가 깨집니다.
- FIFO 크기 미설정 — 응답이
fifo-size보다 크면svm_fifo_enqueue가 부분 enqueue로 끝나고SESSION_IO_EVT_TX를 여러 번 보내야 합니다. 큰 파일을 자주 서빙한다면fifo-size 256k이상 권장합니다. - HPACK 동적 테이블 폭발 — HTTP/2 클라이언트가 헤더를 무한정 갱신하면 메모리를 잠식할 수 있습니다.
SETTINGS_HEADER_TABLE_SIZE를 4KB 정도로 묶어 두는 편이 안전합니다. - builtin 앱에서 블로킹 호출 금지 — RX 콜백은 워커 스레드에서 직접 실행되므로 파일 I/O·뮤텍스(Mutex)·sleep을 걸면 그 워커가 처리 중인 모든 다른 세션이 멈춥니다. 디스크 I/O가 필요하면 별도 스레드로 위임해야 합니다.
WebSocket — HTTP/1.1 Upgrade와 프레임 처리
HTTP는 본래 요청-응답 한 쌍을 주고받는 단방향 모델입니다. 그러나 채팅·실시간(Real-time) 알림·금융 시세·게임 같은 시나리오는 서버가 자발적으로 클라이언트에 메시지를 밀어 보내야 합니다. WebSocket(RFC 6455)은 이 요구를 충족하기 위해 처음에는 일반 HTTP/1.1 요청으로 시작했다가 같은 TCP 연결을 양방향 메시지 채널로 전환하는 프로토콜입니다. FD.io VPP는 builtin HTTP 스택 위에서 Upgrade 핸드셰이크와 WebSocket 프레임 파서를 직접 구현할 수 있는 빌딩 블록을 제공합니다.
Upgrade 핸드셰이크 — Sec-WebSocket-Key/Accept
WebSocket 핸드셰이크는 평범한 HTTP/1.1 GET 요청처럼 시작하지만, 두 가지 헤더가 추가됩니다. Upgrade: websocket과 Connection: Upgrade입니다. 서버는 응답으로 101 Switching Protocols를 반환하고, 그 시점부터 같은 소켓의 데이터는 HTTP 메시지가 아니라 WebSocket 프레임으로 해석됩니다.
GET /chat HTTP/1.1
Host: ws.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v2
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat.v2
핵심은 Sec-WebSocket-Accept 계산입니다. 서버는 클라이언트가 보낸 Sec-WebSocket-Key에 고정 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)를 이어 붙이고, 그 결과를 SHA-1로 해싱한 뒤 base64 인코딩합니다. 이 한 단계가 임의의 HTTP 클라이언트가 WebSocket 서버에 대한 우연한 재해석 공격을 일으키지 못하게 막는 안전장치입니다.
WebSocket 프레임 구조
핸드셰이크 이후의 데이터는 다음과 같은 가변 길이 프레임의 연속입니다.
VPP builtin 에코 서버 구현
/* Sec-WebSocket-Accept 계산 */
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
static u8 *
ws_compute_accept (u8 *client_key)
{
u8 buf[64];
u32 n = clib_min (vec_len (client_key), 24u);
clib_memcpy (buf, client_key, n);
clib_memcpy (buf + n, WS_GUID, sizeof (WS_GUID) - 1);
u8 sha1[20];
SHA1 (buf, n + sizeof (WS_GUID) - 1, sha1);
return format (0, "%U", format_base64, sha1, 20);
}
/* Upgrade 요청 처리: HTTP 200이 아닌 101 응답 송신 */
static int
ws_handle_upgrade (session_t *s, http_msg_t *msg)
{
u8 *key = http_msg_header_get (msg, "Sec-WebSocket-Key");
if (!key) return ws_send_status (s, 400, "Missing key");
u8 *accept = ws_compute_accept (key);
u8 *resp = format (0,
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: %v\r\n\r\n", accept);
svm_fifo_enqueue (s->tx_fifo, vec_len (resp), resp);
session_send_io_evt_to_thread (s->tx_fifo, SESSION_IO_EVT_TX);
/* 세션 상태를 WS 모드로 전환 */
ws_session_t *ws = ws_session_get (s->opaque);
ws->state = WS_STATE_OPEN;
vec_free (resp); vec_free (accept);
return 0;
}
/* WS 프레임 파서 + 에코 송신 */
static int
ws_rx_callback (session_t *s)
{
ws_session_t *ws = ws_session_get (s->opaque);
if (ws->state != WS_STATE_OPEN)
return ws_handle_upgrade_path (s);
u8 hdr[14];
u32 deq = svm_fifo_peek (s->rx_fifo, 0, sizeof hdr, hdr);
if (deq < 2) return 0;
u8 fin = (hdr[0] & 0x80) >> 7;
u8 opcode = hdr[0] & 0x0F;
u8 masked = (hdr[1] & 0x80) >> 7;
u64 plen = hdr[1] & 0x7F;
u32 off = 2;
if (plen == 126) {
plen = (hdr[2] << 8) | hdr[3]; off = 4;
} else if (plen == 127) {
plen = clib_net_to_host_u64 (*(u64 *) &hdr[2]); off = 10;
}
u8 mkey[4] = {0};
if (masked) { clib_memcpy (mkey, &hdr[off], 4); off += 4; }
if (svm_fifo_max_dequeue_cons (s->rx_fifo) < off + plen)
return 0; /* 전체 프레임 미수신 */
/* 헤더 소비 */
svm_fifo_dequeue_drop (s->rx_fifo, off);
u8 *payload = 0; vec_validate (payload, plen - 1);
svm_fifo_dequeue (s->rx_fifo, plen, payload);
if (masked)
for (u64 i = 0; i < plen; i++) payload[i] ^= mkey[i & 3];
if (opcode == 0x8) /* close */
return ws_send_close (s, 1000);
if (opcode == 0x9) /* ping */
return ws_send_frame (s, 0xA, payload, plen);
/* text/binary: 그대로 echo (서버→클라이언트는 mask 없음) */
return ws_send_frame (s, opcode, payload, plen);
}
Common Pitfalls
- 마스킹 미적용 클라이언트 수용 — RFC 6455는 클라이언트 프레임이 반드시 마스킹되어야 한다고 못 박습니다.
masked=0클라이언트 프레임은 즉시 1002(protocol error)로 닫아야 합니다. 그렇지 않으면 캐싱 프록시·방화벽(Firewall)이 응답을 잘못 해석하는 캐시 오염 공격에 노출됩니다. - Origin 검증 누락 — 핸드셰이크는 일반 HTTP이므로 브라우저는 cross-origin도 허용합니다. CSRF 방지를 위해
Origin헤더를 화이트리스트 검증해야 합니다. - 큰 메시지 분할 미처리 — FIN=0인 continuation 프레임이 들어오면 메시지 전체가 도착할 때까지 버퍼링해야 하지만, 상한 없는 버퍼링은 OOM을 부릅니다. 메시지 최대 크기를 정책으로 강제해야 합니다.
- Ping 응답 누락 — 일정 시간 동안 ping에 대한 pong 응답이 없으면 연결을 끊어야 합니다. 그렇지 않으면 NAT 타임아웃으로 사일런트하게 죽은 세션이 누적됩니다.
gRPC와 HTTP/2 프레임·흐름 제어 상세
gRPC는 Google이 내놓은 RPC 프레임워크이며, 전송 계층으로 HTTP/2를 그대로 사용합니다. gRPC가 매력적인 이유는 단순한 단발성 호출(unary)뿐 아니라 서버 스트리밍·클라이언트 스트리밍·양방향 스트리밍 네 가지 통신 모드를 모두 같은 연결 위에서 다중화할 수 있다는 점입니다. 이 절은 HTTP/2 프레임 레벨에서 gRPC가 어떻게 매핑되는지, 그리고 FD.io VPP의 builtin HTTP/2 스택이 흐름 제어를 어떻게 처리하는지 함께 정리합니다.
HTTP/2 프레임 종류와 역할
| 프레임 | 코드 | 목적 | gRPC 사용 |
|---|---|---|---|
| DATA | 0x0 | 스트림 본문 데이터 | gRPC 메시지 본문 |
| HEADERS | 0x1 | HTTP 헤더 (HPACK 압축) | :method/:path/grpc-encoding |
| PRIORITY | 0x2 | 스트림 의존성 트리 | 거의 미사용 (RFC 9218 PRIORITY_UPDATE로 대체) |
| RST_STREAM | 0x3 | 스트림 강제 종료 | gRPC CANCELLED 매핑 |
| SETTINGS | 0x4 | 연결 파라미터 협상 | MAX_CONCURRENT_STREAMS 등 |
| PUSH_PROMISE | 0x5 | 서버 푸시 | gRPC에서는 미사용 |
| PING | 0x6 | RTT 측정·keep-alive | gRPC keepalive |
| GOAWAY | 0x7 | 연결 graceful 종료 | graceful shutdown |
| WINDOW_UPDATE | 0x8 | 흐름 제어 윈도 증가 | 큰 응답 backpressure |
| CONTINUATION | 0x9 | 큰 헤더 블록 분할 | 드물게 사용 |
gRPC over HTTP/2 매핑
gRPC 호출 1개는 HTTP/2 스트림 1개에 정확히 매핑됩니다. 다음은 unary 호출의 프레임 시퀀스입니다.
Client Server
|-- HEADERS (END_HEADERS) --------------->| :method = POST
| | :path = /pkg.Service/Method
| | content-type = application/grpc+proto
| | grpc-timeout = 1S
| | grpc-encoding = gzip
| |
|-- DATA (END_STREAM) ------------------->| [1byte compressed flag]
| | [4byte big-endian length]
| | [N byte protobuf payload]
| |
|<-- HEADERS (END_HEADERS) ---------------| :status = 200
| | content-type = application/grpc+proto
| |
|<-- DATA --------------------------------| [응답 메시지 프레이밍]
| |
|<-- HEADERS (END_STREAM, END_HEADERS) ---| grpc-status = 0 (OK)
| | grpc-message = ""
핵심은 두 가지입니다. ① 응답이 두 번의 HEADERS 프레임으로 둘러싸인다는 점(첫 HEADERS는 HTTP 응답 헤더, 마지막 HEADERS는 gRPC trailer로 상태 코드를 담음). ② DATA 프레임 안에 다시 gRPC 자체의 5byte 길이 프리픽스가 있다는 점. 한 DATA 프레임에 여러 gRPC 메시지가 들어갈 수도 있고, 한 gRPC 메시지가 여러 DATA 프레임에 걸칠 수도 있습니다.
2단계 흐름 제어 — 연결 윈도 vs 스트림 윈도
HTTP/2의 흐름 제어는 두 층으로 나뉩니다. 송신자는 두 윈도 중 작은 쪽을 따릅니다.
VPP의 WINDOW_UPDATE 자동화
VPP의 http2_flow.c는 svm_fifo의 dequeue가 진행될 때마다 회복된 공간을 누적해서, 임계치(보통 윈도 절반)에 도달하면 WINDOW_UPDATE를 한꺼번에 송신합니다. 매 byte마다 보내면 프레임 오버헤드가 폭발하므로, 배치가 핵심입니다.
/* 응용이 svm_fifo에서 데이터를 읽은 뒤 호출 */
void
http2_stream_consumed (http2_stream_t *st, u32 n_bytes)
{
st->recv_window_consumed += n_bytes;
st->conn->recv_window_consumed += n_bytes;
/* 스트림 윈도 회복 */
if (st->recv_window_consumed >= st->recv_window_initial / 2) {
http2_send_window_update (st->conn, st->stream_id,
st->recv_window_consumed);
st->recv_window_consumed = 0;
}
/* 연결 윈도 회복 */
if (st->conn->recv_window_consumed >= st->conn->recv_window_initial / 2) {
http2_send_window_update (st->conn, 0 /* connection */,
st->conn->recv_window_consumed);
st->conn->recv_window_consumed = 0;
}
}
gRPC keepalive와 PING 폭주 방지
gRPC 클라이언트는 죽은 연결을 빨리 감지하려고 짧은 주기로 PING을 보냅니다. 그러나 너무 잦은 PING은 서버 입장에서 DDoS와 구별이 어렵고, RFC 9113의 flood detection에 걸려 GOAWAY로 끊깁니다. VPP는 다음 두 카운터로 이를 방어합니다.
SETTINGS_ENABLE_PING (gRPC 확장)— 서버가 허용한 최소 PING 간격ping_strikes— 최소 간격보다 짧은 PING이 들어올 때마다 +1, 임계 도달 시 GOAWAY ENHANCE_YOUR_CALM
Common Pitfalls
- 스트림 윈도만 회복하고 연결 윈도 누락 — 스트림 32개가 각자 윈도를 회복해도, 연결 윈도가 회복되지 않으면 모든 스트림이 동시에 막힙니다. 항상 두 윈도를 함께 갱신해야 합니다.
- 큰 메시지에 작은 윈도 — 기본 65535 byte 윈도로 100MB 응답을 스트리밍하면 매 64KB마다 RTT 1회의 대기가 끼어 처리량이 BDP에 못 미칩니다.
SETTINGS_INITIAL_WINDOW_SIZE를 BDP × 2 이상으로 설정해야 합니다. - gRPC trailer 누락 — 응답 본문만 보내고 trailer HEADERS를 보내지 않으면 클라이언트는 grpc-status를 받지 못해 영원히 대기합니다. 모든 응답은 반드시 trailer로 마무리해야 합니다.
- RST_STREAM 폭주 — 클라이언트가 짧은 시간에 수많은 스트림을 열고 즉시 RST_STREAM으로 닫으면(2023년 HTTP/2 Rapid Reset CVE-2023-44487), 서버 자원이 고갈됩니다. RST_STREAM 율 제한이 필수입니다.
- HPACK 동적 테이블 시그널 누락 — 클라이언트와 서버의 동적 테이블 크기가 어긋나면 디코딩이 어긋나 프로토콜 에러가 발생합니다. SETTINGS_HEADER_TABLE_SIZE 변경 시 반드시 HPACK 테이블 크기 갱신 신호(동적 테이블 크기 업데이트)를 헤더 블록에 포함해야 합니다.
Host Stack 실전 운영 패턴
앞 절들이 HTTP/2, WebSocket, gRPC의 프로토콜 동작과 builtin 서버 구조를 정리했다면, 이 절은 해당 기능을 프로덕션에서 돌릴 때 마주치는 실전 이슈와 패턴에 집중합니다. 증상은 대부분 단일 레이어가 아니라 세션 레이어 · HTTP 파서 · 워커 스레드 · 커넥션 상태 머신이 맞물리는 지점에서 나타납니다.
HTTP/2 HEADERS 프레임 파싱 오류 케이스
HTTP/2의 HEADERS/CONTINUATION 프레임은 HPACK 동적 테이블과 연결 상태가 맞물려 있어 파싱 실패가 쉽게 전 연결을 죽입니다. VPP builtin HTTP/2 서버에서 자주 관찰되는 실패 패턴:
| 증상 | 루트 원인 | 확인 방법 | 조치 |
|---|---|---|---|
COMPRESSION_ERROR (0x9) 다음 연결 종료 |
HPACK 동적 테이블 불일치 — 클라이언트가 DYNAMIC_TABLE_SIZE_UPDATE 없이 크기 변경 | show http2 session <sid> hpack으로 인코더/디코더 테이블 크기 비교 |
SETTINGS_HEADER_TABLE_SIZE를 양쪽에서 합의한 값으로 고정. 가능하면 4096 기본값 유지. |
PROTOCOL_ERROR (0x1) — CONTINUATION 경계 깨짐 |
HEADERS/CONTINUATION 사이에 다른 스트림 프레임 삽입 — 규격 위반 클라이언트 | vppctl trace add http2-input 20 후 프레임 순서 확인 |
서버 측에서는 규격대로 GOAWAY. 클라이언트 수정 필요. |
특정 스트림만 STREAM_CLOSED (0x5) |
이미 half-closed(local) 상태에서 DATA 수신 — 애플리케이션 로직이 END_STREAM을 잘못 보냄 | HTTP 핸들러에서 vcl_session_free 직전 END_STREAM 플래그 로깅 |
스트림 상태 머신 전이를 h2 표준대로 수정. 미완료 송신 버퍼를 버리지 말 것. |
연결 초기 ENHANCE_YOUR_CALM (0xb) |
SETTINGS 프레임 지연 — 서버가 클라이언트의 INITIAL SETTINGS를 받기 전 요청 처리 | preface 수신 타임스탬프와 첫 HEADERS 타임스탬프 차이 측정 | builtin 서버의 preface 대기 타임아웃을 default 5초에서 10초로 확장. |
RST_STREAM은 해당 스트림만 죽이지만, GOAWAY는 연결 전체 종료입니다. VPP 서버가 GOAWAY를 보낼 때 last_stream_id 이후의 스트림은 모두 취소됨을 클라이언트가 가정해야 합니다. 로드 밸런서가 GOAWAY를 다른 클라이언트로 격리 전파하지 않게 주의하십시오.
WebSocket ping/pong · close frame 상태 머신
WebSocket은 HTTP/1.1 Upgrade로 진입한 뒤에는 단순한 프레임 스트림처럼 보이지만, 실제로는 5개 상태(CONNECTING · OPEN · CLOSING · CLOSED · CLOSED_AFTER_TIMEOUT)의 상태 머신이며 양방향 close 핸드셰이크를 요구합니다. VPP builtin WebSocket 구현에서 운영상 중요한 항목:
- PING/PONG 타이머: 서버가
ws ping-interval(기본 30초)마다 PING 프레임을 보내고,ws pong-timeout(기본 10초) 안에 PONG을 받지 못하면 keepalive 실패로 연결 종료. NAT 유휴 타임아웃이 30초 이하인 환경에서는 ping-interval을 15~20초로 낮춰야 합니다. - CLOSE 핸드셰이크: 한쪽이 Close 프레임(opcode 0x8)을 보내면 상대가 echo해야 깨끗이 종료됩니다. 상대가 echo를 생략하면
ws-close-wait-timeout(기본 5초) 후 강제 종료. 이 경우 RST 패킷으로 종료되어 네트워크 장비 로그에 오류로 기록될 수 있습니다. - 프레임 단편화: 큰 메시지는 여러 프레임(FIN=0)으로 쪼개지는데, 중간 프레임이 손실되면 전체 메시지가 버려집니다. VPP 세션 레이어의 rx-buffer 크기와 메시지 최대 크기(
ws max-message-size) 가 맞지 않으면1009 Message Too Bigclose가 발생합니다. - compression (permessage-deflate): Sec-WebSocket-Extensions로 협상되지만 VPP 25.x 기준 builtin은 미지원. 클라이언트가 요청해도 서버가 거절해야 하며, 잘못하면 압축된 프레임을 raw로 해석해 깨진 페이로드가 애플리케이션에 전달됩니다.
gRPC trailer/metadata 처리 제약
gRPC는 HTTP/2 위에 존재하지만 Trailer를 필수로 사용합니다. 표준 HTTP 라이브러리가 Trailer를 잘 다루지 않는 반면, gRPC는 모든 응답의 상태 코드(grpc-status)와 에러 메시지(grpc-message)를 Trailer로 전달합니다. VPP builtin HTTP/2가 gRPC 트래픽을 서빙하거나 프록시할 때 주의할 점:
- Trailer는 마지막 DATA 프레임 뒤 HEADERS(END_STREAM) 로 전송 — 파서가 Trailer 블록을 누락하면 클라이언트는
grpc-status: 13 (INTERNAL)로 해석합니다.show http2 stream <sid>에서trailers_sent플래그를 확인하십시오. - grpc-timeout 메타데이터: 클라이언트가
grpc-timeout: 5S같은 값을 헤더로 보내면 서버가 타임아웃 범위 안에서만 작업을 수행해야 합니다. VPP는 이 값을 자동으로 세션 데드라인에 매핑하지 않으므로 애플리케이션 핸들러가 직접 파싱해야 합니다. - 메시지 프레이밍: gRPC 메시지는 5바이트 헤더(1바이트 압축 플래그 + 4바이트 길이) + 페이로드 형태입니다. 이 framing 레벨은 HTTP/2 DATA 프레임과 독립적이므로 DATA 프레임 경계와 메시지 경계가 일치하지 않을 수 있습니다. 파셜 메시지를 바이트 단위로 버퍼링하는 로직이 필요합니다.
- Bi-directional streaming: 양방향 스트리밍 RPC는 HTTP/2 스트림의 양쪽이 모두 half-closed 되어야 종료됩니다. VPP 세션 레이어의
SESSION_CLOSE이벤트가 한 방향만 오기 때문에 애플리케이션이 반대 방향 close를 명시적으로 처리해야 합니다.
워커 분산과 세션 어피니티
VPP 멀티 워커 환경에서 HTTP/2 연결은 특정 워커에 고정됩니다. 그러나 TCP 레벨 RSS가 잘못 설정되면 같은 연결의 TCP 패킷이 여러 워커로 분산되어 세션 레이어가 혼란에 빠집니다. 운영 체크리스트:
- NIC RSS 해시: Toeplitz 해시가 5튜플(src/dst IP, protocol, src/dst port)을 사용하는지 확인.
show interface rss <if>에서 해시 알고리즘과 indirection table을 점검. - 세션 마이그레이션: 세션 워커 이동은 비싸므로 기본 비활성. 특정 워커에 HTTP/2 연결이 몰리면
show session worker에서 큐 길이 편향 확인. 50% 이상 편향 시 연결 수용 비율을 워커별로 조정. - HTTP/2 내부 스트림 분산: 한 연결 안의 여러 스트림은 같은 워커에서 처리됩니다. 스트림 수가 수백을 넘어가면 단일 워커에 CPU 부하가 집중되므로, 클라이언트 측에서
MAX_CONCURRENT_STREAMS를 100~200 수준으로 제한하는 것이 안전합니다.
Host Stack 트러블슈팅
지금까지의 절들이 정상 동작 경로를 다뤘다면, 이 절은 운영 중에 자주 만나는 문제 패턴을 진단 → 가설 → 조치 순으로 정리합니다. 모든 항목은 vppctl에서 즉시 재현 가능한 명령에 기반합니다.
show session / show tcp 출력 해석
show session verbose는 세션 레이어의 단일 진입점입니다. 출력의 각 컬럼이 어느 계층의 상태인지 알면 90%의 문제는 이 명령 한 번으로 좁혀집니다.
| 컬럼 | 의미 | 이상 신호 |
|---|---|---|
State | 세션 레이어 상태(LISTEN/READY/CLOSED_WAITING/TRANSPORT_CLOSING) | CLOSED_WAITING 누적 → 앱이 close를 빠뜨림 |
Rx fifo / Tx fifo | 현재 사용 중인 FIFO 크기 | Tx가 항상 가득 → 다운스트림 정체(backpressure) |
Transport | TCP/QUIC/TLS 등 하위 전송 | TLS인데 핸드셰이크 오래 → crypto engine 확인 |
Worker | 이 세션을 담당하는 워커 ID | 한 워커에 80% 이상 집중 → RSS/세션 어피니티 점검 |
# TCP 전용 상세 — 윈도, 재전송, RTT
vpp# show tcp scoreboard trace
vpp# show session verbose 3 | grep -E "TCP|RTT|cwnd"
# half-open 테이블 — connect 폭증 시 가득 차면 신규 연결 거부
vpp# show session half-open
# 워커별 분포 — 편향 측정
vpp# show session worker
증상: TLS CPS가 기대치보다 낮음
"하드웨어는 충분한데 신규 연결이 초당 200개를 넘지 못한다"는 전형적인 TLS 핸드셰이크 병목입니다. 데이터 전송이 아닌 연결 수립 자체가 막혀 있는 상황입니다.
show runtime에서tls-async-process·openssl-process·crypto-dispatch노드의 Clocks/Call을 확인합니다. 한 노드가 압도적으로 높으면 그 단계가 병목입니다.show crypto engines로 활성 엔진(native/openssl/ipsecmb/cryptodev)을 확인합니다. RSA-2048 핸드셰이크는 native에선 매우 느립니다 — QAT나 cryptodev 오프로드가 필요합니다.- 인증서 키 알고리즘을 점검합니다. RSA-2048 → ECDSA-P256 전환만으로도 핸드셰이크 비용이 5~10배 감소합니다.
- 세션 재개(Session Resumption / TLS 1.3 0-RTT)가 활성화되어 있는지 확인합니다. 매 연결이 풀 핸드셰이크면 캐시·티켓 설정을 점검합니다.
show session half-open의 카운터가 천장에 닿아 있다면preallocated-half-open-sessions값을 startup.conf에서 늘립니다.
증상: 세션이 누수되어 메모리·포트가 고갈됨
show session verbose에 좀비 세션이 시간이 지나도 사라지지 않으면 누수입니다. 보통 half-closed 상태에서 한쪽이 close를 호출하지 않아 발생합니다.
show session verbose | grep CLOSED_WAITING갯수가 시간에 따라 단조 증가하면 애플리케이션 누수가 확정적입니다.vppcom_session_close호출 누락을 의심합니다.- HTTP/2의 경우 한 방향
END_STREAM만 보내고 반대 방향 close를 누락한 케이스가 흔합니다. gRPC trailer/metadata 절을 함께 확인합니다. - FIN_WAIT_2가 오래 남는다면 상대측이 마지막 ACK를 보내지 않는 것 — 미들박스(NAT/방화벽) 타임아웃 정렬 문제일 수 있습니다.
- 임시 회피로
session_event_queue_length·tcp time-wait timeout을 줄이되, 근본 원인은 애플리케이션 코드입니다.
증상: 워커 부하가 한쪽으로 쏠림
특정 워커만 100% CPU를 쓰고 다른 워커는 idle인 경우입니다. 데이터 평면 RSS 편향과 세션 레이어 어피니티 두 가지 원인이 있을 수 있습니다.
# (1) 데이터 평면 — 큐가 한 워커에 몰렸는가
vpp# show interface rx-placement
# (2) 세션 평면 — 세션이 한 워커에 집중되었는가
vpp# show session worker
# (3) RSS 해시 키와 타입
vpp# show hardware-interfaces detail | grep -i rss
큐는 균등한데 세션이 편향이라면, 단일 클라이언트 IP가 다수 연결을 만드는 시나리오(로드테스터, NAT 뒤 게이트웨이)일 가능성이 큽니다. RSS는 5-tuple 기반이므로 src IP 다양성이 낮으면 같은 큐로 몰립니다. 4-tuple 해시 또는 Flow Director로 분산하거나, 클라이언트 측에서 source port 다양화를 강제합니다. 세부 운영 절차는 운영 — RX 큐 → 워커 배치를 참고합니다.
HSI · SRTP · Caching DNS — 호스트 스택 확장
앞 절까지가 TCP/TLS/HTTP/QUIC 같은 표준 프로토콜 구현이었다면, 이 절은 호스트 스택 주변의 보조 기능 세 가지를 묶어 정리합니다. 모두 플러그인으로 제공되며, 언제 어떤 용도로 쓰는지에 초점을 둡니다.
HSI — Host Stack Intercept
HSI(Host Stack Intercept)는 VPP 데이터 평면을 지나가는 TCP/UDP 플로우를 호스트 스택으로 가로채는 메커니즘입니다. 일반 포워딩 경로에 있던 트래픽을 특정 5튜플·SNI·플로우 패턴에 따라 세션 레이어로 빼내어, builtin HTTP 서버나 TLS 종단 같은 L7 처리를 수행한 뒤 다시 포워딩으로 돌려보낼 수 있습니다.
vpp# hsi enable <if>
vpp# hsi match proto tcp dst-port 443 sni "*.example.com" action intercept
vpp# show hsi
HSI는 TPROXY(커널 소켓 옵션 기반)와 달리 VPP 세션 레이어 내부에서 동작하므로 VCL 앱이 일반 소켓처럼 인터셉트된 세션을 받을 수 있습니다. SSL Inspection과 결합하면 강력한 투명 검사 파이프라인을 구성할 수 있습니다.
SRTP — Secure Real-time Transport Protocol
SRTP(RFC 3711)는 VoIP·WebRTC·비디오 스트리밍의 미디어 암호화 프로토콜입니다. VPP의 srtp 플러그인은 세션 레이어에 SRTP 종단 모듈을 추가해, WebRTC 미디어 서버를 호스트 스택 위에서 구현할 수 있게 합니다.
vpp# srtp set master-key <base64> crypto-suite aes-cm-128-hmac-sha1-80
vpp# show srtp sessions
SRTP는 DTLS-SRTP(RFC 5764) 조합으로 키 교환을 수행하므로, 일반적으로 dtls 플러그인(VPP TLS 엔진의 DTLS 변형)과 함께 사용됩니다. 미디어 서버·SFU·MCU 같은 WebRTC 인프라를 VPP로 구축할 때 핵심 구성 요소입니다.
Caching DNS Resolver
VPP의 dns 플러그인은 간단한 캐싱 재귀 리졸버를 구현합니다. 대규모 네트워크에서 DNS 트래픽을 VPP 자체에서 캐싱해 외부 리졸버 부하를 줄이거나, 로컬 DNS fast path로 지연을 낮출 수 있습니다.
vpp# dns cache size 10000
vpp# dns upstream-server 8.8.8.8
vpp# dns upstream-server 1.1.1.1
vpp# dns enable
vpp# show dns cache verbose
제한 사항: full-blown BIND/Unbound만큼 완전한 리졸버는 아니며, DNSSEC 검증·고급 zone 기능은 없습니다. 캐시 워밍과 단순 포워딩에 적합합니다.
Static HTTP 서버 — JSON stats 엔드포인트
Static HTTP 서버(http_static 플러그인)에는 외부에서 Stats Segment를 JSON으로 가져갈 수 있는 간단한 REST 엔드포인트 기능이 내장되어 있습니다. Prometheus가 아닌 아주 가벼운 HTTP 질의로 카운터를 읽고 싶을 때 유용합니다.
vpp# http static server www-root /var/www uri tcp://0.0.0.0/80 cache-size 5m
vpp# http static enable-stats-endpoint
vpp# show http static
$ curl http://vpp-host/stats/if | jq .
{
"GigabitEthernet0/0/0": {
"rx_packets": 123456,
"rx_bytes": 98765432,
...
}
}
SVM fifo — OOO 세그먼트 트리 내부
VPP 세션 레이어의 데이터 전달은 커널처럼 스켑버퍼 큐를 쓰지 않고, 공유 메모리에 놓인 SVM fifo 링 버퍼를 통해 이뤄집니다. 단일 생산자·단일 소비자(SPSC) 가정 하에 락 없이 동작하며, TCP가 요구하는 순서 어긋난(out-of-order) 세그먼트 재조립도 이 fifo 내부에서 직접 처리됩니다.
레이아웃과 기본 상태머신
svm_fifo_t는 헤드(소비자 커서)와 테일(생산자 커서) 두 개의 오프셋으로 원형 바이트 버퍼를 표현합니다. 그러나 단순 링버퍼와 다른 점은 테일 이후에도 미리 데이터를 써두는 경로(OOO enqueue)를 허용한다는 것입니다. 이를 위해 ooo_segment_t라는 작은 메타데이터 노드를 RB(Red-Black) 트리로 관리합니다.
/* src/svm/svm_fifo.h 축약 */
typedef struct ooo_segment_ {
u32 start; /* fifo 내 절대 오프셋 (tail 기준) */
u32 length; /* 이 OOO 세그먼트의 바이트 길이 */
u32 prev; /* 이웃 리스트용 */
u32 next;
} ooo_segment_t;
typedef struct svm_fifo_ {
CLIB_CACHE_LINE_ALIGN_MARK(shared_first);
u32 size;
u32 nitems;
u32 flags;
svm_fifo_chunk_t *start_chunk;
svm_fifo_chunk_t *end_chunk;
svm_fifo_chunk_t *new_chunks;
CLIB_CACHE_LINE_ALIGN_MARK(producer);
u32 tail; /* 생산자 커서 */
u32 ooos_list_head; /* 연결된 OOO 리스트 헤드 */
rb_tree_t ooo_enq_lookup; /* 오프셋으로 OOO 조회 */
ooo_segment_t *ooo_segments; /* pool */
u32 ooos_newest;
CLIB_CACHE_LINE_ALIGN_MARK(consumer);
u32 head; /* 소비자 커서 */
u32 deq_thresh;
rb_tree_t ooo_deq_lookup;
} svm_fifo_t;
생산자 영역(producer 마크 아래)과 소비자 영역(consumer 마크 아래)은 별도 캐시라인에 배치되어 false sharing을 피합니다. OOO 관련 자료구조는 생산자 쪽에만 있고, 소비자는 단지 head와 tail의 차이만 보면 됩니다. OOO 세그먼트가 모두 메워져 테일이 앞으로 이동하면 소비자 관점에서는 그냥 링버퍼에 바이트가 추가된 것으로 보입니다.
enqueue_with_offset — OOO 삽입 알고리즘
TCP 수신 경로에서 세그먼트가 도착하면 세션 레이어는 svm_fifo_enqueue_with_offset(f, offset, len, data)를 호출합니다. 여기서 offset은 "현재 tail 기준 상대 오프셋"입니다. 즉 offset이 0이면 테일 바로 다음부터 바이트를 쓰고 즉시 tail을 전진시킵니다(in-order fast path). offset이 양수면 아래 단계를 거칩니다.
/* svm_fifo_enqueue_with_offset 의사 코드 */
int
svm_fifo_enqueue_with_offset(svm_fifo_t *f, u32 offset,
u32 len, u8 *src)
{
u32 tail = f->tail;
u32 start = tail + offset;
u32 end = start + len;
/* 1) 버퍼 가득? */
if ((offset + len) > f_free_count(f, tail))
return SVM_FIFO_EFULL;
/* 2) 먼저 바이트를 원형 버퍼에 복사 (offset 위치에 직접 쓰기) */
svm_fifo_copy_to_chunk(f, f_head_cptr(f) + offset, src, len);
/* 3) OOO 트리에 [start, end) 삽입 + 인접 병합 */
ooo_segment_add(f, offset, f_head_cptr(f), len);
/* 4) 새 세그먼트가 tail(offset==0)을 건드린다면 tail 전진 */
if (offset == 0)
return ooo_segment_try_collect(f, len);
return len;
}
ooo_segment_add가 핵심입니다. 이 함수는 RB 트리에서 시작 오프셋으로 인접 세그먼트를 찾아 세 가지 경우 중 하나를 처리합니다.
- 완전 분리 — 양옆 어떤 세그먼트와도 겹치지 않음. 새
ooo_segment_t를 pool에서 할당하고 트리에 삽입. - 왼쪽 이웃과 접함/겹침 — 기존 세그먼트의
length를 확장. 새 오른쪽 경계가 그다음 세그먼트와도 닿으면 연속 병합. - 오른쪽 이웃과 접함/겹침 — 기존 세그먼트의
start를 앞당기고length를 늘림.
RB 트리로 O(log n) 조회·병합을 보장하지만, VPP는 여기에 추가로 ooos_newest 힌트를 캐싱하여 같은 연결에서 연속 도착하는 세그먼트가 대부분 같은 이웃에 병합된다는 점을 활용합니다. 힌트를 먼저 확인하여 트리 탐색을 건너뛰는 경우가 실측 트래픽에서 압도적입니다.
ooo_segment_try_collect — 갭 메움
tail 바로 앞에 갭이 메워지면(offset==0 enqueue가 들어옴) ooo_segment_try_collect가 호출되어 tail에 인접한 OOO 세그먼트들을 병합하며 tail을 전진시킵니다.
int
ooo_segment_try_collect(svm_fifo_t *f, u32 n_bytes_enqueued)
{
ooo_segment_t *s;
u32 bytes = n_bytes_enqueued;
u32 diff;
/* 인접한 OOO 세그먼트가 있으면 반복적으로 흡수 */
while ((s = ooo_segment_first(f))) {
diff = (f->tail + bytes) - s->start;
if (f_pos_lt(diff, 0))
break; /* 여전히 갭 있음 */
/* tail이 세그먼트 시작을 넘거나 닿음 → 흡수 */
if (diff < s->length)
bytes += s->length - diff;
/* 트리·풀에서 제거 */
rb_tree_del(&f->ooo_enq_lookup, s->start);
ooo_segment_free(f, s);
}
/* 원자적으로 tail 업데이트 → 소비자가 새 바이트를 봄 */
clib_atomic_store_rel_n(&f->tail, f->tail + bytes);
return bytes;
}
tail 업데이트는 반드시 release 순서의 원자 저장입니다. 이 저장 이전의 데이터 쓰기가 소비자에게 모두 보이도록 보장해야 합니다. 그렇지 않으면 소비자가 "tail은 전진했는데 읽은 바이트가 쓰레기"인 상황을 만날 수 있습니다. 이것이 SVM fifo가 락 없이도 SPSC 안전을 유지하는 핵심 동기화 지점입니다.
TCP와의 결합
TCP 스택(tcp_input.c)은 세그먼트 수신 시 tcp_session_enqueue_data를 호출합니다. 여기서 수신 바이트가 기대 시퀀스(rcv_nxt)와 같으면 일반 svm_fifo_enqueue로 in-order 처리, 미래 바이트면 svm_fifo_enqueue_with_offset으로 OOO 처리가 일어납니다.
/* src/vnet/tcp/tcp_input.c 축약 */
static int
tcp_session_enqueue_data(tcp_connection_t *tc, vlib_buffer_t *b,
u32 data_len)
{
int written, error = TCP_ERROR_ENQUEUED;
if (tc->rcv_nxt == vnet_buffer(b)->tcp.seq_number) {
/* In-order: 바로 enqueue */
written = session_enqueue_stream_connection(
&tc->connection, b, 0 /* offset */, 1 /* queue event */, 1);
if (written < 0) {
error = TCP_ERROR_FIFO_FULL;
} else {
tc->rcv_nxt += written;
/* OOO 트리에 채워진 바이트가 있으면 여기서 추가 전진 */
}
} else {
/* OOO: tail 상대 오프셋 계산 */
u32 offset = vnet_buffer(b)->tcp.seq_number - tc->rcv_nxt;
written = session_enqueue_stream_connection(
&tc->connection, b, offset, 0 /* no event yet */, 0);
if (written > 0) {
tc->sack_sb.last_bytes_delivered += written;
error = TCP_ERROR_ENQUEUED_OOO;
/* 후속 ACK에서 SACK 블록 리포트 */
}
}
return error;
}
중요한 부분은 OOO enqueue 시 queue event를 전달하지 않는다는 것입니다. 앱에게 RX 이벤트를 발생시켜도 아직 읽을 수 있는 바이트가 없으므로, 갭이 메워져 tail이 전진할 때까지 이벤트를 지연시킵니다. in-order enqueue가 이어서 들어와 ooo_segment_try_collect가 바이트를 회수한 순간에 비로소 이벤트가 발생합니다. 이 설계 덕분에 손실된 중간 세그먼트가 재전송되어 갭이 메워질 때 단 한 번의 이벤트로 "한꺼번에" 읽을 바이트가 앱에게 전달됩니다.
show svm fifo <fifo-index>로 특정 fifo의 OOO 세그먼트 목록을 볼 수 있습니다. OOO 세그먼트 수가 꾸준히 높다면 경로상에 재배열이나 손실이 있다는 신호입니다. show tcp connections와 교차 확인하면 SACK 재전송 활동이 반영된 sacked_bytes가 보입니다.
TCP 혼잡 제어 플러그인 작성하기
VPP TCP 스택은 혼잡 제어 알고리즘을 vft 플러그인으로 분리합니다. NewReno, CUBIC, BBR이 동일한 인터페이스로 등록되어 있고, 사용자 정의 알고리즘도 같은 방식으로 추가할 수 있습니다. 아래에서는 간단한 "신호형 AIMD" 알고리즘을 예제로 만들면서 인터페이스와 등록 경로, 동작 시점을 짚습니다.
tcp_cc_algorithm_vft_t 인터페이스
/* src/vnet/tcp/tcp_cc.h */
typedef struct tcp_cc_algorithm_ {
const char *name;
uword (*unformat_cfg)(unformat_input_t *input);
void (*init)(tcp_connection_t *tc);
void (*cleanup)(tcp_connection_t *tc);
void (*rcv_ack)(tcp_connection_t *tc, tcp_rate_sample_t *rs);
void (*rcv_cong_ack)(tcp_connection_t *tc,
tcp_cc_ack_t ack, tcp_rate_sample_t *rs);
void (*congestion)(tcp_connection_t *tc);
void (*loss)(tcp_connection_t *tc);
void (*recovered)(tcp_connection_t *tc);
void (*undo_recovery)(tcp_connection_t *tc, u32 snd_una);
void (*event)(tcp_connection_t *tc, tcp_cc_event_t evt);
u64 (*get_pacing_rate)(tcp_connection_t *tc);
} tcp_cc_algorithm_t;
void
tcp_cc_algo_register(tcp_cc_algorithm_type_e type,
const tcp_cc_algorithm_t *vft);
각 콜백의 의미는 다음과 같습니다.
init— 연결 수립 직후 한 번.tc->cwnd,tc->ssthresh등을 초기화합니다.rcv_ack— 정상 ACK 도착. 혼잡 회피/슬로우 스타트 증가 로직.rcv_cong_ack— 혼잡 복구 중 ACK. Partial ACK 처리.congestion— 혼잡 신호 감지 시 1회(첫 진입). ssthresh 감소.loss— RTO 발생. cwnd를 1 MSS로 재설정.recovered— 혼잡 복구 종료.event— ECN 신호, delayed ACK 등 기타 이벤트.get_pacing_rate— BBR류 페이싱 지원(선택).
예제 — "signal_aimd" 플러그인 전체 소스
간단히 설명하기 위해 NewReno와 거의 같지만 cwnd 증가율이 절반인 알고리즘을 작성합니다. 실제 실험용 모델로 쓸 수 있는 최소 예제입니다.
/* src/plugins/signal_aimd/signal_aimd.c */
#include <vnet/tcp/tcp.h>
#include <vnet/tcp/tcp_cc.h>
#include <vlib/vlib.h>
static void
saimd_init(tcp_connection_t *tc)
{
tc->cwnd = tcp_initial_cwnd(tc);
tc->ssthresh = tc->snd_wnd;
tc->prev_cwnd = 0;
tc->prev_ssthresh = 0;
}
static void
saimd_rcv_ack(tcp_connection_t *tc, tcp_rate_sample_t *rs)
{
if (tcp_in_slowstart(tc)) {
/* 슬로우 스타트: cwnd += MSS per ACK */
tc->cwnd += tc->snd_mss;
} else {
/* 혼잡 회피: NewReno의 절반 속도 */
tc->cwnd_acc_cnt += tc->snd_mss;
if (tc->cwnd_acc_cnt >= 2 * tc->cwnd) {
tc->cwnd += tc->snd_mss;
tc->cwnd_acc_cnt = 0;
}
}
}
static void
saimd_congestion(tcp_connection_t *tc)
{
tc->prev_cwnd = tc->cwnd;
tc->prev_ssthresh = tc->ssthresh;
tc->ssthresh = clib_max(tc->cwnd / 2, 2 * tc->snd_mss);
}
static void
saimd_loss(tcp_connection_t *tc)
{
tc->cwnd = tc->snd_mss;
}
static void
saimd_recovered(tcp_connection_t *tc)
{
tc->cwnd = tc->ssthresh;
tc->cwnd_acc_cnt = 0;
}
static void
saimd_undo_recovery(tcp_connection_t *tc, u32 snd_una)
{
tc->cwnd = tc->prev_cwnd;
tc->ssthresh = tc->prev_ssthresh;
}
const static tcp_cc_algorithm_t signal_aimd_alg = {
.name = "signal_aimd",
.init = saimd_init,
.rcv_ack = saimd_rcv_ack,
.rcv_cong_ack = saimd_rcv_ack, /* 동일 처리 */
.congestion = saimd_congestion,
.loss = saimd_loss,
.recovered = saimd_recovered,
.undo_recovery = saimd_undo_recovery,
};
static clib_error_t *
signal_aimd_init_fn(vlib_main_t *vm)
{
tcp_cc_algo_register(TCP_CC_LAST + 1, &signal_aimd_alg);
return 0;
}
VLIB_INIT_FUNCTION(signal_aimd_init_fn) = {
.runs_after = VLIB_INITS("tcp_init"),
};
VLIB_PLUGIN_REGISTER() = {
.version = VPP_BUILD_VER,
.description = "Signal-based AIMD (experimental)",
};
빌드와 활성화
# 소스 트리의 src/plugins/signal_aimd/ 에 CMakeLists.txt 추가 후
$ cd build-root && make vpp-build
# startup.conf 또는 CLI로 알고리즘 선택
vpp# set tcp cc-algo signal_aimd
vpp# show tcp configuration
cc-algo : signal_aimd
initial cwnd : 10 MSS
...
# 연결 시작 후 확인
vpp# show tcp connections
[#0][T] 10.0.0.2:39472->10.0.0.1:80 ESTABLISHED
cc: signal_aimd cwnd 14480 ssthresh 65535 ...
tcp_rate_sample_t — BBR 계열 지원
rcv_ack로 전달되는 tcp_rate_sample_t는 BBR 같은 대역폭 기반 알고리즘을 위한 샘플링 상태입니다. 주요 필드는 다음과 같습니다.
delivered— 이 RTT 동안 전달 확정된 바이트 수prior_delivered— 샘플 시작 시점의 delivered 스냅샷interval_time— 샘플 구간 길이(초)rtt_time— 이번 ACK로 측정된 RTTlost/prior_lost— 손실 바이트 카운터is_app_limited— 앱이 write를 멈춰 혼잡 신호가 아닌 앱 한계로 인한 정체 여부
대역폭 추정을 하려면 bandwidth = (delivered - prior_delivered) / interval_time으로 계산합니다. is_app_limited 플래그가 true인 샘플은 대역폭 추정에서 제외해야 하며, BBR의 BBR.BtlBw max-filter 윈도우 구현은 이 플래그를 그대로 따릅니다.
참고자료
Host Stack은 VCL·Session Layer·TLS·QUIC가 맞물려 있어 VPP 단일 문서만으로는 전체 그림이 잡히지 않습니다. 아래는 TLS/QUIC 프로토콜 표준, VPP 세션 레이어 구현, OpenSSL·quicly 등 외부 엔진 문서를 함께 묶은 1차 자료입니다.
VPP Host Stack 공식 문서/소스
- VPP Feature List — Session Layer — VCL/TLS/QUIC 성숙도
- FD.io Wiki: Host Stack — 아키텍처 개요
- src/vnet/session/ — 세션 레이어 구현
- src/vcl/ — VPP Comms Library
- src/plugins/tlsopenssl/ — OpenSSL 엔진 플러그인
- src/plugins/quic/ — QUIC 플러그인 (quicly 기반)
- src/plugins/http/ — HTTP/1.1·HTTP/2 구현
TLS 관련 RFC
- RFC 8446 — TLS 1.3
- RFC 5246 — TLS 1.2 (레거시 호환)
- RFC 7301 — ALPN
- RFC 6066 — TLS Extensions (SNI)
- RFC 5077 — TLS Session Tickets
- RFC 8879 — TLS Certificate Compression
- RFC 8446 §2.3 — 0-RTT 보안 고려사항
QUIC / HTTP 관련 RFC
- RFC 9000 — QUIC Transport
- RFC 9001 — QUIC-TLS Binding
- RFC 9002 — QUIC Loss Detection & Congestion Control
- RFC 9113 — HTTP/2
- RFC 9114 — HTTP/3
- RFC 8441 — HTTP/2 Extended CONNECT
- RFC 9297 — HTTP Datagrams & Capsule Protocol
- RFC 9298 — CONNECT-UDP (Proxying UDP in HTTP)
암호 엔진·라이브러리
- OpenSSL 3.x 매뉴얼 —
SSL_CTX, provider 모델 - OpenSSL Providers README —
tls openssl load-provider배경 - h2o/quicly — VPP QUIC 플러그인 백엔드
- h2o/picotls — quicly가 쓰는 TLS 스택
- aws/s2n-quic — 대체 QUIC 구현 비교용
운영·성능 참고
- CSIT Performance Report — Host Stack 시험 결과 포함
- LDP (LD_PRELOAD) Shim — 기존 소켓 앱을 VPP로 보내는 경로
- Host Stack Nginx 시험 — LDP + nginx 실사례
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하세요.
오픈소스 코드 인용 고지
| 프로젝트 | 저작권자 | 라이선스 | 공식 저장소 |
|---|---|---|---|
| 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 |
코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.