호스트 스택 개요

VPP 호스트 스택 (VPP 25.02 기준): VCL/VLS 세션 레이어, LD_PRELOAD, TCP 호스트 스택, TLS 아키텍처/인증서, QUIC 프로토콜, TLS 성능 최적화, Host Stack 현황, TLS/QUIC 보안 모범 사례를 다룹니다.

전제 조건: 기초와 아키텍처를 먼저 읽고 TCP 프로토콜의 기본 동작을 이해하시기 바랍니다. 이 문서는 VPP가 유저스페이스에서 TCP와 TLS를 어떻게 붙여 동작시키는지까지 다루므로, 그래프 노드와 TCP 상태 머신을 함께 이해해야 합니다.
📌 문서 기준 버전: 본 문서의 API·플러그인 상태·성능 수치는 VPP 25.02 릴리스를 기준으로 합니다. 릴리스 노트상 TLS 프레임워크·TCP 호스트 스택은 production이지만, 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 엔진이 직접 처리합니다.

단계별 이해

  1. 애플리케이션 진입점(Entry Point) 선택
    새 프로그램이면 vppcom_* 또는 VCL 네이티브 API부터 시작하고, 기존 소켓 프로그램이면 LD_PRELOAD 또는 VLS 적합성을 먼저 판단합니다.
  2. 세션 생성과 워커 소유권 확인
    세션은 생성 순간 특정 워커 스레드에 귀속됩니다. 이후 같은 흐름의 TCP 세그먼트와 FIFO 접근은 이 워커 소유권을 기준으로 진행됩니다.
  3. TCP 연결 수립 이해
    session_open() 또는 accept() 흐름에서 half-open 세션, 연결 완료 이벤트, FIFO 할당, READY 상태 전환 순서를 추적해야 합니다.
  4. TLS 핸드셰이크와 데이터 경로 분리
    핸드셰이크는 인증서와 키 교환을 마치기 위한 제어 단계이고, 그 후에는 App FIFO의 평문이 TLS 엔진을 거쳐 TCP FIFO의 암호문으로 변환됩니다.
  5. 운영 병목(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의 차이

방식핵심 인터페이스장점주의점적합한 경우
직접 VCLvppcom_session_*함수 호출 경로가 가장 짧고 세션, FIFO, 이벤트 큐를 세밀하게 제어할 수 있습니다애플리케이션 수정량이 가장 큽니다신규 TCP/TLS 프록시, 전용 보안 장비, 벤치마크 도구
VLSvls_create(), vls_epoll_wait()기존 멀티스레드 구조를 유지하면서 세션 공유를 단계적으로 이전할 수 있습니다잠금 비용과 경합(Contention)이 늘 수 있습니다자체 C/C++ 서버, 레거시 프록시, 단계적 전환 프로젝트
LD_PRELOAD기존 POSIX 소켓 API기존 프로그램을 거의 수정하지 않고 파일럿을 만들 수 있습니다sendmsg(), 일부 소켓 옵션, 파일 디스크립터(File Descriptor) 전달은 제약을 점검해야 합니다nginx, Envoy, HAProxy, 사내 TCP 서비스의 빠른 적합성 검증
VPP 호스트 스택 진입 경로 기존 소켓 애플리케이션 nginx, curl, iperf3 LD_PRELOAD 파일럿 경로 멀티스레드 사용자 프로그램 세션 공유와 epoll 호환 필요 VLS 적합성 검토 대상 신규 네이티브 애플리케이션 직접 VCL로 세션 제어 가장 짧은 호출 경로 LD_PRELOAD libvcl_ldpreload.so VLS (VCL Locked Sessions) 잠금, 워커 간 RPC, epoll 호환, 세션 공유 보호 직접 VCL vppcom_session_* 세션 레이어 + 공유 메모리 FIFO + TCP/TLS/QUIC 진입 방식은 달라도 결국 동일한 세션 상태 머신과 이벤트 큐로 합류합니다 핵심 차이: 직접 VCL은 경로가 짧고, VLS와 LD_PRELOAD는 호환성과 스레드 안전성을 위해 추가 계층을 둡니다

TCP/TLS 데이터 경로를 읽는 순서

  1. 진입점을 먼저 식별합니다 — 직접 VCL인지, VLS인지, LD_PRELOAD인지에 따라 성능 병목 위치가 달라집니다. 직접 VCL은 애플리케이션 로직과 세션 소유권이 가장 명확하고, VLS/LD_PRELOAD는 호환성이 좋은 대신 잠금 경로가 추가됩니다.
  2. 세션 소유권과 FIFO를 확인합니다 — TCP/TLS 세션은 생성 시점에 특정 워커에 바인딩되며, 이후 송수신은 공유 메모리 FIFO를 통해 이루어집니다. 그래서 워커 불균형이나 큐 편향이 생기면 애플리케이션 문제가 아니라 세션 배치 문제일 수 있습니다.
  3. TCP 책임을 분리합니다 — 연결 수립, ACK 처리, 혼잡 제어(Congestion Control), 재전송(Retransmission), 순서 보장(Ordering)은 TCP가 담당합니다. TLS는 이 위의 바이트 스트림(Byte Stream)을 받아 암복호화할 뿐입니다.
  4. TLS 책임을 따로 봅니다 — 인증서 검증, 핸드셰이크, 세션 재개, 레코드(Record) 경계, 암호화 엔진 선택은 TLS 계층의 문제입니다. CPS가 무너질 때는 RSA/ECDSA 서명, 인증서 캐시(Cache), 비동기 엔진, QAT 가속 여부를 따로 보아야 합니다. TLS 핸드셰이크 타임라인과 엔진 콜백 흐름은 TLS — 핸드셰이크 상세 타임라인에서, 운영 중 디버깅 절차는 프록시 — TLS 디버깅에서 다룹니다.
  5. 파일럿과 운영 아키텍처를 구분합니다 — 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 세션 레이어 → TCP 호스트 스택 → TLS 아키텍처 → QUIC 순으로 상세를 전개합니다. TCP/TLS 프록시와 SSL Inspection 실전은 VPP TCP/TLS 프록시 · SSL Inspection와 SSL Inspection에서, 투명 프록시(TPROXY) 커널 경로는 VPP 보안과 터널링에서, memif 서비스 체이닝은 VPP 데이터 경로에서 이어집니다.

VCL과 세션 레이어

VPP 세션 레이어

VPP는 L4 전송 프로토콜(TCP, UDP, QUIC)을 유저스페이스에서 직접 구현합니다. 세션 레이어가 소켓(Socket)과 유사한 추상화를 제공하며, 애플리케이션은 VCL(VPP Communications Library)을 통해 접근합니다.

항목커널 소켓 APIVPP 세션 API
APIsocket(), bind(), listen(), accept()공유 메모리 기반 세션 큐
데이터 전달send()/recv() (커널 복사)공유 메모리 FIFO (zero-copy)
이벤트epoll/selectVPP 이벤트 큐 (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_CREATED0세션 구조체(Struct) 할당 완료, 아직 연결되지 않은 상태입니다
SESSION_STATE_LISTENING1session_listen() 호출 후 수신 대기 중인 리스너 세션입니다
SESSION_STATE_CONNECTING2session_open() 호출 후 비동기 연결 진행 중입니다
SESSION_STATE_ACCEPTING3전송 계층에서 SYN을 수신하여 수락 처리 중입니다
SESSION_STATE_READY4연결이 완료되어 데이터 송수신이 가능한 상태입니다
SESSION_STATE_TRANSPORT_CLOSING5전송 계층에서 종료를 시작했습니다 (원격 FIN 수신)
SESSION_STATE_CLOSING6애플리케이션이 session_close()를 호출하여 종료 진행 중입니다
SESSION_STATE_CLOSED7세션이 완전히 종료되어 리소스 해제 대기 중입니다

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_indexthread_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_statevolatile 한정자가 붙어 있어 워커 스레드(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와 유사한 생명주기를 따르지만, 내부적으로는 비동기·이벤트 기반으로 동작합니다.

단계함수동작
Listensession_listen()transport_start_listen()을 호출하여 리스너 세션을 생성합니다. 리스너는 SESSION_STATE_LISTENING 상태로 전환됩니다
Connectsession_open()transport_connect()를 호출하여 비동기 연결을 시작합니다. 세션은 CONNECTING 상태가 되며, 연결 완료 시 SESSION_CTRL_EVT_CONNECTED 이벤트가 발생합니다
Acceptsession_stream_accept()전송 계층에서 SYN을 수신하면 새 세션을 할당하고 FIFO를 생성한 뒤, 애플리케이션에 SESSION_CTRL_EVT_ACCEPTED 이벤트를 전달합니다
Datasession_enqueue_stream_connection()수신 데이터를 rx_fifo에 enqueue하고, SESSION_IO_EVT_RX 이벤트로 애플리케이션에 알립니다
Closesession_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.confsession 섹션에서 설정합니다. 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 흐름은 다음과 같습니다.

  1. 데이터 수신 → svm_fifo_enqueue()rx_fifo에 저장합니다
  2. svm_fifo_max_enqueue()로 남은 공간을 확인합니다
  3. 남은 공간이 임계값 이하이면, TCP 윈도우 크기를 축소하여 송신 측에 감속을 요청합니다
  4. 애플리케이션이 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_RXVPP → App수신 데이터가 rx_fifo에 도착했음을 알립니다. 애플리케이션은 svm_fifo_dequeue()로 데이터를 읽습니다
SESSION_IO_EVT_TXApp → VPP애플리케이션이 tx_fifo에 데이터를 기록했음을 알립니다. VPP가 전송을 시작합니다
SESSION_CTRL_EVT_ACCEPTEDVPP → App리스너에 새 연결이 수락되었습니다. 애플리케이션이 accept 응답을 해야 합니다
SESSION_CTRL_EVT_CONNECTEDVPP → App비동기 session_open()의 연결이 완료(또는 실패)되었습니다
SESSION_CTRL_EVT_DISCONNECTEDVPP → App원격 측에서 연결을 정상 종료했습니다 (FIN 수신)
SESSION_CTRL_EVT_RESETVPP → App연결이 비정상 리셋되었습니다 (RST 수신)

svm_msg_q_t — 공유 메모리 메시지 큐

VPP는 각 애플리케이션 워커마다 별도의 svm_msg_q_t 이벤트 큐를 할당합니다. 이 큐는 공유 메모리 세그먼트 위에 위치하며, lock-free ring buffer로 구현되어 있습니다.

/* 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.solibc의 소켓 함수를 가로채어 VPP와 통신합니다.

VCL LD_PRELOAD 투명 가속 아키텍처 Application nginx, iperf3, curl ... socket()/send()/recv() libvcl_ldpreload.so POSIX → VCL 변환 공유 메모리 FIFO VPP Process Session Layer TCP/UDP 스택 DPDK / NIC wire-speed I/O 기존 경로: App → libc → syscall → 커널 TCP → NIC 드라이버 (복사 2회 + 컨텍스트 스위치) VCL 경로: App → VCL → 공유 메모리 FIFO → VPP TCP → DPDK (zero-copy, 유저스페이스)
# 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 vs 커널 워크로드 선택: VCL은 대량 동시 연결(10K+)과 높은 처리량(Throughput) 시나리오에서 효과적입니다. 소규모 연결(수백 개)이나 복잡한 소켓 옵션(SO_REUSEPORT, SCM_RIGHTS 등)이 필요한 워크로드는 커널 소켓이 더 적합합니다.

VCL, VLS, LD_PRELOAD의 역할 분담

실무에서는 VCL, VLS, LD_PRELOAD를 하나의 묶음으로 부르지만, 내부 역할은 분명히 다릅니다. VCL은 네이티브 API이고, VLS는 그 위에 잠금과 세션 공유 정책을 얹은 호환 레이어이며, LD_PRELOAD는 기존 POSIX 소켓 호출을 VLS 쪽으로 우회시키는 진입 방법입니다.

방식핵심 API장점주의점적합한 상황
직접 VCLvppcom_session_*오버헤드가 가장 낮고 제어 범위가 넓습니다애플리케이션 수정이 필요합니다신규 TCP/TLS/QUIC 서버, 전용 프록시, 벤치마크 도구
VLSvls_create(), vls_epoll_wait()멀티스레드 세션 공유를 안전하게 만들 수 있습니다잠금과 워커 간 RPC 비용이 추가됩니다기존 구조를 유지하면서 VPP 호스트 스택으로 옮길 때
LD_PRELOADPOSIX 소켓 API 그대로 사용기존 프로그램을 거의 수정 없이 붙일 수 있습니다지원되지 않는 소켓 옵션이나 sendmsg() 계열은 제약이 있습니다nginx, iperf3, curl, 사내 프록시의 빠른 파일럿 전환
VCL, VLS, LD_PRELOAD 계층 구조 기존 애플리케이션 nginx, curl, iperf3 POSIX 소켓 호출 멀티스레드 프록시 Envoy, HAProxy, 자체 서버 스레드 간 세션 공유 필요 신규 VCL 애플리케이션 vppcom_* 직접 호출 최소 오버헤드 경로 LD_PRELOAD libvcl_ldpreload.so VLS (VCL Locked Sessions) 세션 잠금, 워커 간 RPC, epoll 호환 래퍼 VCL 네이티브 API vppcom_session_* 호출 세션 레이어 + TCP/TLS/QUIC 전송 계층 워크로드별로 진입 방식은 달라도 결국 동일한 FIFO, 이벤트 큐, 세션 상태 머신으로 합류합니다 핵심 차이: 직접 VCL은 함수 호출 경로가 짧고, VLS/LD_PRELOAD는 스레드 안전성과 호환성을 위해 잠금 계층을 추가합니다

VLS 잠금 모드와 스레드 모델

현재 VPP 소스의 src/vcl/vcl_locked.c 주석에는 VLS가 세 가지 동작 모드를 가진다고 명시되어 있습니다. 이 구분을 이해하지 못하면, 왜 어떤 애플리케이션은 잘 붙고 어떤 애플리케이션은 갑자기 락 경합(Contention)과 워커 간 RPC 때문에 성능이 떨어지는지 설명할 수 없습니다.

  1. 프로세스별 워커(per-process workers)fork()된 자식 프로세스(Process)를 새로운 VCL 워커로 간주합니다. 공유 세션은 명시적으로 잠그고, 한 시점에 한 프로세스만 세션을 만질 수 있게 합니다.
  2. 스레드별 워커(per-thread workers) — 새 pthread를 새로운 워커로 등록합니다. 다른 스레드가 소유하지 않은 세션에 접근하면 clone-and-share RPC를 보내어 세션을 해당 워커에도 매핑(Mapping)합니다.
  3. 단일 워커 멀티스레드(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가지:

  1. listener per thread — 하나의 listening 소켓을 여러 스레드가 공유하는 대신, SO_REUSEPORT로 각 스레드가 독립 리스너를 갖습니다. VLS는 이를 워커별 독립 세션 풀로 매핑하여 write lock 발생 지점을 제거합니다.
  2. accept 스레드 분리 — accept만 담당하는 스레드를 1~2개 두고, 수락된 fd를 해시(Hash) 기반으로 워커 큐에 분배합니다. VLS는 이 이전 과정에서 한 번만 lock을 잡으므로 경합이 O(connection) → O(accept/batch)로 감소합니다.
  3. batch-closeclose()를 즉시 호출하지 않고 짧은 지연 후 일괄 처리합니다. close는 내부적으로 세션 테이블 write lock을 요구하므로, 배치 처리만으로도 경합을 대폭 줄일 수 있습니다.
  4. 작업자 핀닝pthread_setaffinity_np로 애플리케이션 스레드를 특정 코어에 고정하고, 동일 코어의 VPP 워커와 짝을 이루게 합니다. cross-worker RPC가 극적으로 감소합니다.
패턴CPS 개선코드 변경 비용호환성
기본(공유 listener)기준 1.0×
SO_REUSEPORT per-thread3.2×적음nginx·envoy 동일 패턴
accept 스레드 분리2.4×중간일반적 M:N 모델
batch-close1.5×적음응답성 약간 희생
조합 (1+4)4.8×중간권장

측정 기반 권장: 단일 커넥션당 5KB 이하의 짧은 요청-응답(예: API 게이트웨이) 워크로드에서는 조합 (1+4)가 거의 항상 최선입니다. 반대로 장기 연결·대용량 스트리밍(WebSocket, 파일 전송)은 경합이 적어 기본 모드로도 충분합니다.

VCL 구현 심화: API 레퍼런스와 최소 예제

VCL을 실제로 사용하려면 vppcom_* 함수군이 POSIX 소켓의 어느 호출에 대응하는지, 각 함수가 요구하는 호출 순서는 무엇인지, 오류 코드는 어떻게 해석하는지를 먼저 이해해야 합니다. 이 절에서는 핵심 API를 단계별로 나누고, 단일 스레드 TCP 에코 서버를 처음부터 끝까지 따라가면서 VCL 네이티브 API의 호출 흐름을 짚어봅니다.

VCL 수명주기와 API 호출 순서 — 서버 · 클라이언트 공통 ① 초기화 vppcom_app_create 공유 메모리 attach ② 세션 생성 vppcom_session_create TCP / TLS / QUIC ③ 속성 설정 vppcom_session_attr CKPAIR · SNI · 옵션 ④ 바인드/연결 bind / listen / connect endpoint 지정 ⑤ accept 서버만 ⑥ 이벤트 루프 (데이터 평면) vppcom_epoll_create VCL 전용 핸들 vppcom_epoll_ctl(ADD) 세션 등록 vppcom_epoll_wait timeout = double(sec) session_read session_write ⑦ 종료 vppcom_session_close ⑧ ckpair 해제 vppcom_del_cert_key_pair ⑨ 앱 파괴 vppcom_app_destroy 오류 경로 VPPCOM_EAGAIN/EBADFD 아래 소절의 레퍼런스 표와 에코 서버 코드는 이 다이어그램의 ①~⑦ 단계를 1:1로 따릅니다.
실수 최소화 체크리스트: (1) 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_NONBLOCKVPPCOM_EAGAIN
vppcom_session_connect(sh, endpt)connect()원격지 연결, half-open 상태에서 CONNECTED 이벤트 대기
I/Ovppcom_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/setsockoptTLS 인증서 설정, SNI, 플래그 제어 등
vppcom_session_close(sh)close()FIN 전송, FIFO 반환, 세션 해제
주의: VCL의 세션 핸들(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 sessionstream 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 송신 */
스트림 vs 연결의 수명: 스트림 세션을 닫아도 같은 connection 위의 다른 스트림은 계속 사용 가능합니다. connection 세션을 닫으면 모든 스트림이 한꺼번에 종료되고 QUIC의 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-nameVPP binary API 소켓 경로/run/vpp/api.sock
use-mq-eventfd메시지 큐 이벤트 통지에 eventfd 사용켜는 편이 지연이 낮고 epoll 통합이 자연스러움
namespace-id / namespace-secretVPP 세션 네임스페이스 식별자와 비밀값멀티테넌시 분리 필요 시 VPP 쪽 app namespace add와 일치시켜야 함
event-queue-size앱 워커 이벤트 큐 깊이고연결 환경에서는 256 이상
네임스페이스 불일치: VPP 쪽에서 app ns add id X secret Y sw_if_index N으로 네임스페이스를 정의했는데 vcl.confnamespace-id/secret이 일치하지 않으면, vppcom_app_create는 성공하지만 bind/connect가 조용히 실패하며 패킷이 어디로도 나가지 않습니다. 이 증상은 VPP 로그에 거의 단서가 남지 않으므로 초기 구성 단계에서 양쪽 값을 반드시 문서화해 두어야 합니다.

VCL 오류 코드와 해석

VCL API는 음수 반환값으로 오류를 알려주며, 각 코드는 vcl/vppcom.hVPPCOM_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된 핸들을 다시 사용, 다른 앱 워커의 핸들 접근
진단 팁: VPP 콘솔에서 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_* 대응 부분만 빠르게 훑으시면 됩니다.

워커 등록 3줄 원칙 — 이 절 전체에 적용됩니다
  1. 프로세스당 한 번: vppcom_app_create(name) — 메인 스레드에서 단 한 번만 호출합니다. 여러 번 부르면 VCL이 중복 앱으로 인식합니다.
  2. VLS 사용 시 한 번 더: vls_app_create(name) — VLS 래퍼를 쓰는 프로세스에서만, 역시 한 번만 호출합니다.
  3. pthread당 한 번: 추가 워커 스레드에서는 최초 진입 시 vls_register_vcl_worker()를 호출해 자신의 VCL 워커를 등록합니다. 순수 VCL(단일 스레드) 애플리케이션에서는 이 호출이 필요하지 않습니다.

레시피 1 — 단일 스레드 TCP 에코 서버 (VCL 네이티브)

가장 기본적인 서버 루프입니다. vppcom_app_createvppcom_session_createbind/listenepoll_waitaccept/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_CFGtransport_endpt_ext_cfg_t를 넘깁니다. 그 안의 crypto 유니언 멤버(transport_endpt_crypto_cfg_t)에 hostname[256]alpn_protos[4] 배열을 채웁니다. alpn_protosvppcom_proto_t 값이 아니라 내부 ALPN ID(예: TLS_ALPN_PROTO_HTTP_2)를 0으로 종단된 리스트로 담는 4바이트 필드입니다.

🔄 25.02 대비 변경: ALPN 필드(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_CKPAIRTLS 인증서 쌍 결합u32 *
SET_ENDPT_EXT_CFGSNI·ALPN·crypto 엔진 등 확장 설정 — transport_endpt_ext_cfg_t 전달void * (가변)
SET_CONNECTEDaccept 이후 애플리케이션이 완전한 소유권을 확보했음을 알림NULL
GET_PROTOCOL / GET_LISTEN / GET_ERROR세션 메타정보 조회 — half-open 판별은 별도 속성이 아니라 epoll 이벤트로 합니다int *
SET_TCP_KEEPIDLE / SET_TCP_KEEPINTVLTCP Keepalive 파라미터u32 *
SET_DSCPDSCP 마킹 — 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 정리 후 closeECONNRESET
VPPCOM_ENOTCONN아직 connect 완료 전에 read/write 시도ENOTCONN
VPPCOM_EBADFD이미 close된 세션 핸들에 접근EBADF
VPPCOM_ETIMEDOUTTCP 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는 해당 워커에서만 하시기 바랍니다.
잠금 경합 주의: 4 워커 이상으로 확장하면 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 내부는 세션 테이블을 rwlock으로 보호합니다. 스레드 수가 늘수록 경합이 늘어 순수 VCL 대비 처리량이 떨어집니다. 워커 3~4개까지는 스케일하지만 그 이상은 VLS 잠금 모드에서 설명한 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간헐적 SEGVclose는 소유 워커에서만. 잡 큐 이동 시 detach/attach.
EWOULDBLOCK을 에러로 처리연결이 갑자기 끊김epoll 재진입 신호로 해석.

VCL 네이티브 구현 예시 심화 — 프로토콜·상태 머신·운영 패턴

앞 절 레시피 모음은 각 API를 "한 가지씩" 보여주는 데 초점을 맞췄습니다. 이 절은 실제 서비스에서 필요한 완결된 구현을 다룹니다. VLS나 LD_PRELOAD를 쓰지 않고 vppcom_* API만으로 HTTP/1.1 파서, 커넥션 풀 클라이언트, 양방향 프록시 릴레이, mTLS 서버, 제어 이벤트 상태 머신, 우아한 종료(graceful shutdown)까지 처음부터 끝까지 구현합니다. 바탕은 모두 단일 VCL 워커 + epoll 이벤트 루프(Event Loop)입니다.

VCL 네이티브의 경계: VCL 네이티브 모드는 하나의 VCL 워커가 자신이 만든 세션만 소유한다는 단순한 원칙을 지킵니다. 성능은 최고이고 디버깅(Debugging)이 쉽습니다. 대신 여러 pthread가 같은 리스너를 공유해야 한다면 VLS 쪽이 맞습니다. 선택 기준은 이미 VCL, VLS, LD_PRELOAD의 역할 분담에서 정리했습니다.

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에 포함시키거나 컨트롤 플레인이 별도로 신뢰 목록을 주입하는 것입니다.

CKPAIR는 CLI가 아니라 바이너리 API입니다. 일부 외부 문서에 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);
}
🔄 26.02 변경 — 서버측 mTLS와 peer cert 조회: v26.02 릴리스 노트에 "Server side mtls support""Support retrieving peer cert"가 정식 항목으로 포함됐습니다(변경 요약 참조). 25.02 시절의 제약 — "VCL 앱이 클라이언트 인증서를 얻을 수 없어 HTTP X-Forwarded-Client-Cert 같은 우회 헤더가 필요함" — 이 26.02부터 해소됩니다. 정확한 속성 이름·시그니처는 master 브랜치에서 빠르게 확정되는 중이므로, 실제 적용 전에 src/plugins/tlsopensslsrc/vnet/session/application_interface.h 최신 소스를 확인하시기 바랍니다.
(25.02 이하에서 필요한 우회책) VCL은 TLS 메타데이터를 앱에 직접 노출하지 않습니다. 협상 결과의 SNI, 선택된 ALPN, 상대 인증서의 DN/SAN/fingerprint 같은 정보는 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_CONNECTINGconnect 호출, EINPROGRESS 수신EPOLLOUT 또는 EPOLLERR 대기성공 시 APP_READY로, 실패 시 제거
APP_LISTENlisten 성공EPOLLIN(accept 준비)vppcom_session_accept 호출
APP_READYaccept 세션 또는 connect 완료EPOLLIN/EPOLLOUT프로토콜 상태 머신 드라이브
APP_PEER_CLOSEDread 반환 0 또는 EPOLLRDHUPEPOLLRDHUP쓰기 큐 소진 후 close
APP_ERROR시스템콜이 음수 반환(EAGAIN 제외)EPOLLERR로그 기록 후 close, 풀에서 제거
APP_CLOSEDvppcom_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 상태에서 프로토콜 로직 실행 ... */
}
상태 질의 API를 기대하지 마시기 바랍니다. 여러 다른 런타임(Go, Python 등)에서 소켓 상태를 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 verboseVPP가 보는 세션 상태, FIFO 사용량vppctl show session verbose 3
VPP show app등록된 애플리케이션, 네임스페이스vppctl show app
VCL_DEBUG 환경변수VCL 내부 로그를 stderr로VCL_DEBUG=3 ./my-app
perf recordVCL 함수별 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 애플리케이션 성능 튜닝 체크

가장 빠른 실패 탐지: VCL 애플리케이션에서 처리량이 기대보다 낮을 때 제일 먼저 볼 것은 show errorssession-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는 이미 비어 있는데 왜 쓰기가 막히는지 같은 문제를 분석할 수 있습니다.

TCP 세션 생명주기와 이벤트 매핑 애플리케이션 세션 레이어 TCP 전송 계층 connect() 또는 listen() half-open 세션 생성 app_wrk_index와 thread_index 고정 SYN 송신 / SYN 수신 상태: SYN-SENT 또는 SYN-RCVD 3-way handshake 완료 ESTABLISHED 진입 FIFO 할당 + READY 전환 SESSION_STATE_READY CONNECTED / ACCEPTED 이벤트 앱이 이제 읽기/쓰기를 시작 vppcom_session_write() App TX FIFO에 평문 기록 세션 큐 이벤트 발생 송신 가능 여부와 FIFO 여유 추적 tcp_output() / ACK 처리 혼잡 제어와 재전송은 TCP가 담당 FIN 또는 RST 수신 정상 종료 또는 비정상 종료 CLOSING / RESET 이벤트 생성 잔여 FIFO 정리 후 세션 해제 close() / cleanup 앱 쪽 리소스 반환
  1. 클라이언트 connect 경로session_open()이 먼저 half-open 세션을 만들고 TCP 연결 요청을 전송합니다. 이 단계에서는 아직 애플리케이션 FIFO가 없습니다.
  2. 서버 accept 경로 — SYN이 들어오면 TCP가 연결 컨텍스트를 만들고, 세션 레이어가 새 세션과 FIFO를 붙인 뒤 SESSION_CTRL_EVT_ACCEPTED를 큐에 넣습니다.
  3. READY 전환 — 3-way handshake가 끝나야 SESSION_STATE_READY가 됩니다. 애플리케이션이 connect 호출 직후 곧바로 읽고 쓸 수 있다고 가정하면 안 됩니다.
  4. 수신 경로tcp_input이 순서 보장과 재조립을 마친 뒤에만 세션 FIFO에 데이터를 넣습니다. 그래서 애플리케이션은 재정렬된 바이트 스트림만 보게 됩니다.
  5. 송신 경로 — 애플리케이션은 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 세션 수를 별도로 산정합니다
관련 문서: 이 섹션은 별도 페이지(Page)로 분리되었습니다 — VPP TLS — TLS 아키텍처, TLS 설정 및 인증서 관리.
관련 문서: 이 섹션은 별도 페이지로 분리되었습니다 — VPP QUIC · HTTP/3 — VPP QUIC 프로토콜.
관련 문서: 이 섹션은 별도 페이지로 분리되었습니다 — VPP TLS — TLS 성능 최적화.

2026년 기준 Host Stack 현재 상태

📌 본 문서 기준 버전: v26.02 (2026-02-25 릴리스, 최신 stable). 필요 시 과거 기준점이었던 v25.02와의 차이를 본문 곳곳의 "25.02 대비 변경" 박스로 명시합니다. 릴리스 타임라인은 v25.02v25.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.02v26.02도입 릴리스
TLS ALPN 협상지원 없음 — 서버 설정으로만 고정VCL 애플리케이션이 alpn_protos[4]로 클라이언트 우선순위(Priority) 지정 가능25.06
서버측 mTLS (VCL 차원)플러그인 내부 경로만, VCL API 미노출server side mtls support, peer cert retrieval 지원26.02
QUIC 엔진 APIquicly 직결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.xDPDK 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 네이티브 PMD26.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
제거된 APIavf_create/avf_delete 메시지 제거26.02
Deprecated APItap_create_v2 (26.02), pg_create_interface_v2 (25.10)25.10/26.02
⚠ 가장 영향이 큰 두 가지: (1) TLS ALPN 협상(25.06)은 본 문서의 VCL TLS 레시피에 이미 반영되어 있습니다. 25.02 기준에서 돌아간 구버전 코드는 ALPN 인자를 무시하고 서버 설정으로만 협상이 이루어지므로, 구버전 사용자는 서버 측에서 ALPN 리스트를 고정해야 합니다. (2) 서버측 mTLS + peer cert retrieval(26.02)은 오랫동안 "VCL에서 클라이언트 인증서 조회 불가"였던 제약을 해소합니다. 자세한 내용은 해당 섹션의 인라인 박스를 참고하시기 바랍니다.

2026년 4월 기준 공식 기능 목록을 보면 Session Layer, VPP Comms Library (VCL), TCP, TLS 프레임워크는 production으로 분류됩니다. 반면 TLS OpenSSL 엔진, QUIC, QUICLY는 experimental입니다. 따라서 운영 설계는 "호스트 스택 전체가 불안정합니다"가 아니라, 세션 추상화는 주력으로 쓰되 엔진과 프로토콜 확장은 경계를 나눠 배치해야 합니다라는 식으로 읽어야 정확합니다.

구성 요소공식 분류최근 변화실무 해석
Session Layerproduction25.06에서 app용 session eventing 인프라 추가이벤트 기반 애플리케이션과 모니터링의 중심 계층으로 보는 편이 맞습니다.
VCLproduction25.02에서 transport attributes 강화신규 TCP/TLS 프록시는 직접 VCL을 기준으로 설계하는 편이 예측 가능성이 높습니다.
TCPproduction호스트 스택의 안정 축대량 연결, 투명 프록시, TLS 종단의 기본 운반 계층으로 적합합니다.
TLS 프레임워크production플러그형 엔진 구조 유지TLS 자체보다 어떤 엔진과 신뢰 체인(Chain of Trust)을 선택하느냐가 더 중요한 문제입니다.
TLS OpenSSLexperimental25.06 ALPN, 25.10 configurable trusted CAs, early data 프록시 훅기능은 강력하지만, 전체 트래픽에 일괄 적용하기보다 역할을 좁혀 검증해야 합니다.
QUIC / QUICLYexperimental25.06 QUIC engine API파일럿, 특정 서비스, 별도 티어(Tier)로는 좋지만 핵심 검사 경로와 강결합시키지 않는 편이 안전합니다.
Host Stack은 안정 축과 빠르게 변하는 축을 분리해서 읽어야 합니다 production 축 Session Layer VCL TCP TLS framework experimental 축 TLS OpenSSL engine QUIC / QUICLY HTTP/2 CONNECT · UDP tunnel 안정 축은 기본 경로로 두고, 실험 축은 별도 서비스 티어, 기능 플래그, 제한된 도메인에서 검증하는 구성이 현실적입니다

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를 인지하는 프록시 설계가 더 현실적인 주제가 되었습니다.
해석: HTTP/2의 CONNECT, Extended CONNECT, UDP over HTTP/2 지원은 QUIC/ECH 시대에 모든 트래픽을 "무조건 복호화"하기보다, 명시적 터널, 선택적 종단, 별도 검사 티어를 조합하는 설계가 중요해졌다는 신호로 읽는 편이 맞습니다.

현재 시점 배치 권장안

운영 환경에서는 신뢰 경계, 메모리 경계, 프로토콜 경계를 먼저 정하고 들어가는 편이 좋습니다. 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 지원으로 반응형 제어 루프 구성이 쉬워졌습니다.데이터 경로와 제어 경로의 재연결 실패 처리 분리가 필요합니다.
관련 문서: 이 섹션은 별도 페이지로 분리되었습니다 — VPP TLS — TLS/QUIC 보안 모범 사례.

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가 이를 세션 레이어 위에 어떻게 얹는지를 함께 정리합니다.

왜 builtin HTTP가 필요한가: 헬스체크 응답, 라이트 API 게이트웨이, 디버그 콘솔, 텔레메트리 노출 같은 용도라면 외부 HTTP 서버를 띄우지 않고 VPP 워커 안에서 직접 응답하는 편이 컨텍스트 스위치·복사 비용을 모두 제거합니다. 단, 본격적인 웹 서버 워크로드는 여전히 nginx/envoy 같은 전용 사용자 공간 스택이 적합합니다.

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 큐로 나가기까지의 전체 경로를 정리합니다.

NIC RX (DPDK/AF_XDP) dpdk-input 노드 L3/L4 그래프 노드 ip4-input → tcp4-input tcp_input_dispatch() 세션 레이어 session_enqueue_data() → rx_fifo (svm) http_static 앱 builtin_session_rx_callback → http1_parse_request() → static_server_serve() → session_send_data() tx_fifo (svm) session_tx_fifo_dequeue tcp_output tcp4-output 노드 NIC TX SVM 공유 FIFO로 zero-copy 전달

핵심은 요청 데이터가 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
성능 지점: 단일 VPP 워커 + DPDK + 100Gbps NIC 기준으로 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 매핑구현 파일
Connectionsession_t (TCP/TLS 위)http2_conn.c
Streamhttp2_stream_t (논리 세션)http2_stream.c
HEADERS frameHPACK 디코더 → http_msg_thttp2_hpack.c
DATA frame스트림별 sub-FIFOhttp2_stream.c
WINDOW_UPDATEFIFO 가용 공간 신호http2_flow.c

스트림 단위로 흐름 제어가 분리되어 있어, 큰 응답 1개가 작은 응답 99개를 막는 HOL(Head-of-Line) 블로킹이 완화됩니다. 다만 TCP 위 HTTP/2는 전송 계층 HOL은 여전히 남아 있고, 이를 제거하려면 QUIC 위 HTTP/3로 가야 합니다(이 장 후반의 QUIC 절 참고).

Common Pitfalls

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: websocketConnection: 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 프레임 구조

핸드셰이크 이후의 데이터는 다음과 같은 가변 길이 프레임의 연속입니다.

bit 0 bit 7 bit 8 bit 15 FIN 1bit RSV 3bit Opcode 4bit MASK 1bit Payload Len 7bit (또는 +16/64bit) Masking-Key (32bit, 클라이언트→서버 전용) Payload (Application Data, 0..2^63 byte) Opcode 종류: 0x0 continuation 0x1 text (UTF-8) 0x2 binary 0x8 close 0x9 ping 0xA pong FIN=1: 마지막 프레임. FIN=0: continuation 프레임이 뒤따름 (큰 메시지 분할). MASK=1: payload는 4byte 키와 XOR. 클라이언트→서버는 항상 MASK=1, 서버→클라이언트는 0. Payload Len=126: 다음 16bit가 실제 길이. =127: 다음 64bit가 실제 길이. Ping/Pong은 keep-alive 용도. Close 프레임의 payload 첫 2byte는 종료 코드(1000=normal).

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);
}
성능 지점: 서버→클라이언트 프레임은 마스킹이 없어 zero-copy 송신이 가능합니다. 반면 수신 측에서는 XOR 마스킹 해제가 필수입니다. 이 비용은 SSE2/AVX2 정렬 XOR로 16~32byte 단위 처리하면 무시할 만한 수준이지만, 작은 프레임이 폭발적으로 들어오는 시세 푸시 워크로드에서는 옵코드별 fast path를 분리하는 편이 효과적입니다.

