TCP/TLS 프록시 · SSL Inspection

VPP 프록시: TCP 프록시와 TPROXY 기본 원리, VPP TLS 종단 프록시 실전 구성, SSL/TLS Inspection 아키텍처·이중 세션·동적 인증서·SNI 바이패스·TLS 1.3 고려사항·운영 런북을 다룹니다.

전제 조건: VPP 호스트 스택·TLS·QUIC의 VCL/VLS 세션 레이어·TCP 호스트 스택·TLS 아키텍처를 먼저 이해하세요. 또한 TPROXY 커널 구현의 IP_TRANSPARENT 소켓(Socket) 옵션과 정책 라우팅, Netfilter의 mangle 테이블이 배경 지식으로 필요합니다.
⚠️ 엔진 상태 (VPP 25.02 기준): 본 문서의 TLS 종단·SSL Inspection 예제는 tlsopenssl 플러그인과 OpenSSL ENGINE 비동기 연동을 전제로 합니다. VPP 릴리스 노트상 TLS 프레임워크 자체는 production이지만, OpenSSL 엔진·QUIC·picotls·AF_XDP 등은 experimental로 분류됩니다. 또한 OpenSSL 3.x부터는 ENGINE API가 deprecated되고 provider API로 이전 중이므로 QAT 등 HW 가속 경로의 호환성을 프로덕션 배포 전 최신 릴리스 노트에서 반드시 재확인하세요.

핵심 요약

  • TCP 프록시(TCP Proxy) — 클라이언트와 서버 사이에서 두 개의 독립적 TCP 연결을 유지하며 바이트 스트림(Byte Stream)을 중계합니다. L7 프록시와 달리 애플리케이션 프로토콜을 해석하지 않습니다.
  • TPROXY(투명 프록시) — 원본 클라이언트 IP를 보존한 채 L7 프록시로 리다이렉트합니다. 커널 측은 IP_TRANSPARENT 소켓 옵션과 mangle 테이블 규칙으로 구현하고, VPP 측은 session layer로 구현합니다.
  • VPP TLS 종단 프록시 — VCL + VPP TLS 엔진으로 TLS 세션을 유저스페이스에서 종단(Termination)한 뒤 평문 바이트 스트림을 애플리케이션 로직에 전달합니다. 커널 기반 프록시보다 처리량(Throughput)과 핸드셰이크 속도가 높습니다.
  • SSL Inspection 이중 세션 — 인스펙션(Inspection) 프록시는 클라이언트와 한 TLS 세션을, 서버와 또 다른 TLS 세션을 유지하며 중간에서 평문으로 복호화(Decryption)·검사·재암호화(Re-encryption)를 수행합니다.
  • 동적 인증서 생성 엔진 — 인스펙션 프록시가 클라이언트에 제시할 리프(Leaf) 인증서를 매 연결마다 온디맨드로 서명합니다. 루트 CA는 사전에 클라이언트 신뢰 저장소에 등록되어야 합니다.
  • SNI 바이패스와 정책 엔진 — Server Name Indication을 ClientHello에서 추출하여 민감 도메인(뱅킹, 헬스케어)은 복호화 없이 바이패스하고, 나머지만 검사합니다. TLS 1.3 ECH(Encrypted ClientHello)는 SNI 자체를 암호화(Encryption)하여 사전 분류를 어렵게 합니다.
  • IDS/IPS 연동 — 복호화된 평문을 Suricata, Snort, Zeek 같은 엔진에 memif 또는 UNIX 소켓으로 전달하여 시그니처 매칭과 이상 탐지를 수행합니다. SFC(Service Function Chaining) 구조로 분리 가능합니다.
  • QUIC / ECH 도전 — QUIC은 TLS 1.3을 내부에 통합하고 UDP 위에서 핸드셰이크를 수행하므로 기존 TCP 기반 프록시 방식으로 가시성을 확보할 수 없습니다. UDP 인터셉트와 QUIC 전용 프록시가 필요합니다.

단계별 이해

  1. TCP 프록시 원리 이해
    두 독립 TCP 연결의 상태·버퍼(Buffer)·혼잡 제어(Congestion Control)가 서로 분리되어 동작합니다. 스플라이싱(splice)으로 커널 내 제로카피 복사 경로를 확보하거나, 유저스페이스 복사 경로를 선택할 수 있습니다.
  2. TPROXY 커널 구성 선행
    iptables -t mangle -A PREROUTING -p tcp --dport 80 -j TPROXY --on-port 8080 --tproxy-mark 1/1ip rule add fwmark 1 lookup 100, ip route add local default dev lo table 100으로 커널 측 기반을 준비합니다. VPP는 이 구성을 이해한 상태에서 session layer로 인라인 경로를 구현합니다.
  3. VCL 기반 TLS 종단 프록시 작성
    vppcom_session_accept()로 클라이언트 연결을 받고, vppcom_session_connect()로 업스트림 연결을 생성합니다. 양쪽 세션의 FIFO를 epoll로 폴링(Polling)하여 데이터를 릴레이(Relay)합니다.
  4. 루트 CA 인프라 구축
    OpenSSL 또는 cfssl로 루트 CA와 중간 CA를 생성하고, 엔터프라이즈 환경에서는 MDM(Mobile Device Management)이나 Group Policy로 클라이언트 신뢰 저장소에 루트 CA를 설치합니다. 이 과정 없이는 브라우저가 인증서 경고를 표시합니다.
  5. 동적 인증서 생성 엔진 구현
    ClientHello에서 SNI를 추출하고 실제 서버 인증서의 Subject/SAN을 복제한 리프 인증서를 루트 CA 키로 즉시 서명합니다. 생성 비용을 줄이기 위해 호스트별 캐시(Cache)와 LRU 정책을 함께 둡니다.
  6. 정책 엔진과 관측성
    SNI 바이패스 목록, IDS/IPS 시그니처 결과, 세션 로그, 복호화된 평문의 일부 해시(Hash)를 결합하여 정책 결정을 기록합니다. TLS 1.3 세션 티켓 재사용과 0-RTT는 운영 중 예외 케이스로 별도 추적합니다.

TCP 프록시와 TPROXY: VPP TLS 구현까지의 기술 경로

📌 문서 기준 버전 · 프록시/HTTP 관련 25.02 → 26.02 변경:
  • 25.06: TLS ALPN 협상 지원(VCL 애플리케이션이 클라이언트로서 우선순위(Priority) 지정 가능), QUIC engine API 공식 출시, HTTP/2 전체 구현(HPACK·프레임·흐름제어·멀티플렉싱·클라이언트), HTTP 클라이언트 병렬 세션.
  • 25.10: HTTP/2 CONNECT / PUT / extended CONNECT / UDP over HTTP/2 터널 지원. MASQUE 스타일의 UDP 터널링이 가능해졌습니다. Session 레이어에 trusted CA 구성·proxy_write_early_data 콜백·per-FIFO 최대 메모리가 들어왔습니다.
  • 26.02: HTTP/3 core + H3 클라이언트, HTTP connect proxy client 내장, HTTP 클라이언트 basic redirect, 서버측 mTLS 지원 + peer cert 조회(VCL 차원).
25.02 기준 프록시 구현에서 "HTTP/2 CONNECT 터널이 안 돼요", "클라이언트 인증서를 얻을 수 없어요"라는 두 가지 제약을 해결하려 우회책을 쌓아 두셨다면, 26.02로 올리는 것만으로 코드가 대폭 줄어들 여지가 있습니다. 전체 릴리스별 변화는 Host Stack 문서의 변경 요약 표를 참고하시기 바랍니다.

VPP TLS 종단 프록시를 이해하려면, 먼저 TCP 프록시의 기본 원리와 리눅스 커널의 TPROXY(투명 프록시) 메커니즘을 이해해야 합니다. 이 섹션에서는 기존 커널 기반 프록시의 동작 원리부터 VPP 유저스페이스 TLS 프록시 구현까지의 전체 기술 경로를 단계별로 다룹니다.

TCP 프록시 기초

L4 프록시 개념

TCP 프록시(L4 프록시)는 클라이언트와 서버 사이에서 두 개의 독립적인 TCP 연결을 유지하며, 양쪽 간 데이터를 중계하는 중간자(intermediary) 역할을 합니다. L7 프록시(HTTP 리버스 프록시 등)와 달리 애플리케이션 프로토콜을 해석하지 않고, TCP 바이트 스트림을 그대로 전달합니다.

TCP 프록시 (L4 Proxy) 기본 구조 클라이언트 10.0.0.100:54321 connect(proxy:443) TCP 연결 ① 프론트엔드 연결 TCP 프록시 accept() 프론트 소켓 fd relay connect() 백엔드 소켓 fd epoll 이벤트 루프 (양방향 중계) recv(fd1) → send(fd2) | recv(fd2) → send(fd1) TCP 연결 ② 백엔드 연결 백엔드 서버 192.168.1.10:8080 실제 서비스 핵심: 프록시는 두 개의 독립 TCP 연결을 유지하며, 클라이언트는 백엔드의 존재를 모릅니다 프록시 IP가 클라이언트의 목적지 → NAT/라우팅으로 트래픽 유인 필요

소켓 기반 TCP 릴레이 구현

가장 기본적인 TCP 프록시는 두 소켓 사이에서 recv()/send()를 반복하는 릴레이 방식입니다. 이 방식의 구조와 성능 한계를 이해하는 것이 VPP 프록시의 필요성을 파악하는 첫걸음입니다.

/* 기본 TCP 프록시 릴레이 — 소켓 기반 (단순화) */
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define BUF_SIZE 65536

/* 양방향 릴레이: client_fd ↔ backend_fd */
static void
relay_data (int src_fd, int dst_fd)
{
    char buf[BUF_SIZE];
    ssize_t nread, nwritten;

    /* 1. 커널 → 유저 공간으로 복사 (recv) */
    nread = recv (src_fd, buf, sizeof(buf), 0);
    if (nread <= 0)
        return;

    /* 2. 유저 공간 → 커널로 복사 (send) */
    nwritten = send (dst_fd, buf, nread, 0);
    /* 최소 2회 syscall + 2회 메모리 복사 per 방향 */
}

/* epoll 기반 이벤트 루프 */
static void
proxy_event_loop (int epoll_fd)
{
    struct epoll_event events[1024];

    while (1) {
        /* 3. epoll_wait — 3번째 syscall */
        int n = epoll_wait (epoll_fd, events, 1024, -1);

        for (int i = 0; i < n; i++) {
            proxy_conn_t *conn = events[i].data.ptr;

            if (events[i].events & EPOLLIN) {
                /* client→backend 또는 backend→client 릴레이 */
                relay_data (conn->src_fd, conn->peer->dst_fd);
            }
        }
    }
}

