VPP TLS/QUIC 네트워킹
VPP(Vector Packet Processing) 프레임워크의 TLS/QUIC 네트워킹 기능을 다룹니다. VCL(VPP Communications Library) 세션 레이어, TLS 아키텍처와 인증서 관리, QUIC 프로토콜 통합, 성능 최적화, TLS 종단 프록시 실전 구성까지 포괄합니다.
VCL과 세션 레이어
VPP 세션 레이어
VPP는 L4 전송 프로토콜(TCP, UDP, QUIC)을 유저스페이스에서 직접 구현합니다. 세션 레이어가 소켓과 유사한 추상화를 제공하며, 애플리케이션은 VCL(VPP Communications Library)을 통해 접근합니다.
| 항목 | 커널 소켓 API | VPP 세션 API |
|---|---|---|
| API | socket(), bind(), listen(), accept() | 공유 메모리 기반 세션 큐 |
| 데이터 전달 | send()/recv() (커널 복사) | 공유 메모리 FIFO (zero-copy) |
| 이벤트 | epoll/select | VPP 이벤트 큐 (eventfd) |
| 멀티플렉싱 | epoll_wait() | vcl_epoll_wait() |
| 성능 | ~200K conn/s | ~1M+ conn/s |
| 호환성 | 모든 애플리케이션 | VCL 또는 LD_PRELOAD 필요 |
/* startup.conf — 세션 레이어 활성화 */
session {
evt_qs_memfd_seg /* memfd 기반 이벤트 큐 */
event-queue-length 100000 /* 이벤트 큐 크기 */
}
/* 세션 활성화 확인 */
vpp# session enable
vpp# show session verbose
세션 레이어 아키텍처 상세
VPP 세션 레이어는 커널의 소켓 서브시스템에 해당하는 유저스페이스 구현체입니다. src/vnet/session/ 디렉터리에 위치하며, 전송 프로토콜(TCP, UDP, TLS, QUIC)과 애플리케이션 사이의 추상화 계층을 제공합니다.
session_state_t — 세션 상태 머신
세션은 생성부터 종료까지 다음과 같은 상태를 거칩니다.
| 상태 | 값 | 설명 |
|---|---|---|
SESSION_STATE_CREATED | 0 | 세션 구조체 할당 완료, 아직 연결되지 않은 상태입니다 |
SESSION_STATE_LISTENING | 1 | session_listen() 호출 후 수신 대기 중인 리스너 세션입니다 |
SESSION_STATE_CONNECTING | 2 | session_open() 호출 후 비동기 연결 진행 중입니다 |
SESSION_STATE_ACCEPTING | 3 | 전송 계층에서 SYN을 수신하여 수락 처리 중입니다 |
SESSION_STATE_READY | 4 | 연결이 완료되어 데이터 송수신이 가능한 상태입니다 |
SESSION_STATE_TRANSPORT_CLOSING | 5 | 전송 계층에서 종료를 시작했습니다 (원격 FIN 수신) |
SESSION_STATE_CLOSING | 6 | 애플리케이션이 session_close()를 호출하여 종료 진행 중입니다 |
SESSION_STATE_CLOSED | 7 | 세션이 완전히 종료되어 리소스 해제 대기 중입니다 |
session_t 핵심 필드
session_t는 VPP 세션 레이어의 중심 데이터 구조체입니다. 하나의 세션은 하나의 전송 연결(TCP 커넥션, UDP 바인딩 등)에 대응합니다.
/* src/vnet/session/session_types.h — session_t 핵심 필드 (간략화) */
typedef struct session_ {
/** 세션 풀 내 인덱스 — 세션 식별에 사용됩니다 */
u32 session_index;
/** 이 세션을 소유한 워커 스레드 인덱스 */
u32 thread_index;
/** 세션 유형: 전송 프로토콜 + FIB 프로토콜 인코딩 */
session_type_t session_type;
/** 현재 세션 상태 (CREATED → READY → CLOSED) */
volatile session_state_t session_state;
/** 소유 애플리케이션의 워커 인덱스 */
u32 app_wrk_index;
/** 전송 계층 연결 인덱스 (TCP/UDP connection) */
u32 connection_index;
/** 공유 메모리 FIFO — 수신/송신 데이터 버퍼 */
svm_fifo_t *rx_fifo;
svm_fifo_t *tx_fifo;
/** 세션 플래그 (EVT_Q_SHM, IS_DGRAM 등) */
session_flags_t flags;
/** 리스너 세션인 경우 수락된 세션 수 추적 */
u32 n_accepted;
} session_t;
코드 설명
-
3~5행
session_index와thread_index의 조합이 세션의 고유 식별자입니다. 워커별 세션 풀을 분리하여 락 없이 세션을 할당/해제할 수 있습니다. -
7~8행
session_type은 전송 프로토콜(TCP/UDP/TLS/QUIC)과 FIB 프로토콜(IPv4/IPv6)을 하나의 값으로 인코딩합니다. 세션 검색 시 프로토콜 조합을 단일 비교로 판별할 수 있습니다. -
14~15행
rx_fifo/tx_fifo는 공유 메모리(SVM) 위의 FIFO입니다. VPP와 애플리케이션이 mmap으로 동일 메모리를 공유하여 데이터 복사 없이 제로 카피 통신을 수행합니다. -
10행
session_state에volatile한정자가 붙어 있어 워커 스레드와 애플리케이션 스레드 간 상태 변경이 즉시 가시적입니다.
session_type_t 인코딩
session_type_t는 전송 프로토콜과 네트워크 프로토콜을 하나의 값으로 인코딩합니다. 상위 비트에 transport_proto_t(TCP, UDP, TLS, QUIC 등)를, 하위 비트에 fib_protocol_t(IP4, IP6)를 저장합니다.
/* session_type_t 인코딩 — 전송 프로토콜 × FIB 프로토콜 */
static inline session_type_t
session_type_from_proto_and_ip (transport_proto_t proto,
u8 is_ip4)
{
return (proto << 1 | is_ip4);
}
/* 디코딩 예시 */
transport_proto_t tp = session_type >> 1; /* TCP=0, UDP=1, TLS=2, QUIC=3 */
u8 is_ip4 = session_type & 0x1; /* 0=IPv6, 1=IPv4 */
/* 예: TCP+IPv4 = (0 << 1 | 1) = 1
* UDP+IPv6 = (1 << 1 | 0) = 2
* QUIC+IPv4 = (3 << 1 | 1) = 7 */
세션 생명주기
세션은 소켓 API와 유사한 생명주기를 따르지만, 내부적으로는 비동기·이벤트 기반으로 동작합니다.
| 단계 | 함수 | 동작 |
|---|---|---|
| Listen | session_listen() | transport_start_listen()을 호출하여 리스너 세션을 생성합니다. 리스너는 SESSION_STATE_LISTENING 상태로 전환됩니다 |
| Connect | session_open() | transport_connect()를 호출하여 비동기 연결을 시작합니다. 세션은 CONNECTING 상태가 되며, 연결 완료 시 SESSION_CTRL_EVT_CONNECTED 이벤트가 발생합니다 |
| Accept | session_stream_accept() | 전송 계층에서 SYN을 수신하면 새 세션을 할당하고 FIFO를 생성한 뒤, 애플리케이션에 SESSION_CTRL_EVT_ACCEPTED 이벤트를 전달합니다 |
| Data | session_enqueue_stream_connection() | 수신 데이터를 rx_fifo에 enqueue하고, SESSION_IO_EVT_RX 이벤트로 애플리케이션에 알립니다 |
| Close | session_close() | session_transport_close()를 통해 전송 계층에 종료를 요청합니다. FIN 교환 후 세션이 CLOSED 상태로 전환됩니다 |
/* session_open() — 비동기 연결 흐름 (간략화) */
int
session_open (session_endpoint_cfg_t *rmt, u32 opaque)
{
transport_proto_t tp = rmt->transport_proto;
transport_connection_t *tc;
session_t *s;
int rv;
/* 1. 전송 계층에 연결 요청 (TCP SYN 전송 등) */
rv = transport_connect (tp, rmt);
if (rv < 0)
return rv;
/* 2. 전송 연결 객체 조회 */
tc = transport_get_half_open (tp, rv);
/* 3. 세션 할당 및 초기화 */
s = session_alloc_for_connection (tc);
s->session_state = SESSION_STATE_CONNECTING;
s->app_wrk_index = rmt->app_wrk_index;
s->opaque = opaque;
/* 4. 연결 완료는 비동기 — transport에서 콜백으로 알림
* → session_connected_callback()
* → SESSION_CTRL_EVT_CONNECTED 이벤트 발생 */
return 0;
}
/* 연결 완료 콜백 — transport에서 호출됩니다 */
static void
session_connected_callback (u32 app_wrk_index,
u32 opaque,
transport_connection_t *tc,
session_error_t err)
{
session_t *s = session_get (tc->s_index, tc->thread_index);
/* FIFO 할당 */
session_alloc_fifos (s);
/* 상태 전환: CONNECTING → READY */
s->session_state = SESSION_STATE_READY;
/* 애플리케이션에 연결 완료 이벤트 전달 */
app_worker_connect_notify (s->app_wrk_index, s, err);
}
세션 FIFO 메커니즘
VPP 세션 레이어의 핵심 성능 비결은 공유 메모리 FIFO입니다. 커널 소켓이 send()/recv()마다 커널-유저 복사를 수행하는 것과 달리, VPP는 애플리케이션과 동일한 FIFO를 공유하여 zero-copy 데이터 전달을 달성합니다.
svm_fifo_t 구조체
svm_fifo_t는 공유 메모리 위에 구현된 원형 버퍼입니다. Lock-free 단일 생산자/단일 소비자(SPSC) 설계로, VPP 워커 스레드(생산자)와 애플리케이션 스레드(소비자)가 잠금 없이 동시에 접근할 수 있습니다.
/* src/svm/svm_fifo.h — svm_fifo_t 핵심 필드 (간략화) */
typedef struct svm_fifo_ {
/** 공유 구조체 — VPP와 app이 mmap으로 공유합니다 */
svm_fifo_shared_t *shr;
/** FIFO 최대 크기 (바이트) */
u32 nitems;
/** 현재 저장된 데이터 크기 */
u32 cursize;
/** 읽기 위치 — 소비자(app)가 갱신합니다 */
u32 head;
/** 쓰기 위치 — 생산자(VPP)가 갱신합니다 */
u32 tail;
/** 소유 워커 스레드 인덱스 */
u32 master_thread_index;
/** 소유 세션 인덱스 */
u32 master_session_index;
/** 세그먼트 관리자 — 다중 청크 지원 */
svm_fifo_chunk_t *start_chunk;
svm_fifo_chunk_t *end_chunk;
} svm_fifo_t;
FIFO 크기 설정과 Backpressure
FIFO 크기는 startup.conf의 session 섹션에서 설정합니다. FIFO가 가득 차면 VPP가 전송 계층에 backpressure를 적용하여 TCP 수신 윈도우를 축소합니다.
/* startup.conf — FIFO 크기 설정 */
session {
rx-fifo-size 64K /* 수신 FIFO 기본 크기 */
tx-fifo-size 64K /* 송신 FIFO 기본 크기 */
evt_qs_memfd_seg /* memfd 기반 이벤트 큐 */
event-queue-length 100000
preallocated-sessions 1024 /* 세션 사전 할당 */
}
/* 런타임 FIFO 크기 확인 */
vpp# show session [verbose]
vpp# show session fifo trace
FIFO 기반 backpressure 흐름은 다음과 같습니다.
- 데이터 수신 →
svm_fifo_enqueue()로rx_fifo에 저장합니다 svm_fifo_max_enqueue()로 남은 공간을 확인합니다- 남은 공간이 임계값 이하이면, TCP 윈도우 크기를 축소하여 송신 측에 감속을 요청합니다
- 애플리케이션이
svm_fifo_dequeue()로 데이터를 소비하면 윈도우가 다시 확장됩니다
주요 FIFO API
| 함수 | 용도 |
|---|---|
svm_fifo_enqueue() | FIFO에 데이터를 기록합니다 (VPP → app 방향) |
svm_fifo_dequeue() | FIFO에서 데이터를 읽고 소비합니다 (app → VPP 방향) |
svm_fifo_peek() | 데이터를 소비하지 않고 읽습니다 (head 이동 없음) |
svm_fifo_dequeue_drop() | 데이터를 읽지 않고 소비합니다 (skip 용도) |
svm_fifo_max_enqueue() | FIFO의 남은 쓰기 공간을 반환합니다 |
svm_fifo_max_dequeue() | FIFO에서 읽을 수 있는 데이터 크기를 반환합니다 |
svm_fifo_segments() | zero-copy 직접 포인터 접근 (wrap-around 시 2개 세그먼트 반환) |
/* FIFO enqueue/dequeue 기본 패턴 */
/* 생산자 (VPP 워커) — 수신 데이터를 rx_fifo에 저장 */
int
session_enqueue_stream_connection (session_t *s,
vlib_buffer_t *b)
{
u32 enqueued;
u32 max_enq = svm_fifo_max_enqueue (s->rx_fifo);
if (max_enq == 0)
return 0; /* FIFO 가득 참 — backpressure */
enqueued = svm_fifo_enqueue (s->rx_fifo,
vlib_buffer_length_in_chain (vm, b),
vlib_buffer_get_current (b));
/* 애플리케이션에 수신 이벤트 알림 */
if (enqueued > 0)
session_send_io_evt_to_thread (s->rx_fifo,
SESSION_IO_EVT_RX);
return enqueued;
}
/* 소비자 (애플리케이션/VCL) — rx_fifo에서 데이터 읽기 */
int
app_recv_stream (session_t *s, u8 *buf, u32 len)
{
u32 max_deq = svm_fifo_max_dequeue (s->rx_fifo);
u32 to_read = clib_min (max_deq, len);
if (to_read == 0)
return 0;
svm_fifo_dequeue (s->rx_fifo, to_read, buf);
return to_read;
}
/* zero-copy 패턴 — 데이터 복사 없이 직접 접근 */
svm_fifo_seg_t segs[2];
u32 n_segs = 2;
/* wrap-around 시 최대 2개 세그먼트로 분할됩니다 */
svm_fifo_segments (s->rx_fifo, segs, &n_segs);
for (int i = 0; i < n_segs; i++)
process_data (segs[i].data, segs[i].len);
/* 처리 완료 후 소비 확정 */
svm_fifo_dequeue_drop (s->rx_fifo, total_len);
세션 이벤트 큐와 애플리케이션 통신
VPP와 애플리케이션 사이의 이벤트 전달은 공유 메모리 메시지 큐(svm_msg_q_t)를 통해 이루어집니다. 커널의 epoll 메커니즘에 대응하지만, 시스템 콜 오버헤드 없이 동작합니다.
session_event_t 이벤트 유형
| 이벤트 | 방향 | 설명 |
|---|---|---|
SESSION_IO_EVT_RX | VPP → App | 수신 데이터가 rx_fifo에 도착했음을 알립니다. 애플리케이션은 svm_fifo_dequeue()로 데이터를 읽습니다 |
SESSION_IO_EVT_TX | App → VPP | 애플리케이션이 tx_fifo에 데이터를 기록했음을 알립니다. VPP가 전송을 시작합니다 |
SESSION_CTRL_EVT_ACCEPTED | VPP → App | 리스너에 새 연결이 수락되었습니다. 애플리케이션이 accept 응답을 해야 합니다 |
SESSION_CTRL_EVT_CONNECTED | VPP → App | 비동기 session_open()의 연결이 완료(또는 실패)되었습니다 |
SESSION_CTRL_EVT_DISCONNECTED | VPP → App | 원격 측에서 연결을 정상 종료했습니다 (FIN 수신) |
SESSION_CTRL_EVT_RESET | VPP → App | 연결이 비정상 리셋되었습니다 (RST 수신) |
svm_msg_q_t — 공유 메모리 메시지 큐
VPP는 각 애플리케이션 워커마다 별도의 svm_msg_q_t 이벤트 큐를 할당합니다. 이 큐는 공유 메모리 세그먼트 위에 위치하며, lock-free ring buffer로 구현되어 있습니다.
- VPP → App 방향: VPP 워커가
app_send_io_evt_rx()또는app_worker_send_event()를 호출하면, 이벤트가 애플리케이션의 메시지 큐에 enqueue됩니다 - App → VPP 방향: 애플리케이션이
tx_fifo에 데이터를 기록한 후app_send_io_evt_to_vpp()를 호출하여 VPP에 전송을 요청합니다 - eventfd 기반 깨우기(Wakeup): 애플리케이션이 이벤트를 기다릴 때,
epoll_wait()에 eventfd를 등록하여 블로킹 대기합니다. VPP가 이벤트를 enqueue한 후 eventfd에 write하여 스레드를 깨웁니다
/* VCL 애플리케이션의 이벤트 처리 루프 (간략화) */
while (1) {
svm_msg_q_msg_t msg;
session_event_t *evt;
/* 이벤트 큐에서 대기 — eventfd로 블로킹 */
svm_msg_q_wait (app_mq, SVM_MQ_WAIT_EMPTY);
while (svm_msg_q_sub (app_mq, &msg, SVM_Q_NOWAIT, 0) == 0) {
evt = (session_event_t *) svm_msg_q_msg_data (app_mq, &msg);
switch (evt->event_type) {
case SESSION_IO_EVT_RX:
/* 수신 데이터 처리 */
s = session_get_from_handle (evt->session_handle);
n = svm_fifo_dequeue (s->rx_fifo, buf_sz, buf);
handle_rx_data (s, buf, n);
break;
case SESSION_IO_EVT_TX:
/* 송신 공간 확보 — 추가 데이터 전송 가능 */
s = session_get_from_handle (evt->session_handle);
resume_sending (s);
break;
case SESSION_CTRL_EVT_ACCEPTED:
/* 새 연결 수락 */
accepted_msg = (session_accepted_msg_t *) evt->data;
handle_accept (accepted_msg);
break;
case SESSION_CTRL_EVT_CONNECTED:
/* 연결 완료 */
connected_msg = (session_connected_msg_t *) evt->data;
if (connected_msg->retval == 0)
handle_connected (connected_msg);
else
handle_connect_failed (connected_msg);
break;
case SESSION_CTRL_EVT_DISCONNECTED:
/* 원격 종료 — 정리 후 disconnect reply 전송 */
handle_disconnect (evt);
break;
case SESSION_CTRL_EVT_RESET:
/* 비정상 리셋 — 즉시 세션 정리 */
handle_reset (evt);
break;
}
/* 메시지 소비 완료 — 슬롯 반환 */
svm_msg_q_free_msg (app_mq, &msg);
}
}
이 이벤트 기반 모델의 핵심 장점은 시스템 콜 횟수의 최소화입니다. 커널 소켓에서는 epoll_wait() + recv()마다 2회의 시스템 콜이 필요하지만, VPP 세션 레이어에서는 eventfd read() 1회로 여러 이벤트를 배치 처리할 수 있습니다. 고부하 상황에서는 eventfd 없이 공유 메모리를 직접 폴링하여 시스템 콜을 완전히 제거할 수도 있습니다.
LD_PRELOAD 투명 가속
VCL의 LD_PRELOAD 기능은 기존 POSIX 소켓 기반 애플리케이션을 수정 없이 VPP 세션 레이어로 가속합니다. libvcl_ldpreload.so가 libc의 소켓 함수를 가로채어 VPP와 통신합니다.
# iperf3에 VCL LD_PRELOAD 적용
$ VCL_CONFIG=/etc/vpp/vcl.conf \
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
iperf3 -s
# nginx에 적용
$ VCL_CONFIG=/etc/vpp/vcl.conf \
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so \
nginx -c /etc/nginx/nginx.conf
# vcl.conf 예제
vcl {
rx-fifo-size 4000000 /* RX FIFO 4MB */
tx-fifo-size 4000000 /* TX FIFO 4MB */
app-scope-global /* 글로벌 세션 네임스페이스 */
api-socket-name /run/vpp/api.sock
}
| POSIX 소켓 함수 | VCL 대응 | 지원 수준 |
|---|---|---|
| socket() | vls_create() | TCP/UDP/TLS |
| bind() | vls_bind() | 완전 |
| listen() | vls_listen() | 완전 |
| accept() | vls_accept() | 완전 |
| connect() | vls_connect() | 완전 |
| send()/write() | vls_write() | 완전 |
| recv()/read() | vls_read() | 완전 |
| epoll_*() | vls_epoll_*() | 완전 |
| select()/poll() | vls_select() | 부분 (epoll 권장) |
| sendmsg()/recvmsg() | — | 미지원 |
VPP TLS 아키텍처
VPP는 유저스페이스 TLS 종단을 자체 세션 레이어 위에 구현합니다. 커널의 kTLS가 소켓 계층에서 암호화를 처리하는 것과 달리, VPP는 세션 레이어의 전송 프로토콜 추상화(transport_register_protocol())를 활용하여 TLS를 TCP와 동일한 레벨의 전송 프로토콜로 등록합니다. 이를 통해 애플리케이션은 TLS 여부와 무관하게 동일한 세션 API를 사용할 수 있습니다.
TLS 세션 레이어 통합
VPP의 TLS는 세션 레이어(src/vnet/session/)와 TLS 플러그인(src/plugins/tlsopenssl/, src/plugins/tlsmbedtls/) 사이의 추상 계층인 src/vnet/tls/tls.c를 통해 동작합니다:
/* src/vnet/tls/tls.h — TLS 컨텍스트 구조체 */
typedef struct tls_ctx_
{
union {
transport_connection_t connection; /* 세션 레이어 전송 연결 */
};
u32 tls_session_handle; /* 하부 TCP 세션 */
u32 app_session_handle; /* 상위 애플리케이션 세션 */
u32 listener_ctx_index; /* 리스너 컨텍스트 인덱스 */
u8 is_passive_close; /* 수동 종료 플래그 */
u8 resume; /* 비동기 핸드셰이크 재개 */
u8 app_closed; /* 애플리케이션 종료 여부 */
tls_ctx_type_t tls_type; /* openssl / mbedtls / picotls */
u32 ckpair_index; /* 인증서/키 쌍 인덱스 */
/* ... 엔진별 opaque 데이터 ... */
} tls_ctx_t;
/* TLS를 전송 프로토콜로 등록 */
static const transport_proto_vft_t tls_proto = {
.connect = tls_connect,
.close = tls_disconnect,
.send_params = tls_send_params,
.get_connection = tls_connection_get,
.get_listener = tls_listener_get,
.custom_tx = tls_custom_tx_callback,
.format_connection = format_tls_connection,
};
/* 초기화 시 TRANSPORT_PROTO_TLS로 등록 */
transport_register_protocol (TRANSPORT_PROTO_TLS,
&tls_proto, FIB_PROTOCOL_IP4, ~0);
transport_register_protocol (TRANSPORT_PROTO_TLS,
&tls_proto, FIB_PROTOCOL_IP6, ~0);
이 구조 덕분에 세션 레이어는 TLS를 별도 처리 없이 일반 전송 프로토콜처럼 취급하며, session_open() 호출 시 TRANSPORT_PROTO_TLS를 지정하면 자동으로 TCP 연결 위에 TLS 핸드셰이크가 수행됩니다.
TLS 컨텍스트의 생명 주기는 다음과 같은 상태 전이를 따릅니다:
- TLS_CONN_STATE_NONE:
tls_ctx_alloc()로 컨텍스트 할당, 엔진 유형 결정 - TLS_CONN_STATE_HANDSHAKE: TCP 연결 수립 후 엔진별 핸드셰이크 시작 (
ctx_init_server()또는ctx_init_client()) - TLS_CONN_STATE_ESTABLISHED: 핸드셰이크 완료, 양방향 암호화 데이터 전송 가능
- TLS_CONN_STATE_PASSIVE_CLOSE: 원격 측이
close_notify전송, 잔여 데이터 드레인 - TLS_CONN_STATE_CLOSED: 양방향 종료 완료, 컨텍스트 해제
각 상태 전이에서 TLS 추상 계층은 엔진 VFT(Virtual Function Table)를 통해 실제 암호 라이브러리 호출을 위임합니다:
/* src/vnet/tls/tls.h — TLS 엔진 플러그인 인터페이스 */
typedef struct tls_engine_vft_
{
u32 (*ctx_alloc) (void); /* 컨텍스트 할당 */
void (*ctx_free) (tls_ctx_t *ctx); /* 컨텍스트 해제 */
tls_ctx_t *(*ctx_get) (u32 ctx_index); /* 인덱스로 컨텍스트 조회 */
/* 핸드셰이크: 서버/클라이언트 초기화 */
int (*ctx_init_server) (tls_ctx_t *ctx);
int (*ctx_init_client) (tls_ctx_t *ctx);
/* 데이터 전송: 애플리케이션 FIFO ↔ TLS 레코드 */
int (*ctx_write) (tls_ctx_t *ctx, /* 평문→암호화→TCP */
session_t *app_session,
transport_send_params_t *sp);
int (*ctx_read) (tls_ctx_t *ctx, /* TCP→복호화→평문 */
session_t *tls_session);
/* 핸드셰이크 진행 (WANT_READ/WRITE 시 재호출) */
int (*ctx_handshake_is_over) (tls_ctx_t *ctx);
/* 전송 종료 및 리셋 */
int (*ctx_transport_close) (tls_ctx_t *ctx);
int (*ctx_app_close) (tls_ctx_t *ctx);
} tls_engine_vft_t;
/* 엔진 등록 (각 플러그인의 init 함수에서 호출) */
void tls_register_engine (const tls_engine_vft_t *vft,
tls_engine_type_t type);
tls_engine_vft_t의 콜백(Callback)들만 구현하면 됩니다. 세션 레이어와 TLS 추상 계층의 코드는 변경할 필요가 없으며, 이것이 VPP TLS의 핵심 설계 원칙입니다.
TLS 데이터 경로: FIFO 기반 암복호화
VPP TLS의 데이터 경로는 이중 세션(dual-session) 모델을 사용합니다. 하나의 TLS 연결은 내부적으로 두 개의 세션을 유지합니다: 하부 TCP 세션(암호문)과 상위 애플리케이션 세션(평문). 각 세션은 독립적인 FIFO(rx/tx) 쌍을 가지며, TLS 엔진이 양쪽 FIFO 사이에서 암복호화를 수행합니다:
이 제로-카피(zero-copy)에 가까운 데이터 경로가 VPP TLS의 성능 핵심입니다. 커널 kTLS가 sendfile()과 splice()를 통해 커널-유저 경계를 최소화하려는 것과 달리, VPP는 애초에 전체 경로가 유저스페이스이므로 경계 자체가 존재하지 않습니다.
VPP TLS vs 커널 kTLS 아키텍처 비교
VPP TLS와 커널 kTLS는 근본적으로 다른 설계 철학을 따릅니다. 두 접근 방식의 구조적 차이를 이해하면 적합한 사용 시나리오를 판단할 수 있습니다:
| 비교 항목 | VPP TLS | 커널 kTLS |
|---|---|---|
| 핸드셰이크 | 유저스페이스 (VPP 내) | 유저스페이스 (OpenSSL) |
| 데이터 암복호화 | 유저스페이스 (VPP 내) | 커널 (tls_sw/tls_device) |
| TCP 스택 | 유저스페이스 (VPP 내장) | 커널 TCP |
| NIC 접근 | DPDK/AF_XDP (직접) | 커널 드라이버 |
| syscall 횟수 | 0 (완전 유저스페이스) | send/recv 호출마다 |
| sendfile 지원 | 미지원 (FIFO 기반) | 지원 (제로카피 가능) |
| NIC HW TLS | DPDK Cryptodev 경유 | tls_device 네이티브 |
| 기존 앱 호환 | LD_PRELOAD 또는 VCL 전환 | 소켓 옵션만 추가 |
| 인증서 교체 | 무중단 (CLI/API) | 프로세스 재시작(Reboot) 필요 |
| 적합 워크로드 | 고 CPS, 대규모 동시 연결 | 고 처리량, 파일 서빙 |
TLS 플러그인 비교: OpenSSL vs mbedTLS vs picotls
VPP는 세 가지 TLS 엔진 플러그인을 제공합니다. 각각 다른 유스케이스에 최적화되어 있으며, startup.conf에서 선택할 수 있습니다:
| 항목 | tlsopenssl | tlsmbedtls | picotls |
|---|---|---|---|
| 라이브러리 | OpenSSL / BoringSSL | Mbed TLS (ARM) | picotls (H2O) |
| TLS 1.2 | 완전 지원 | 완전 지원 | 미지원 |
| TLS 1.3 | 완전 지원 | 3.x에서 지원 | 완전 지원 (전용) |
| 비동기 암호화 | 지원 (ENGINE API) | 미지원 | 미지원 |
| HW 오프로드 | QAT, Cryptodev | 미지원 | 미지원 |
| Cipher Suite | 전체 | 제한적 | TLS 1.3 전용 |
| 메모리 사용 | 높음 (~50KB/ctx) | 낮음 (~10KB/ctx) | 매우 낮음 (~5KB/ctx) |
| 성능 (SW) | 높음 | 중간 | 매우 높음 (1.3 전용) |
| 라이선스 | Apache 2.0 | Apache 2.0 | MIT |
| 적합 시나리오 | 범용, HW 가속 | 임베디드, IoT | TLS 1.3 전용 고성능 |
tlsopenssl이 권장됩니다. TLS 1.3만 필요하고 최대 성능이 목표라면 picotls가 적합하며, 메모리가 극히 제한된 임베디드 환경에서는 tlsmbedtls를 고려합니다.
각 엔진은 VPP 소스 트리의 독립적인 플러그인으로 존재합니다:
src/plugins/
├── tlsopenssl/ # OpenSSL/BoringSSL 엔진
│ ├── tls_openssl.c # VFT 구현: ctx_init, ctx_write, ctx_read
│ ├── tls_async.c # 비동기 ENGINE 연동 (QAT 등)
│ └── tls_bio.c # BIO_s_mem 기반 FIFO 브릿지
├── tlsmbedtls/ # Mbed TLS 엔진
│ └── tls_mbedtls.c # VFT 구현, mbedtls_ssl_* 래핑
├── tlspicotls/ # picotls 엔진 (TLS 1.3 전용)
│ └── tls_picotls.c # VFT 구현, ptls_* 래핑
└── quic/ # QUIC 플러그인 (quicly 기반)
└── quic.c # TLS 1.3은 quicly 내부에서 처리
TLS 엔진 플러그인 내부 구현 분석
VPP의 TLS 엔진 플러그인은 각각 고유한 I/O 패턴으로 암복호화를 수행합니다. 여기서는 OpenSSL, mbedTLS, picotls 세 엔진의 내부 데이터 경로와 구현 차이를 상세히 분석합니다.
OpenSSL 엔진 ctx_write() 분석
tls_openssl_ctx_write() 함수는 애플리케이션이 보낸 평문 데이터를 TLS 레코드로 암호화하여 TCP 스택에 전달하는 핵심 경로입니다. OpenSSL의 BIO(Basic I/O) 추상화 계층을 활용하여 메모리 기반 I/O를 수행합니다:
- App TX FIFO에서 평문 dequeue:
svm_fifo_peek()로 애플리케이션이 기록한 평문 데이터를 읽어옵니다 - BIO_write() → SSL_write(): 평문 데이터를 OpenSSL의 내부 BIO 버퍼에 기록하면,
SSL_write()가 TLS 레코드를 생성하고 암호화를 수행합니다 - BIO_read()로 암호문 추출: 암호화된 TLS 레코드를 출력 BIO에서 읽어옵니다
- TCP TX FIFO에 암호문 enqueue:
svm_fifo_enqueue()로 암호문을 TCP 전송 큐에 삽입합니다
/* tls_openssl_ctx_write() — 평문 → TLS 레코드 암호화 경로 */
static int
tls_openssl_ctx_write (tls_ctx_t *ctx, session_t *app_session,
transport_send_params_t *sp)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
svm_fifo_t *f = app_session->tx_fifo;
u32 deq_max, wrote = 0;
int rv;
/* 1단계: App TX FIFO에서 전송 가능한 최대 바이트 확인 */
deq_max = svm_fifo_max_dequeue_cons (f);
if (!deq_max)
return 0;
/* 2단계: FIFO에서 평문 데이터를 읽어 SSL_write()로 암호화 */
while (deq_max > 0)
{
u32 len = clib_min (deq_max, TLS_CHUNK_SIZE);
svm_fifo_peek (f, wrote, len, oc->write_buf);
/* SSL_write()가 내부적으로 BIO를 통해 TLS 레코드 생성 */
rv = SSL_write (oc->ssl, oc->write_buf, len);
if (rv <= 0)
{
int err = SSL_get_error (oc->ssl, rv);
if (err == SSL_ERROR_WANT_WRITE)
break;
return -1;
}
wrote += rv;
deq_max -= rv;
}
/* 3단계: 출력 BIO에서 암호문을 읽어 TCP TX FIFO에 enqueue */
openssl_write_from_bio_to_fifo (oc->ssl, app_session);
/* 4단계: 소비된 바이트만큼 App TX FIFO에서 제거 */
if (wrote)
svm_fifo_dequeue_drop (f, wrote);
return wrote;
}
openssl_write_from_bio_to_fifo() 내부에서는 BIO_ctrl_pending()으로 출력 BIO에 대기 중인 암호문 크기를 확인한 후, BIO_read()로 추출하여 TCP 세션의 TX FIFO에 기록합니다. 이 BIO 기반 간접 경로가 OpenSSL 엔진의 특징이며, 동시에 성능 오버헤드의 원인이기도 합니다.
OpenSSL 엔진 ctx_read() 분석
tls_openssl_ctx_read()는 TCP에서 수신한 암호문을 복호화하여 애플리케이션에 전달하는 수신 경로입니다. 쓰기 경로의 역방향으로 BIO를 활용합니다:
- TCP RX FIFO에서 암호문 dequeue: TCP 스택이 수신한 TLS 레코드를 읽어옵니다
- BIO_write()로 입력 BIO에 주입: 암호문을 OpenSSL의 입력 BIO 버퍼에 기록합니다
- SSL_read()로 복호화: OpenSSL이 TLS 레코드를 파싱하고 복호화하여 평문을 반환합니다
- App RX FIFO에 평문 enqueue: 복호화된 평문을 애플리케이션 수신 큐에 삽입합니다
/* tls_openssl_ctx_read() — TLS 레코드 복호화 → 평문 경로 */
static int
tls_openssl_ctx_read (tls_ctx_t *ctx, session_t *tls_session)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
session_t *app_session;
svm_fifo_t *app_rx_fifo;
int read = 0, rv;
/* 1단계: TCP RX FIFO → 입력 BIO로 암호문 전달 */
openssl_read_from_fifo_to_bio (oc->ssl, tls_session);
app_session = session_get_from_handle (ctx->app_session_handle);
app_rx_fifo = app_session->rx_fifo;
/* 2단계: SSL_read()로 복호화된 평문 추출 */
while (1)
{
rv = SSL_read (oc->ssl, oc->read_buf, TLS_CHUNK_SIZE);
if (rv <= 0)
{
int err = SSL_get_error (oc->ssl, rv);
if (err == SSL_ERROR_WANT_READ)
break; /* 추가 데이터 대기 필요 */
if (err == SSL_ERROR_ZERO_RETURN)
break; /* TLS close_notify 수신 */
return -1;
}
/* 3단계: 복호화된 평문을 App RX FIFO에 enqueue */
rv = svm_fifo_enqueue (app_rx_fifo, rv, oc->read_buf);
if (rv < 0)
break;
read += rv;
}
return read;
}
SSL_ERROR_WANT_READ는 OpenSSL이 완전한 TLS 레코드를 구성하기에 데이터가 부족할 때 반환됩니다. 이 경우 VPP는 이벤트 루프로 제어를 반환하고, TCP로부터 추가 데이터가 도착하면 다시 ctx_read()를 호출합니다. SSL_ERROR_ZERO_RETURN은 상대방이 TLS close_notify를 전송했음을 의미하며, 정상적인 연결 종료 절차를 시작합니다.
mbedTLS 엔진 차이점
mbedTLS 엔진은 OpenSSL의 BIO 추상화 대신 콜백 기반 I/O 모델을 사용합니다. mbedtls_ssl_set_bio()로 등록한 커스텀 send/recv 콜백이 FIFO에 직접 접근하므로, 별도의 BIO 계층 오버헤드가 발생하지 않습니다:
/* mbedTLS 엔진 — 콜백 기반 I/O 설정 */
static void
mbedtls_ctx_init (tls_ctx_t *ctx)
{
mbedtls_ctx_t *mc = (mbedtls_ctx_t *) ctx;
mbedtls_ssl_init (&mc->ssl);
mbedtls_ssl_setup (&mc->ssl, &mc->conf);
/* BIO 대신 커스텀 콜백으로 FIFO 직접 연결 */
mbedtls_ssl_set_bio (&mc->ssl, ctx,
tls_mbedtls_send_cb, /* 송신 콜백 */
tls_mbedtls_recv_cb, /* 수신 콜백 */
NULL); /* 타임아웃 콜백 없음 */
}
/* 송신 콜백: 암호문을 TCP TX FIFO에 직접 기록 */
static int
tls_mbedtls_send_cb (void *ctx_ptr, const unsigned char *buf,
size_t len)
{
tls_ctx_t *ctx = (tls_ctx_t *) ctx_ptr;
session_t *tls_session;
int rv;
tls_session = session_get_from_handle (ctx->tls_session_handle);
rv = svm_fifo_enqueue (tls_session->tx_fifo, len, buf);
if (rv < 0)
return MBEDTLS_ERR_SSL_WANT_WRITE;
return rv;
}
/* 수신 콜백: TCP RX FIFO에서 암호문을 직접 읽기 */
static int
tls_mbedtls_recv_cb (void *ctx_ptr, unsigned char *buf,
size_t len)
{
tls_ctx_t *ctx = (tls_ctx_t *) ctx_ptr;
session_t *tls_session;
int rv;
tls_session = session_get_from_handle (ctx->tls_session_handle);
rv = svm_fifo_dequeue (tls_session->rx_fifo, len, buf);
if (rv < 0)
return MBEDTLS_ERR_SSL_WANT_READ;
return rv;
}
콜백 내부에서 svm_fifo_enqueue() / svm_fifo_dequeue()를 직접 호출하기 때문에, OpenSSL처럼 BIO 버퍼를 거치는 중간 복사가 제거됩니다. 다만 mbedTLS 자체의 암호화 연산 속도가 OpenSSL 대비 느리므로, 전체 처리량에서는 큰 이점을 얻기 어렵습니다. mbedTLS 엔진은 주로 메모리 제약이 있는 임베디드 환경이나 라이선스 요건(Apache 2.0)이 중요한 경우에 선택됩니다.
picotls 엔진 최적화
picotls 엔진은 TLS 1.3 전용 구현체로, TLS 1.0~1.2 레거시 코드가 전혀 없어 코드베이스가 극히 작고 성능이 우수합니다. VPP의 picotls 엔진이 최고 성능을 달성하는 핵심 요인은 다음과 같습니다:
- TLS 1.3 전용: 이전 버전 호환 코드가 없으므로 분기 예측 실패가 감소하고, 핸드셰이크 경로가 단순합니다
- 직접 버퍼 관리:
ptls_buffer_t구조체를 통해 VPP FIFO와 직접 연동하며, BIO나 콜백 계층 없이 버퍼 포인터를 직접 전달합니다 - Zero-copy 최적화:
ptls_send()와ptls_receive()가 입출력 버퍼를 직접 참조하므로 불필요한 메모리 복사가 최소화됩니다 - 경량 컨텍스트: 세션당 메모리 사용량이 약 5KB 수준으로, OpenSSL의 ~34KB 대비 매우 작습니다
/* picotls 엔진 — 직접 버퍼 기반 암호화 경로 */
static int
picotls_ctx_write (tls_ctx_t *ctx, session_t *app_session,
transport_send_params_t *sp)
{
picotls_ctx_t *ptc = (picotls_ctx_t *) ctx;
svm_fifo_t *f = app_session->tx_fifo;
ptls_buffer_t sendbuf;
u32 deq_max, wrote = 0;
int rv;
deq_max = svm_fifo_max_dequeue_cons (f);
if (!deq_max)
return 0;
/* ptls_buffer를 스택에 초기화 — 힙 할당 회피 */
ptls_buffer_init (&sendbuf, "", 0);
while (deq_max > 0)
{
u32 len = clib_min (deq_max, TLS_CHUNK_SIZE);
svm_fifo_peek (f, wrote, len, ptc->write_buf);
/* ptls_send()가 TLS 1.3 레코드를 직접 생성 */
rv = ptls_send (ptc->tls, &sendbuf,
ptc->write_buf, len);
if (rv != 0)
break;
wrote += len;
deq_max -= len;
}
/* sendbuf에 축적된 암호문을 TCP TX FIFO에 일괄 전송 */
if (sendbuf.off > 0)
{
session_t *tls_session;
tls_session = session_get_from_handle (ctx->tls_session_handle);
svm_fifo_enqueue (tls_session->tx_fifo,
sendbuf.off, sendbuf.base);
}
if (wrote)
svm_fifo_dequeue_drop (f, wrote);
ptls_buffer_dispose (&sendbuf);
return wrote;
}
/* picotls 엔진 — 직접 버퍼 기반 복호화 경로 */
static int
picotls_ctx_read (tls_ctx_t *ctx, session_t *tls_session)
{
picotls_ctx_t *ptc = (picotls_ctx_t *) ctx;
ptls_buffer_t decryptbuf;
session_t *app_session;
u32 deq_max;
int rv;
deq_max = svm_fifo_max_dequeue_cons (tls_session->rx_fifo);
if (!deq_max)
return 0;
ptls_buffer_init (&decryptbuf, "", 0);
/* TCP RX FIFO에서 암호문을 읽어 직접 복호화 */
svm_fifo_dequeue (tls_session->rx_fifo, deq_max,
ptc->read_buf);
size_t consumed = deq_max;
rv = ptls_receive (ptc->tls, &decryptbuf,
ptc->read_buf, &consumed);
if (rv == 0 && decryptbuf.off > 0)
{
app_session = session_get_from_handle (
ctx->app_session_handle);
svm_fifo_enqueue (app_session->rx_fifo,
decryptbuf.off, decryptbuf.base);
}
ptls_buffer_dispose (&decryptbuf);
return (rv == 0) ? decryptbuf.off : -1;
}
ptls_send()는 입력 평문에서 TLS 1.3 레코드를 직접 생성하여 ptls_buffer_t에 축적합니다. ptls_receive()는 consumed 포인터를 통해 실제 처리된 바이트 수를 반환하므로, 부분 레코드 도착 시에도 정확한 FIFO 관리가 가능합니다.
TLS 엔진 내부 API 비교
| 항목 | OpenSSL | mbedTLS | picotls |
|---|---|---|---|
| I/O 모델 | BIO (메모리 BIO 쌍) | 콜백 (send/recv) |
직접 버퍼 (ptls_buffer_t) |
| 암호화 호출 | SSL_write() |
mbedtls_ssl_write() |
ptls_send() |
| 복호화 호출 | SSL_read() |
mbedtls_ssl_read() |
ptls_receive() |
| FIFO 연동 방식 | BIO_write → BIO_read 간접 | 콜백 내 FIFO 직접 접근 | 버퍼 포인터 직접 전달 |
| 중간 복사 횟수 | 2회 (BIO 입출력) | 1회 (콜백 버퍼) | 0~1회 (zero-copy 가능) |
| TLS 버전 지원 | 1.0 / 1.1 / 1.2 / 1.3 | 1.2 / 1.3 | 1.3 전용 |
| 세션당 메모리 | ~34KB | ~10KB | ~5KB |
| 핸드셰이크 모드 | 비동기 (WANT_READ/WRITE) | 비동기 (WANT_READ/WRITE) | 동기 (단일 RTT) |
| 라이선스 | Apache 2.0 | Apache 2.0 | MIT |
| 비동기 HW 가속 | ENGINE API 지원 | ALT 함수 교체 | 미지원 |
TLS 핸드셰이크 유저스페이스 처리 흐름
VPP의 TLS 핸드셰이크는 전적으로 유저스페이스에서 수행됩니다. 커널 소켓을 거치지 않으므로 syscall 오버헤드가 없으며, VPP의 이벤트 루프(Event Loop)와 통합되어 비차단(Non-blocking) 방식으로 처리됩니다:
- session_open(TRANSPORT_PROTO_TLS): 세션 레이어가 TLS 연결 요청
- tls_connect(): TLS 추상 계층이 먼저 TCP 연결 수립
- TCP 3-way 핸드셰이크: VPP TCP 스택에서 수행
- tls_session_connected_cb(): TCP 연결 완료 콜백
- tls_ctx_handshake_*(): 엔진별 TLS 핸드셰이크 시작
- 비동기 핸드셰이크:
SSL_do_handshake()가SSL_ERROR_WANT_READ/WRITE반환 시 이벤트 대기 - 핸드셰이크 완료: 애플리케이션에 연결 완료 통지
TLS 연결 수립 소스 코드 분석
VPP TLS 연결 수립은 클라이언트 측과 서버 측에서 서로 다른 경로를 거칩니다. 전체 흐름은 세션 레이어 → 전송(TCP) 연결 → TLS 핸드셰이크 → 암호화 엔진 초기화 순서로 진행됩니다.
클라이언트 측 연결 수립 흐름
클라이언트의 TLS 연결은 tls_connect()에서 시작하여, TCP 연결이 완료된 후 비로소 TLS 핸드셰이크가 시작됩니다. 이 2단계 구조가 VPP TLS의 핵심 설계입니다:
/* 클라이언트 TLS 연결 수립 전체 흐름 */
/* 1단계: tls_connect() — TLS 컨텍스트 생성 + TCP 연결 시작 */
static int
tls_connect (transport_endpoint_cfg_t *tep)
{
tls_ctx_t *ctx;
u32 ctx_handle;
/* TLS 컨텍스트 할당 및 초기화 */
ctx = tls_ctx_alloc (tep->transport_proto);
ctx_handle = tls_ctx_handle (ctx);
/* 인증서, SNI(Server Name), ALPN 등 설정 복사 */
ctx->tls_type = TLS_CLIENT;
clib_memcpy (&ctx->srv_hostname, tep->hostname, hostname_len);
/* 하부 TCP 전송 연결 시작 — 아직 TLS 아님 */
session_open (TRANSPORT_PROTO_TCP, tep, ctx->app_session_handle);
/* → TCP SYN 전송, 응답 대기 */
return 0;
}
/* 2단계: TCP 연결 완료 콜백 → TLS 핸드셰이크 시작 */
static int
tls_session_connected_cb (u32 tls_app_index,
u32 ctx_handle,
session_t *tcp_session,
session_error_t err)
{
tls_ctx_t *ctx = tls_ctx_get (ctx_handle);
if (err)
{
/* TCP 연결 실패 → 상위 레이어에 오류 전파 */
tls_notify_app_connected (ctx, SESSION_E_REFUSED);
return -1;
}
/* TCP 세션 핸들 저장 */
ctx->tls_session_handle = session_handle (tcp_session);
/* 암호화 엔진의 클라이언트 초기화 호출 */
return tls_ctx_init_client (ctx);
/* → openssl: SSL_new() + SSL_set_connect_state() + SSL_do_handshake() */
/* → picotls: ptls_new() + ptls_handshake() */
}
코드 분석: 2단계 연결 수립
tls_connect()는 즉시 TLS 핸드셰이크를 시작하지 않습니다. 먼저 session_open(TRANSPORT_PROTO_TCP, ...)으로 TCP 3-way 핸드셰이크를 시작하고, TCP 연결이 완료되면 VPP 세션 레이어가 tls_session_connected_cb() 콜백을 호출합니다. 이 시점에서 비로소 tls_ctx_init_client()를 통해 SSL 객체가 생성되고 SSL_do_handshake()가 호출됩니다. 이러한 비동기 2단계 설계 덕분에 TCP 연결 대기 중에도 VPP 메인 루프가 다른 패킷을 계속 처리할 수 있습니다.
서버 측 연결 수락 흐름
서버 측에서는 tls_start_listen()으로 TLS 리스너를 등록한 후, 클라이언트로부터 TCP 연결이 들어오면 자동으로 TLS 핸드셰이크가 시작됩니다:
/* 서버 TLS 리스너 등록 */
static u32
tls_start_listen (u32 tls_listener_index,
transport_endpoint_cfg_t *tep)
{
tls_ctx_t *lctx;
u32 tcp_listener;
/* TLS 리스너 컨텍스트 생성 */
lctx = tls_listener_ctx_alloc ();
lctx->tls_type = TLS_SERVER;
/* 인증서/키 파일 경로 설정 */
lctx->tls_cert_file = tep->cert_file;
lctx->tls_key_file = tep->key_file;
/* 하부 TCP 리스너 등록 */
tcp_listener = session_start_listen (TRANSPORT_PROTO_TCP, tep);
return lctx->listener_index;
}
/* TCP 연결 수락 → TLS 핸드셰이크 시작 */
static int
tls_session_accept_callback (session_t *tcp_session)
{
tls_ctx_t *lctx, *ctx;
/* 리스너 컨텍스트에서 인증서 정보 복사 */
lctx = tls_listener_ctx_get (tcp_session->listener_handle);
ctx = tls_ctx_alloc (lctx->tls_type);
ctx->tls_session_handle = session_handle (tcp_session);
ctx->listener_ctx_index = lctx->listener_index;
/* 서버 암호화 엔진 초기화 → SSL_set_accept_state() */
return tls_ctx_init_server (ctx);
/* → ClientHello 수신 대기 → SSL_do_handshake() 진행 */
}
코드 분석: 서버 측 연결 수락
tls_start_listen()은 TCP 리스너를 내부적으로 등록하고, 새로운 TCP 연결이 들어올 때마다 tls_session_accept_callback()이 호출됩니다. 이 콜백에서 tls_ctx_init_server()를 호출하여 SSL 컨텍스트를 생성하고, SSL_set_accept_state()로 서버 모드를 설정합니다. 이후 클라이언트의 ClientHello가 TCP RX FIFO에 도착하면 tls_handshake_rx()에서 이를 읽어 OpenSSL에 전달하여 핸드셰이크를 진행합니다.
핸드셰이크 오류 경로 분석
TLS 핸드셰이크가 실패하는 주요 원인과 VPP의 내부 처리 경로입니다:
| 실패 원인 | OpenSSL 오류 코드 | VPP 내부 처리 | 디버깅 방법 |
|---|---|---|---|
| 인증서 검증 실패 | X509_V_ERR_* | tls_ctx_init_client()에서 verify callback 실패 → 세션 종료 | show errors에 tls-handshake-fail 카운터 확인 |
| cipher 불일치 | SSL_R_NO_SHARED_CIPHER | SSL_do_handshake() 반환값 < 0 → alert 전송 → 세션 정리 | trace add tls-input 100으로 핸드셰이크 메시지 확인 |
| 인증서 만료 | X509_V_ERR_CERT_HAS_EXPIRED | verify callback에서 거부 → connected_cb에 오류 전파 | show tls certs로 인증서 유효기간 확인 |
| 메모리 부족 | SSL_R_MALLOC_FAILURE | SSL_new() NULL 반환 → ctx_init 실패 → 연결 거부 | show memory verbose로 세그먼트 사용량 확인 |
| 핸드셰이크 타임아웃 | N/A | 세션 idle-timeout 만료 → session_cleanup() 호출 | show tls ctx verbose에서 HANDSHAKE 상태 체류 시간 |
/* 핸드셰이크 진행 및 오류 처리 (openssl_ctx_handshake_rx) */
static int
openssl_ctx_handshake_rx (tls_ctx_t *ctx, session_t *tcp_session)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
int rv;
/* TCP RX FIFO에서 암호화된 핸드셰이크 데이터 읽기 */
openssl_try_handshake_read (oc, tcp_session);
/* OpenSSL 핸드셰이크 계속 진행 */
rv = SSL_do_handshake (oc->ssl);
if (rv == 1)
{
/* 핸드셰이크 성공 → 애플리케이션에 알림 */
ctx->state = TLS_STATE_ESTABLISHED;
if (ctx->tls_type == TLS_CLIENT)
tls_notify_app_connected (ctx, SESSION_E_NONE);
else
tls_notify_app_accept (ctx);
return 0;
}
int ssl_err = SSL_get_error (oc->ssl, rv);
if (ssl_err == SSL_ERROR_WANT_READ || ssl_err == SSL_ERROR_WANT_WRITE)
{
/* 핸드셰이크 진행 중 — 추가 데이터 필요 */
openssl_try_handshake_write (oc, tcp_session);
return 0;
}
/* 핸드셰이크 실패 — 오류 처리 */
tls_handshake_failed (ctx);
return -1;
}
코드 분석: 핸드셰이크 상태 머신
openssl_ctx_handshake_rx()는 TLS 핸드셰이크의 핵심 반복 루프입니다. TCP FIFO에서 데이터를 읽어 SSL_do_handshake()에 전달하고, 반환값에 따라 세 가지 경로로 분기합니다. 반환값 1은 핸드셰이크 완료를 의미하며, SSL_ERROR_WANT_READ/WRITE는 추가 데이터가 필요하다는 의미입니다. 그 외의 오류는 tls_handshake_failed()로 처리되어 세션이 정리됩니다. VPP의 비동기 모델에서 이 함수는 TCP RX 이벤트마다 반복 호출되므로, 전체 핸드셰이크가 여러 벡터 처리 사이클에 걸쳐 비차단(Non-blocking)으로 완료됩니다.
암호화 엔진 선택
VPP CLI에서 런타임에 TLS 암호화 엔진을 변경할 수 있습니다:
# 현재 TLS 엔진 확인
vpp# show tls engines
# OpenSSL 엔진 선택
vpp# set tls crypto handler openssl
# picotls 엔진 선택 (TLS 1.3 전용)
vpp# set tls crypto handler picotls
# startup.conf에서 기본 엔진 지정
# tls {
# default-crypto-engine openssl
# }
picotls는 TLS 1.3만 지원하므로, TLS 1.2 연결이 필요한 클라이언트가 있다면 openssl을 유지해야 합니다.
OpenSSL BIO 메모리 체인 구현 분석
VPP의 tls_openssl 엔진은 OpenSSL의 BIO(Basic I/O) 추상화 계층을 활용하여 TLS 프로토콜 처리와 세션 FIFO 간의 데이터 흐름을 연결합니다. 핵심은 두 개의 BIO_s_mem 객체를 생성하여 각각 수신(RX)과 송신(TX) 방향의 TLS 레코드 버퍼로 사용하는 것입니다.
BIO_s_mem → FIFO 브릿지 구조: VPP는 OpenSSL의 표준 소켓 BIO 대신 메모리 BIO를 사용합니다. tls_openssl_bio_read() 콜백은 TCP RX FIFO에서 암호화된 TLS 레코드를 디큐(Dequeue)하여 OpenSSL에 전달하고, tls_openssl_bio_write() 콜백은 OpenSSL이 생성한 암호화된 레코드를 TCP TX FIFO에 인큐(Enqueue)합니다. 이 구조 덕분에 OpenSSL은 실제 소켓 없이도 TLS 처리를 수행할 수 있습니다.
/* OpenSSL 서버 컨텍스트 초기화 의사코드 */
static int
openssl_ctx_init_server (tls_ctx_t *ctx)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
openssl_main_t *om = &openssl_main;
/* 1. SSL_CTX 생성 및 인증서 설정 */
SSL_CTX *ssl_ctx = SSL_CTX_new (TLS_server_method ());
SSL_CTX_use_certificate_chain_file (ssl_ctx, ctx->tls_cert_file);
SSL_CTX_use_PrivateKey_file (ssl_ctx, ctx->tls_key_file, SSL_FILETYPE_PEM);
SSL_CTX_set_verify (ssl_ctx, SSL_VERIFY_NONE, NULL);
/* 2. SSL 세션 객체 생성 */
oc->ssl = SSL_new (ssl_ctx);
/* 3. 메모리 BIO 쌍 생성 및 연결 */
BIO *rbio = BIO_new (BIO_s_mem ()); /* TCP RX → OpenSSL 입력 */
BIO *wbio = BIO_new (BIO_s_mem ()); /* OpenSSL 출력 → TCP TX */
BIO_set_mem_eof_return (rbio, -1);
BIO_set_mem_eof_return (wbio, -1);
/* SSL 객체에 BIO 연결 (SSL이 소유권 획득) */
SSL_set_bio (oc->ssl, rbio, wbio);
/* 4. 서버 모드(accept)로 설정 */
SSL_set_accept_state (oc->ssl);
return 0;
}
데이터 송신 경로에서는 애플리케이션 FIFO의 평문 데이터가 OpenSSL을 거쳐 암호화된 후 TCP FIFO로 전달됩니다. 이 과정은 openssl_ctx_write() 함수에서 처리됩니다:
/* 데이터 송신: 앱 FIFO → OpenSSL 암호화 → TCP TX FIFO */
static int
openssl_ctx_write (tls_ctx_t *ctx, session_t *app_session,
transport_send_params_t *sp)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
svm_fifo_t *app_rx_fifo = app_session->rx_fifo; /* 앱→VPP 방향 */
svm_fifo_t *tls_tx_fifo = ctx->tls_session->tx_fifo;
int wrote = 0, rv;
u32 deq_max, deq_now;
deq_max = svm_fifo_max_dequeue_cons (app_rx_fifo);
if (!deq_max)
return 0;
/* 1단계: 앱 FIFO에서 평문 데이터를 peek */
deq_now = clib_min (deq_max, sp->max_burst_size);
u8 *buf = vec_new (u8, deq_now);
svm_fifo_peek (app_rx_fifo, 0, deq_now, buf);
/* 2단계: SSL_write()로 OpenSSL에 평문 전달 → 내부 암호화 */
rv = SSL_write (oc->ssl, buf, deq_now);
if (rv > 0)
{
wrote = rv;
svm_fifo_dequeue_drop (app_rx_fifo, rv);
/* 3단계: wbio에서 암호화된 데이터를 읽어 TCP TX FIFO에 인큐 */
BIO *wbio = SSL_get_wbio (oc->ssl);
int pending = BIO_ctrl_pending (wbio);
if (pending > 0)
{
u8 *tls_buf = vec_new (u8, pending);
BIO_read (wbio, tls_buf, pending);
svm_fifo_enqueue (tls_tx_fifo, pending, tls_buf);
vec_free (tls_buf);
}
}
vec_free (buf);
return wrote;
}
수신 경로는 반대 방향으로 동작합니다. TCP RX FIFO의 암호화된 데이터가 OpenSSL을 거쳐 복호화된 후 애플리케이션 FIFO로 전달됩니다:
/* 데이터 수신: TCP RX FIFO → OpenSSL 복호화 → 앱 FIFO */
static int
openssl_ctx_read (tls_ctx_t *ctx, session_t *tls_session)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
svm_fifo_t *tls_rx_fifo = tls_session->rx_fifo;
svm_fifo_t *app_tx_fifo = ctx->app_session->tx_fifo;
int read = 0, rv;
u32 deq_max;
deq_max = svm_fifo_max_dequeue_cons (tls_rx_fifo);
if (!deq_max)
return 0;
/* 1단계: TCP RX FIFO에서 암호화된 TLS 레코드를 peek */
u8 *buf = vec_new (u8, deq_max);
svm_fifo_peek (tls_rx_fifo, 0, deq_max, buf);
/* 2단계: rbio에 암호화된 데이터 기록 → OpenSSL 입력 */
BIO *rbio = SSL_get_rbio (oc->ssl);
BIO_write (rbio, buf, deq_max);
svm_fifo_dequeue_drop (tls_rx_fifo, deq_max);
/* 3단계: SSL_read()로 복호화된 평문 추출 */
u8 plain_buf[16384]; /* TLS 레코드 최대 크기 */
rv = SSL_read (oc->ssl, plain_buf, sizeof (plain_buf));
if (rv > 0)
{
read = rv;
/* 4단계: 복호화된 데이터를 앱 FIFO에 인큐 */
svm_fifo_enqueue (app_tx_fifo, rv, plain_buf);
}
vec_free (buf);
return read;
}
FIFO 제로카피 최적화: 위의 의사코드는 이해를 위해 버퍼 복사 방식으로 작성되었지만, 실제 VPP 구현에서는 svm_fifo_segments()를 사용하여 FIFO 내부 메모리에 대한 직접 포인터를 얻습니다. 이를 통해 중간 버퍼 할당과 복사를 제거하여 성능을 극대화합니다:
/* 제로카피 FIFO 세그먼트 접근 */
svm_fifo_seg_t segs[2]; /* 링버퍼이므로 최대 2개 세그먼트 */
u32 n_segs = 2;
/* FIFO 내부 메모리에 대한 직접 포인터 획득 (복사 없음) */
svm_fifo_segments (tls_rx_fifo, 0, segs, &n_segs, deq_max);
/* 세그먼트 데이터를 직접 BIO에 기록 */
for (int i = 0; i < n_segs; i++)
BIO_write (rbio, segs[i].data, segs[i].len);
/* 처리 완료 후 FIFO에서 제거 */
svm_fifo_dequeue_drop (tls_rx_fifo, deq_max);
이 최적화는 특히 대용량 트래픽 처리 시 효과적입니다. SVM FIFO가 공유 메모리 위의 링버퍼로 구현되어 있기 때문에, 데이터가 버퍼 경계를 넘는 경우 최대 2개의 세그먼트가 반환됩니다. 각 세그먼트는 연속 메모리 영역을 가리키므로, BIO_write()에 직접 전달할 수 있습니다.
TLS 세션 재사용 내부 구현
TLS 핸드셰이크는 CPU 집약적인 비대칭 암호 연산을 포함하므로, 세션 재사용은 대규모 연결 환경에서 필수적인 최적화입니다. VPP는 TLS 1.2의 Session Ticket과 TLS 1.3의 PSK(Pre-Shared Key) 메커니즘을 모두 지원합니다.
Session Ticket 메커니즘
VPP는 SSL_CTX_set_tlsext_ticket_key_cb()를 통해 세션 티켓의 암호화/복호화 키를 직접 관리합니다. 이 방식은 다중 워커 스레드 환경에서 모든 워커가 동일한 티켓 키를 공유할 수 있게 합니다:
- 티켓 키 구조: 각 리스너(listener)별로 고유한 티켓 키 세트를 관리합니다. 키는 128비트 키 이름(key name), 256비트 AES 키, 256비트 HMAC 키로 구성됩니다.
- 키 로테이션: 현재 키(current key)와 이전 키(previous key)를 동시에 유지하여 키 전환 시 기존 티켓이 즉시 무효화(Invalidation)되지 않도록 합니다.
- 암호화 알고리즘: 티켓 암호화에는 AES-256-CBC를, 무결성(Integrity) 검증에는 HMAC-SHA-256을 사용합니다.
/* Session Ticket 키 콜백 의사코드 */
typedef struct
{
u8 key_name[16]; /* 티켓 식별용 키 이름 */
u8 aes_key[32]; /* AES-256-CBC 암호화 키 */
u8 hmac_key[32]; /* HMAC-SHA-256 무결성 키 */
f64 created_at; /* 키 생성 시각 */
} tls_ticket_key_t;
typedef struct
{
tls_ticket_key_t current; /* 새 티켓 암호화에 사용 */
tls_ticket_key_t previous; /* 기존 티켓 복호화용 */
f64 rotation_interval; /* 키 로테이션 주기 (초) */
} tls_ticket_key_ctx_t;
static int
tls_ticket_key_cb (SSL *ssl, u8 *key_name,
u8 *iv, EVP_CIPHER_CTX *ectx,
HMAC_CTX *hctx, int enc)
{
tls_ticket_key_ctx_t *tkc = get_ticket_key_ctx (ssl);
if (enc) /* 암호화: 새 티켓 생성 */
{
/* 현재 키의 이름을 티켓에 기록 */
clib_memcpy (key_name, tkc->current.key_name, 16);
/* 랜덤 IV 생성 */
RAND_bytes (iv, EVP_MAX_IV_LENGTH);
/* AES-256-CBC로 세션 상태 암호화 */
EVP_EncryptInit_ex (ectx, EVP_aes_256_cbc (),
NULL, tkc->current.aes_key, iv);
/* HMAC-SHA-256으로 무결성 서명 */
HMAC_Init_ex (hctx, tkc->current.hmac_key, 32,
EVP_sha256 (), NULL);
return 1; /* 성공 */
}
else /* 복호화: 기존 티켓 검증 */
{
tls_ticket_key_t *key = NULL;
/* 키 이름으로 현재/이전 키 매칭 */
if (!memcmp (key_name, tkc->current.key_name, 16))
key = &tkc->current;
else if (!memcmp (key_name, tkc->previous.key_name, 16))
key = &tkc->previous;
else
return 0; /* 알 수 없는 키 → 전체 핸드셰이크 수행 */
HMAC_Init_ex (hctx, key->hmac_key, 32,
EVP_sha256 (), NULL);
EVP_DecryptInit_ex (ectx, EVP_aes_256_cbc (),
NULL, key->aes_key, iv);
/* 이전 키로 복호화된 경우 새 티켓 발급 권고 */
return (key == &tkc->previous) ? 2 : 1;
}
}
PSK (Pre-Shared Key) 재연결
TLS 1.3에서는 Session Ticket 대신 PSK 기반의 세션 재사용을 사용합니다. 서버는 핸드셰이크 완료 후 New Session Ticket(NST) 메시지를 전송하여 클라이언트에게 PSK를 제공합니다:
- NST 메시지 처리:
SSL_CTX_sess_set_new_cb()를 통해 새 세션이 생성될 때마다 콜백이 호출됩니다. VPP는 이 콜백에서 세션 정보를 캐시하고 티켓 수명을 설정합니다. - 0-RTT Early Data: TLS 1.3 PSK를 사용하면 핸드셰이크 완료 전에 애플리케이션 데이터를 전송할 수 있습니다. VPP는
SSL_CTX_set_max_early_data()로 0-RTT 데이터의 최대 크기를 설정합니다. - 재전송 방어: 0-RTT 데이터는 재전송 공격(replay attack)에 취약하므로, VPP는
SSL_CTX_set_options(SSL_OP_NO_ANTI_REPLAY)를 명시적으로 해제하지 않는 한 기본 재전송 방어를 활성화합니다.
/* TLS 1.3 PSK / 0-RTT 설정 의사코드 */
static void
openssl_configure_tls13_resumption (SSL_CTX *ssl_ctx,
tls_cfg_t *cfg)
{
/* 세션 캐시 모드: 서버측 캐시 + 자동 NST 발행 */
SSL_CTX_set_session_cache_mode (ssl_ctx,
SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_INTERNAL);
/* 새 세션 생성 시 콜백 등록 */
SSL_CTX_sess_set_new_cb (ssl_ctx, tls_session_new_cb);
/* 세션 티켓 수명 설정 (기본 7200초) */
SSL_CTX_set_timeout (ssl_ctx, cfg->session_timeout);
/* 0-RTT Early Data 설정 */
if (cfg->enable_early_data)
{
/* 최대 early data 크기 (16KB 권장) */
SSL_CTX_set_max_early_data (ssl_ctx, 16384);
/* 재전송 방어 활성화 (기본값) */
SSL_CTX_set_options (ssl_ctx, SSL_OP_ANTI_REPLAY);
}
}
/* 새 세션 콜백: NST 수신 시 호출 */
static int
tls_session_new_cb (SSL *ssl, SSL_SESSION *session)
{
tls_ctx_t *ctx = SSL_get_app_data (ssl);
/* 세션 티켓을 직렬화하여 캐시에 저장 */
u32 len;
u8 *data = NULL;
len = i2d_SSL_SESSION (session, &data);
if (len > 0)
tls_session_cache_add (ctx->listener_index, data, len);
OPENSSL_free (data);
return 0; /* OpenSSL이 세션 참조 해제 */
}
/* 서버측 0-RTT early data 수신 처리 */
static int
openssl_handle_early_data (tls_ctx_t *ctx)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
u8 buf[16384];
size_t read_bytes;
int status;
status = SSL_read_early_data (oc->ssl, buf,
sizeof (buf), &read_bytes);
switch (status)
{
case SSL_READ_EARLY_DATA_SUCCESS:
/* 복호화된 0-RTT 데이터를 앱 FIFO에 전달 */
svm_fifo_enqueue (ctx->app_session->tx_fifo,
read_bytes, buf);
return read_bytes;
case SSL_READ_EARLY_DATA_FINISH:
/* Early data 완료, 일반 핸드셰이크로 전환 */
return 0;
case SSL_READ_EARLY_DATA_ERROR:
/* 재전송 감지 또는 PSK 불일치 → 전체 핸드셰이크 */
return -1;
}
return -1;
}
TLS 레코드 레이어와 프래그먼테이션
VPP의 TLS 엔진은 애플리케이션 데이터를 TLS 레코드(최대 16KB)로 분할하여 TCP FIFO에 기록합니다. 이 과정에서 VPP 특유의 최적화가 적용됩니다:
/* TLS 레코드 구조 (RFC 8446 Section 5.1) */
struct {
ContentType type; /* 0x17=application_data, 0x16=handshake */
ProtocolVersion version; /* TLS 1.2: 0x0303, TLS 1.3: 0x0303 (호환) */
uint16 length; /* 최대 16384 + 256 (패딩) */
opaque fragment[length]; /* 암호화된 페이로드 */
} TLSPlaintext;
/* VPP 최적화: 레코드 크기 조절 */
/*
* - 핸드셰이크 시: 작은 레코드 (빠른 첫 바이트)
* - 벌크 전송 시: 최대 레코드 (오버헤드 최소화)
* - FIFO 잔량 기반 동적 크기 결정
* - AES-GCM: 레코드당 16바이트 인증 태그 + 8바이트 nonce
*/
| 레코드 크기 | 오버헤드 | 적합 시나리오 |
|---|---|---|
| 1KB | ~2.3% (24B/1024B) | 인터랙티브, 낮은 지연 |
| 4KB | ~0.6% | 웹 페이지(Page) 전송 |
| 16KB (최대) | ~0.15% | 대용량 파일, 스트리밍 |
VPP TLS 설정 및 인증서 관리
startup.conf TLS 설정
VPP의 TLS 관련 설정은 startup.conf의 여러 섹션에 분산되어 있습니다:
# /etc/vpp/startup.conf — TLS 관련 설정
tls {
# 기본 암호화 엔진
default-crypto-engine openssl
# 최소 TLS 버전 (1.2 또는 1.3)
tls-min-version 1.2
# TLS 세션 캐시 크기 (세션 재사용)
ca-cert-path /etc/vpp/certs/ca.pem
# 비동기 암호화 활성화 (openssl 엔진만)
async
}
# OpenSSL 엔진 특정 설정
tlsopenssl {
# OpenSSL ENGINE 로드 (QAT 등)
engine qat
# 비동기 모드 (연산 큐잉)
async
# 최대 비동기 대기 프레임 수
max-async-frames 256
}
인증서 및 개인 키 관리
VPP는 CLI와 API를 통해 TLS 인증서를 동적으로 관리합니다. 인증서는 인증서-키 쌍(ckpair) 단위로 등록되며, 애플리케이션이 리스너를 생성할 때 ckpair 인덱스를 참조합니다:
# PEM 형식 인증서/키 추가
vpp# tls cert add cert /etc/vpp/certs/server.pem \
key /etc/vpp/certs/server.key
# 인증서 목록 확인
vpp# show tls certs
# DER 형식도 지원
vpp# tls cert add cert /etc/vpp/certs/server.der \
key /etc/vpp/certs/server.key.der \
format der
# CA 인증서 (mTLS 클라이언트 검증용)
vpp# tls ca-cert add /etc/vpp/certs/ca-chain.pem
SNI 기반 멀티 도메인 인증서 관리
VPP는 SNI(Server Name Indication)를 기반으로 하나의 리스너에서 여러 도메인의 인증서를 제공할 수 있습니다. 클라이언트가 ClientHello에 포함한 서버 이름에 따라 적절한 인증서가 자동으로 선택됩니다:
# 여러 도메인 인증서 등록
vpp# tls cert add cert /etc/vpp/certs/example-com.pem \
key /etc/vpp/certs/example-com.key
# → ckpair_index = 0
vpp# tls cert add cert /etc/vpp/certs/api-example-com.pem \
key /etc/vpp/certs/api-example-com.key
# → ckpair_index = 1
# 와일드카드 인증서도 지원
vpp# tls cert add cert /etc/vpp/certs/wildcard-example-com.pem \
key /etc/vpp/certs/wildcard-example-com.key
# → ckpair_index = 2 (*.example.com 매칭)
SNI 콜백을 통한 인증서 선택 (OpenSSL 엔진)
tls_openssl.c에서 SSL_CTX_set_tlsext_servername_callback()으로 SNI 콜백을 등록합니다. ClientHello의 server_name 확장을 파싱하여 매칭되는 ckpair_index의 SSL_CTX로 전환합니다.
매칭 우선순위:
- 정확한 도메인 매칭 (api.example.com)
- 와일드카드 매칭 (*.example.com)
- 기본 인증서 (fallback)
인증서 무중단 교체
VPP의 유저스페이스 TLS는 인증서를 프로세스 재시작 없이 동적으로 교체할 수 있습니다. 이는 커널 kTLS나 nginx에서 인증서 교체 시 reload/restart가 필요한 것과 대비되는 핵심 장점입니다:
# 1. 현재 인증서 상태 확인
vpp# show tls certs
[0] CN=example.com expires=2026-12-31
active connections: 15234
# 2. 새 인증서 추가 (기존 인덱스 덮어쓰기)
vpp# tls cert update index 0 \
cert /etc/vpp/certs/new-server.pem \
key /etc/vpp/certs/new-server.key
# 3. 기존 연결: 원래 인증서로 계속 동작 (중단 없음)
# 4. 새 연결: 새 인증서 사용
# Let's Encrypt ACME 자동화 스크립트 예시:
# certbot renew --deploy-hook \
# "vppctl tls cert update index 0 \
# cert /etc/letsencrypt/live/example.com/fullchain.pem \
# key /etc/letsencrypt/live/example.com/privkey.pem"
Cipher Suite 설정
TLS 1.2와 1.3은 Cipher Suite 협상 방식이 근본적으로 다릅니다. VPP에서의 설정 방법:
# TLS 1.2 cipher suite 설정 (OpenSSL 형식)
vpp# set tls cipher ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256
# TLS 1.3 ciphersuite 설정 (별도 설정)
vpp# set tls ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
# 현재 설정 확인
vpp# show tls config
| TLS 버전 | 권장 Cipher Suite | 보안 수준 |
|---|---|---|
| TLS 1.3 | TLS_AES_256_GCM_SHA384 | 256-bit, AEAD |
| TLS 1.3 | TLS_CHACHA20_POLY1305_SHA256 | 256-bit, 소프트웨어 최적 |
| TLS 1.2 | ECDHE-RSA-AES256-GCM-SHA384 | PFS + 256-bit AEAD |
| TLS 1.2 | ECDHE-ECDSA-AES128-GCM-SHA256 | PFS + ECDSA + AEAD |
상호 인증(mTLS) 구성
mTLS(Mutual TLS)는 서버뿐 아니라 클라이언트도 인증서를 제시하여 양방향 인증을 수행합니다. VPP에서의 mTLS 구성:
# 1. 서버 인증서/키 등록
vpp# tls cert add cert /etc/vpp/certs/server.pem \
key /etc/vpp/certs/server.key
# 2. CA 인증서 등록 (클라이언트 인증서 검증용)
vpp# tls ca-cert add /etc/vpp/certs/client-ca.pem
# 3. 클라이언트 인증서 검증 활성화
# VCL 애플리케이션에서 session_attr에 설정:
# transport_cfg.is_mtls = 1;
/* mTLS 서버 VCL 설정 예시 */
vppcom_session_tls_set_verify (session_handle,
VPPCOM_TLS_VERIFY_PEER |
VPPCOM_TLS_VERIFY_FAIL_IF_NO_PEER_CERT);
/* 클라이언트 인증서 DN 확인 콜백 */
vppcom_session_tls_set_verify_cb (session_handle,
my_verify_callback, my_ctx);
TLS 세션 재사용과 0-RTT
VPP는 TLS 1.3의 세션 티켓(Session Ticket)과 PSK(Pre-Shared Key)를 지원하여 재연결 시 핸드셰이크를 생략하거나 0-RTT로 단축할 수 있습니다:
TLS 1.3 세션 티켓 흐름
최초 연결:
- 전체 핸드셰이크를 수행합니다 (1-RTT).
- 서버가 NewSessionTicket 메시지를 전송합니다.
- 클라이언트가 티켓 +
resumption_master_secret을 저장합니다.
재연결:
- ClientHello에
pre_shared_key확장을 포함합니다. - 서버가 PSK를 검증하여 0-RTT 또는 1-RTT로 재개합니다.
- 0-RTT 시 ClientHello와 함께 Early Data를 전송할 수 있습니다.
VPP에서 세션 티켓 설정 (startup.conf 또는 CLI):
tls {
session-ticket-lifetime 3600 # 티켓 유효기간 (초)
session-ticket-key-rotation 1800 # 키 교체 주기
}
SSL_get_early_data_status()로 Early Data 여부를 확인하여 적절히 처리하세요.
VPP QUIC 프로토콜
VPP는 quicly 라이브러리(H2O 프로젝트)를 기반으로 QUIC 전송 프로토콜을 네이티브 지원합니다. QUIC은 UDP 위에 TLS 1.3을 내장한 다중화(Multiplexing) 전송 프로토콜로, VPP의 유저스페이스 스택과 결합하면 커널 경유 없이 고성능 암호화 통신이 가능합니다.
QUIC 전송 아키텍처
VPP의 QUIC 구현(src/plugins/quic/)은 세션 레이어의 전송 프로토콜로 등록되며, 단일 QUIC 연결 위에 여러 스트림을 다중화합니다:
/* QUIC 전송 프로토콜 등록 */
transport_register_protocol (TRANSPORT_PROTO_QUIC,
&quic_proto, FIB_PROTOCOL_IP4, ~0);
/* QUIC 세션 계층 구조 */
/*
* Application Stream ──→ QUIC Stream (session)
* │
* 여러 스트림 ──────→ QUIC Connection (session)
* │
* ──→ UDP Transport (session)
*/
typedef struct quic_ctx_
{
union {
transport_connection_t connection;
};
quicly_conn_t *conn; /* quicly 연결 객체 */
u32 listener_ctx_id;
u32 udp_session_handle; /* 하부 UDP 세션 */
u8 conn_state; /* 연결 상태 머신 */
u8 udp_is_ip4;
/* TLS 1.3은 quicly 내부에서 처리 */
} quic_ctx_t;
QUIC 내부 패킷 처리 흐름
VPP의 QUIC 플러그인은 quicly 라이브러리를 핵심 엔진으로 사용합니다. quicly는 QUIC 프로토콜의 패킷 파싱, 암호화(TLS 1.3 via picotls), 흐름 제어, 혼잡 제어를 모두 처리하는 C 라이브러리입니다. VPP는 quicly를 그래프 노드 기반 파이프라인에 통합하여 고성능 QUIC 처리를 구현합니다.
QUIC 패킷 수신 경로
QUIC 패킷이 네트워크에서 도착하여 애플리케이션 데이터로 전달되기까지의 경로는 다음과 같습니다:
/* QUIC 수신 경로: UDP input → quicly → 애플리케이션 */
/* 1. VPP UDP 입력 노드가 QUIC 포트(443) 패킷을 QUIC 앱에 전달 */
/* udp-input → session-queue → quic_app_rx_callback() 호출 */
static int
quic_app_rx_callback (session_t *udp_session)
{
quic_ctx_t *ctx;
svm_fifo_t *rx_fifo;
u8 *data;
u32 data_len;
int rv;
/* UDP RX FIFO에서 QUIC 패킷 읽기 */
rx_fifo = udp_session->rx_fifo;
data_len = svm_fifo_max_dequeue (rx_fifo);
vec_validate (data, data_len - 1);
svm_fifo_dequeue (rx_fifo, data_len, data);
/* Connection ID로 QUIC 컨텍스트 검색 */
ctx = quic_find_ctx_by_conn_id (data);
/* 2. quicly_receive()로 패킷 처리 (복호화 + 프레임 파싱) */
rv = quicly_receive (ctx->conn, NULL,
&udp_session->transport.rmt_ip,
data, data_len);
if (rv != 0)
{
/* 패킷 처리 오류: 잘못된 패킷 또는 복호화 실패 */
quic_proto_error (ctx, rv);
return -1;
}
/* 3. 스트림 데이터가 있으면 앱 세션의 RX FIFO에 인큐 */
quic_check_streams_for_data (ctx);
/* 4. ACK 등 응답 패킷이 필요하면 송신 스케줄링 */
quic_send_packets (ctx);
return 0;
}
코드 분석: QUIC 패킷 수신 경로
quic_app_rx_callback()은 VPP 세션 레이어가 UDP 패킷을 수신할 때 호출되는 콜백입니다. 핵심 동작은 quicly_receive() 호출입니다. quicly는 패킷 헤더를 파싱하여 Connection ID를 추출하고, AEAD(Authenticated Encryption with Associated Data)로 페이로드를 복호화한 후, 내부 프레임(STREAM, ACK, MAX_DATA 등)을 처리합니다. STREAM 프레임에 포함된 애플리케이션 데이터는 해당 스트림의 수신 버퍼에 저장되며, 이후 quic_check_streams_for_data()가 이를 앱 세션의 RX FIFO에 복사합니다.
QUIC 패킷 송신 경로
애플리케이션 데이터가 QUIC 패킷으로 변환되어 네트워크로 전송되는 경로입니다:
/* QUIC 송신 경로: 앱 데이터 → quicly → UDP output */
static int
quic_send_packets (quic_ctx_t *ctx)
{
quicly_address_t dest, src;
struct iovec packets[QUIC_MAX_PACKET_BATCH];
size_t num_packets = QUIC_MAX_PACKET_BATCH;
int rv;
/* 1. quicly_send()로 전송할 패킷 생성 (암호화 포함) */
rv = quicly_send (ctx->conn, &dest, &src,
packets, &num_packets,
ctx->send_buf, sizeof(ctx->send_buf));
if (rv != 0 || num_packets == 0)
return 0;
/* 2. 생성된 패킷을 UDP TX FIFO에 인큐 */
for (size_t i = 0; i < num_packets; i++)
{
svm_fifo_enqueue (ctx->udp_session->tx_fifo,
packets[i].iov_len,
packets[i].iov_base);
}
/* 3. UDP 세션에 TX 이벤트 발생 → VPP가 패킷 전송 */
session_send_io_evt_to_thread (ctx->udp_session->tx_fifo,
SESSION_IO_EVT_TX);
return num_packets;
}
코드 분석: QUIC 패킷 송신 경로
quic_send_packets()는 quicly_send()를 호출하여 전송 대기 중인 모든 데이터(스트림 데이터, ACK, 흐름 제어 프레임 등)를 QUIC 패킷으로 만듭니다. quicly는 내부적으로 혼잡 윈도우를 확인하고, 패킷 번호를 부여하며, AEAD로 암호화합니다. 생성된 패킷은 struct iovec 배열로 반환되며, VPP는 이를 UDP TX FIFO에 일괄 인큐합니다. 배치 처리(QUIC_MAX_PACKET_BATCH)를 통해 시스템 콜 오버헤드를 최소화합니다.
QUIC 세션 API
QUIC은 연결(Connection)과 스트림(Stream)의 2계층 세션 모델을 사용합니다. VCL에서의 QUIC 사용:
/* QUIC 서버 — 연결 수락 + 스트림 수락 */
/* 1. QUIC 리스너 생성 */
int listener = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_session_bind (listener, &addr);
vppcom_session_listen (listener, 10);
/* 2. QUIC 연결 수락 (connection-level) */
int quic_conn = vppcom_session_accept (listener, &client_ep, 0);
/* 3. 스트림 수락 (stream-level, 실제 데이터 교환) */
int stream = vppcom_session_accept (quic_conn, &stream_ep, 0);
/* 4. 스트림에서 데이터 송수신 */
vppcom_session_read (stream, buf, sizeof(buf));
vppcom_session_write (stream, response, resp_len);
/* QUIC 클라이언트 — 연결 + 스트림 생성 */
int quic_conn = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_session_connect (quic_conn, &server_ep);
/* 동일 연결에 여러 스트림 생성 */
int stream1 = vppcom_session_create (VPPCOM_PROTO_QUIC, 0);
vppcom_session_stream_connect (stream1, quic_conn);
QUIC 스트림 생명주기 소스 분석
QUIC 스트림은 QUIC 연결 내부에서 독립적인 데이터 채널 역할을 합니다. VPP에서 스트림의 생성부터 종료까지의 전체 생명주기를 소스 코드 수준에서 분석합니다.
스트림 생성 (quic_connect_stream)
클라이언트가 새 스트림을 열 때 quic_connect_stream()이 호출됩니다. 이 함수는 quicly 연결 위에 새 스트림 객체를 생성하고 VPP 세션과 연결합니다:
/* QUIC 스트림 연결 (클라이언트 → 서버) */
static int
quic_connect_stream (session_t *quic_session,
session_endpoint_cfg_t *sep)
{
quic_ctx_t *qctx, *sctx;
quicly_stream_t *stream;
session_t *stream_session;
int rv;
/* 상위 QUIC 연결 컨텍스트 조회 */
qctx = quic_ctx_get (quic_session->connection_index);
/* 1. quicly에 양방향 스트림 생성 요청 */
rv = quicly_open_stream (qctx->conn, &stream, 0 /* bidi */);
if (rv)
{
/* MAX_STREAMS 한도 초과 시 실패 */
return SESSION_E_REFUSED;
}
/* 2. VPP 스트림 컨텍스트 할당 */
sctx = quic_ctx_alloc (qctx->c_thread_index);
sctx->parent_ctx_id = qctx->ctx_id;
sctx->quicly_stream = stream;
sctx->conn_state = QUIC_CONN_STATE_STREAM_OPEN;
/* 3. VPP 세션 생성 및 FIFO 할당 */
stream_session = session_alloc (qctx->c_thread_index);
stream_session->session_type = SESSION_TYPE_QUIC_STREAM;
svm_fifo_alloc_pair (&stream_session->rx_fifo,
&stream_session->tx_fifo,
QUIC_FIFO_SIZE);
/* 4. quicly 스트림 콜백 등록 (데이터 수신 시 호출) */
stream->callbacks = &quic_stream_callbacks;
stream->data = sctx; /* VPP 컨텍스트 연결 */
/* 5. 애플리케이션에 연결 완료 알림 */
app_worker_connect_notify (stream_session);
return 0;
}
코드 분석: 스트림 생성 흐름
quic_connect_stream()의 핵심은 quicly 레벨 스트림과 VPP 세션 레벨 스트림을 양방향으로 연결하는 것입니다. quicly_open_stream()은 QUIC 프로토콜의 스트림 ID를 할당하고(클라이언트 시작 양방향 = 0, 4, 8, ...), MAX_STREAMS 한도를 확인합니다. 이후 VPP 세션을 할당하고 RX/TX FIFO 쌍을 만들어 애플리케이션과의 데이터 교환 통로를 설정합니다. stream->callbacks에 등록된 콜백은 quicly가 해당 스트림에 데이터를 수신할 때 자동으로 호출됩니다.
스트림 데이터 송수신
/* 스트림 수신: quicly → 앱 RX FIFO */
static int
quic_stream_rx (quicly_stream_t *stream, size_t off,
const void *src, size_t len)
{
quic_ctx_t *sctx = (quic_ctx_t *) stream->data;
session_t *stream_session;
stream_session = session_get (sctx->session_index);
/* quicly가 순서 재조립 완료한 데이터를 앱 FIFO에 인큐 */
svm_fifo_enqueue_with_offset (stream_session->rx_fifo,
off, len, src);
/* 앱에 RX 이벤트 알림 */
session_send_io_evt_to_thread (stream_session->rx_fifo,
SESSION_IO_EVT_RX);
return 0;
}
/* 스트림 송신: 앱 TX FIFO → quicly */
static int
quic_stream_tx (session_t *stream_session)
{
quic_ctx_t *sctx;
svm_fifo_t *tx_fifo = stream_session->tx_fifo;
u32 deq_len;
u8 *data;
sctx = quic_ctx_get (stream_session->connection_index);
deq_len = svm_fifo_max_dequeue (tx_fifo);
if (deq_len == 0)
return 0;
/* TX FIFO에서 데이터 읽기 */
vec_validate (data, deq_len - 1);
svm_fifo_dequeue (tx_fifo, deq_len, data);
/* quicly 스트림 버퍼에 데이터 추가 */
quicly_streambuf_egress_write (sctx->quicly_stream, data, deq_len);
/* 패킷 송신 스케줄링 */
quic_send_packets (sctx->parent_ctx);
return 0;
}
코드 분석: 스트림 데이터 경로
수신 방향에서 quic_stream_rx()는 quicly의 스트림 콜백으로 등록된 함수입니다. quicly가 STREAM 프레임을 복호화하고 순서를 재조립한 후 이 콜백을 호출합니다. off 파라미터는 스트림 내 바이트 오프셋으로, 비순차 도착 패킷도 올바른 위치에 저장됩니다. 송신 방향에서 quic_stream_tx()는 앱이 TX FIFO에 쓴 데이터를 quicly_streambuf_egress_write()로 quicly 송신 버퍼에 전달하고, quic_send_packets()로 실제 UDP 패킷 생성을 트리거합니다.
스트림 상태 전이
QUIC 스트림은 RFC 9000에 정의된 상태 머신을 따릅니다. VPP에서 각 상태 전이는 conn_state 필드로 추적됩니다:
스트림 종료 및 오류 처리
/* 스트림 정상 종료 */
static void
quic_stream_close (quic_ctx_t *sctx)
{
/* 송신 방향 FIN 전송 */
quicly_streambuf_egress_shutdown (sctx->quicly_stream);
sctx->conn_state = QUIC_CONN_STATE_STREAM_HALF_CLOSED;
/* 수신 FIN도 받았으면 완전 종료 */
if (quicly_recvstate_transfer_complete (&sctx->quicly_stream->recvstate))
{
sctx->conn_state = QUIC_CONN_STATE_STREAM_CLOSED;
quic_stream_cleanup (sctx);
}
}
/* 스트림 리셋 (비정상 종료) */
static void
quic_stream_reset (quic_ctx_t *sctx, u64 error_code)
{
/* RESET_STREAM 프레임 전송 (즉시 종료) */
quicly_reset_stream (sctx->quicly_stream, error_code);
/* 세션 정리: FIFO 해제, 컨텍스트 반환 */
session_transport_reset_notify (sctx->session_index);
quic_stream_cleanup (sctx);
}
코드 분석: 스트림 종료
QUIC 스트림의 정상 종료는 양방향 FIN 교환으로 이루어집니다. quicly_streambuf_egress_shutdown()은 송신 방향을 닫아 STREAM 프레임에 FIN 비트를 설정합니다. 수신 방향도 FIN을 받으면 quicly_recvstate_transfer_complete()가 true를 반환하여 스트림이 완전히 종료됩니다. 비정상 종료 시 quicly_reset_stream()은 RESET_STREAM 프레임을 전송하여 상대방에게 오류 코드와 함께 스트림 즉시 종료를 통보합니다. 오류 코드는 H3_NO_ERROR(0), H3_REQUEST_CANCELLED(0x10c) 등 애플리케이션 프로토콜에 따라 달라집니다.
QUIC 연결 마이그레이션
QUIC의 가장 혁신적인 기능 중 하나가 연결 마이그레이션(Connection Migration)입니다. TCP는 (src_ip, src_port, dst_ip, dst_port) 4-tuple로 연결을 식별하므로 IP가 변경되면 연결이 끊어지지만, QUIC은 Connection ID로 연결을 식별하여 IP/포트 변경 시에도 연결을 유지합니다:
QUIC 흐름 제어와 혼잡 제어(Congestion Control)
QUIC은 TCP와 달리 2계층 흐름 제어를 제공합니다:
| 흐름 제어 레벨 | 프레임 | 설명 |
|---|---|---|
| 스트림 레벨 | MAX_STREAM_DATA | 개별 스트림의 수신 버퍼 한도 |
| 연결 레벨 | MAX_DATA | 전체 연결의 총 수신 데이터 한도 |
| 스트림 수 | MAX_STREAMS | 동시 활성 스트림 수 제한 |
VPP QUIC 흐름 제어 파라미터
quicly 내부에서 관리하는 주요 파라미터입니다:
initial_max_data- 연결 초기 수신 윈도우 (기본: 1MB)
initial_max_stream_data_bidi- 양방향 스트림 윈도우 (기본: 256KB)
initial_max_stream_data_uni- 단방향 스트림 윈도우
initial_max_streams_bidi- 최대 양방향 스트림 수 (기본: 100)
혼잡 제어는 quicly 기본 Reno + ECN을 지원하며, VPP 24.x부터 BBRv2 혼잡 제어 옵션이 추가되었습니다.
QUIC의 손실 감지(Loss Detection)는 TCP의 재전송 메커니즘보다 정교합니다. 각 패킷에 고유한 번호가 부여되어(모호성 없음) ACK 기반의 정확한 RTT 측정과 빠른 손실 감지가 가능합니다. 또한 스트림별 독립 복구로 하나의 스트림 손실이 다른 스트림을 차단하지 않습니다(Head-of-Line blocking 해결).
quicly 혼잡 제어 알고리즘 상세
quicly는 기본적으로 Reno 혼잡 제어를 사용하며, VPP 24.x부터 BBRv2 옵션이 추가되었습니다. 두 알고리즘의 동작 방식과 적합한 사용 시나리오가 다릅니다:
| 항목 | Reno (기본) | BBRv2 |
|---|---|---|
| 동작 원리 | 패킷 손실 기반 (AIMD) | 대역폭·RTT 측정 기반 모델 |
| 혼잡 감지 | 패킷 손실 발생 시 | 대역폭 포화·큐잉 지연 감지 |
| 슬로우 스타트 | 지수 증가 → 손실 시 절반 감소 | 대역폭 측정 후 모델 기반 전환 |
| 버퍼블로트(Bufferbloat) 대응 | 약함 (큐 가득 차야 감지) | 강함 (큐잉 지연 직접 측정) |
| 고손실 네트워크 | 성능 급감 | 안정적 처리량 유지 |
| CPU 오버헤드 | 낮음 | 중간 (RTT 샘플링·모델 갱신) |
| 적합 시나리오 | LAN, 저지연 데이터센터 | WAN, 위성 링크, 모바일 네트워크 |
# startup.conf — 혼잡 제어 알고리즘 선택
quic {
# 기본 Reno 혼잡 제어 (명시적 지정 불필요)
cc-algorithm reno
# BBRv2 혼잡 제어 활성화 (VPP 24.x+)
# cc-algorithm bbr
}
# CLI에서 런타임 확인
vpp# show quic connections verbose
# 출력에 cc_algorithm, cwnd, ssthresh, bytes_in_flight 표시
패킷 손실 감지 메커니즘
quicly의 손실 감지는 RFC 9002에 기반하여 두 가지 메커니즘을 병행합니다:
- ACK 기반 손실 감지
- 수신측이 보낸 ACK 프레임을 분석하여, ACK된 가장 큰 패킷 번호보다
kPacketThreshold(기본 3) 이상 작은 번호의 미확인 패킷을 손실로 판정합니다. QUIC은 패킷 번호가 절대 재사용되지 않으므로 TCP의 재전송 모호성(Retransmission Ambiguity) 문제가 없습니다. - PTO(Probe Timeout) 기반 감지
- 일정 시간 동안 ACK를 받지 못하면 프로브 패킷을 전송하여 경로 상태를 확인합니다. PTO는
smoothed_rtt + max(4 * rttvar, 1ms) + max_ack_delay로 계산됩니다. TCP의 RTO(Retransmission Timeout)보다 정밀한 RTT 측정이 가능합니다.
/* quicly 손실 감지 핵심 로직 (의사코드) */
static void
quicly_loss_on_ack_received (quicly_loss_t *loss,
u64 largest_acked,
int64_t now)
{
quicly_sent_packet_t *sent;
/* ACK 기반 손실 판정 */
vec_foreach (sent, loss->sent_packets)
{
if (sent->acked)
continue;
/* 패킷 번호 임계값 초과 확인 */
if (largest_acked - sent->pn >= QUICLY_LOSS_PACKET_THRESHOLD)
{
quicly_loss_mark_lost (loss, sent);
continue;
}
/* 시간 임계값 초과 확인 */
int64_t time_threshold = loss->smoothed_rtt * 9 / 8;
if (now - sent->sent_at > time_threshold)
quicly_loss_mark_lost (loss, sent);
}
/* 혼잡 제어 업데이트 */
loss->cc->on_loss_detected (loss->cc, loss->num_lost);
}
코드 분석: 손실 감지
QUIC의 손실 감지는 패킷 번호 임계값과 시간 임계값을 모두 사용합니다. 패킷 번호 기반 판정은 ACK된 가장 큰 번호와 3 이상 차이나는 미확인 패킷을 즉시 손실로 처리합니다. 시간 기반 판정은 smoothed_rtt * 9/8을 초과하여 미확인 상태인 패킷을 손실로 처리합니다. 손실이 감지되면 혼잡 제어의 on_loss_detected 콜백이 호출되어 Reno의 경우 cwnd를 절반으로 줄이고, BBR의 경우 대역폭 모델을 업데이트합니다.
HTTP/3 over VPP QUIC
HTTP/3는 QUIC 위에서 동작하는 HTTP의 차세대 버전으로, VPP의 QUIC 플러그인 위에 구현할 수 있습니다:
HTTP/3 프로토콜 스택 (VPP 환경)
┌────────────────────────────────┐
│ HTTP/3 (QPACK 헤더 압축) │ ← HTTP 시맨틱
├────────────────────────────────┤
│ QUIC Streams (다중화) │ ← 스트림별 독립 전송
├────────────────────────────────┤
│ QUIC Connection (quicly) │ ← TLS 1.3 내장, 혼잡 제어
├────────────────────────────────┤
│ UDP (TRANSPORT_PROTO_UDP) │ ← 포트 443 (IANA 표준)
├────────────────────────────────┤
│ VPP ip4/ip6 → DPDK/AF_XDP │ ← 유저스페이스 네트워크
└────────────────────────────────┘
주요 차이: HTTP/2 vs HTTP/3
• HTTP/2: TCP 위 TLS → HoL blocking 존재
• HTTP/3: QUIC 위 → 스트림별 독립 → HoL blocking 해결
• 서버 푸시, 우선순위, 헤더 압축은 동일 시맨틱
QUIC 설정과 활성화
# startup.conf — QUIC 플러그인 활성화
plugins {
plugin quic_plugin.so { enable }
}
quic {
# 최대 동시 연결 수
max-connections 100000
# 연결당 최대 스트림 수
max-streams-per-connection 100
# 0-RTT 활성화
enable-0rtt
# 유휴 타임아웃 (초)
idle-timeout 60
}
QUIC vs TCP+TLS 성능 비교
VPP 환경에서 QUIC과 TCP+TLS의 주요 차이점:
| 항목 | TCP + TLS 1.3 | QUIC |
|---|---|---|
| 연결 수립 | 2-RTT (TCP + TLS) | 1-RTT (통합 핸드셰이크) |
| 재연결 | 2-RTT | 0-RTT (PSK) |
| HoL Blocking | 있음 (TCP 순서 보장(Ordering)) | 없음 (스트림별 독립) |
| 다중화 | 불가 (HTTP/2로 해결) | 네이티브 스트림 다중화 |
| 연결 마이그레이션 | 불가 | Connection ID 기반 가능 |
| 패킷 손실 복구 | TCP 재전송 (느림) | 스트림별 독립 복구 (빠름) |
| VPP 처리량 | ~40 Gbps (단일 워커) | ~25 Gbps (단일 워커) |
| CPU 오버헤드 | 낮음 | 중간 (UDP+QUIC 레이어) |
| HW 오프로드 | QAT, NIC Crypto | 제한적 (UDP checksum만) |
QUIC 디버깅 및 트러블슈팅
VPP QUIC 환경에서 문제를 진단하고 해결하기 위한 CLI 명령, 이벤트 로그, 일반적인 문제 패턴을 정리합니다.
QUIC 상태 확인 CLI 명령
# QUIC 연결 목록 조회
vpp# show quic connections
# 출력: conn_id, state, streams, rx/tx bytes, rtt
# QUIC 연결 상세 정보
vpp# show quic connections verbose
# 출력: 혼잡 윈도우, ssthresh, bytes_in_flight, 스트림 상세
# 활성 스트림 목록
vpp# show quic streams
# 출력: stream_id, state, tx/rx offset, flow_control_limit
# QUIC 플러그인 통계
vpp# show errors
# quic-input, quic-output 노드의 오류 카운터 확인
# QUIC 패킷 트레이싱
vpp# trace add session-queue 100
vpp# show trace
# QUIC 패킷의 수신/처리/송신 경로 확인
# UDP 레벨 패킷 트레이싱 (QUIC 하부 전송)
vpp# trace add udp-input 100
vpp# trace add udp-output 100
quicly 이벤트 로그 활용
quicly는 내부적으로 상세한 이벤트 로그를 생성할 수 있습니다. VPP에서 이를 활성화하면 QUIC 프로토콜 수준의 문제를 세밀하게 분석할 수 있습니다:
# startup.conf — quicly 이벤트 로깅 활성화
quic {
# 이벤트 로그 파일 경로
event-log /tmp/quicly-events.json
# 로그 레벨: packet, cc(혼잡 제어), loss(손실), stream
event-log-level packet
}
# quicly 이벤트 로그 분석 (JSON 형식)
# 연결 수립 이벤트
cat /tmp/quicly-events.json | jq '.[] | select(.type == "connect")'
# 패킷 손실 이벤트 필터링
cat /tmp/quicly-events.json | jq '.[] | select(.type == "packet-lost")'
# 혼잡 윈도우 변화 추적
cat /tmp/quicly-events.json | jq '.[] | select(.type == "cc-cwnd-update")'
# qvis (QUIC 시각화 도구)로 분석
# https://qvis.quictools.info/ 에서 JSON 파일 업로드
일반적인 QUIC 문제 및 해결
| 문제 | 증상 | 진단 방법 | 해결책 |
|---|---|---|---|
| 핸드셰이크 실패 | 연결 수립 불가, 타임아웃 | show errors에 quic-handshake-fail | 인증서 경로·유효기간 확인, TLS 1.3 지원 여부 점검 |
| 0-RTT 거부 | 0-RTT 데이터가 무시됨 | 이벤트 로그에 early-data-rejected | 서버의 PSK 캐시 유효기간 확인, ALPN 일치 여부 점검 |
| 스트림 생성 실패 | MAX_STREAMS 초과 오류 | show quic connections verbose에서 스트림 수 확인 | max-streams-per-connection 설정 증가 |
| 처리량 저하 | 예상보다 낮은 대역폭 | show quic connections verbose에서 cwnd 확인 | 혼잡 제어 알고리즘 변경(BBR), 초기 윈도우 크기 조정 |
| 연결 마이그레이션 실패 | IP 변경 시 연결 끊김 | 이벤트 로그에 path-validation 실패 | 방화벽에서 새 경로의 UDP 트래픽 허용 |
| 과도한 패킷 손실 | 높은 재전송률 | 이벤트 로그에 packet-lost 빈도 확인 | 네트워크 경로 점검, MTU 설정 확인(PMTUD) |
| 메모리 증가 | QUIC 세션 수 대비 과다 메모리 | show memory verbose로 quic 힙 확인 | idle-timeout 줄이기, 비활성 연결 정리 |
| UDP 블로킹 | QUIC 패킷이 도달하지 않음 | show interface에서 UDP 드롭 확인 | 방화벽·NAT에서 UDP 443 포트 허용 |
QUIC 성능 진단 절차
# 1. 기본 상태 확인
vpp# show version
vpp# show quic connections
vpp# show quic streams
# 2. 혼잡 제어 상태 확인
vpp# show quic connections verbose
# cwnd, ssthresh, bytes_in_flight 값 확인
# cwnd가 비정상적으로 작으면 손실 과다 의심
# 3. 세션 FIFO 상태 확인
vpp# show session verbose
# RX/TX FIFO 사용률이 100%에 가까우면 병목
# 4. 오류 카운터 확인
vpp# show errors
# quic-input, quic-output 노드별 오류 확인
# 5. 패킷 경로 트레이싱
vpp# trace add session-queue 50
vpp# show trace
# 패킷이 어느 노드에서 드롭되는지 확인
# 6. 워커 스레드별 부하 확인
vpp# show runtime
# quic 관련 노드의 vectors/call, clocks/vector 확인
ping과 간단한 UDP 에코 테스트로 UDP 연결성을 확인하는 것이 좋습니다. 또한 quicly의 이벤트 로그를 qvis 도구(https://qvis.quictools.info/)에 업로드하면 패킷 타임라인, 혼잡 윈도우 변화, 스트림별 데이터 흐름을 시각적으로 분석할 수 있습니다.
VPP TLS 성능 최적화
비동기 암호화 프레임워크
VPP의 비동기 암호화(src/vnet/tls/tls_async.c)는 TLS 핸드셰이크와 데이터 암복호화를 VPP 메인 루프에서 분리하여, 암호 연산이 완료될 때까지 다른 패킷 처리를 계속할 수 있게 합니다:
/* 비동기 암호화 흐름 */
/* 1. 암호 연산 큐잉 */
tls_async_enqueue_op (vm, ctx, op_type, data, len);
/* 2. 메인 루프가 다른 노드 처리 계속 */
/* ... 벡터 패킷 처리 ... */
/* 3. 완료된 연산 결과 수거 */
n_ops = tls_async_dequeue (vm, &completed_ops);
/* 4. 결과 처리 (암호문 전송 또는 평문 전달) */
for (i = 0; i < n_ops; i++)
tls_async_process_completed (completed_ops[i]);
비동기와 동기 모드의 핵심 차이를 이해하는 것이 중요합니다:
| 항목 | 동기 모드 (기본) | 비동기 모드 |
|---|---|---|
| 암호화 실행 | 메인 루프에서 즉시 수행 | 큐에 넣고 나중에 결과 수거 |
| 메인 루프 블로킹 | 암호화 완료까지 대기 | 다른 패킷 계속 처리 |
| 처리량 | CPU 바운드 | HW 가속기 파이프라이닝으로 향상 |
| 핸드셰이크 CPS | ~30K (RSA-2048) | ~80K (SW), ~150K (QAT) |
| 지연시간 | 안정적 (즉시 완료) | 약간 증가 (큐잉 오버헤드) |
| 적합 시나리오 | 적은 연결, 낮은 지연 필수 | 대량 연결, 높은 CPS 필요 |
/* 비동기 암호화 상세 흐름 (tls_async.c) */
/* 핸드셰이크 비동기 처리 */
int tls_async_openssl_ctx_init (tls_ctx_t *ctx)
{
openssl_ctx_t *oc = (openssl_ctx_t *) ctx;
/* SSL_MODE_ASYNC 활성화 → SSL_do_handshake()가
* ASYNC_pause_job()으로 제어 반환 가능 */
SSL_set_mode (oc->ssl, SSL_MODE_ASYNC);
/* ENGINE 설정 (QAT 등): RSA/ECDH 연산을
* 하드웨어에 오프로드 */
SSL_set_engine (oc->ssl, async_engine);
return 0;
}
/* 메인 루프 통합: tls-input 노드에서 호출 */
static uword
tls_async_process_node_fn (vlib_main_t *vm, ...)
{
/* 1단계: 완료된 비동기 연산 수거 */
n = openssl_async_poll_events (&events);
/* 2단계: 각 완료 이벤트 처리 */
for (i = 0; i < n; i++) {
ctx = tls_ctx_get (events[i].ctx_index);
if (ctx->resume)
/* 핸드셰이크 재개 또는 데이터 전달 */
tls_ctx_resume_handshake (ctx);
}
}
DPDK Cryptodev TLS 오프로드
VPP는 DPDK의 Cryptodev 프레임워크를 통해 하드웨어 암호화 가속기에 TLS 연산을 오프로드할 수 있습니다. 대표적인 하드웨어:
| 디바이스 | 인터페이스 | 지원 알고리즘 | 성능 |
|---|---|---|---|
| Intel QAT | Cryptodev PMD | AES-GCM, ChaCha20, RSA, ECDH | ~100 Gbps (bulk), ~50K CPS |
| NVIDIA ConnectX-6+ | inline TLS | AES-128/256-GCM | ~200 Gbps (NIC inline) |
| ARM CryptoCell | Cryptodev PMD | AES-GCM, SHA-256 | ~10 Gbps |
| SW fallback | OpenSSL PMD | 전체 | CPU 의존 |
# startup.conf — QAT Cryptodev 설정
dpdk {
dev 0000:3d:01.0 {
name crypto0
}
}
tlsopenssl {
# DPDK Cryptodev 엔진 사용
engine cryptodev
# 비동기 모드 (QAT 파이프라이닝)
async
# Cryptodev 큐 쌍 수
cryptodev-queue-pairs 4
}
멀티 워커 TLS 분산
VPP의 멀티 워커 환경에서 TLS 세션은 워커 간에 분산됩니다. 각 워커는 독립적인 TLS 컨텍스트 풀을 유지하며, NUMA 인지 메모리 할당으로 원격 메모리 접근을 최소화합니다:
# startup.conf — 멀티 워커 TLS 최적화
cpu {
main-core 0
corelist-workers 1-7
# TLS 워커를 NUMA 0/1에 분산
}
session {
# 워커별 TLS 세션 풀 크기
preallocated-sessions 128000
v4-session-table-buckets 64000
v4-session-table-memory 512m
# 이벤트 큐 크기 (TLS 비동기에 충분히)
event-queue-length 100000
}
cpu { ... } 섹션에서 워커-NUMA 매핑을 정확히 설정하면, 원격 NUMA 접근에 의한 ~30% 지연 증가를 방지할 수 있습니다.
멀티 워커 환경에서 TLS 세션의 워커 할당은 RSS(Receive Side Scaling) 해시에 의해 결정됩니다. NIC의 RSS가 5-tuple 해시로 패킷을 특정 큐에 분배하면, 해당 큐를 담당하는 VPP 워커가 TCP 연결과 TLS 세션을 모두 소유합니다. 이 구조에서 중요한 최적화 포인트:
| 설정 항목 | 올바른 설정 | 잘못된 설정 시 영향 |
|---|---|---|
| NIC 큐 수 | = 워커 수 | 일부 워커 유휴 또는 과부하 |
| NIC-워커 NUMA 매핑 | NIC과 같은 NUMA의 코어 | PCIe 교차 트래픽, +30~60% 지연 |
| QAT-워커 NUMA 매핑 | QAT와 같은 NUMA의 워커 | 암호화 연산 원격 메모리 접근 |
| Hugepage NUMA 분배 | 각 NUMA 노드에 균등 할당 | 한쪽 NUMA에서 메모리 부족 |
| TLS 세션 풀 | 워커별 독립 풀 | 풀 공유 시 락 경합(Contention) |
성능 비교: VPP TLS vs kTLS vs nginx
동일 하드웨어(Xeon Platinum 8380, 2.3 GHz, 128GB)에서의 대표적 TLS 성능 비교:
| 항목 | VPP TLS (SW) | VPP TLS (QAT) | kTLS | nginx (userspace) |
|---|---|---|---|---|
| 새 연결 (CPS) | ~80,000 | ~150,000 | ~50,000 | ~30,000 |
| 처리량 (Gbps) | ~40 | ~80 | ~30 | ~15 |
| p99 지연 (ms) | 0.3 | 0.2 | 0.5 | 1.2 |
| 동시 연결 | 500K+ | 500K+ | 100K | 50K |
| CPU 사용률 | 높음 (전용 코어) | 낮음 (오프로드) | 중간 (커널) | 높음 (프로세스) |
| 컨텍스트 스위칭(Context Switching) | 없음 | 없음 | 있음 | 많음 |
TLS 성능 튜닝 체크리스트
| 항목 | 권장 설정 | 효과 |
|---|---|---|
| 비동기 암호화 | tlsopenssl { async } | 핸드셰이크 처리량 2~5x 향상 |
| 세션 캐시 | TLS 1.3 PSK 활성화 | 재연결 0-RTT, CPU 절약 |
| Cipher 선택 | AES-128-GCM (AES-NI 있을 때) | AES-256 대비 ~15% 빠름 |
| Cipher 선택 | ChaCha20 (AES-NI 없을 때) | 소프트웨어 최적, ARM 유리 |
| Hugepages | 1GB 또는 2MB 페이지 | TLB 미스 감소, ~10% 처리량 향상 |
| 워커 수 | NIC 큐 수와 동일 | RSS 기반 워커 분산 최적화 |
| NUMA 배치 | NIC과 같은 NUMA 노드 | 원격 NUMA 접근 ~30% 지연 방지 |
| 세션 프리얼로케이션 | preallocated-sessions 128000 | 런타임 할당 지연 제거 |
| QAT 오프로드 | engine cryptodev | CPU 사용률 ~70% 감소 |
실전 예제: VPP TLS 종단 프록시
HTTPS 리버스 프록시 구성
VPP를 HTTPS 리버스 프록시로 구성하면, 외부 클라이언트의 TLS를 VPP에서 종단하고 내부 백엔드 서버에는 평문 HTTP로 전달할 수 있습니다:
# 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
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
TLS 디버깅
VPP TLS 문제를 진단하기 위한 CLI 명령과 기법:
# 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
# TLS 엔진별 통계
vpp# show tls engines
Engine: openssl
Active contexts: 15234
Handshakes completed: 892341
Async operations pending: 42
# 세션 레이어 TLS 필터
vpp# show session verbose proto tls
# 트레이싱으로 TLS 핸드셰이크 관찰
vpp# trace add tls-input 10
vpp# show trace
| 증상 | 진단 명령 | 일반적 원인 |
|---|---|---|
| 핸드셰이크 실패 | show errors | grep tls | 인증서 만료, cipher 불일치 |
| 성능 저하 | show tls ctx verbose | 비동기 미활성, SW 암호화 병목 |
| 연결 끊김 | show session verbose | 세션 타임아웃, FIFO 오버플로 |
| 인증서 오류 | show tls certs | 키 불일치, CA 체인 누락 |
| mTLS 거부 | trace add tls-input | 클라이언트 인증서 미제출 |
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 tls ctx에서 HANDSHAKE 상태 고착 | 방화벽(Firewall) 규칙 확인, session { idle-timeout } 조정 |
| "no shared cipher" 오류 | show errors에 tls-handshake-fail 증가 | set tls cipher로 양측 호환 cipher 설정 |
| 인증서 체인 오류 | trace add tls-input에서 verify 실패 | tls ca-cert add로 중간 CA 포함한 전체 체인 등록 |
| 메모리 부족 | show memory verbose에서 session 세그먼트 고갈 | session { evt_qs_memfd_seg_size } 증가, Hugepage 추가 |
| 비대칭 성능 (한쪽만 느림) | show session verbose에서 FIFO 사용률 확인 | TX/RX FIFO 크기 조정: session { tx-fifo-size } |
| QAT 오프로드 미동작 | show dpdk crypto devices에 디바이스 없음 | QAT 드라이버 로드 확인, dpdk { dev PCI_ADDR } 설정 |
| 세션 누수 | show session 연결 수가 계속 증가 | 애플리케이션에서 session_close() 호출 누락 확인 |
| mTLS 클라이언트 거부 | show errors에 cert-verify-fail | 클라이언트 CA 인증서가 tls ca-cert에 등록되었는지 확인 |
# 종합 TLS 상태 진단 스크립트
vpp# show version
vpp# show tls engines
vpp# show tls certs
vpp# show tls ctx verbose
vpp# show session verbose
vpp# show errors
vpp# show memory verbose
vpp# show dpdk crypto devices # QAT 사용 시
SSL Inspection (TLS 가시성 확보)
SSL Inspection(TLS Inspection)은 암호화된 트래픽의 내용을 중간에서 복호화하여 보안 검사(IDS/IPS, DLP, 악성코드 탐지)를 수행한 후 재암호화하여 전달하는 기술입니다. 엔터프라이즈 방화벽, NGFW, 보안 웹 게이트웨이(SWG)의 핵심 기능이며, VPP의 유저스페이스 TLS 스택은 이 기능을 고성능으로 구현하기에 최적의 플랫폼입니다.
SSL Inspection 아키텍처 개요
SSL Inspection은 Man-in-the-Middle(MITM) 프록시 방식으로 동작합니다. 프록시가 클라이언트와 서버 사이에서 두 개의 독립적인 TLS 세션을 유지하며, 중간에서 평문 데이터에 접근하여 보안 정책을 적용합니다.
SSL Inspection의 두 가지 배포 모드가 있습니다:
| 모드 | 동작 방식 | 클라이언트 설정 | 적용 시나리오 |
|---|---|---|---|
| 투명 프록시(Transparent) | 네트워크 경로 상에서 패킷을 가로채어 검사합니다. 클라이언트는 프록시 존재를 인식하지 못합니다 | 프록시 CA 인증서만 설치 | 엔터프라이즈 방화벽, ISP 보안 장비 |
| 명시적 프록시(Explicit) | 클라이언트가 HTTP CONNECT 메서드로 프록시에 터널 요청을 보냅니다 | 프록시 주소 + CA 인증서 설정 | SWG(Secure Web Gateway), 클라우드 프록시 |
이중 TLS 세션 구조
SSL Inspection의 핵심은 이중 TLS 세션(Dual TLS Session) 관리입니다. VPP의 TLS 추상 계층이 이미 이중 세션 모델(TCP 세션 + App 세션)을 사용하므로, SSL Inspection은 이를 확장하여 4개의 세션을 동시에 관리합니다.
/* SSL Inspection 프록시의 핵심 데이터 구조 (개념적 설계) */
typedef struct ssl_inspect_session_ {
/** 클라이언트 측 TLS 세션 (프록시가 서버 역할) */
u32 client_tls_session_handle;
tls_ctx_t *client_tls_ctx; /* 동적 생성 인증서 사용 */
/** 서버 측 TLS 세션 (프록시가 클라이언트 역할) */
u32 server_tls_session_handle;
tls_ctx_t *server_tls_ctx; /* 원본 서버 인증서 검증 */
/** 검사 정책 결과 */
ssl_inspect_action_t action; /* ALLOW / BLOCK / LOG / BYPASS */
/** 원본 서버 정보 (SNI에서 추출) */
u8 *server_name; /* ClientHello SNI 값 */
u8 *original_cert_subject; /* 원본 인증서 CN/SAN */
/** 동적 인증서 캐시 인덱스 */
u32 cert_cache_index;
/** 검사 통계 */
u64 bytes_inspected;
u64 threats_detected;
} ssl_inspect_session_t;
코드 설명
-
3~5행
client_tls_ctx는 프록시가 서버 역할을 수행하는 TLS 컨텍스트입니다. 클라이언트가 요청한 SNI 도메인에 맞는 동적 인증서(프록시 CA로 서명)를 사용합니다. -
7~9행
server_tls_ctx는 프록시가 클라이언트 역할을 수행하는 TLS 컨텍스트입니다. 원본 서버의 실제 인증서를 CA 체인으로 검증하여 중간자 공격(Man-in-the-Middle)이 아닌 정당한 서버임을 확인합니다. -
12행
action필드는 보안 검사 엔진의 판정 결과를 저장합니다. ALLOW(통과), BLOCK(차단), LOG(기록 후 통과), BYPASS(검사 생략) 중 하나입니다. -
18행
동적 인증서 생성은 비용이 높으므로
cert_cache_index로 동일 도메인의 인증서를 캐싱합니다. 캐시 히트 시 RSA/ECDSA 서명 연산을 생략할 수 있습니다.
동적 인증서 생성 엔진
SSL Inspection의 핵심 컴포넌트는 동적 인증서 생성 엔진입니다. 클라이언트가 ClientHello에 포함한 SNI(Server Name Indication) 값을 기반으로, 프록시 CA 개인 키로 해당 도메인의 인증서를 실시간으로 서명합니다.
/* 동적 인증서 생성 구현 (OpenSSL 기반, 간략화) */
static X509 *
ssl_inspect_generate_cert (ssl_inspect_ctx_t *ctx,
const char *server_name,
X509 *original_cert)
{
X509 *cert = X509_new ();
EVP_PKEY *pkey;
X509_NAME *subj;
/* 1. 키 쌍 생성 또는 캐시에서 재사용 */
pkey = ssl_inspect_get_or_create_key (server_name);
/* 2. 원본 인증서의 Subject/SAN 복사 */
subj = X509_get_subject_name (original_cert);
X509_set_subject_name (cert, subj);
/* 3. Issuer를 프록시 CA로 설정 */
X509_set_issuer_name (cert,
X509_get_subject_name (ctx->proxy_ca_cert));
/* 4. SAN(Subject Alternative Name) 복사 */
ssl_inspect_copy_san (cert, original_cert);
/* 5. 유효기간 설정 (원본과 동일 또는 단축) */
X509_set_notBefore (cert, X509_get_notBefore (original_cert));
X509_set_notAfter (cert, X509_get_notAfter (original_cert));
/* 6. 시리얼 번호 (고유) */
ASN1_INTEGER_set (X509_get_serialNumber (cert),
ssl_inspect_next_serial ());
/* 7. 공개 키 설정 */
X509_set_pubkey (cert, pkey);
/* 8. 프록시 CA 개인 키로 서명 (비용이 큰 연산) */
X509_sign (cert, ctx->proxy_ca_key, EVP_sha256 ());
/* 9. 캐시에 저장 (TTL: 인증서 유효기간 또는 설정값) */
ssl_inspect_cache_cert (server_name, cert, pkey);
return cert;
}
/* 인증서 캐시 구조 */
typedef struct {
u8 *domain; /* 해시 키: 서버 도메인 */
X509 *cert; /* 동적 생성된 인증서 */
EVP_PKEY *pkey; /* 대응하는 개인 키 */
f64 created_at; /* 생성 시각 (VPP 시간) */
f64 ttl; /* Time-to-Live (초) */
u32 hit_count; /* 캐시 히트 횟수 */
} ssl_inspect_cert_cache_entry_t;
코드 설명
- 11행 도메인별 키 쌍을 캐싱하여 재사용합니다. 매 연결마다 RSA 키를 생성하면 ~2ms가 추가되므로, 키 재사용은 성능에 핵심적입니다.
- 14~16행 원본 인증서의 Subject(CN)를 그대로 복사합니다. 클라이언트의 인증서 검증 로직이 CN을 확인하므로, 원본과 동일해야 브라우저 경고가 발생하지 않습니다(프록시 CA를 신뢰하는 경우).
-
22행
SAN(Subject Alternative Name) 확장을 원본에서 복사합니다. 현대 브라우저는 CN보다 SAN을 우선하므로, SAN 누락 시
ERR_CERT_COMMON_NAME_INVALID오류가 발생합니다. -
34행
X509_sign()이 전체 흐름에서 가장 비용이 큰 연산입니다. RSA-2048 서명은 CPU에서 ~0.5ms, QAT 오프로드 시 ~0.02ms가 소요됩니다. 이 연산을 줄이기 위해 인증서 캐싱이 필수적입니다.
투명 SSL Inspection 구현
투명(Transparent) 모드의 SSL Inspection은 네트워크 경로 상에서 패킷을 가로채는 방식입니다. VPP의 그래프 노드 아키텍처를 활용하여, ip4-input 이후 특정 조건(목적지 포트 443)의 트래픽을 검사 노드로 리다이렉트합니다.
/* 투명 SSL Inspection 그래프 노드 흐름 */
/*
* 일반 트래픽:
* dpdk-input → ip4-input → ip4-lookup → ip4-rewrite → dpdk-output
*
* SSL Inspection 대상:
* dpdk-input → ip4-input → ip4-lookup
* → ssl-inspect-classify (dst port 443 검출)
* → tcp-input (TCP 연결 수립)
* → tls-input (TLS 세션 ① 핸드셰이크)
* → ssl-inspect-engine (평문 검사)
* → tls-output (TLS 세션 ② 재암호화)
* → tcp-output → ip4-rewrite → dpdk-output
*/
/* ssl-inspect-classify 노드: 검사 대상 분류 */
static uword
ssl_inspect_classify_fn (vlib_main_t *vm,
vlib_node_runtime_t *node,
vlib_frame_t *frame)
{
u32 *from = vlib_frame_vector_args (frame);
u32 n_left = frame->n_vectors;
u32 next_index;
while (n_left > 0) {
vlib_buffer_t *b = vlib_get_buffer (vm, from[0]);
ip4_header_t *ip = vlib_buffer_get_current (b);
tcp_header_t *tcp;
if (ip->protocol == IP_PROTOCOL_TCP) {
tcp = (tcp_header_t *) ip4_next_header (ip);
if (clib_net_to_host_u16 (tcp->dst_port) == 443) {
/* 바이패스 정책 확인 */
if (ssl_inspect_should_bypass (b, ip, tcp))
next_index = SSL_INSPECT_NEXT_BYPASS;
else
next_index = SSL_INSPECT_NEXT_INTERCEPT;
} else {
next_index = SSL_INSPECT_NEXT_PASSTHROUGH;
}
} else {
next_index = SSL_INSPECT_NEXT_PASSTHROUGH;
}
vlib_validate_buffer_enqueue_x1 (vm, node, next_index,
to_next, n_left_to_next,
from[0], next_index);
from += 1;
n_left -= 1;
}
return frame->n_vectors;
}
코드 설명
-
1~14행
VPP 그래프 노드 체인에서 SSL Inspection을 투명하게 삽입하는 구조를 보여줍니다.
ssl-inspect-classify노드가 ip4-lookup 이후에 위치하여, 목적지 포트 443인 패킷만 검사 경로로 분기합니다. -
31~38행
목적지 포트 443(HTTPS) 트래픽을 감지하면, 먼저
ssl_inspect_should_bypass()로 바이패스 정책을 확인합니다. 금융, 의료, 인증서 피닝 도메인 등은 검사를 건너뛰어야 합니다. -
35~38행
바이패스가 아닌 경우
SSL_INSPECT_NEXT_INTERCEPT로 분기하여 TLS 종단 → 검사 → 재암호화 경로로 진입합니다. 바이패스 트래픽은 원래 경로(ip4-rewrite)로 직접 전달됩니다.
/* ssl-inspect-engine 노드: 평문 검사 핵심 로직 */
static uword
ssl_inspect_engine_fn (vlib_main_t *vm,
vlib_node_runtime_t *node,
vlib_frame_t *frame)
{
ssl_inspect_main_t *sim = &ssl_inspect_main;
u32 *from = vlib_frame_vector_args (frame);
u32 n_left = frame->n_vectors;
while (n_left > 0) {
ssl_inspect_session_t *sis;
session_t *app_session;
u8 *plaintext;
u32 plaintext_len;
ssl_inspect_action_t action;
/* 클라이언트 측 TLS에서 복호화된 평문 읽기 */
app_session = session_get (sis->client_tls_session_handle);
plaintext_len = svm_fifo_max_dequeue (app_session->rx_fifo);
if (plaintext_len == 0)
goto next;
plaintext = svm_fifo_peek (app_session->rx_fifo,
plaintext_len);
/* ─── 보안 검사 파이프라인 ─── */
/* 1단계: URL/도메인 필터링 (HTTP Host 헤더 검사) */
action = ssl_inspect_url_filter (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 2단계: IDS/IPS 패턴 매칭 (Suricata 룰셋) */
action = ssl_inspect_ids_check (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 3단계: DLP (데이터 유출 방지) 검사 */
action = ssl_inspect_dlp_check (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 4단계: 악성코드 시그니처 스캔 */
action = ssl_inspect_malware_scan (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 모든 검사 통과 → 서버 측 TLS로 포워딩 */
ssl_inspect_forward_to_server (sis, plaintext,
plaintext_len);
sis->bytes_inspected += plaintext_len;
goto next;
block:
/* 차단: 클라이언트에 에러 응답 + 연결 종료 */
ssl_inspect_send_block_page (sis);
ssl_inspect_close_session (sis);
sis->threats_detected++;
next:
from += 1;
n_left -= 1;
}
return frame->n_vectors;
}
코드 설명
- 19~21행 클라이언트 측 TLS 세션(프록시가 서버 역할)의 App RX FIFO에서 복호화된 평문을 읽습니다. 이 평문은 클라이언트가 보낸 원본 HTTP 요청(또는 다른 프로토콜 데이터)입니다.
- 30~53행 4단계 보안 검사 파이프라인을 순차적으로 적용합니다. URL 필터링 → IDS/IPS → DLP → 악성코드 순서로 진행하며, 어느 단계에서든 차단 판정이 나오면 즉시 중단합니다. 이 순서는 비용이 낮은 검사(URL 필터)를 먼저 수행하여 불필요한 비용을 줄이는 최적화입니다.
- 56~57행 모든 검사를 통과하면 평문을 서버 측 TLS 세션(프록시가 클라이언트 역할)의 App TX FIFO에 기록합니다. TLS 엔진이 이를 재암호화하여 원본 서버로 전송합니다.
- 62행 차단 시 클라이언트에 커스텀 차단 페이지(Block Page)를 전송합니다. 일반적으로 HTTP 403 응답과 차단 사유를 포함한 HTML 페이지를 반환합니다.
SNI 추출과 사전 판정
SSL Inspection에서 가장 먼저 수행하는 작업은 ClientHello 메시지에서 SNI(Server Name Indication)를 추출하는 것입니다. SNI는 TLS 핸드셰이크의 첫 메시지에 평문으로 포함되어 있으므로, TLS 복호화 없이도 접근할 수 있습니다. 이를 통해 바이패스 정책을 TLS 세션 수립 전에 판정할 수 있습니다.
/* ClientHello에서 SNI 추출 (TLS 레코드 파싱) */
static int
ssl_inspect_extract_sni (u8 *data, u32 len, u8 **sni_out)
{
u8 *p = data;
u16 tls_version, handshake_len, extensions_len;
/* TLS Record Layer: ContentType(1) + Version(2) + Length(2) */
if (len < 5 || p[0] != 0x16) /* 0x16 = Handshake */
return -1;
tls_version = (p[1] << 8) | p[2];
u16 record_len = (p[3] << 8) | p[4];
p += 5;
/* Handshake: MsgType(1) + Length(3) */
if (p[0] != 0x01) /* 0x01 = ClientHello */
return -1;
u32 hs_len = (p[1] << 16) | (p[2] << 8) | p[3];
p += 4;
/* ClientHello: Version(2) + Random(32) + SessionID(var)
* + CipherSuites(var) + Compression(var)
* + Extensions(var) */
p += 2 + 32; /* Version + Random */
p += 1 + p[0]; /* SessionID (길이 + 데이터) */
p += 2 + ((p[0] << 8) | p[1]); /* CipherSuites */
p += 1 + p[0]; /* Compression */
/* Extensions 순회하여 SNI(type=0x0000) 찾기 */
extensions_len = (p[0] << 8) | p[1];
p += 2;
u8 *ext_end = p + extensions_len;
while (p + 4 <= ext_end) {
u16 ext_type = (p[0] << 8) | p[1];
u16 ext_len = (p[2] << 8) | p[3];
p += 4;
if (ext_type == 0x0000) { /* server_name 확장 */
/* ServerNameList: Length(2) + Type(1) + NameLength(2) + Name */
u16 sni_list_len = (p[0] << 8) | p[1];
u8 name_type = p[2];
u16 name_len = (p[3] << 8) | p[4];
if (name_type == 0x00) { /* host_name */
*sni_out = vec_new (u8, name_len + 1);
clib_memcpy (*sni_out, p + 5, name_len);
(*sni_out)[name_len] = 0;
return 0;
}
}
p += ext_len;
}
return -1; /* SNI 없음 (IP 직접 연결 등) */
}
코드 설명
- 8~10행 TLS Record Layer의 ContentType을 확인합니다. 0x16은 Handshake 메시지를 의미하며, 첫 번째 패킷이 ClientHello임을 확인합니다.
- 30~31행 ClientHello의 고정 필드(Version, Random)와 가변 필드(SessionID, CipherSuites, Compression)를 건너뛰어 Extensions 영역에 도달합니다.
- 40행 Extension type 0x0000은 SNI(server_name) 확장입니다. TLS 1.3에서도 이 확장은 동일한 형식으로 유지됩니다.
- 55행 SNI가 없는 경우는 IP 주소로 직접 연결하거나, ESNI(Encrypted SNI)/ECH(Encrypted Client Hello)를 사용하는 경우입니다. 이 경우 도메인 기반 정책 판정이 불가능합니다.
바이패스 정책 엔진
모든 TLS 트래픽을 검사하는 것은 법적·기술적으로 적절하지 않습니다. 바이패스 정책 엔진은 특정 조건에 해당하는 트래픽을 검사 없이 통과시킵니다.
| 바이패스 조건 | 판정 시점 | 이유 |
|---|---|---|
| 금융 기관 도메인 | SNI 확인 후 | 온라인 뱅킹 인증 정보 보호, 규제 준수 (PCI-DSS) |
| 의료 기관 도메인 | SNI 확인 후 | HIPAA 개인 건강 정보(PHI) 보호 의무 |
| 인증서 피닝(Certificate Pinning) | SNI 확인 후 | 앱이 특정 인증서/공개 키를 하드코딩하여 MITM 인증서 거부 |
| mTLS (상호 인증) | ClientHello 분석 후 | 클라이언트 인증서가 필요한 연결은 프록시가 대행 불가 |
| 내부 서버 통신 | IP 기반 | 내부 서비스 간 통신은 검사 불필요 |
| VPN/터널 트래픽 | 포트/프로토콜 기반 | 이미 터널로 보호된 트래픽의 이중 검사 방지 |
| QUIC/HTTP3 | UDP 443 포트 | QUIC은 핸드셰이크 자체가 암호화되어 투명 MITM 불가 |
/* 바이패스 정책 판정 */
static int
ssl_inspect_should_bypass (ssl_inspect_main_t *sim,
const u8 *sni,
ip4_address_t *dst_ip)
{
/* 1. 도메인 화이트리스트 확인 (해시 테이블 O(1) 조회) */
if (hash_get (sim->bypass_domain_hash, sni))
return 1;
/* 2. 와일드카드 매칭 (*.bank.com) */
if (ssl_inspect_wildcard_match (sim->bypass_wildcards, sni))
return 1;
/* 3. IP 대역 확인 (내부 서버) */
if (ip4_prefix_match (dst_ip, &sim->internal_prefix,
sim->internal_prefix_len))
return 1;
/* 4. 카테고리 기반 (금융, 의료 — 외부 URL DB 연동) */
url_category_t cat = url_db_lookup (sni);
if (cat == URL_CAT_FINANCIAL || cat == URL_CAT_HEALTHCARE)
return 1;
return 0; /* 바이패스 조건 불일치 → 검사 진행 */
}
/* CLI: 바이패스 도메인 추가 */
/* vpp# ssl-inspect bypass add domain banking.example.com */
/* vpp# ssl-inspect bypass add domain *.medical-provider.org */
/* vpp# ssl-inspect bypass add prefix 10.0.0.0/8 */
/* vpp# ssl-inspect bypass add category financial */
/* vpp# show ssl-inspect bypass */
IDS/IPS 엔진 통합
SSL Inspection으로 확보한 평문 트래픽에 대해 침입 탐지/방지 시스템(IDS/IPS)을 적용할 수 있습니다. VPP는 두 가지 통합 방식을 지원합니다.
인라인 IDS 엔진은 Intel Hyperscan 라이브러리를 활용합니다. Hyperscan은 SIMD 명령어로 수천 개의 정규표현식 패턴을 동시에 매칭할 수 있으며, VPP의 벡터 처리 모델과 잘 어울립니다.
/* Hyperscan 기반 인라인 IDS 패턴 매칭 */
typedef struct {
hs_database_t *db; /* 컴파일된 패턴 데이터베이스 */
hs_scratch_t **scratch; /* 워커별 스크래치 공간 */
u32 n_patterns; /* 로드된 시그니처 수 */
} ssl_inspect_ids_t;
/* 패턴 매칭 콜백 */
static int
ids_match_handler (unsigned int id,
unsigned long long from,
unsigned long long to,
unsigned int flags,
void *ctx)
{
ssl_inspect_match_ctx_t *mc = ctx;
mc->matched_rule_id = id;
mc->action = SSL_INSPECT_BLOCK;
/* 첫 번째 매칭에서 중단 (IPS 모드) */
return 1;
}
/* VPP 노드 내에서 Hyperscan 호출 */
ssl_inspect_action_t
ssl_inspect_ids_check (ssl_inspect_main_t *sim,
u8 *data, u32 len)
{
ssl_inspect_match_ctx_t match = { .action = SSL_INSPECT_ALLOW };
u32 thread_index = vlib_get_thread_index ();
/* Hyperscan 스트리밍 스캔: 워커별 scratch 사용 */
hs_scan (sim->ids.db, (const char *) data, len,
0, sim->ids.scratch[thread_index],
ids_match_handler, &match);
return match.action;
}
QUIC/ECH 환경에서의 SSL Inspection 과제
QUIC과 ECH(Encrypted Client Hello)의 등장은 전통적인 SSL Inspection에 근본적인 도전을 제기합니다.
| 기술 | SSL Inspection에 미치는 영향 | 대응 전략 |
|---|---|---|
| QUIC (UDP 443) | 핸드셰이크 자체가 암호화되어 투명 MITM이 불가능합니다. TCP처럼 SYN을 가로채는 방식을 사용할 수 없습니다 | UDP 443 차단 → TCP 폴백 유도, 또는 QUIC 인지 명시적 프록시 구현 |
| ECH (Encrypted Client Hello) | SNI가 암호화되어 도메인 기반 바이패스 정책 판정이 불가능합니다 | DNS-over-HTTPS 프록시에서 ECHConfig를 제거하여 ECH 비활성화 유도 |
| Certificate Transparency | 동적 생성 인증서가 CT 로그에 없으므로 브라우저가 경고할 수 있습니다 | 프록시 CA를 엔터프라이즈 정책으로 CT 예외 등록 |
| TLS 1.3 0-RTT | Early Data가 핸드셰이크 완료 전에 전송되어 검사 시점 문제가 발생합니다 | Early Data 차단 후 1-RTT로 다운그레이드 |
| 인증서 피닝 앱 | 모바일 앱이 특정 인증서를 하드코딩하여 프록시 CA를 거부합니다 | 앱별 바이패스 정책 또는 MDM으로 피닝 해제 |
# VPP에서 QUIC 차단 → TCP 폴백 유도 설정
# 1. ACL로 UDP 443 차단
vpp# create access-list ip permit+reflect src 0.0.0.0/0 \
dst 0.0.0.0/0 proto udp dport 443 action deny
# 2. 인터페이스에 ACL 적용
vpp# set acl-plugin acl 0 interface host-eth0 input
# 3. 확인: 브라우저가 QUIC 시도 실패 후 TCP 443으로 폴백
# Chrome: chrome://flags/#enable-quic → Disabled로 확인 가능
# 브라우저 DevTools → Network → Protocol 컬럼에서 h2(TCP) 확인
SSL Inspection 성능 최적화
SSL Inspection은 이중 TLS 처리로 인해 단순 TLS 종단 대비 2배 이상의 암호화 연산 부하가 발생합니다. VPP 환경에서의 최적화 전략:
| 최적화 항목 | 구현 방법 | 성능 향상 |
|---|---|---|
| 인증서 캐싱 | 도메인별 동적 인증서 + 키 쌍 캐시, LRU 교체 정책 | 캐시 히트 95%+ 시 CPS 3x 향상 |
| ECDSA 전환 | 프록시 CA와 동적 인증서를 RSA → ECDSA P-256으로 전환 | 서명 속도 5x 향상 (0.5ms → 0.1ms) |
| QAT 이중 오프로드 | 양쪽 TLS 세션의 벌크 암호화 + 핸드셰이크를 QAT에 오프로드 | CPU 사용률 70% 감소 |
| 비동기 인증서 생성 | 인증서 서명을 비동기 큐에 넣고 핸드셰이크를 파이프라이닝 | 핸드셰이크 병목 해소 |
| 선택적 검사(Selective Inspection) | SNI 기반 카테고리 분류 → 위험 카테고리만 심층 검사 | 검사 대상 트래픽 50~70% 감소 |
| TLS 1.3 전용 | TLS 1.2 이하 비활성화 → 1-RTT 핸드셰이크, 간소화된 cipher | 핸드셰이크 지연 30% 감소 |
| 벡터 배치 검사 | Hyperscan의 hs_scan_stream()으로 여러 세션 평문을 벡터 처리 | IDS 처리량 2x 향상 |
# startup.conf — SSL Inspection 고성능 설정 예시
session {
evt_qs_memfd_seg
event-queue-length 200000
preallocated-sessions 256000 # 이중 세션이므로 2배 할당
rx-fifo-size 32K
tx-fifo-size 32K
}
tls {
default-crypto-engine openssl
async # 비동기 암호화 필수
}
tlsopenssl {
engine cryptodev # QAT 오프로드
async
max-async-frames 512
}
cpu {
main-core 0
corelist-workers 1-15 # SSL Inspection은 CPU 집약적
}
# SSL Inspection 전용 설정
ssl-inspect {
proxy-ca-cert /etc/vpp/ssl-inspect/proxy-ca.pem
proxy-ca-key /etc/vpp/ssl-inspect/proxy-ca.key
cert-cache-size 100000 # 도메인 인증서 캐시 (10만 개)
cert-cache-ttl 86400 # 캐시 TTL: 24시간
cert-key-type ecdsa-p256 # 동적 인증서 키 유형
bypass-list /etc/vpp/ssl-inspect/bypass-domains.txt
ids-engine hyperscan # 인라인 IDS 엔진
ids-rules /etc/vpp/ssl-inspect/suricata.rules
}
SSL Inspection 환경의 대표적 성능 수치(Xeon Platinum 8380, QAT C62x, 16 워커):
| 항목 | 단순 TLS 종단 | SSL Inspection (SW) | SSL Inspection (QAT) |
|---|---|---|---|
| 새 연결 (CPS) | ~150,000 | ~40,000 | ~100,000 |
| 처리량 (Gbps) | ~80 | ~20 | ~50 |
| p99 지연 (ms) | 0.2 | 0.8 | 0.4 |
| 동시 연결 | 500K | 250K | 250K |
| CPU 사용률 | 25% | 90% | 35% |
| 메모리 (연결당) | ~50KB | ~120KB | ~120KB |
명시적 HTTP CONNECT 프록시 구현
명시적(Explicit) 프록시 모드에서는 클라이언트가 HTTP CONNECT 메서드로 프록시에 터널을 요청합니다. 프록시는 이 요청을 가로채어 SSL Inspection을 수행합니다.
/* HTTP CONNECT 프록시 핸들러 (개념적 구현) */
static int
ssl_inspect_handle_connect (session_t *client_session,
u8 *request, u32 len)
{
u8 *host;
u16 port;
ssl_inspect_session_t *sis;
/* "CONNECT api.example.com:443 HTTP/1.1" 파싱 */
if (parse_connect_request (request, len, &host, &port))
return -1;
/* 바이패스 정책 확인 */
if (ssl_inspect_should_bypass_domain (host)) {
/* 바이패스: 원본 서버에 직접 TCP 터널 */
send_connect_response (client_session,
"200 Connection Established");
setup_tcp_tunnel (client_session, host, port);
return 0;
}
/* SSL Inspection 세션 초기화 */
sis = ssl_inspect_session_alloc ();
sis->server_name = vec_dup (host);
/* 1. 원본 서버에 TLS 연결 (프록시가 클라이언트 역할) */
sis->server_tls_session_handle =
ssl_inspect_connect_to_server (host, port);
/* 2. 서버 인증서 수신 대기 → 동적 인증서 생성 */
/* → 비동기 콜백에서 처리 (server_connected_cb) */
return 0;
}
/* 서버 연결 완료 콜백 */
static void
ssl_inspect_server_connected_cb (ssl_inspect_session_t *sis,
session_t *server_session)
{
X509 *server_cert, *proxy_cert;
SSL *server_ssl;
/* 서버 인증서 추출 */
server_ssl = tls_ctx_get_ssl (sis->server_tls_ctx);
server_cert = SSL_get_peer_certificate (server_ssl);
/* 서버 인증서 검증 (CA 체인, CRL/OCSP) */
if (ssl_inspect_verify_server_cert (server_cert) != 0) {
/* 서버 인증서 검증 실패 → 클라이언트에 경고 */
ssl_inspect_send_cert_error (sis);
return;
}
/* 동적 인증서 생성 (캐시 확인 포함) */
proxy_cert = ssl_inspect_get_or_generate_cert (
sis, sis->server_name, server_cert);
/* 클라이언트에 "200 Connection Established" 응답 */
send_connect_response (sis->client_session,
"200 Connection Established");
/* 클라이언트 측 TLS 세션 시작 (동적 인증서 사용) */
sis->client_tls_ctx = ssl_inspect_start_client_tls (
sis, proxy_cert);
/* 이후 양쪽 TLS 세션이 모두 ESTABLISHED되면
* ssl_inspect_engine 노드에서 평문 검사 시작 */
}
코드 설명
-
10~11행
클라이언트가 보낸
CONNECT api.example.com:443 HTTP/1.1요청에서 대상 호스트와 포트를 추출합니다. 이 시점에서는 아직 TLS 핸드셰이크가 시작되지 않았으므로 평문입니다. - 14~20행 바이패스 대상 도메인인 경우, SSL Inspection 없이 단순 TCP 터널을 설정합니다. 클라이언트와 서버가 직접 TLS 핸드셰이크를 수행하며 프록시는 바이트를 투명하게 중계합니다.
- 27~28행 먼저 원본 서버에 TLS 연결을 수립합니다. 서버의 실제 인증서를 받아야 동적 인증서를 생성할 수 있기 때문입니다. 이 연결은 비동기로 진행됩니다.
- 47~50행 서버 인증서의 유효성을 검증합니다. 프록시가 만료되거나 위조된 서버 인증서를 그대로 전달하면 안 되므로, CA 체인 검증 + CRL/OCSP 확인을 수행합니다. 검증 실패 시 클라이언트에 적절한 에러를 전달합니다.
-
59~60행
200 Connection Established응답을 보낸 후에 클라이언트 측 TLS 핸드셰이크를 시작합니다. 이 순서가 중요합니다. 클라이언트는 200 응답을 받은 후에 TLS ClientHello를 전송하기 때문입니다.
SSL Inspection 모니터링 및 디버깅
# SSL Inspection 상태 모니터링
vpp# show ssl-inspect summary
Active inspection sessions: 12,345
Bypassed sessions: 8,901
Certificate cache entries: 45,678
Certificate cache hit rate: 97.3%
Threats blocked: 42
# 인증서 캐시 상태
vpp# show ssl-inspect cert-cache [verbose]
[0] *.google.com hits: 15234 age: 3600s
[1] api.github.com hits: 2341 age: 1800s
[2] cdn.example.com hits: 892 age: 7200s
# 바이패스 통계
vpp# show ssl-inspect bypass stats
Domain whitelist hits: 5,432
Category bypass (financial): 2,100
Category bypass (healthcare): 890
Certificate pinning detected: 479
Internal IP bypass: 1,000
# IDS 매칭 통계
vpp# show ssl-inspect ids stats
Patterns loaded: 12,345
Scans performed: 1,234,567
Matches found: 42
Average scan time: 0.02ms
# 개별 세션 상세
vpp# show ssl-inspect session verbose index 42
Client: 192.168.1.100:52341
Server: 93.184.216.34:443 (example.com)
Client TLS: TLSv1.3 TLS_AES_256_GCM_SHA384
Server TLS: TLSv1.3 TLS_AES_256_GCM_SHA384
Proxy cert: CN=example.com (ECDSA P-256)
Bytes inspected: 1,048,576
IDS matches: 0
Action: ALLOW
# 실시간 검사 트레이싱
vpp# trace add ssl-inspect-classify 10
vpp# trace add ssl-inspect-engine 10
vpp# show trace
| 문제 | 진단 명령 | 해결책 |
|---|---|---|
| 인증서 오류 (브라우저 경고) | show ssl-inspect cert-cache verbose | 프록시 CA가 클라이언트에 설치되었는지 확인, SAN 복사 로직 검증 |
| 핸드셰이크 지연 | show ssl-inspect summary 캐시 히트율 확인 | 캐시 크기 증가, ECDSA 전환, QAT 오프로드 활성화 |
| 특정 앱 연결 실패 | show ssl-inspect bypass stats에서 피닝 감지 확인 | 해당 앱 도메인을 바이패스 목록에 추가 |
| IDS 오탐(False Positive) | show ssl-inspect ids stats + 매칭 룰 ID 확인 | 해당 룰을 suppress 또는 threshold 조정 |
| 처리량 부족 | show runtime에서 노드별 사이클 확인 | 선택적 검사로 대상 트래픽 축소, 워커 수 증가 |
| 메모리 부족 | show memory verbose에서 세션 풀 확인 | preallocated-sessions을 이중 세션 고려하여 2배로 설정 |
TLS/QUIC 보안 모범 사례
VPP 환경에서 TLS/QUIC을 운영할 때 보안을 강화하기 위한 설정, 인증서 관리, 공격 방어 전략을 다룹니다.
Cipher Suite 선택 가이드
TLS 1.3에서 사용 가능한 cipher suite는 5개로 제한되며, 보안성과 성능 간 균형을 고려하여 선택해야 합니다:
| Cipher Suite | 보안 등급 | 성능 | 권장 여부 | 비고 |
|---|---|---|---|---|
| TLS_AES_256_GCM_SHA384 | 최고 | 높음 | 권장 | AES-NI 하드웨어 가속 지원 |
| TLS_AES_128_GCM_SHA256 | 높음 | 매우 높음 | 권장 | 기본값, 대부분 환경에 적합 |
| TLS_CHACHA20_POLY1305_SHA256 | 높음 | 높음 | 권장 | AES-NI 없는 환경(ARM 등)에 최적 |
| TLS_AES_128_CCM_SHA256 | 높음 | 중간 | 조건부 | IoT/임베디드 환경 전용 |
| TLS_AES_128_CCM_8_SHA256 | 중간 | 중간 | 비권장 | 태그 크기 축소로 보안성 저하 |
# VPP TLS cipher suite 설정
vpp# set tls cipher TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256
# TLS 1.2 하위 호환이 필요한 경우 (비권장)
vpp# set tls cipher ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
# 비권장/폐기 cipher 명시적 비활성화
# RC4, DES, 3DES, MD5, SHA1 기반은 절대 사용 금지
# CBC 모드는 BEAST/POODLE 취약점으로 비권장
TLS_AES_128_GCM_SHA256이며, quicly의 picotls 엔진에서 처리됩니다. 별도 설정 없이도 안전한 기본값이 적용됩니다.
인증서 관리 자동화
운영 환경에서 인증서 만료는 서비스 장애의 주요 원인입니다. 자동 갱신 체계를 구축하여 이를 방지해야 합니다:
# Let's Encrypt ACME 클라이언트로 인증서 자동 발급
certbot certonly --standalone -d vpp.example.com \
--preferred-challenges http \
--non-interactive --agree-tos -m admin@example.com
# 발급된 인증서를 VPP에 등록
vpp# tls cert add /etc/letsencrypt/live/vpp.example.com/fullchain.pem
vpp# tls key add /etc/letsencrypt/live/vpp.example.com/privkey.pem
# 갱신 자동화 (crontab)
# 0 3 * * * certbot renew --post-hook "vppctl tls cert reload"
# 인증서 만료일 모니터링
openssl x509 -enddate -noout -in /etc/letsencrypt/live/vpp.example.com/cert.pem
# notAfter=Mar 15 12:00:00 2027 GMT
키 순환(Key Rotation) 전략
- TLS 세션 티켓 키 순환
- 세션 티켓(Session Ticket) 암호화 키는 주기적으로 교체해야 합니다. 오래된 티켓 키가 유출되면 과거 세션의 PFS(Perfect Forward Secrecy)가 침해될 수 있습니다. 권장 순환 주기는 24시간입니다.
- QUIC 0-RTT 키 관리
- 0-RTT에 사용되는 PSK(Pre-Shared Key)는 재전송 공격(Replay Attack)에 취약합니다. 서버 측에서 0-RTT 티켓의 유효 기간을 제한하고, 사용된 티켓을 추적하여 중복 사용을 방지해야 합니다.
- 인증서 키 순환
- RSA/ECDSA 개인 키는 연 1회 이상 교체를 권장합니다. ECDSA P-256 키는 RSA 2048 대비 키 크기가 작아 핸드셰이크 성능이 우수하며 동등한 보안 수준을 제공합니다.
보안 강화 설정
OCSP 스테이플링(Stapling)
OCSP 스테이플링은 서버가 인증서 유효성 증명을 미리 확보하여 클라이언트의 OCSP 조회 지연을 제거합니다:
# OpenSSL 엔진에서 OCSP 스테이플링 활성화
# startup.conf
tls {
# OCSP 응답 파일 경로 (주기적으로 갱신 필요)
ocsp-response /etc/vpp/ocsp-response.der
}
# OCSP 응답 수동 갱신
openssl ocsp -issuer ca.pem -cert server.pem \
-url http://ocsp.ca-provider.com \
-respout /etc/vpp/ocsp-response.der
# 자동 갱신 스크립트 (12시간마다)
# 0 */12 * * * /usr/local/bin/update-ocsp.sh
0-RTT 재전송 공격 방어
QUIC과 TLS 1.3의 0-RTT는 지연시간을 줄이지만, 재전송 공격에 취약합니다. 방어 전략은 다음과 같습니다:
| 방어 수단 | 적용 대상 | 설명 |
|---|---|---|
| 멱등성(Idempotent) 요청만 허용 | 애플리케이션 | 0-RTT에서 GET 요청만 처리, POST/PUT 등 상태 변경 요청은 1-RTT 이후 처리 |
| single-use 티켓 | 서버 | 0-RTT 티켓을 1회 사용 후 폐기하여 재전송 차단 |
| 타임스탬프 검증 | 서버 | 0-RTT 데이터의 시간 범위를 제한하여 오래된 재전송 거부 |
| 0-RTT 비활성화 | 서버 | 보안이 최우선인 환경에서 enable-0rtt 설정 제거 |
속도 제한 및 DDoS 완화
QUIC은 UDP 기반이므로 IP 스푸핑 기반 반사 공격에 취약할 수 있습니다. VPP에서의 방어 설정입니다:
# VPP ACL 기반 속도 제한
vpp# set acl-plugin acl permit+reflect src 0.0.0.0/0 dst 0.0.0.0/0 \
proto udp dport 443 rate-limit 10000/s
# QUIC Initial 패킷 크기 검증 (RFC 9000: 최소 1200 바이트)
# quicly가 자동으로 크기 미달 Initial 패킷 드롭
# Retry 토큰 기반 주소 검증
quic {
# 클라이언트 주소 검증 활성화
require-address-validation
# Retry 토큰 유효 기간 (초)
retry-token-lifetime 120
}
# 연결 수 제한 (IP당)
quic {
max-connections-per-ip 100
}
보안 설정 비교 요약
| 보안 항목 | 기본값 | 권장 설정 | 최고 보안 |
|---|---|---|---|
| TLS 버전 | 1.2+ | 1.3 전용 | 1.3 전용 |
| Cipher | AES-128-GCM | AES-256-GCM + ChaCha20 | AES-256-GCM 단독 |
| 키 교환 | ECDHE P-256 | ECDHE P-256/P-384 | X25519 + P-384 |
| 인증서 | RSA 2048 | ECDSA P-256 | ECDSA P-384 |
| OCSP | 비활성 | 스테이플링 활성 | 필수(Must-Staple) |
| 0-RTT | 비활성 | 멱등 요청만 허용 | 비활성 |
| 세션 티켓 순환 | 수동 | 24시간 자동 | 6시간 자동 |
| mTLS | 비활성 | 선택적 | 필수 |
| 주소 검증 | 비활성 | 활성 | 필수 + IP 제한 |
ssllabs.com 또는 testssl.sh 도구로 설정 검증을 권장합니다.
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하세요.