Common Pitfalls

gRPC와 HTTP/2 프레임·흐름 제어 상세

gRPC는 Google이 내놓은 RPC 프레임워크이며, 전송 계층으로 HTTP/2를 그대로 사용합니다. gRPC가 매력적인 이유는 단순한 단발성 호출(unary)뿐 아니라 서버 스트리밍·클라이언트 스트리밍·양방향 스트리밍 네 가지 통신 모드를 모두 같은 연결 위에서 다중화할 수 있다는 점입니다. 이 절은 HTTP/2 프레임 레벨에서 gRPC가 어떻게 매핑되는지, 그리고 FD.io VPP의 builtin HTTP/2 스택이 흐름 제어를 어떻게 처리하는지 함께 정리합니다.

HTTP/2 프레임 종류와 역할

프레임코드목적gRPC 사용
DATA0x0스트림 본문 데이터gRPC 메시지 본문
HEADERS0x1HTTP 헤더 (HPACK 압축):method/:path/grpc-encoding
PRIORITY0x2스트림 의존성 트리거의 미사용 (RFC 9218 PRIORITY_UPDATE로 대체)
RST_STREAM0x3스트림 강제 종료gRPC CANCELLED 매핑
SETTINGS0x4연결 파라미터 협상MAX_CONCURRENT_STREAMS 등
PUSH_PROMISE0x5서버 푸시gRPC에서는 미사용
PING0x6RTT 측정·keep-alivegRPC keepalive
GOAWAY0x7연결 graceful 종료graceful shutdown
WINDOW_UPDATE0x8흐름 제어 윈도 증가큰 응답 backpressure
CONTINUATION0x9큰 헤더 블록 분할드물게 사용

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의 흐름 제어는 두 층으로 나뉩니다. 송신자는 두 윈도 중 작은 쪽을 따릅니다.