/* 프록시 리스너: 새 연결마다 백엔드 연결 생성 */
static void
handle_new_connection (int listen_fd)
{
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);

    /* 클라이언트 연결 수락 */
    int client_fd = accept4 (listen_fd,
        (struct sockaddr *)&client_addr, &addr_len,
        SOCK_NONBLOCK);

    /* 백엔드 서버에 연결 */
    int backend_fd = socket (AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    struct sockaddr_in backend_addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = inet_addr("192.168.1.10")
    };
    connect (backend_fd, (struct sockaddr *)&backend_addr,
             sizeof(backend_addr));

    /* 양쪽 fd를 epoll에 등록하여 릴레이 시작 */
    register_proxy_pair (client_fd, backend_fd);
}
코드 설명
  • relay_data() 한 방향의 데이터 전달에 recv() + send() 2회 syscall이 필요합니다. 양방향이면 4회이며, 각 syscall마다 커널-유저 간 데이터 복사가 발생합니다. 이것이 커널 프록시의 근본적 성능 병목(Bottleneck)입니다.
  • handle_new_connection() 클라이언트가 프록시에 연결하면, 프록시가 별도의 TCP 연결을 백엔드에 맺습니다. 클라이언트는 백엔드의 IP를 알 필요가 없으며, 프록시의 IP로만 통신합니다. 이 구조가 L4 프록시의 핵심입니다.

splice()를 이용한 제로카피 TCP 릴레이

리눅스 커널의 splice() 시스템 콜(System Call)은 커널 내부 파이프 버퍼를 활용하여 두 파일 디스크립터(File Descriptor) 간 데이터를 유저스페이스 복사 없이 전달합니다. TCP 프록시에서 성능을 크게 향상시키는 기법입니다.

TCP 프록시 데이터 경로 비교: recv/send vs splice 기존 방식: recv() + send() NIC RX 커널 TCP RX 소켓 버퍼 복사① 유저 버퍼 복사② 커널 TCP TX 소켓 버퍼 syscall 4회 (recv + send × 양방향) + 복사 4회 최적화: splice() — 커널 내부 제로카피 NIC RX 커널 TCP RX 소켓 버퍼 페이지 ref pipe 버퍼 페이지 ref 커널 TCP TX 소켓 버퍼 syscall 4회 (splice × 2 × 양방향) + 복사 0회 (페이지 참조 전달) VPP 방식: 공유 메모리 FIFO — syscall 0회 + 복사 0회 DPDK RX VPP TCP → FIFO SHM ref FIFO → VPP TCP DPDK TX 전체 경로가 유저스페이스 — 커널 경유 없음, 컨텍스트 스위칭 없음
/* splice()를 이용한 제로카피 TCP 릴레이 */
#include <fcntl.h>

static void
splice_relay (int src_fd, int dst_fd, int pipe_fd[2])
{
    ssize_t n;

    /* 1단계: 소켓 → 파이프 (커널 내부에서 페이지 참조 이동) */
    n = splice (src_fd, NULL, pipe_fd[1], NULL,
               65536, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    if (n <= 0)
        return;

    /* 2단계: 파이프 → 소켓 (유저스페이스 복사 없음) */
    splice (pipe_fd[0], NULL, dst_fd, NULL,
            n, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
}

/* 연결당 파이프 쌍 생성 — 양방향이므로 2쌍 필요 */
int pipe_c2b[2], pipe_b2c[2];
pipe2 (pipe_c2b, O_NONBLOCK);  /* client → backend */
pipe2 (pipe_b2c, O_NONBLOCK);  /* backend → client */

/* epoll에서 이벤트 발생 시 */
splice_relay (client_fd, backend_fd, pipe_c2b);
splice_relay (backend_fd, client_fd, pipe_b2c);
방식syscall 횟수/방향메모리 복사fd 오버헤드처리량 (1Gbps NIC)
recv/send2회2회 (커널↔유저)없음~600 Mbps
splice2회0회 (페이지(Page) ref)파이프 2쌍/연결~900 Mbps
VPP FIFO0회0회 (SHM)없음~10+ Gbps
splice()의 한계: splice()는 커널 내부 복사를 제거하지만, 여전히 시스템 콜 오버헤드(컨텍스트 스위칭(Context Switching))가 남아있습니다. 또한 TLS 암복호화가 필요한 경우 splice()를 사용할 수 없습니다 — TLS 레코드를 파싱하고 복호화하려면 데이터를 유저스페이스에서 처리해야 하기 때문입니다. 이것이 커널 기반 TLS 프록시가 성능 한계에 부딪히는 근본 원인이며, VPP 유저스페이스 프록시가 필요한 이유입니다.

TPROXY (투명 프록시) 메커니즘

투명 프록시가 필요한 이유

일반 TCP 프록시(명시적 프록시)에서 클라이언트는 프록시의 IP에 직접 연결합니다. 그러나 많은 시나리오에서 클라이언트가 프록시의 존재를 인식하지 못한 채 통신하도록 해야 합니다. 투명 프록시(Transparent Proxy)는 네트워크 경로상의 트래픽을 가로채어, 클라이언트가 원래 목적지와 직접 통신하는 것처럼 보이게 합니다.

비교 항목명시적 프록시 (Explicit)투명 프록시 (Transparent)
클라이언트 설정프록시 IP/포트 설정 필요설정 불필요 (네트워크가 리다이렉트)
트래픽 유인클라이언트가 직접 프록시에 연결iptables/라우팅(Routing)으로 리다이렉트
원본 목적지 보존프록시가 목적지 결정원본 dst IP/포트 보존 필요
소켓 옵션일반 소켓IP_TRANSPARENT 필요
커널 지원불필요TPROXY 모듈 또는 REDIRECT 필요
대표 사용례HTTP 프록시, SOCKS방화벽, SSL Inspection, CDN

리눅스 TPROXY 동작 원리

리눅스 커널의 TPROXY는 Netfilter(iptables)-j TPROXY 타겟을 통해 동작합니다. 패킷(Packet)의 원본 목적지 주소를 변경하지 않고 로컬 소켓으로 전달하는 것이 핵심입니다.

Linux TPROXY 투명 프록시 패킷 흐름 클라이언트 패킷 src=10.0.0.100 dst=93.184.216.34:443 NIC RX ip_rcv() PREROUTING (mangle 테이블) -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8443 패킷에 fwmark 설정 + 로컬 소켓 할당 ip_route_input (정책 라우팅) fwmark 0x1 → ip rule → 로컬 테이블 → ip_local_deliver tcp_v4_rcv() → TPROXY 소켓 매칭 dst=93.184.216.34:443 패킷이 로컬 소켓에 전달 프록시 애플리케이션 (IP_TRANSPARENT) getsockopt(SO_ORIGINAL_DST) → 93.184.216.34:443 획득 TPROXY 핵심 차이점 REDIRECT: dst IP를 127.0.0.1로 변경 TPROXY: dst IP 유지 → 원본 목적지 보존 → 프록시가 원래 서버인 것처럼 응답 가능 TPROXY는 NAT를 사용하지 않으므로 conntrack 부하가 없고, 원본 IP 쌍이 보존됩니다 이는 방화벽 로깅, 접근 제어, SSL Inspection에서 중요한 장점입니다

TPROXY iptables 설정 상세

TPROXY 투명 프록시를 구성하는 전체 iptables/ip rule 설정과 각 규칙의 역할입니다.

# ━━━━ 1단계: TPROXY iptables 규칙 설정 ━━━━

# HTTPS(443) 트래픽을 TPROXY로 리다이렉트
# mangle 테이블의 PREROUTING 체인에서 동작
iptables -t mangle -A PREROUTING \
    -p tcp --dport 443 \
    -j TPROXY \
    --tproxy-mark 0x1/0x1 \
    --on-ip 0.0.0.0 \
    --on-port 8443

# --tproxy-mark: 패킷에 fwmark를 설정 (정책 라우팅에 사용)
# --on-port: 패킷을 전달할 로컬 리스닝 포트
# --on-ip: 0.0.0.0이면 원본 dst IP 유지

# ━━━━ 2단계: 정책 라우팅 설정 ━━━━

# fwmark 0x1 패킷에 대한 라우팅 테이블 지정
ip rule add fwmark 0x1 lookup 100

# 테이블 100: 모든 트래픽을 로컬로 전달
ip route add local 0.0.0.0/0 dev lo table 100

# ━━━━ 3단계: 역방향 경로 필터 비활성화 ━━━━

# TPROXY는 다른 IP를 목적지로 하는 패킷을 수신하므로
# rp_filter가 이를 스푸핑으로 간주하여 드롭할 수 있습니다
sysctl -w net.ipv4.conf.all.rp_filter=0
sysctl -w net.ipv4.conf.eth0.rp_filter=0

# IP 포워딩 활성화 (게이트웨이로 동작하는 경우)
sysctl -w net.ipv4.ip_forward=1

# ━━━━ 확인 명령 ━━━━
iptables -t mangle -L PREROUTING -nv
ip rule show
ip route show table 100

IP_TRANSPARENT 소켓을 이용한 TPROXY 프록시 구현

TPROXY로 리다이렉트된 트래픽을 수신하려면 프록시 소켓에 IP_TRANSPARENT 옵션을 설정해야 합니다. 이 옵션은 소켓이 로컬에 할당되지 않은 IP 주소의 트래픽도 수신하고, 해당 IP로 응답을 보낼 수 있게 합니다.

/* TPROXY 투명 프록시 서버 구현 (핵심 부분) */
#include <sys/socket.h>
#include <netinet/in.h>
#include <linux/netfilter_ipv4.h>

/* IP_TRANSPARENT 리스닝 소켓 생성 */
static int
create_tproxy_listener (uint16_t port)
{
    int fd = socket (AF_INET, SOCK_STREAM, 0);
    int one = 1;

    /* IP_TRANSPARENT: 로컬에 없는 IP의 트래픽도 수신 가능 */
    setsockopt (fd, SOL_IP, IP_TRANSPARENT, &one, sizeof(one));

    /* SO_REUSEADDR: 빠른 포트 재사용 */
    setsockopt (fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons (port),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind (fd, (struct sockaddr *)&addr, sizeof(addr));
    listen (fd, 4096);

    return fd;
}

/* 수락된 연결에서 원본 목적지 주소 획득 */
static int
get_original_dst (int client_fd,
                   struct sockaddr_in *orig_dst)
{
    socklen_t len = sizeof(*orig_dst);

    /* TPROXY는 SO_ORIGINAL_DST 대신 getsockname()으로 원본 dst 획득 */
    /* TPROXY가 dst IP를 변경하지 않으므로, 소켓의 로컬 주소가
     * 곧 클라이언트가 연결하려 했던 원본 목적지입니다 */
    return getsockname (client_fd,
                        (struct sockaddr *)orig_dst, &len);
}

/* 백엔드 연결 시 IP_TRANSPARENT로 원본 src IP 유지 */
static int
connect_to_backend_transparent (
    struct sockaddr_in *orig_dst,
    struct sockaddr_in *client_addr)
{
    int fd = socket (AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    int one = 1;

    /* 백엔드에도 IP_TRANSPARENT 설정:
     * 클라이언트의 원본 src IP로 바인딩하여
     * 백엔드 서버가 프록시 존재를 인식하지 못하게 합니다 */
    setsockopt (fd, SOL_IP, IP_TRANSPARENT, &one, sizeof(one));

    /* 클라이언트 IP로 소스 주소 바인딩 (스푸핑) */
    bind (fd, (struct sockaddr *)client_addr,
          sizeof(*client_addr));

    /* 원본 목적지 서버에 연결 */
    connect (fd, (struct sockaddr *)orig_dst,
             sizeof(*orig_dst));

    return fd;
}
코드 설명
  • IP_TRANSPARENT IP_TRANSPARENT 소켓 옵션은 CAP_NET_ADMIN 권한이 필요합니다. 이 옵션이 설정된 소켓은 로컬에 할당되지 않은 IP 주소로의 바인드와 해당 IP의 트래픽 수신이 가능합니다. TPROXY의 핵심 요소입니다.
  • getsockname() TPROXY는 REDIRECT와 달리 패킷의 dst IP를 변경하지 않으므로, accept()한 소켓의 getsockname()이 원본 목적지 주소를 반환합니다. REDIRECT 방식에서 사용하는 SO_ORIGINAL_DST와 다른 점에 주의하세요.
  • connect_to_backend_transparent() 백엔드 연결에서도 IP_TRANSPARENT를 사용하여 클라이언트의 원본 소스 IP로 바인드합니다. 이렇게 하면 백엔드 서버의 로그에 실제 클라이언트 IP가 기록되며, 완전한 투명성을 달성합니다.

TPROXY vs REDIRECT 비교

리눅스에서 투명 프록시를 구현하는 두 가지 Netfilter 방식의 차이입니다.

항목TPROXY (-j TPROXY)REDIRECT (-j REDIRECT)
Netfilter 테이블mangle (PREROUTING)nat (PREROUTING)
패킷 변조dst IP 변경 없음dst IP를 127.0.0.1로 변경
원본 목적지 획득getsockname()getsockopt(SO_ORIGINAL_DST)
conntrack 의존불필요필요 (NAT 추적)
성능높음 (NAT 오버헤드 없음)낮음 (conntrack 부하)
소스 IP 스푸핑가능 (IP_TRANSPARENT)불가능
커널 모듈(Kernel Module)xt_TPROXYxt_REDIRECT + nf_conntrack
권한 요구CAP_NET_ADMINCAP_NET_ADMIN
적합 시나리오고성능 프록시, 방화벽간단한 로컬 리다이렉트
# REDIRECT 방식 (비교용) — conntrack 의존
iptables -t nat -A PREROUTING \
    -p tcp --dport 443 \
    -j REDIRECT --to-ports 8443

# 프록시에서 원본 목적지 획득 (REDIRECT 방식)
# getsockopt(client_fd, SOL_IP, SO_ORIGINAL_DST,
#            &orig_dst, &len);

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# TPROXY 방식 — conntrack 불필요, 고성능
iptables -t mangle -A PREROUTING \
    -p tcp --dport 443 \
    -j TPROXY --on-port 8443 --tproxy-mark 0x1/0x1

# 프록시에서 원본 목적지 획득 (TPROXY 방식)
# getsockname(client_fd, &orig_dst, &len);

TPROXY + TLS의 한계: 커널 프록시의 성능 병목

TPROXY와 TLS를 조합하면 커널 기반 투명 TLS 프록시를 구성할 수 있지만, 심각한 성능 병목이 발생합니다.

커널 TPROXY + TLS 프록시의 성능 병목 데이터 경로: 최소 6회 syscall + 4회 메모리 복사 (per 요청) NIC → 커널 TPROXY mangle hook 복사① recv() 유저스페이스 SSL_read() TLS 복호화 평문 처리 SSL_write() TLS 암호화 복사② send() 성능 병목 분석: 커널 TPROXY + OpenSSL TLS 프록시 syscall 오버헤드 recv + SSL + send + epoll_wait = 최소 6회/요청 × 양방향 컨텍스트 스위칭 ~1μs/회 → 100K req/s에서 ~600ms/s 낭비 메모리 복사 오버헤드 커널→유저(recv) + 유저→커널(send) = 4회 복사/요청 (양방향) splice() 불가 (TLS 복호화 필요) → 10Gbps에서 메모리 대역폭 포화 VPP 유저스페이스 TLS 프록시가 해결하는 문제 syscall 제거 전체 경로가 유저스페이스 DPDK 폴링 → TCP → TLS → 앱 → syscall 0회, 컨텍스트 스위칭 0회 제로카피 FIFO 공유 메모리 FIFO로 데이터 전달 TLS 엔진이 FIFO 직접 접근 → 복사 0~1회, 100Gbps+ 가능

커널 프록시에서 VPP TLS 프록시로의 전환

이 절부터 "실전 예제: VPP TLS 종단 프록시" 끝까지의 CLI 블록은 대부분 개념적 의사 CLI입니다. tls cert add, tls ca-cert add, tls key add, show tls ctx verbose, show tls certs, set tls cipher 같은 명령은 공식 tlsopenssl 플러그인 소스에 존재하지 않습니다. 실제 CLI는 tls openssl set·tls openssl set-tls·tls openssl load-provider 세 개이고, 인증서·CA·cipher·세션 티켓은 바이너리 API(app_add_cert_key_pair) 또는 OpenSSL 설정으로 다룹니다. 정정된 코드 경로는 Host Stack 문서의 VCL TLS 레시피TLS 운영 심화에 있습니다. 아래 블록은 "무엇을 해야 하는가"를 나타내는 가이드로 읽어 주시기 바랍니다.

기술 스택 비교: 커널 vs VPP

커널 기반 TPROXY+TLS 프록시와 VPP 기반 TLS 프록시의 전체 기술 스택을 비교합니다.

계층커널 기반 (TPROXY + OpenSSL)VPP 기반
NIC 접근커널 NIC 드라이버 (인터럽트(Interrupt) 기반)DPDK/AF_XDP (폴링 기반, 제로카피)
패킷 분류Netfilter (iptables hooks)VPP classifier 노드
투명 프록시TPROXY (xt_TPROXY 모듈)세션 레이어 라우팅 (classify + output-feature)
TCP 스택커널 TCPVPP 내장 TCP (유저스페이스)
TLS 처리OpenSSL (유저스페이스 라이브러리)TLS 엔진 플러그인 (세션 레이어 통합)
데이터 전달recv/send syscall + 커널 복사SVM FIFO (공유 메모리, 제로카피)
이벤트 모델epoll (syscall per wait)공유 메모리 이벤트 큐 (폴링)
멀티코어프로세스(Process)/스레드(Thread) (잠금 경합(Lock Contention))워커 스레드 (RSS 해시 기반 무락)
인증서 교체프로세스 재시작(Reboot) 필요무중단 CLI/API 교체
처리량 (10G NIC)~3 Gbps (TLS)~9+ Gbps (TLS)
CPS (연결/초)~30K (RSA-2048)~150K (QAT), ~80K (SW)

VPP 프록시 애플리케이션 아키텍처

VPP에서 TCP/TLS 프록시를 구현하는 방법은 두 가지입니다: 내장 프록시 플러그인을 사용하거나, VCL 기반 외부 애플리케이션을 작성하는 것입니다.

VPP 프록시 구현: 내장 플러그인 vs VCL 애플리케이션 방식 A: 내장 프록시 플러그인 클라이언트 TLS 세션 백엔드 TCP 세션 proxy_session_relay() FIFO-to-FIFO 직접 릴레이 (내부 API) 장점: 최고 성능 (프로세스 간 통신 없음) 데이터가 VPP 프로세스 내부에서만 이동 단점: VPP C 플러그인 개발 필요 방식 B: VCL 외부 애플리케이션 VPP (TLS + TCP) SHM FIFO VCL 프록시 앱 vppcom_session_read/write() VCL API로 양쪽 세션 릴레이 (외부 프로세스) 장점: C/Python 등 자유로운 개발 LD_PRELOAD로 기존 프록시 앱도 가속 가능 단점: SHM FIFO 경유 (미미한 오버헤드) VPP 내장 프록시: proxy_session 릴레이 흐름 클라이언트 TLS App RX FIFO 평문 proxy_session_relay() svm_fifo_segments() 직접 전달 평문 백엔드 TCP App TX FIFO tcp_output → DPDK TX 백엔드 서버로 전달 핵심: FIFO-to-FIFO 릴레이는 svm_fifo_segments()로 직접 포인터를 전달하여 복사를 제거합니다 TLS 복호화된 평문이 이미 App RX FIFO에 있으므로, 그 내용을 백엔드 TCP의 TX FIFO에 직접 연결합니다 src/plugins/http/http_proxy.c — VPP 내장 HTTP(S) 프록시 참조 구현

VPP 프록시 세션 릴레이 소스 분석

VPP 내장 프록시의 핵심은 두 세션의 FIFO를 직접 연결하는 proxy_session 릴레이입니다. TLS 복호화 후의 평문 데이터를 백엔드 TCP 세션으로 전달하는 경로를 분석합니다.

/* VPP 프록시 세션 릴레이 — FIFO 직접 전달 (의사코드) */
static int
proxy_rx_callback (session_t *s)
{
  proxy_session_t *ps = proxy_session_get (s);
  session_t *peer;
  svm_fifo_t *rx_fifo, *peer_tx_fifo;
  svm_fifo_seg_t segs[2];
  u32 n_segs, max_deq, max_enq, to_relay;

  /* 피어 세션 (클라이언트↔백엔드) 조회 */
  peer = session_get_from_handle (ps->peer_session_handle);
  rx_fifo = s->rx_fifo;
  peer_tx_fifo = peer->tx_fifo;

  /* 릴레이 가능한 바이트 수 계산 */
  max_deq = svm_fifo_max_dequeue_cons (rx_fifo);
  max_enq = svm_fifo_max_enqueue_prod (peer_tx_fifo);
  to_relay = clib_min (max_deq, max_enq);

  if (to_relay == 0)
    return 0;

  /* 제로카피 세그먼트 접근: RX FIFO 내부 메모리 직접 참조 */
  n_segs = 2;
  svm_fifo_segments (rx_fifo, 0, segs, &n_segs, to_relay);

  /* 피어 TX FIFO에 직접 enqueue (중간 버퍼 없이) */
  for (u32 i = 0; i < n_segs; i++)
    svm_fifo_enqueue (peer_tx_fifo, segs[i].len, segs[i].data);

  /* 원본 FIFO에서 릴레이한 바이트 제거 */
  svm_fifo_dequeue_drop (rx_fifo, to_relay);

  /* 피어에 TX 이벤트 발생 → 전송 시작 */
  session_send_io_evt_to_thread (peer_tx_fifo,
                                 SESSION_IO_EVT_TX);
  return 0;
}
코드 설명
  • svm_fifo_segments() 이 함수는 FIFO 내부 메모리에 대한 직접 포인터를 반환합니다. 링버퍼이므로 데이터가 경계를 넘는 경우 최대 2개 세그먼트가 반환됩니다. 별도의 중간 버퍼 할당이 불필요하여 진정한 제로카피를 달성합니다.
  • proxy_rx_callback() 이 콜백(Callback)은 세션 레이어가 RX 데이터를 감지할 때 자동 호출됩니다. TLS 세션인 경우, 이 시점에서 데이터는 이미 TLS 엔진에 의해 복호화된 평문 상태입니다. 따라서 프록시 릴레이 코드는 TLS 존재 여부에 무관하게 동일하게 동작합니다.
  • backpressure clib_min(max_deq, max_enq)로 피어 FIFO의 남은 공간만큼만 릴레이합니다. 피어가 느려서 TX FIFO가 가득 차면 자동으로 backpressure가 적용되어, TCP 윈도우가 축소되고 결국 클라이언트의 전송 속도가 조절됩니다.

VPP에서의 투명 프록시 구현 방법

VPP는 커널의 TPROXY와 다른 방식으로 투명 프록시를 구현합니다. DPDK가 커널을 우회하여 패킷을 직접 수신하므로, iptables TPROXY 규칙이 동작하지 않습니다. 대신 VPP의 그래프 노드 파이프라인(Pipeline)에서 투명 프록시 기능을 구현합니다.

# ━━━━ VPP 투명 TLS 프록시 설정 ━━━━

# 1. 인터페이스 설정 (DPDK 바인딩)
vpp# create interface dpdk GigabitEthernet3/0/0
vpp# set interface state GigabitEthernet3/0/0 up
vpp# set interface ip address GigabitEthernet3/0/0 10.0.0.1/24

# 2. 세션 레이어 활성화
vpp# session enable

# 3. TLS 인증서 등록
vpp# tls cert add cert /etc/vpp/certs/server.pem \
                  key /etc/vpp/certs/server.key

# 4. 내장 프록시 플러그인 활성화
# http_proxy 또는 echo_proxy 사용

# 5. TLS 리스너 생성 (프론트엔드: 클라이언트 대면)
#    세션 레이어가 자동으로 TCP+TLS 핸드셰이크 처리
vpp# http static server www-root /var/www/html \
     uri tls://0.0.0.0/443 \
     cache-size 10m

# 또는 CLI 기반 프록시 설정
vpp# test proxy server uri tls://0.0.0.0/443 \
     backend tcp://192.168.1.10/8080

# 6. 투명 모드: classify를 이용한 트래픽 리다이렉트
# 목적지 포트 443 트래픽을 로컬 세션 레이어로 유인
vpp# classify session acl-hit-next local \
     table-index 0 match l4 dst_port 443

# 7. 동작 확인
vpp# show session verbose
vpp# show tls ctx verbose
vpp# show proxy statistics
커널 TPROXY → VPP 전환 체크리스트:
  1. NIC 바인딩: dpdk-devbind -b vfio-pci <PCI_ADDR>로 NIC을 DPDK에 바인딩합니다. 이후 iptables TPROXY 규칙은 해당 NIC에 적용되지 않습니다.
  2. 세션 레이어 활성화: session enable이 필수이며, FIFO 크기와 세션 수를 startup.conf에서 설정합니다.
  3. 인증서 이전: 기존 프록시의 TLS 인증서를 tls cert add로 VPP에 등록합니다.
  4. 트래픽 유인: VPP의 classify/ACL 노드로 iptables TPROXY 역할을 대체합니다.
  5. 라우팅 설정: 백엔드 서버로의 경로가 VPP의 FIB에 등록되어야 합니다.

TCP 프록시 → TLS 프록시 구현 경로

단계별 구현 경로

순수 TCP 프록시에서 출발하여 완전한 VPP TLS 종단 프록시까지 도달하는 구현 경로를 단계별로 정리합니다.

단계구현 내용핵심 API/기능성능 이점
1. 커널 TCP 릴레이recv/send 기반 양방향 릴레이socket, epoll, recv, send기준선 (~600 Mbps)
2. splice 최적화커널 내부 제로카피 릴레이splice, pipe~1.5x (복사 제거)
3. TPROXY 투명화iptables + IP_TRANSPARENTxt_TPROXY, SO_ORIGINAL_DST동일 (투명성 확보)
4. TLS 종단 추가OpenSSL로 TLS 복호화/암호화SSL_read, SSL_write~0.5x (TLS 오버헤드)
5. VPP 이전: TCPVPP 세션 레이어 기반 릴레이session_open, SVM FIFO~5x (syscall 제거)
6. VPP TLS 통합VPP TLS 엔진 + FIFO 릴레이TRANSPORT_PROTO_TLS~10x (완전 유저스페이스)
7. 비동기 + HW 오프로드QAT/Cryptodev 비동기 암호화tls_async, Cryptodev PMD~15x (HW 가속)

VCL 기반 TLS 프록시 전체 구현 예제

VCL API를 사용한 TLS 종단 프록시의 전체 구현 예제입니다. 프론트엔드에서 TLS를 종단하고, 백엔드에는 평문 TCP로 연결합니다.

/* VCL 기반 TLS 종단 프록시 (전체 구현) */
#include <vcl/vppcom.h>
#include <pthread.h>

#define BACKEND_IP    "192.168.1.10"
#define BACKEND_PORT  8080
#define LISTEN_PORT   443
#define BUF_SIZE      65536

/* 연결 쌍: TLS 프론트 ↔ TCP 백엔드 */
typedef struct {
    int frontend;     /* TLS 세션 (클라이언트) */
    int backend;      /* TCP 세션 (백엔드 서버) */
} proxy_pair_t;

/* 양방향 릴레이 스레드 */
static void *
relay_thread (void *arg)
{
    proxy_pair_t *pp = (proxy_pair_t *)arg;
    uint8_t buf[BUF_SIZE];
    int epoll_fd, n_events;
    struct epoll_event events[2];

    /* VCL epoll 생성 (vppcom 전용) */
    epoll_fd = vppcom_epoll_create ();

    /* 양쪽 세션을 epoll에 등록 */
    vppcom_epoll_ctl (epoll_fd, EPOLL_CTL_ADD,
                      pp->frontend, EPOLLIN);
    vppcom_epoll_ctl (epoll_fd, EPOLL_CTL_ADD,
                      pp->backend, EPOLLIN);

    while (1) {
        n_events = vppcom_epoll_wait (epoll_fd, events,
                                       2, -1);
        for (int i = 0; i < n_events; i++) {
            int src, dst;

            if (events[i].data.fd == pp->frontend) {
                src = pp->frontend;  /* TLS (평문으로 읽힘) */
                dst = pp->backend;   /* TCP (평문으로 전송) */
            } else {
                src = pp->backend;
                dst = pp->frontend;
            }

            /* VCL read: TLS 세션이면 자동 복호화 */
            int nread = vppcom_session_read (src, buf,
                                              BUF_SIZE);
            if (nread <= 0) {
                /* 연결 종료 */
                vppcom_session_close (pp->frontend);
                vppcom_session_close (pp->backend);
                free (pp);
                return NULL;
            }

            /* VCL write: TLS 세션이면 자동 암호화 */
            vppcom_session_write (dst, buf, nread);
        }
    }
}

int main (void)
{
    vppcom_endpt_t endpt = {0};
    int listener, client;

    /* 1. VCL 초기화 */
    vppcom_app_create ("tls-proxy");

    /* 2. TLS 리스너 생성 */
    listener = vppcom_session_create (VPPCOM_PROTO_TLS, 0);

    /* 3. 인증서 설정 */
    vppcom_session_tls_add_cert (listener, cert_pem, cert_len);
    vppcom_session_tls_add_key (listener, key_pem, key_len);

    /* 4. 바인드 + 리슨 */
    endpt.is_ip4 = 1;
    endpt.port = htons (LISTEN_PORT);
    vppcom_session_bind (listener, &endpt);
    vppcom_session_listen (listener, 4096);

    /* 5. 메인 루프: 연결 수락 + 백엔드 연결 + 릴레이 */
    while (1) {
        /* accept: TLS 핸드셰이크 완료된 세션 반환 */
        client = vppcom_session_accept (listener, &endpt, 0);
        if (client < 0) continue;

        /* 백엔드 TCP 세션 생성 및 연결 */
        int backend = vppcom_session_create (VPPCOM_PROTO_TCP, 0);
        vppcom_endpt_t bk_ep = {
            .is_ip4 = 1,
            .port = htons (BACKEND_PORT),
        };
        inet_pton (AF_INET, BACKEND_IP, bk_ep.ip);
        vppcom_session_connect (backend, &bk_ep);

        /* 릴레이 스레드 시작 */
        proxy_pair_t *pp = malloc (sizeof(*pp));
        pp->frontend = client;
        pp->backend = backend;

        pthread_t tid;
        pthread_create (&tid, NULL, relay_thread, pp);
        pthread_detach (tid);
    }

    return 0;
}
코드 설명
  • VPPCOM_PROTO_TLS vs VPPCOM_PROTO_TCP 프론트엔드 리스너는 VPPCOM_PROTO_TLS로 생성하여 TLS 핸드셰이크를 자동 처리합니다. vppcom_session_accept()가 반환하는 세션은 이미 TLS 핸드셰이크가 완료된 상태이므로, vppcom_session_read()는 복호화된 평문을 반환합니다. 백엔드는 VPPCOM_PROTO_TCP로 평문 TCP 연결을 생성합니다.
  • 릴레이 구조 VCL의 epoll API는 커널 epoll과 동일한 인터페이스지만, 내부적으로 VPP의 공유 메모리 이벤트 큐를 사용합니다. 커널 epoll_wait()는 syscall이지만, VCL epoll_wait()는 유저스페이스에서 이벤트 큐를 폴링하므로 훨씬 빠릅니다.
  • 성능 참고 이 예제는 스레드 기반이지만, 프로덕션에서는 VPP 내장 프록시 플러그인이나 이벤트 기반 단일 스레드 모델이 더 효율적입니다. 내장 플러그인은 FIFO-to-FIFO 직접 릴레이로 중간 버퍼 복사를 완전히 제거합니다.
# VCL TLS 프록시 빌드 및 실행
$ gcc -o tls-proxy tls-proxy.c \
    -I/usr/include/vpp \
    -lvppcom -lvlibmemoryclient -lsvm -lpthread

# VCL 설정 파일
$ cat /etc/vpp/vcl-proxy.conf
vcl {
    rx-fifo-size 4000000
    tx-fifo-size 4000000
    app-scope-global
    use-mq-eventfd
    api-socket-name /run/vpp/api.sock
}

# 실행 (VPP 프로세스가 미리 실행 중이어야 합니다)
$ VCL_CONFIG=/etc/vpp/vcl-proxy.conf ./tls-proxy

# 테스트
$ curl -k https://10.0.0.1:443/
# → VPP TLS 종단 → 평문으로 백엔드 192.168.1.10:8080에 전달

성능 벤치마크: 커널 TPROXY+OpenSSL vs VPP TLS

측정 항목커널 TPROXY + OpenSSLVPP TLS (SW)VPP TLS (QAT)
CPS (RSA-2048)~25K~80K~150K
CPS (ECDSA P-256)~40K~120K~200K
처리량 (AES-128-GCM)~3 Gbps~9 Gbps~40 Gbps
지연(Latency)시간 (P99)~800 μs~200 μs~150 μs
CPU 사용률 (10K conn)~80%~30%~15%
메모리 (50K ctx)~2.5 GB~1.7 GB~1.7 GB
벤치마크 환경: Intel Xeon Gold 6330 (2.0GHz), 25G NIC (Intel E810), Ubuntu 22.04, OpenSSL 3.0, VPP 24.02. CPS = Connections Per Second (새 연결/초). 처리량은 TLS 1.3 + AES-128-GCM, 16KB 레코드 기준. QAT = Intel QuickAssist Technology (C62x). 실제 성능은 하드웨어, 설정, 트래픽 패턴에 따라 달라질 수 있습니다.

실전 예제: VPP TLS 종단 프록시

HTTPS 리버스 프록시 구성

VPP를 HTTPS 리버스 프록시로 구성하면, 외부 클라이언트의 TLS를 VPP에서 종단하고 내부 백엔드 서버에는 평문 HTTP로 전달할 수 있습니다:

VPP HTTPS 리버스 프록시 토폴로지 클라이언트 HTTPS 요청 TLS 1.3 VPP TLS 종단 프록시 TLS 복호화 HTTP 프록시 http_static / http_proxy 플러그인 DPDK / AF_XDP NIC 인터페이스 평문 HTTP 백엔드 서버 1 백엔드 서버 2 암호화 구간 평문 구간 (내부 네트워크)
# VPP HTTPS 리버스 프록시 설정 예시

# 1. 인터페이스 설정
vpp# create host-interface name eth0
vpp# set interface state host-eth0 up
vpp# set interface ip address host-eth0 10.0.0.1/24

# 2. TLS 인증서 로드
vpp# tls cert add cert /etc/vpp/certs/server.pem \
                  key /etc/vpp/certs/server.key

# 3. HTTP static 서버 + TLS 활성화
vpp# http static server www-root /var/www/html \
     uri tls://0.0.0.0/443 \
     cache-size 10m \
     fifo-size 32k

# 또는 리버스 프록시 모드
vpp# http connect-proxy uri tls://0.0.0.0/443

# 4. 동작 확인
vpp# show session verbose
vpp# show tls ctx verbose

VCL + TLS 서버 C 코드 예제

VCL API를 사용한 TLS 에코 서버의 핵심 코드:

/* VCL TLS Echo Server — 핵심 흐름 */
#include <vcl/vppcom.h>

int main (void)
{
  vppcom_endpt_t endpt = {0};
  uint8_t buf[4096];
  int rv, listener, client;

  /* 1. VCL 초기화 */
  rv = vppcom_app_create ("tls-echo-server");
  if (rv) return rv;

  /* 2. TLS 리스너 생성 (VPPCOM_PROTO_TLS) */
  listener = vppcom_session_create (VPPCOM_PROTO_TLS,
                                    0 /* non-blocking */);

  /* 3. 인증서/키 설정 */
  vppcom_session_tls_add_cert (listener,
      cert_pem, cert_len);
  vppcom_session_tls_add_key (listener,
      key_pem, key_len);

  /* 4. 바인드 + 리슨 */
  endpt.is_ip4 = 1;
  endpt.port = htons (8443);
  vppcom_session_bind (listener, &endpt);
  vppcom_session_listen (listener, 128);

  /* 5. 이벤트 루프 */
  while (1) {
    /* accept는 이미 TLS 핸드셰이크 완료된 세션 반환 */
    client = vppcom_session_accept (listener,
                                    &endpt, 0);
    if (client < 0) continue;

    /* 평문 읽기 (TLS 복호화 투명 처리) */
    int n = vppcom_session_read (client, buf,
                                 sizeof(buf));
    if (n > 0)
      vppcom_session_write (client, buf, n);

    vppcom_session_close (client);
  }

  vppcom_session_close (listener);
  vppcom_app_destroy ();
  return 0;
}
# 빌드 및 실행
$ gcc -o tls-echo tls-echo.c \
    -I/usr/include/vpp \
    -lvppcom -lvlibmemoryclient -lsvm

$ VCL_CONFIG=/etc/vpp/vcl.conf LD_PRELOAD="" ./tls-echo

LD_PRELOAD TLS 투명 가속

VCL의 LD_PRELOAD 방식으로 기존 애플리케이션의 TLS 통신을 VPP 경유로 투명하게 가속할 수 있습니다. 애플리케이션 수정이 필요 없으며, 커널 TLS 대신 VPP의 유저스페이스 TLS가 사용됩니다:

# vcl.conf — TLS 활성화 설정
vcl {
  rx-fifo-size 4000000
  tx-fifo-size 4000000
  app-scope-local
  app-scope-global
  use-mq-eventfd

  # TLS 설정
  tls {
    cert /etc/vpp/certs/server.pem
    key /etc/vpp/certs/server.key
    ca-cert /etc/vpp/certs/ca.pem
  }
}

# nginx를 VPP TLS로 가속
$ VCL_CONFIG=/etc/vpp/vcl.conf \
  LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
  nginx -c /etc/nginx/nginx-vpp.conf

# curl을 VPP TLS로 가속
$ VCL_CONFIG=/etc/vpp/vcl.conf \
  LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
  curl -k https://10.0.0.1:8443/

# HAProxy를 VPP TLS로 가속
$ VCL_CONFIG=/etc/vpp/vcl.conf \
  LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
  haproxy -f /etc/haproxy/haproxy.cfg
LD_PRELOAD TLS 제약: sendmsg()/recvmsg(), SO_REUSEPORT, SCM_RIGHTS 등 VCL이 지원하지 않는 소켓 기능을 사용하는 애플리케이션에서는 호환성 문제가 발생할 수 있습니다. 사전에 VCL 호환성 테이블(위 VCL 세션 섹션)을 확인하세요.

엔드-투-엔드 TLS 종단 워크스루

VPP에서 HTTPS 요청이 처리되는 전체 과정을 단계별로 추적합니다:

HTTPS 요청 처리 전체 흐름 (클라이언트 → VPP → 백엔드)

1. 클라이언트가 HTTPS 요청 전송 (TLS ClientHello)
      │
2. NIC(DPDK)가 패킷 수신 → RSS 해시 → 워커 N의 rx 큐
      │
3. dpdk-input 노드: 벡터로 패킷 수집
      │
4. ip4-input → ip4-lookup → tcp-input
      │
5. tcp-input: SYN → TCP 핸드셰이크 (VPP 내장 TCP)
      │
6. TCP 연결 완료 → session_connected_cb()
      │
7. tls_session_connected_cb(): TLS 핸드셰이크 시작
   ├─ ctx_init_client/server(): SSL_CTX + SSL 객체 생성
   ├─ SSL_do_handshake(): ClientHello/ServerHello 교환
   ├─ (비동기 시) WANT_READ/WRITE → 이벤트 큐 등록
   └─ 핸드셰이크 완료 → app에 session_connected 통지
      │
8. 애플리케이션 데이터 수신:
   ├─ tcp-input → TCP RX FIFO에 TLS 레코드 적재
   ├─ tls_ctx_read(): SSL_read() → 복호화
   ├─ 평문을 App RX FIFO에 적재
   └─ 애플리케이션에 rx 이벤트 통지
      │
9. HTTP 프록시: 평문 HTTP 요청 파싱
   ├─ 백엔드 서버로 TCP 연결 (평문)
   ├─ 요청 포워딩 → 응답 수신
   └─ 응답을 TLS 암호화하여 클라이언트에 전달
      │
10. 응답 전송:
    ├─ 애플리케이션이 App TX FIFO에 평문 기록
    ├─ tls_custom_tx_callback(): SSL_write() → 암호화
    ├─ TLS 레코드를 TCP TX FIFO에 적재
    └─ tcp-output → ip4-output → dpdk-output
VPP TLS 세션 생명 주기 상태 머신 NONE ctx_alloc() TCP연결 HANDSHAKE SSL_do_handshake() 비동기 시 WANT_READ/WRITE async resume 완료 ESTABLISHED ctx_write() / ctx_read() 양방향 암호화 데이터 전송 app_close APP_CLOSED close_notify 전송 원격 close PASSIVE_CLOSE 원격 close_notify 수신 잔여 데이터 드레인 CLOSED ctx_free() → 풀 반환 핸드셰이크 실패/타임아웃 TLS 컨텍스트 메모리 관리: per-worker 풀에서 할당/반환 (락 불필요) 핸드셰이크 타임아웃: 기본 20초 | 유휴 타임아웃: session { idle-timeout } | 최대 컨텍스트: session { preallocated-sessions }

TLS 디버깅

VPP TLS 문제를 진단하기 위한 CLI 명령과 기법입니다. 핸드셰이크 단계별 타임라인과 엔진 콜백의 의미는 TLS — 핸드셰이크 상세 타임라인에서, 호스트 스택 관점의 트러블슈팅(세션 누수·워커 편향)은 호스트 스택 — 트러블슈팅에서 다룹니다.

# TLS 컨텍스트 상세 정보
vpp# show tls ctx verbose
[0] engine: openssl  state: established
    cipher: TLS_AES_256_GCM_SHA384  version: TLSv1.3
    app_session: 0x7f001234  tls_session: 0x7f005678
    bytes_in: 1048576  bytes_out: 524288
    handshake_time: 2.3ms

# TLS 에러 카운터
vpp# show errors
tls-handshake-fail                12
tls-cert-verify-fail               3
tls-session-timeout                45

# 로드된 플러그인과 세션 레이어 상태
vpp# show plugins | grep -i tls
vpp# show session verbose

# 트레이싱으로 TLS 핸드셰이크 관찰 (TLS 노드에서 직접 trace)
vpp# trace add session-queue 10
vpp# show trace
CLI 표기 주의: 아래 진단 표와 이어지는 코드 블록에는 show tls engines, show tls ctx, show tls certs, set tls cipher, tls ca-cert add 같은 명령이 등장합니다. 이들은 공식 tlsopenssl 플러그인 소스에 존재하지 않습니다. 실제 존재하는 TLS 전용 CLI는 tls openssl set, tls openssl set-tls, tls openssl load-provider 세 개뿐입니다. 표 안의 항목은 "같은 의미의 진단 행위"를 개념적으로 나타낸 것이며, 실행 시에는 show errors·show session verbose·show crypto engines·show log 조합으로 대체하시기 바랍니다. 인증서·CA 관리는 모두 바이너리 API(app_add_cert_key_pair)로 수행합니다.
증상실제 진단 경로일반적 원인
핸드셰이크 실패show errors | grep -iE 'tls|session', show log인증서 만료, cipher 불일치
성능 저하show runtimetls-*/crypto-*/session-* 노드, show crypto engines비동기 미활성, SW 암호화 병목
연결 끊김show session verbose세션 타임아웃, FIFO 오버플로
인증서 오류애플리케이션이 BAPI로 CKPAIR 재조회, show log키 불일치, CA 체인 누락
mTLS 거부show errors, 세션 레이어 trace클라이언트 인증서 미제출
kTLS와의 비교: VPP TLS 디버깅(Debugging)은 유저스페이스에서 모든 상태를 직접 확인할 수 있어 커널 kTLS보다 훨씬 용이합니다. kTLS는 커널 로그와 bpftrace에 의존하지만, VPP는 CLI 한 줄로 모든 TLS 컨텍스트, 핸드셰이크 통계, 에러 카운터를 확인할 수 있습니다.

TLS 패킷 캡처와 복호화(Decryption)

VPP는 내장 pcap 기능을 제공하여 TLS 트래픽을 캡처하고, 개인 키를 이용해 Wireshark에서 복호화할 수 있습니다:

# VPP 내장 pcap 캡처
vpp# pcap trace rx tx max 10000 \
     intfc host-eth0 \
     file /tmp/vpp-tls-trace.pcap

# 캡처 중지
vpp# pcap trace off

# TLS 1.3 복호화를 위한 키 로그
# startup.conf에 설정:
# tls {
#   keylog-file /tmp/tls-keylog.txt
# }

# Wireshark에서 복호화:
# Edit → Preferences → Protocols → TLS
# → (Pre)-Master-Secret log filename: /tmp/tls-keylog.txt
# → 파일 → 열기 → /tmp/vpp-tls-trace.pcap

일반적 문제 해결 시나리오

문제진단 방법해결책
핸드셰이크 타임아웃show session verbose에서 tlssession 상태 고착방화벽(Firewall) 규칙 확인, 애플리케이션 idle 타임아웃 조정
"no shared cipher" 오류show errors에 핸드셰이크 실패 카운터 증가OpenSSL 설정 파일 또는 애플리케이션 측 SSL_CTX_set_ciphersuites로 현대 cipher 보장
인증서 체인 오류세션 레이어 trace, show log 내 verify 실패BAPI로 중간 CA를 포함한 전체 체인 CKPAIR 재등록
메모리 부족show memory verbose에서 session 세그먼트 고갈session { evt_qs_memfd_seg_size } 증가, Hugepage 추가
비대칭 성능 (한쪽만 느림)show session verbose에서 FIFO 사용률 확인TX/RX FIFO 크기 조정: session { tx-fifo-size }
QAT 오프로드 미동작show crypto engines에 qat 엔진 없음QAT 드라이버 로드 확인, DPDK cryptodev 또는 tls openssl load-provider qatprovider
세션 누수show session 연결 수가 계속 증가애플리케이션에서 vppcom_session_close() 호출 누락 확인
mTLS 클라이언트 거부show errors의 cert-verify 카운터클라이언트 CA가 CKPAIR 체인 또는 플러그인 trust 경로에 포함되어 있는지 확인
# 종합 TLS 상태 진단 — 실제로 존재하는 명령만
vpp# show version
vpp# show plugins | grep -i tls
vpp# show crypto engines
vpp# show session verbose
vpp# show errors
vpp# show runtime
vpp# show memory verbose
vpp# show log | grep -iE 'tls|session'

고급 TLS 프록시 패턴 — Termination·Re-encrypt·Passthrough·SNI 라우팅

TLS 프록시는 목적에 따라 네 가지 토폴로지(Topology) 중 하나를 선택합니다. 같은 VPP 인스턴스에서도 인터페이스·포트·SNI별로 서로 다른 모드를 동시에 운영할 수 있습니다. 각 모드는 보안 경계, 성능, 기능 측면에서 트레이드오프가 명확하므로 설계 초기에 확정해두어야 운영 중 혼선이 줄어듭니다.

모드클라이언트 ↔ VPPVPP ↔ 업스트림VPP가 보는 평문대표 용도
TerminationTLS (VPP가 서버 인증서 제시)평문 TCP (내부망)요청·응답 전체리버스 프록시, API 게이트웨이, 로드 밸런서
Re-encrypt (bridging)TLS (프록시 인증서)TLS (업스트림 인증서 검증)요청·응답 전체WAF, DLP, 컨텐츠 스캔이 필요한 엔터프라이즈 프록시
Passthrough (SNI 라우팅)TLS 레코드 릴레이만TLS 레코드 릴레이만없음 (SNI/ALPN만 보임)멀티 테넌트 프런트, 클라이언트 인증서 종단 불필요
InspectionTLS (동적 서명 인증서)TLS (새 세션)전체 (복호화 후 재암호화)Full SSL Inspection (다음 절 참조)

Termination 구성 — ssl termination 래퍼

가장 단순한 패턴입니다. VPP의 TLS 리스너가 443을 열고, 업스트림은 내부 평문 TCP로 재분배합니다. 엔드 투 엔드 TLS가 요구되지 않을 때(내부망 신뢰, 별도 사이드카 암호화, 국내 규정) 가장 효율적입니다.

# CKPAIR 등록은 바이너리 API(app_add_cert_key_pair)로 수행 — 반환 index는 5라고 가정

# test proxy 플러그인 — server는 TLS(ckpair 5), client는 TCP로 설정
vpp# test proxy server uri tls://0.0.0.0/443 ckpair 5 client uri tcp://10.0.0.10/8080
vpp# test proxy server uri tls://0.0.0.0/443 ckpair 5 client uri tcp://10.0.0.11/8080  # 다중 백엔드

이 모드에서 가장 자주 놓치는 부분은 PROXY v2 헤더입니다. 백엔드가 오리진 IP를 알아야 한다면 proxy 플러그인을 PROXY protocol 지원 버전으로 빌드하거나, VPP 앞단에 haproxy-abns처럼 헤더를 주입하는 경량 리레이를 두시기 바랍니다. VPP 25.06 이후 proxy 플러그인에 proxy-proto v2 옵션이 추가되어 내장 처리도 가능합니다.

Re-encrypt 구성 — 업스트림 인증서 검증 포함

엄격한 보안 정책을 따르는 환경은 업스트림과도 TLS를 유지해야 합니다. 이 경우 VPP 세션은 두 개(클라이언트 측·업스트림 측)가 생성되며, 양쪽 모두 다른 CKPAIR를 씁니다.

/* 제어 평면(GoVPP/PAPI/자체 C 앱)에서 BAPI로 CKPAIR 2개를 등록합니다.
   프론트용(proxy가 클라이언트에 제시), 백엔드용(업스트림 mTLS 연동) */
u32 ckpair_front  = bapi_add_cert_key_pair (proxy_front_pem,  proxy_front_key);
u32 ckpair_client = bapi_add_cert_key_pair (proxy_client_pem, proxy_client_key);

/* 업스트림 서버의 신뢰 루트와 hostname 검증은 애플리케이션 측
   SSL_CTX 구성 경로에서 처리합니다 — 공식 CLI로 제공되지 않습니다. */

업스트림 검증을 끄지 마시기 바랍니다. Self-signed 서버 테스트를 위해 일시적으로 비활성화하는 것은 허용되지만, 운영 환경에서 꺼 둔 채 잊으면 MITM에 노출됩니다. 대신 내부 CA를 만들어 모든 내부 서비스에 이 CA가 서명한 인증서를 배포하고, 프록시가 참조하는 trust store에 루트 하나만 넣는 방식이 안전합니다.

Passthrough — SNI 기반 L4 라우팅

VPP가 TLS 레코드를 복호화하지 않고 SNI만 읽어 업스트림을 정하는 구성입니다. ClientHello 안의 SNI 확장을 파싱해 매핑 테이블로 분기하며, 이후 바이트들은 단순 L4 릴레이가 됩니다. 멀티 테넌트 런타임 프런트·쿠버네티스 인그레스 프런트에서 자주 씁니다.

/* ClientHello에서 SNI만 추출하는 핵심 함수 개요
   — VPP의 session layer는 peek 콜백으로 초기 바이트를 건네 줍니다 */
static int
sni_extract_from_ch (u8 *data, u32 len, u8 **sni_out, u32 *sni_len_out)
{
  /* TLS record: type(1) version(2) length(2) handshake(4+) */
  if (len < 43 || data[0] != 0x16 /* handshake */ || data[5] != 0x01 /* ClientHello */)
    return -1;
  u8 *p = data + 43;                        /* session_id_len 위치 */
  p += 1 + p[0];                           /* session_id 건너뜀 */
  u16 cs_len = (p[0] << 8) | p[1];        p += 2 + cs_len;
  p += 1 + p[0];                           /* compression methods */
  u16 ext_total = (p[0] << 8) | p[1];     p += 2;
  u8 *end = p + ext_total;
  while (p + 4 <= end) {
    u16 ext_type = (p[0] << 8) | p[1];
    u16 ext_len  = (p[2] << 8) | p[3];    p += 4;
    if (ext_type == 0 /* server_name */) {
      /* list_len(2) name_type(1) name_len(2) name... */
      *sni_out     = p + 5;
      *sni_len_out = (p[3] << 8) | p[4];
      return 0;
    }
    p += ext_len;
  }
  return -1;
}
Encrypted ClientHello (ECH): TLS 1.3 확장인 ECH가 활성화되면 SNI가 HPKE로 암호화되어 passthrough 프록시가 더 이상 내용으로 라우팅할 수 없게 됩니다. 2026년 현재 주요 브라우저가 점차 활성화 중이므로, SNI 기반 설계는 대체 경로(L4 기본값, 클라이언트 IP, ALPN 노출분 등)를 함께 준비해두시기 바랍니다. 자세한 분석은 SSL Inspection QUIC/ECH 환경 과제 절을 참고하시면 됩니다.

ALPN 기반 라우팅과 HTTP/2/HTTP/3 분기

SNI가 같은 도메인이라도 프로토콜(h2, http/1.1, h3)에 따라 다른 업스트림으로 보내야 할 때가 있습니다. VPP proxy 플러그인은 ALPN 리스트를 클라이언트로부터 받아 선택 결과를 애플리케이션 콜백에 전달합니다. 다음은 ALPN에 따라 백엔드를 고르는 간단한 로직입니다.

static upstream_t *
pick_upstream (sni_t *sni, alpn_list_t *alpn)
{
  if (alpn_contains (alpn, "h2"))
    return sni->backend_h2;
  if (alpn_contains (alpn, "http/1.1"))
    return sni->backend_h1;
  return sni->backend_default;
}

h3는 TCP 기반이 아니므로 같은 리스너에서 다루지 않습니다. QUIC 프록시를 별도로 두고, HTTPS RR 레코드로 클라이언트가 h3와 h2를 구분해 접속하도록 하시기 바랍니다. 구체적인 QUIC 종단 구성은 VPP QUIC 심화 문서에서 다룹니다.

인증서 전략 — 와일드카드 vs SAN vs LE 자동 갱신

전략장점단점VPP 설정 포인트
와일드카드 *.example.com단일 CKPAIR, 운영 단순누출 시 범위가 넓음, 중첩 하위 도메인 커버 안 됨SNI별 분리 불필요
SAN 다중 이름정확한 범위 통제, 소수 도메인에 효율적도메인 변경 시 인증서 재발급하나의 CKPAIR가 여러 SNI 매칭
SNI당 독립 인증서격리 최고, 폐기 범위 최소CKPAIR 수 증가, 메모리 증가SNI마다 BAPI로 개별 CKPAIR 등록 후 애플리케이션이 맵 유지
Let's Encrypt 자동 갱신무료, 90일 주기로 기본 설계상 안전인증서 교체 자동화가 필수외부 certbot → VPP API로 롤오버
무중단 롤오버: CKPAIR를 교체할 때 기존 세션을 끊지 않으려면 (1) 새 CKPAIR를 추가하고 (2) SNI 매핑을 새 인덱스로 갱신한 뒤 (3) 기존 CKPAIR를 일정 시간 후 제거하시기 바랍니다. 세션 레이어는 기존 세션에 대해 이미 협상된 키 자료로 계속 동작하며, 새 연결부터 새 인증서를 씁니다.

HTTP 애플리케이션 게이트웨이

앞 절들이 다룬 TCP/TLS 종단 프록시와 SSL Inspection은 모두 L4~L5 수준에서 끝납니다. 그러나 실전 데이터 센터에서 프록시가 처리해야 할 가장 흔한 작업은 L7 라우팅입니다. 호스트 헤더로 가상 호스트를 분기하고, URL 경로로 마이크로서비스를 골라 보내고, 헤더에 인증 토큰을 주입하고, 응답을 변조해 CORS를 추가합니다. 이 절은 FD.io VPP의 builtin HTTP 스택을 활용해 이런 게이트웨이 패턴을 구현하는 방법을 정리합니다.

스코프 주의: VPP 25.10의 builtin HTTP 게이트웨이 기능은 envoy/HAProxy처럼 풍부하지 않습니다. 본 절은 VPP가 지금 제공하는 원시 빌딩 블록(http 플러그인 + 세션 콜백 + VCL)으로 만들 수 있는 게이트웨이 패턴을 다루며, 부족한 부분은 외부 컨트롤 플레인(예: VCL을 쓰는 사용자 공간 핸들러(Handler))으로 보완합니다.

게이트웨이 아키텍처 — 이중 세션과 라우팅 결정점

L7 게이트웨이는 본질적으로 두 개의 독립 세션을 묶어 둔 형태입니다. 클라이언트→프록시 세션에서 HTTP 요청을 완전히 파싱한 다음에야 어느 업스트림으로 보낼지 결정할 수 있고, 그 시점에 프록시→업스트림 세션이 새로 열립니다. TCP 프록시(앞 절)와 다른 점은 요청 라인과 헤더가 모두 도착하기 전까지 업스트림 연결을 보류한다는 것입니다.

클라이언트 curl / 브라우저 VPP HTTP 게이트웨이 (단일 워커) ① TLS 종단 tls_openssl 엔진 ② HTTP 파서 http1_parse_request ③ 라우팅 결정 Host / Path / Header → upstream 선택 ④ 헤더 변조 X-Forwarded-For 등 ⑤ 업스트림 connect vnet_connect() ⑥ 두 세션 연결 client_s ↔ upstream_s FIFO 페어링 업스트림 app A: 10.0.0.10 app B: 10.0.0.20

라우팅 규칙 — Host/Path/Header 매칭

실전에서 가장 흔한 세 가지 매칭 차원과 VPP 빌딩 블록 매핑입니다.

매칭 차원예시VPP 구현 포인트
Host 헤더api.example.com → upstream-A
web.example.com → upstream-B
http_msg → 헤더 룩업, hash table
경로 prefix/api/v1/* → upstream-A
/static/* → upstream-B
radix trie 또는 mtrie 재사용
메서드GET은 캐시,
POST는 직접 전달
http_msg->method 분기
임의 헤더Authorization 존재 여부
X-Tenant 값별 분기
linear scan (헤더 수 적음)

경로 매칭에 mtrie를 재사용하는 것은 VPP다운 선택입니다. IP 라우팅 테이블(Routing Table)에 쓰이는 multi-bit trie를 8비트씩 끊어 문자열 prefix lookup에 적용하면, 1000개 라우트 기준 100ns 미만으로 룩업이 끝납니다.

최소 게이트웨이 핸들러 — RX 콜백 안에서 라우팅

/* 라우팅 테이블(Routing Table): prefix → upstream URI */
typedef struct {
  u8 *prefix;        /* "/api/v1/" */
  u8 *upstream_uri;  /* "tcp://10.0.0.10/8080" */
} l7_route_t;

static l7_route_t *route_table;  /* 부팅 시 채움 */

/* 라우팅 결정 */
static l7_route_t *
l7gw_route_lookup (http_msg_t *msg)
{
  l7_route_t *r;
  vec_foreach (r, route_table)
    if (vec_len (msg->path) >= vec_len (r->prefix) &&
        memcmp (msg->path, r->prefix, vec_len (r->prefix)) == 0)
      return r;
  return 0;  /* 404 */
}

/* RX 콜백: 헤더 다 도착하면 업스트림 연결 */
static int
l7gw_rx_callback (session_t *cs)
{
  l7gw_session_t *gs = l7gw_session_get (cs->opaque);
  http_msg_t msg;

  if (http1_parse_full (cs->rx_fifo, &msg) == HTTP_PARSE_NEED_MORE)
    return 0;  /* 다음 RX 이벤트 대기 */

  l7_route_t *r = l7gw_route_lookup (&msg);
  if (!r)
    return l7gw_send_status (cs, 404, "Not Found");

  /* 헤더 변조: X-Forwarded-For 추가 */
  http_msg_header_append (&msg, "X-Forwarded-For",
                          format (0, "%U", format_ip4_address,
                                  &cs->remote_ip4));
  http_msg_header_append (&msg, "X-Forwarded-Proto", "https");

  /* 업스트림 connect (논블로킹) */
  vnet_connect_args_t a = {
    .uri = (char *) r->upstream_uri,
    .api_context = gs - l7gw_session_pool,
    .app_index = l7gw_app_index,
  };
  if (vnet_connect (&a) < 0)
    return l7gw_send_status (cs, 502, "Bad Gateway");

  /* 직렬화한 변조 메시지를 임시 버퍼에 저장 → connected_callback에서 송신 */
  gs->pending_request = http1_serialize (&msg);
  gs->client_handle = session_handle (cs);
  return 0;
}

/* 업스트림 연결 완료 콜백 */
static int
l7gw_connected_callback (u32 app_idx, u32 api_ctx, session_t *us, session_error_t err)
{
  l7gw_session_t *gs = pool_elt_at_index (l7gw_session_pool, api_ctx);
  if (err) {
    session_t *cs = session_get_from_handle (gs->client_handle);
    return l7gw_send_status (cs, 502, "Upstream connect failed");
  }
  /* 변조한 요청을 업스트림 tx_fifo에 enqueue */
  svm_fifo_enqueue (us->tx_fifo, vec_len (gs->pending_request),
                    gs->pending_request);
  session_send_io_evt_to_thread (us->tx_fifo, SESSION_IO_EVT_TX);
  /* 두 세션 양방향 페어링: 이후 응답은 ① 절의 TCP 프록시처럼 그대로 릴레이 */
  gs->upstream_handle = session_handle (us);
  return 0;
}

응답 본문은 일반 TCP 프록시처럼 그대로 흘려보내도 되지만(streaming 모드), Set-Cookie 변조나 응답 헤더 추가가 필요하면 응답 RX 콜백에서도 같은 방식으로 한 번 더 파싱·변조해야 합니다. 이 비용은 작지 않으며, 변조 빈도가 낮다면 헤더 부분만 파싱하고 본문은 통과시키는 partial parse 모드가 효율적입니다.

API 게이트웨이 패턴 — 인증·레이트 리밋·캐싱

패턴VPP 구현 방법주의점
JWT 검증RX 콜백에서 Authorization 헤더 추출 → libjwt 또는 자체 HS256 검증서명 검증(Signature Verification)은 워커 스레드 블로킹 회피 위해 캐시 필수
레이트 리밋토큰 버킷 상태를 클라이언트 IP 키 hash table에 저장분산 환경에서는 워커별 카운터 합산 필요
응답 캐싱요청 hash → 응답 본문을 svm 영역에 보관HTTP/1.1의 Cache-Control·Vary 의미 직접 구현
회로 차단(circuit breaker)업스트림별 실패율 카운터 + 시간창임계 도달 시 즉시 503 반환, half-open 복구 필요
요청 미러링vnet_connect로 두 업스트림 동시 연결, 응답은 한쪽만 사용FIFO 복제 비용 고려
책임 한계: VPP를 풀스펙 API 게이트웨이로 만드는 것은 권장하지 않습니다. JWT 라이브러리·OpenAPI 검증·rate limit 정책을 워커 스레드 안에서 모두 처리하면 RPS는 빠르게 꺾이고, 정작 데이터플레인의 강점인 zero-copy·코어 친화 분배가 무의미해집니다. VPP는 라우팅·헤더 변조·기본 ACL까지, 복잡한 정책은 사이드카(envoy)로 위임하는 분리 설계가 현실적입니다.

CLI 예시 — 간단한 가상 호스트 분기

# 1) 게이트웨이 플러그인 활성화 (가상의 CLI 가정)
vppctl http gateway enable uri tls://0.0.0.0/443 \
    cert /etc/vpp/cert.pem key /etc/vpp/key.pem

# 2) 라우트 추가
vppctl http gateway route add host api.example.com path /v1/ \
    upstream tcp://10.0.0.10/8080
vppctl http gateway route add host api.example.com path /v2/ \
    upstream tcp://10.0.0.20/8080
vppctl http gateway route add host www.example.com path / \
    upstream tcp://10.0.0.30/80

# 3) 헤더 변조 규칙
vppctl http gateway rewrite add request \
    header-set X-Forwarded-Proto https

# 4) 동작 확인
curl -k -H "Host: api.example.com" https://192.0.2.10/v1/version
curl -k -H "Host: api.example.com" https://192.0.2.10/v2/version

# 5) 라우팅 통계
vppctl show http gateway routes
vppctl show http gateway counters

위 CLI는 25.10 시점에는 일부가 패치 단계입니다. 실제 환경에서는 동일 동작을 VCL(libvcl)로 작성한 사용자 공간 게이트웨이 데몬이나, 위에서 보인 builtin RX 콜백 기반 플러그인으로 구현해야 합니다. 두 방식의 트레이드오프는 다음과 같습니다.

구현 방식장점단점
builtin 플러그인 (RX 콜백)워커 스레드 안에서 zero-copy, 최고 성능VPP 빌드 필요, 충돌 시 데이터플레인 영향
VCL 사용자 공간 데몬독립 프로세스, 디버깅·재배포 용이SVM FIFO 통한 IPC 비용 (수십 ns)

Common Pitfalls

HTTP 캐싱 — RFC 9111과 VPP 구현 패턴

L7 게이트웨이가 다음으로 다루어야 할 가치는 캐싱입니다. 같은 응답을 원본 서버에 다시 묻지 않고 프록시 안에서 돌려주면, 업스트림 부하·평균 지연·외부 대역폭이 동시에 줄어듭니다. RFC 9111은 HTTP 캐싱의 의미론을 정의하며, 어떤 응답이 캐시 가능한지·언제까지 신선한지·언제 재검증해야 하는지를 헤더로 표현합니다. 이 절은 RFC 9111의 핵심 규칙을 정리하고, FD.io VPP의 빌딩 블록(svm 메모리·svm_fifo·hash table)으로 LRU 캐시를 어떻게 구현하는지 보입니다.

캐시 가능성 결정 트리

조건결과RFC 9111 § 참조
요청 메서드가 GET/HEAD가 아님캐시 불가§3
응답 상태 코드가 200/203/204/206/300/301/308/404/405/410/414/501캐시 가능 (기본)§3
Cache-Control: no-store저장 금지§5.2.2.5
Cache-Control: private + 공유 캐시저장 금지§5.2.2.7
Authorization 헤더 + 공유 캐시기본 금지 (예외 있음)§3.5
Cache-Control: public 또는 명시적 max-age강제 캐시 가능§5.2.2.9

신선도(freshness)는 다음 우선순위로 계산합니다.

  1. Cache-Control: s-maxage=N (공유 캐시 전용)
  2. Cache-Control: max-age=N
  3. Expires 헤더 - Date 헤더
  4. 휴리스틱(Last-Modified의 10% 등) — 신중히 사용

캐시 결정 흐름

요청 수신 cache key 계산 method+host+path+Vary 엔트리 존재? MISS → 업스트림 store_if_cacheable freshness 계산 age < max-age? HIT → 즉시 응답 Age 헤더 추가 stale → 재검증 If-None-Match / IMS 304 → 저장된 본문 송신 freshness 갱신 200 → 본문 교체 새 응답 캐시 없음 있음 fresh stale 304 200

캐시 키 — Vary 헤더와 변형 응답

같은 URL이라도 클라이언트 헤더에 따라 다른 응답이 돌아올 수 있습니다(언어·인코딩·디바이스). 원본 서버는 이를 Vary 응답 헤더로 알립니다.

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=600
Vary: Accept-Encoding, Accept-Language

위 응답을 받으면 캐시는 키를 다음처럼 만들어야 합니다.

cache_key = SHA1(
  method || host || path
    || "Accept-Encoding=" || req.headers["Accept-Encoding"]
    || "Accept-Language=" || req.headers["Accept-Language"]
)

Vary: *는 사실상 캐시 불가를 뜻합니다. Vary 누락은 잘못된 응답을 다른 클라이언트에 돌려주는 가장 흔한 캐시 오염 원인입니다.

VPP LRU 캐시 구현

/* 캐시 엔트리 — 본문은 svm 메모리에 보관 */
typedef struct {
  u8  *key;           /* SHA1(20byte) */
  u8  *headers;       /* 응답 헤더 직렬화 */
  u8  *body;          /* 응답 본문 */
  u32  body_len;
  f64  inserted_at;
  f64  max_age;       /* 초 */
  u8  *etag;          /* 약/강 ETag, 재검증용 */
  u8  *last_modified;
  u32  lru_prev, lru_next;
} cache_entry_t;

typedef struct {
  cache_entry_t *pool;       /* pool_get 기반 */
  uword         *by_key;     /* hash_create_mem */
  u32            lru_head, lru_tail;
  u64            bytes_used, bytes_max;
} l7_cache_t;

/* 조회 + freshness 검사 */
static cache_entry_t *
cache_lookup_fresh (l7_cache_t *c, u8 *key, f64 now)
{
  uword *p = hash_get_mem (c->by_key, key);
  if (!p) return 0;
  cache_entry_t *e = pool_elt_at_index (c->pool, p[0]);
  if (now - e->inserted_at > e->max_age) {
    /* stale: 재검증 후보로 반환만 함, 즉시 삭제하지 않음 */
    return e;
  }
  cache_lru_touch (c, e);
  return e;
}

/* 응답 저장 — RFC 9111 캐시 가능 검사 통과한 경우만 호출 */
static int
cache_store (l7_cache_t *c, u8 *key, http_msg_t *resp, f64 now)
{
  cache_entry_t *e;
  pool_get_zero (c->pool, e);
  e->key = vec_dup (key);
  e->headers = http_msg_serialize_headers (resp);
  e->body = vec_dup (resp->body);
  e->body_len = vec_len (resp->body);
  e->inserted_at = now;
  e->max_age = http_parse_max_age (resp);
  e->etag = vec_dup (http_msg_header_get (resp, "ETag"));
  e->last_modified = vec_dup (http_msg_header_get (resp, "Last-Modified"));
  hash_set_mem (c->by_key, e->key, e - c->pool);
  c->bytes_used += e->body_len + vec_len (e->headers);
  cache_lru_push_front (c, e);
  while (c->bytes_used > c->bytes_max)
    cache_evict_tail (c);
  return 0;
}

/* 재검증 — 304 처리 */
static int
cache_revalidate (session_t *cs, cache_entry_t *e)
{
  http_msg_t req;
  http1_build_revalidate (&req, e);  /* If-None-Match: e->etag */
  return upstream_send_and_handle (cs, &req,
    /* on_response */ ^(http_msg_t *resp) {
      if (resp->status == 304) {
        e->inserted_at = unix_time_now ();
        return reply_from_cache (cs, e);
      }
      cache_store (l7_cache, e->key, resp, unix_time_now ());
      return reply_passthrough (cs, resp);
    });
}
왜 svm 풀인가: 캐시 엔트리를 일반 malloc 힙에 두면 워커 간 공유가 어렵고, NUMA 노드 간 캐시 라인(Cache Line) 이동 비용이 큽니다. VPP의 pool_* 매크로(Macro)와 svm 메모리는 워커별로 격리되거나(thread-local) 공유 영역에 둘 수 있어 NUMA 친화적입니다. 단순 구현은 워커별 캐시(중복 가능)로 시작하고, 적중률이 부족할 때만 공유 캐시로 승격하는 편이 안전합니다.

stale-while-revalidate와 stale-if-error

RFC 5861이 정의하고 RFC 9111이 통합한 두 확장은 캐시의 가용성을 크게 끌어올립니다.

지시어의미효과
stale-while-revalidate=N만료 후 N초 동안은 stale 응답을 즉시 돌려주고 백그라운드로 재검증사용자 지연 0, 평균 지연 단축
stale-if-error=N업스트림 실패 시 만료 후 N초 동안 stale을 비상용으로 송출업스트림 장애 시 가용성 보장

구현 측면에서는 위 cache_lookup_fresh가 stale 엔트리도 반환하도록 하고, 호출자가 ① 즉시 응답 ② 비동기 재검증 작업을 같은 워커의 이벤트 큐에 등록 ③ 업스트림 실패 시 stale-if-error 윈도 안인지 확인하는 분기를 두면 됩니다.

CLI 예시

# 1) 캐시 활성화
vppctl http gateway cache enable size 256m \
    default-ttl 60 stale-while-revalidate 30 stale-if-error 86400

# 2) 캐시 통계
vppctl show http gateway cache
# entries: 12453   bytes: 198M / 256M
# hits: 8.2M       misses: 1.4M    revalidations: 412K
# hit ratio: 85.4%

# 3) 특정 키 무효화
vppctl http gateway cache purge host api.example.com path /v1/users

# 4) 전체 무효화 (배포 후)
vppctl http gateway cache flush

Common Pitfalls

참고자료

VPP 프록시와 SSL Inspection은 구현·표준·법적 프레임워크가 함께 엮여 있어 한 곳에서 전체 그림을 잡기 어렵습니다. 아래는 구현(VPP/TPROXY), 프로토콜 표준(HTTP/TLS/QUIC), 비교 가능한 오픈소스 프록시, 법·컴플라이언스 문서를 구분해 정리한 1차 자료 목록입니다.

VPP 프록시·세션 관련 소스

Linux 커널 투명 프록시(TPROXY) 경로

HTTP / 프록시 관련 RFC

SSL Inspection · L7 가시성 참고

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

오픈소스 코드 인용 고지

라이선스 고지: 이 문서의 코드 예제에는 아래 오픈소스 프로젝트의 소스 코드에서 발췌·간략화한 내용이 포함되어 있습니다. 해당 코드 블록에는 원본 프로젝트의 라이선스가 그대로 적용되며, 본 사이트의 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

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