연결 윈도 (Connection-level Window) 초기 65535 byte. 모든 스트림이 공유. WINDOW_UPDATE(stream=0)으로 증가. BDP 큰 환경에서는 SETTINGS_INITIAL_WINDOW_SIZE를 16MB 이상으로. Stream 1 window: 65535 consumed: 32K 남은: 33K Stream 3 window: 65535 consumed: 60K 남은: 5K (거의 막힘) Stream 5 window: 65535 consumed: 0 남은: 65K Stream 7 (대형 응답) window: 65535 consumed: 65535 남은: 0 (HOL 위험) 송신자는 min(stream_window, connection_window)만큼만 송신 가능. 수신자는 데이터 처리 후 WINDOW_UPDATE를 보내 윈도를 회복시킴. VPP는 svm_fifo의 가용 공간을 추적해 자동으로 WINDOW_UPDATE 송신.

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는 다음 두 카운터로 이를 방어합니다.

Common Pitfalls

관련 문서: 이 섹션은 별도 페이지로 분리되었습니다 — VPP QUIC · HTTP/3 — HTTP/3 실전.

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초로 확장.
함정: HTTP/2의 에러 코드는 스트림 단위와 연결 단위가 겹칩니다. 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 구현에서 운영상 중요한 항목:

gRPC trailer/metadata 처리 제약

gRPC는 HTTP/2 위에 존재하지만 Trailer를 필수로 사용합니다. 표준 HTTP 라이브러리가 Trailer를 잘 다루지 않는 반면, gRPC는 모든 응답의 상태 코드(grpc-status)와 에러 메시지(grpc-message)를 Trailer로 전달합니다. VPP builtin HTTP/2가 gRPC 트래픽을 서빙하거나 프록시할 때 주의할 점:

워커 분산과 세션 어피니티

VPP 멀티 워커 환경에서 HTTP/2 연결은 특정 워커에 고정됩니다. 그러나 TCP 레벨 RSS가 잘못 설정되면 같은 연결의 TCP 패킷이 여러 워커로 분산되어 세션 레이어가 혼란에 빠집니다. 운영 체크리스트:

  1. NIC RSS 해시: Toeplitz 해시가 5튜플(src/dst IP, protocol, src/dst port)을 사용하는지 확인. show interface rss <if>에서 해시 알고리즘과 indirection table을 점검.
  2. 세션 마이그레이션: 세션 워커 이동은 비싸므로 기본 비활성. 특정 워커에 HTTP/2 연결이 몰리면 show session worker에서 큐 길이 편향 확인. 50% 이상 편향 시 연결 수용 비율을 워커별로 조정.
  3. HTTP/2 내부 스트림 분산: 한 연결 안의 여러 스트림은 같은 워커에서 처리됩니다. 스트림 수가 수백을 넘어가면 단일 워커에 CPU 부하가 집중되므로, 클라이언트 측에서 MAX_CONCURRENT_STREAMS를 100~200 수준으로 제한하는 것이 안전합니다.
연계 문서: HTTP/2 멀티플렉싱과 워커 분산의 프레임 레벨 심화는 기초와 아키텍처 — HTTP/2 멀티플렉싱과 세션 워커 분산을 참고하시기 바랍니다. HTTP/3으로 넘어갔을 때의 대응은 QUIC · HTTP/3에서 다룹니다.

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)
TransportTCP/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 핸드셰이크 병목입니다. 데이터 전송이 아닌 연결 수립 자체가 막혀 있는 상황입니다.

진단 절차:
  1. show runtime에서 tls-async-process·openssl-process·crypto-dispatch 노드의 Clocks/Call을 확인합니다. 한 노드가 압도적으로 높으면 그 단계가 병목입니다.
  2. show crypto engines로 활성 엔진(native/openssl/ipsecmb/cryptodev)을 확인합니다. RSA-2048 핸드셰이크는 native에선 매우 느립니다 — QAT나 cryptodev 오프로드가 필요합니다.
  3. 인증서 키 알고리즘을 점검합니다. RSA-2048 → ECDSA-P256 전환만으로도 핸드셰이크 비용이 5~10배 감소합니다.
  4. 세션 재개(Session Resumption / TLS 1.3 0-RTT)가 활성화되어 있는지 확인합니다. 매 연결이 풀 핸드셰이크면 캐시·티켓 설정을 점검합니다.
  5. 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 처리를 수행한 뒤 다시 포워딩으로 돌려보낼 수 있습니다.

언제 쓰나: 프록시 모드가 아닌 투명 L7 검사가 필요할 때. 예를 들어 일반 트래픽은 라우터처럼 그냥 전달하면서 특정 SNI(지정된 도메인)만 TLS 종단해 inspection을 수행하는 시나리오. TPROXY의 내부 호스트스택 버전이라고 생각하면 됩니다.
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 관련 자료구조는 생산자 쪽에만 있고, 소비자는 단지 headtail의 차이만 보면 됩니다. 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 트리에서 시작 오프셋으로 인접 세그먼트를 찾아 세 가지 경우 중 하나를 처리합니다.

  1. 완전 분리 — 양옆 어떤 세그먼트와도 겹치지 않음. 새 ooo_segment_t를 pool에서 할당하고 트리에 삽입.
  2. 왼쪽 이웃과 접함/겹침 — 기존 세그먼트의 length를 확장. 새 오른쪽 경계가 그다음 세그먼트와도 닿으면 연속 병합.
  3. 오른쪽 이웃과 접함/겹침 — 기존 세그먼트의 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);

각 콜백의 의미는 다음과 같습니다.

예제 — "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 같은 대역폭 기반 알고리즘을 위한 샘플링 상태입니다. 주요 필드는 다음과 같습니다.

대역폭 추정을 하려면 bandwidth = (delivered - prior_delivered) / interval_time으로 계산합니다. is_app_limited 플래그가 true인 샘플은 대역폭 추정에서 제외해야 하며, BBR의 BBR.BtlBw max-filter 윈도우 구현은 이 플래그를 그대로 따릅니다.

주의 — 시험 평가 전용: 위 예제 알고리즘은 신뢰할 수 있는 혼잡 제어가 아닙니다. 안정성 분석 없이 공유 네트워크에 배포하면 기존 flow에 대한 공정성을 해칠 수 있습니다. 연구·평가 목적의 isolated testbed에서만 사용하시기 바랍니다.

참고자료

Host Stack은 VCL·Session Layer·TLS·QUIC가 맞물려 있어 VPP 단일 문서만으로는 전체 그림이 잡히지 않습니다. 아래는 TLS/QUIC 프로토콜 표준, VPP 세션 레이어 구현, OpenSSL·quicly 등 외부 엔진 문서를 함께 묶은 1차 자료입니다.

VPP Host Stack 공식 문서/소스

TLS 관련 RFC

QUIC / HTTP 관련 RFC

암호 엔진·라이브러리

운영·성능 참고

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

오픈소스 코드 인용 고지

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

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