VPP on DPU
VPP를 서버 CPU 대신 DPU(Data Processing Unit, BlueField 같은 SmartNIC형 연산 장치)에 올릴 때의 흐름·분할·SSL Inspection 오프로드를 0~15 소절로 심화 정리한 문서입니다. 본 문서는 운영 · 플랫폼 · 확장에서 DPU 섹션만을 분리한 심화 분리본이며, vpp-operations.html의 운영 본문은 그대로 두고 DPU 고유 설계만 이곳에서 깊이 다룹니다.
이 페이지(Page)에서 다루는 주요 흐름은 다음과 같습니다. 0번에서 SSL Inspection의 개념·필요성·최소 구현을 먼저 정리한 뒤, 1번에서 DPU와 SmartNIC의 차이를 설명합니다. 2번은 DPU에 VPP를 올리는 세 가지 배포 패턴을, 3번은 DPU VPP 빌드와 부팅 차이를 다룹니다. 4번은 cryptodev/HSM 같은 가속기 연동을 VPP 측 추상화 관점에서 정리하고, 5번은 SSL Inspection on DPU의 핵심 설계를 종합합니다. 6번은 VPP 설정으로 보는 구성 예시이며, 6.5번은 DPU↔Host 왕복 배치의 VCL+OpenSSL 구현을 5개 하위 소절(흐름·VCL 골격·Host forwarder·바이트 워크·함정)로 풀어냅니다. 7번은 성능과 운영 포인트, 8번은 호스트 경로와 DPU 경로의 패킷(Packet) 워크 비교, 9번은 TLS 1.3 핸드셰이크 단계별 트레이스, 10번은 동적 leaf 인증서와 cert cache, 11번은 세션 계층 배선을 VPP 코드 관점에서 다룹니다. 12번은 그래프 노드 워크스루, 13번은 관측·디버깅(Debugging), 14번은 실습 랩 스켈레톤, 마지막 15번은 한 장 요약입니다.
DPU에서 VPP 구현 — 흐름·분할·SSL Inspection 오프로드
이 절은 "VPP를 서버 CPU 대신 DPU(Data Processing Unit)에 올린다"는 구체적 상황을 전제로, 무엇이 달라지고 무엇을 추가로 고려해야 하는지를 정리합니다. 특히 SSL Inspection처럼 TCP 재조립·TLS 레코드 암복호화(Decryption)·인증서 서명이 동시에 필요한 워크로드는 DPU 배포에서 가장 큰 설계 포인트가 되므로, 이 시나리오를 중심축으로 삼아 설명합니다. 일반 프록시·TLS 종단 내용은 TCP/TLS 프록시의 SSL Inspection을, 정책·거버넌스 관점은 보안과 터널링의 SSL Inspection 보안 통합을 함께 참고하시기 바랍니다.
1. DPU란 무엇인가 — SmartNIC와의 관계
DPU는 기존의 스마트 NIC(SmartNIC) 흐름이 자연스럽게 도달한 한 형태로, 세 개의 층이 한 카드 안에 통합된 장치입니다.
- 고속 NIC ASIC — 100/200/400GbE 이더넷 MAC/PHY, 플로우 매칭 엔진(eSwitch, P4 파이프라인), 호스트/업링크 양방향 포트.
- 범용 컴퓨팅 코어 — 대개 8~16개의 Arm Cortex-A 코어, 자체 DDR 메모리, 독립 운영체제(리눅스). 호스트와 PCIe를 통해 연결되지만 별도의 CPU complex로 동작합니다.
- 도메인 특화 가속기 — AES-GCM/ChaCha20 같은 대칭 암호화(대개 IPsec·TLS record용), RegEx, 압축, SHA/공개키 가속, 연결 추적(Connection Tracking, conntrack), RDMA 오프로드 엔진.
대표 제품군은 NVIDIA BlueField-2/3, Intel IPU E2000/Mount Evans, AMD(구 Pensando) DSC, Marvell Octeon 등이며, 배포 모드(하드웨어가 어떤 "가면"을 쓰고 호스트에 노출되는가)에 따라 VPP를 올리는 방식이 달라집니다.
2. DPU에 VPP를 올리는 세 가지 패턴
DPU는 호스트에 어떻게 "보이느냐"에 따라 배포 모드가 달라지고, 그에 맞춰 VPP의 책임 범위도 바뀝니다. 여기서는 현장에서 가장 많이 만나는 세 가지 패턴을 정리합니다.
| 패턴 | VPP 위치 | 호스트가 보는 것 | 장점 | 단점/주의 |
|---|---|---|---|---|
| A. 호스트 VPP + DPU 패스스루 | 호스트 CPU | DPU는 일반 NIC VF로 노출 | 구성 단순, DPDK mlx5 PMD 그대로 사용 | 가속기 활용 제한 — DPU Arm은 놀고 있음 |
| B. DPU Arm VPP (bump-in-the-wire) | DPU Arm | DPU는 "투명 L2"로 동작 — 호스트는 DPU의 존재를 모름 | 제로 트러스트(Zero Trust): 호스트 OS 침해와 무관하게 정책 시행 가능, 키 격리 | Arm 성능 한계(8~16코어), 메모리 작음(16~64GB), 빌드 타깃이 aarch64 |
| C. 호스트 VPP ↔ DPU VPP 분할 | 양쪽 모두 | 호스트와 DPU가 서로 대등한 피어 | 빠른 경로는 DPU에, L7/SSL Inspection은 호스트에 — 혹은 반대로 | memif/RDMA로 연결된 두 VPP 인스턴스의 동기화가 복잡, 구성 실수 많음 |
SSL Inspection처럼 "호스트를 믿지 않겠다"는 요구가 있는 경우 B 모드가 기본 선택지입니다. 클라이언트 프라이빗 키와 CA 키를 호스트 OS가 아니라 DPU의 TPM/HSM(Hardware Security Module)에 보관할 수 있고, 호스트가 탈취되어도 복호화된 평문은 DPU 메모리에서만 존재하다 사라지기 때문입니다. 반면 "복호화 후 DLP/IDS로 넘긴다"처럼 후속 처리까지 고려하면 C 모드로 호스트 VPP를 L7 분석 티어로 붙이는 쪽이 확장성이 좋습니다.
3. DPU VPP 빌드와 부팅 — 무엇이 달라지는가
DPU Arm에서 VPP를 돌릴 때 처음 만나는 벽은 빌드 타깃과 드라이버 스택입니다. 호스트 x86 경험을 그대로 가져올 수 없는 부분만 모아 정리했습니다.
# 1) 타깃: aarch64 — 호스트에서 크로스 빌드하거나, DPU에 직접 빌드
# BlueField의 경우 NVIDIA DOCA BSP(Board Support Package)가 제공하는
# aarch64 빌드 환경(Ubuntu 22.04 기반)에서 그대로 make build-release가 동작합니다.
dpu$ sudo apt install -y build-essential python3 libnuma-dev libmnl-dev \
libssl-dev libelf-dev libpcap-dev
dpu$ git clone https://github.com/FDio/vpp.git
dpu$ cd vpp && make install-dep && make build-release
# 2) 드라이버 스택: mlx5 계열은 커널 모듈(mlx5_core/ib_core)로 DPDK가 감쌉니다.
# x86에서 익숙한 "vfio-pci로 bind 후 dpdk가 take over"가 아니라,
# BlueField에서는 kernel이 먼저 mlx5_core로 잡고 DPDK는 bifurcated driver로 동거합니다.
dpu$ lsmod | grep mlx5
mlx5_ib ...
mlx5_core ...
dpu$ ibv_devices # RDMA 장치로도 보임 (rdma-core)
dpu$ dpdk-devbind.py -s # "kernel" 상태여도 DPDK가 사용 가능
# 3) startup.conf — Arm 8코어 BlueField-2 예시
dpu$ cat /etc/vpp/startup.conf
unix {
nodaemon
log /var/log/vpp/vpp.log
cli-listen /run/vpp/cli.sock
}
cpu {
main-core 1
corelist-workers 2-5 # Arm 코어 4개만 워커로 — 2코어는 OS/관리용 여유
}
buffers {
buffers-per-numa 32768 # DPU DDR은 작음(8~64GB) → 버퍼 수 보수적으로
default data-size 2048
}
dpdk {
dev 0000:03:00.0 { num-rx-queues 4 } # 업링크(wire 방향)
dev 0000:03:00.1 { num-rx-queues 4 } # 호스트 방향(virtio 백엔드)
# 가속기 활성화 — mlx5_crypto PMD를 cryptodev로 노출
vdev crypto_mlx5
}
plugins {
plugin dpdk_plugin.so { enable }
plugin crypto_native_plugin.so { disable } /* 가속기를 쓰므로 AESNI SW 경로 끔 */
plugin crypto_openssl_plugin.so { enable }
plugin tlsopenssl_plugin.so { enable }
}
코드 설명
- 1) 빌드 타깃VPP는 aarch64에서 1급 시민으로 빌드를 지원합니다. BlueField OS(DOCA BSP 기반 Ubuntu)는 필요한 라이브러리가 이미 준비되어 있어 별도 크로스 빌드 환경을 만들 필요가 대개 없습니다. 다만 PMD 옵션(
CONFIG_RTE_LIBRTE_MLX5_PMD등)은 반드시 활성화되어야 하며, NVIDIA DOCA 패키지를 함께 설치하면 crypto/regex PMD가 자동으로 포함됩니다. - 2) bifurcated driverx86 환경의 "vfio-pci로 bind"와 달리 BlueField의 mlx5는 커널이 먼저 잡아도 DPDK가 같이 쓸 수 있습니다. 이는 RDMA 제어 평면(
ibv_*)과 DPDK 데이터 평면이 동일한 장치 큐를 서로 다른 영역에서 공유하도록 설계된 덕분입니다. 그 결과 VPP는 DPDK로 패킷을 받으면서도 동시에 호스트 측 RDMA 관리 도구를 살려 둘 수 있습니다. - main-core 1 / corelist-workers 2-5BlueField-2의 8 Arm 코어 중 4개만 VPP 워커로 사용하고 2코어를 OS/컨트롤에 남겨 두는 설정입니다. DPU Arm은 호스트 CPU보다 코어당 성능이 낮으므로 코어를 다 밀어 넣는다고 선형으로 성능이 올라가지 않습니다. 오히려 ARM 캐시/메모리 대역폭(Bandwidth) 경쟁 때문에 4~6워커가 sweet spot인 경우가 많습니다.
- buffers-per-numa 32768x86 서버에서 흔히 쓰는 131072 같은 값은 DPU에서 그대로 쓰면 메모리를 소진합니다. DPU의 총 DDR이 16~32GB에 불과하므로, 버퍼(Buffer) 풀을 보수적으로 설정하고 세션 풀·히프 크기에 여유를 남기는 것이 원칙입니다.
- vdev crypto_mlx5이 한 줄이 DPU 내장 AES/ChaCha20 엔진을 DPDK cryptodev로 등록합니다. 이후 VPP의 crypto engine 프레임워크가 이 장치를 발견하면, IPsec·TLS 레코드 처리 경로가 자동으로 DPU 가속기를 쓰도록 바인딩됩니다(뒤 절 참조).
4. DPU 가속기 연동 — VPP 측 추상화
VPP는 암호화 연산을 crypto engine 프레임워크(src/vnet/crypto)를 통해 추상화합니다. 알고리즘(AES-GCM, ChaCha20-Poly1305, HMAC-SHA256 등)별로 여러 엔진 구현이 등록되고, 운영자는 "어느 엔진을 어떤 알고리즘에 쓸지"를 런타임에 선택할 수 있습니다. DPU 가속기는 이 프레임워크 위에 DPDK cryptodev를 감싸는 엔진으로 올라옵니다.
/* src/plugins/dpdk/cryptodev/cryptodev_op_data_path.c — 핵심만 추림 */
static u32
cryptodev_frame_enqueue_gcm (vlib_main_t *vm,
vnet_crypto_async_frame_t *frame,
void *user_data)
{
cryptodev_engine_thread_t *cet = &cryptodev_inst->per_thread_data[vm->thread_index];
rte_crypto_op *cops[VNET_CRYPTO_FRAME_SIZE];
for (u32 i = 0; i < frame->n_elts; i++)
{
/* 1) VPP의 crypto op를 DPDK rte_crypto_op로 변환 */
cops[i] = rte_crypto_op_alloc(cet->op_pool, RTE_CRYPTO_OP_TYPE_SYMMETRIC);
cops[i]->sym->m_src = vlib_buffer_to_mbuf(frame->elts[i].buffer_index);
cops[i]->sym->aead.data.offset = frame->elts[i].crypto_start_offset;
cops[i]->sym->aead.data.length = frame->elts[i].crypto_total_length;
cops[i]->sym->aead.aad.data = frame->elts[i].aad;
cops[i]->sym->session = cet->sessions[frame->elts[i].key_index];
}
/* 2) 배치 단위로 DPU 하드웨어 큐에 submit (doorbell 1회) */
u32 n_enq = rte_cryptodev_enqueue_burst(
cet->cryptodev_id, cet->cryptodev_q, cops, frame->n_elts);
/* 3) 프레임을 in-flight 리스트에 매달아 두고 return — 진짜 완료는 dequeue에서 */
cryptodev_mark_frame_inflight(cet, frame, n_enq);
return n_enq;
}
코드 설명
- 1) VPP op → DPDK rte_crypto_op 변환VPP의
vnet_crypto_async_frame_t는 수십~수백 개의 암호화 요청을 한 배치(batch)로 모아 둔 구조체(Struct)입니다. 각 요소는 vlib 버퍼 포인터와 AAD/IV/키 인덱스만을 갖고 있으며, 여기서 DPDK가 요구하는rte_crypto_op형태로 얇게 변환됩니다. 변환이 얇은 이유는 버퍼 본문을 복사하지 않기 때문입니다 — mbuf 포인터와 오프셋(Offset)만 넘깁니다. - 2) enqueue_burst (doorbell 1회)DPU 가속기와의 PCIe 상호작용은 doorbell 레지스터(Register) write로 "큐에 요청이 쌓였음"을 알리는 방식입니다. 이 doorbell은 배치당 1회만 발생하도록 설계되어 있어, 개별 요청마다 레지스터 write를 하는 naive 방식보다 수십 배 효율적입니다. VPP의 "100개 패킷 단위 처리"라는 규율이 DPU 가속기 활용에도 그대로 유리하게 작용하는 지점입니다.
- 3) in-flight 리스트enqueue는 비동기입니다 — 바로 완료되지 않습니다. VPP는 프레임을 in-flight 리스트에 매달아 두고 다른 노드로 제어를 돌려주며, 이후
crypto-dequeue라는 별도 노드가 주기적으로rte_cryptodev_dequeue_burst()를 호출해 완료된 프레임을 수거합니다. 이 덕분에 암호화 지연(Latency)이 다른 패킷 처리를 블로킹하지 않고, DPU 가속기와 Arm 코어가 파이프라인으로 돌아갑니다.
5. SSL Inspection on DPU — 핵심 설계
SSL Inspection을 DPU에 올리는 이유는 세 가지입니다. ① 라인레이트(line-rate)에 가까운 TLS 종단이 필요하고, ② 호스트 OS와 분리된 키 격리(Key Isolation)가 중요하며, ③ 복호화된 평문이 호스트 메모리에 절대 나타나면 안 되는 요구사항이 존재하기 때문입니다. 구현 관점에서는 VPP host stack의 TCP 재조립, TLS 레코드 암복호화, 인증서 서명이 모두 DPU 안에서 끝나야 하며, 이 세 작업을 가속기와 Arm 코어가 어떻게 분담하는지가 설계의 핵심입니다.
핵심은 다음 네 가지입니다.
- 두 개의 독립된 TLS 상태 머신 — 클라이언트 측 세션과 서버 측 세션은 VPP host stack 안에서 별도의
tls_ctx_t로 관리됩니다. ClientHello에서 SNI를 뽑아 업스트림 연결을 결정하고, 동시에 그 SNI에 맞는 임시 서버 인증서를 내부 CA로 즉석 서명하여 클라이언트에 제시합니다. - AEAD는 가속기로, 핸드셰이크는 Arm으로 — 대량 트래픽에 해당하는 AES-GCM/ChaCha20은 cryptodev(mlx5_crypto 등)에 배치 처리로 밀어 넣고, 상대적으로 저빈도인 핸드셰이크(ECDHE·인증서 검증)는 Arm 코어의 OpenSSL로 돌립니다. 이 분업이 "DPU 7배 효율"의 실체입니다.
- 동적 서명은 HSM으로 우회 — 임시 서버 인증서 서명에 쓰이는 CA 개인키는 Arm 코어 메모리에 평문으로 올라오지 않아야 합니다. VPP는 OpenSSL의 PKCS#11 엔진을 통해 HSM/TPM에 signing 작업을 위임하고, 개인키는 HSM 밖으로 나가지 않습니다.
- 평문 영역의 경계 관리 — 복호화된 HTTP가 노출되는 "평문 구간"은 DPU 메모리 안의 vlib 버퍼에만 존재합니다. DPI/정책 매칭이 끝나면 즉시 재암호화되어 업스트림으로 나가며, 필요하다면 별도 감사 전용 pipe(예: memif 채널)를 통해서만 호스트 측 분석 티어로 복사됩니다. 이 pipe는 mTLS로 인증되어야 하며, 설계상 단방향이어야 합니다.
6. 구성 예시 — VPP 설정으로 보는 SSL Inspection on DPU
# 1) TLS 엔진으로 openssl 선택, async crypto로 cryptodev 엔진 지정
dpu# set session enable
dpu# tls openssl set ciphers TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256
dpu# set crypto async handler aes-128-gcm cryptodev
dpu# set crypto async handler aes-256-gcm cryptodev
dpu# set crypto async handler chacha20-poly1305 cryptodev
dpu# set crypto async dispatch on
# 2) 내부 CA를 PKCS#11 HSM URI로 로드 (OpenSSL pkcs11 engine 사용)
# - 개인키는 DPU 메모리로 절대 나오지 않음
dpu# tls openssl set-engine pkcs11
dpu# tls ca-cert pkcs11:token=DPU_CA;object=SSLI-CA-ECC
dpu# tls ca-key pkcs11:token=DPU_CA;object=SSLI-CA-ECC-PRIV?pin-source=/dev/shm/pin
# 3) 이중 세션(투명 프록시) 세팅 — 클라이언트와 업스트림 양쪽에 listener 생성
dpu# tls-inspect proxy add listener 0.0.0.0:443 upstream-resolve dns \
upstream-verify strict \
dynamic-cert on \
alpn http/1.1,h2
# 4) 정책 — 평문 버퍼를 DPI 엔진으로 연결
dpu# regex set backend rte_regexdev device mlx5_regex
dpu# ssli policy add match regex-set IDS-SIGNATURES action log
dpu# ssli policy add match host "*.bank.example" action bypass # 우회
# 5) 감사 채널 — 호스트 분석 티어로 "평문 메타데이터"만 단방향 송신
dpu# create memif socket id 1 filename /run/vpp/ssli-audit.sock
dpu# create interface memif id 1 socket-id 1 master
dpu# ssli audit sink memif1 fields sni,host,url,verdict # 페이로드 제외
# 6) 상태 확인
dpu# show ssli sessions
dpu# show crypto async status
dpu# show tls ctx detail
코드 설명
- 1) async cryptoVPP는 동기(sync) 엔진과 비동기(async) 엔진을 구분합니다. cryptodev는 비동기 엔진이므로
set crypto async handler ... cryptodev로 알고리즘을 가속기에 바인딩하고,dispatch on으로 crypto-dispatch 노드를 활성화해야 실제 enqueue/dequeue가 그래프에 삽입됩니다. 이 단계를 빠뜨리면 TLS 레코드가 여전히 OpenSSL SW 경로로 돕니다 — 흔한 실수입니다. - 2) PKCS#11 HSMCA 개인키를 HSM에 넣는다는 것은 "서명 연산마다 PKCS#11
C_Sign호출을 HSM에 보낸다"는 의미입니다. 핸드셰이크 지연이 약간 늘지만, 핸드셰이크 빈도는 AEAD 레코드 처리 빈도의 수백분의 1이므로 보통 허용 범위 안입니다. 핀(PIN)은 부팅 시 tmpfs에 올려 두고 로드 직후 언마운트하는 식으로 영구 저장을 피합니다. - 3) upstream-verify strict업스트림 방향 인증서 검증을 완화하면 SSL Inspection이 곧 MitM 공격 도구가 됩니다. 반드시 strict로 두고, 인증서 pinning/CT 로그 확인 실패 시 세션을 차단해야 합니다. 보안·터널링 페이지의 위협 모델 표의 "TLS 다운그레이드" 항목과 같은 맥락입니다.
- 5) 감사 채널은 단방향 + 필드 선택"평문 전체"를 호스트로 넘기면 DPU에 격리한 의미가 사라집니다. 실무에서는 SNI·호스트·URL·판정 결과 같은 메타데이터만 호스트 분석 티어로 보내고, 본문이 필요한 케이스(악성코드 샘플 추출 등)는 별도 승인 워크플로우로만 허용합니다. memif를 master 단방향으로 구성하는 이유도 동일합니다.
6.5 심화 구성 — DPU↔Host 왕복 배치의 VCL+OpenSSL 구현
실전 DPU 배포에서 자주 등장하는 한 가지 흐름이 "복호화는 DPU에서, L7 분석·포워딩은 호스트에서, 재암호화는 다시 DPU에서" 하는 왕복(bounce) 모델입니다. 사용자 요청 표기 그대로 정리하면 다음 한 줄이 됩니다.
Rx ─▶ (Client & Server SSL MITM) DPU ─▶ (Plain TCP) HostCPU (Forward) ─▶ DPU (Client & Server SSL MITM) ─▶ Tx
왕복이 일어나는 이유는 두 축이 서로 상충하기 때문입니다. ① 암호·키 격리는 DPU에 있어야 빠르고 안전하지만, ② 풍부한 L7 분석(DLP 엔진, 레거시 IDS 룰, DPI 플러그인, 로깅 파이프라인)은 이미 호스트 CPU에 쌓아 둔 생태계를 재활용(Recycling)하는 편이 현실적입니다. 평문을 호스트로 한 번 넘겨 검사·수정·포워딩 결정을 마친 뒤 다시 DPU로 돌려보내 재암호화하면, 두 축의 이점을 모두 살릴 수 있습니다. 이 소절은 그 흐름을 VCL(VPP Comms Library)과 OpenSSL만으로 구현하는 골격을 제시합니다. 본격 플러그인(vnet/tls)을 건드리지 않고도 프로토타입이 가능하기 때문에, 현장에서 POC용으로 가장 자주 쓰이는 조합입니다.
6.5.1 데이터 흐름과 주체 분담
흐름을 바이트 관점에서 풀어 쓰면 다음과 같습니다. 각 단계는 이어지는 코드에서 한 줄씩 대응됩니다.
- Rx 진입 (DPU) — 업링크 NIC의 eSwitch 플로우가 TCP/443 트래픽을 DPU 상 VPP로 보내고, VCL listener가 accept합니다. VCL 세션 위에 OpenSSL
SSL*(서버 역할) 객체가 붙습니다. - ClientHello 파싱 · 업스트림 연결 (DPU) — SNI를 보고 원서버로 두 번째 VCL 세션을
vppcom_session_connect()로 여는 동시에, OpenSSL(클라이언트 역할) 핸드셰이크를 시작합니다. - 원서버 leaf 확보 → SignLeaf (DPU) — 업스트림 핸드셰이크가
SSL_get_peer_certificate()까지 오면, HSM의 CA 키로 leaf를 서명해 클라이언트 방향SSL_CTX에 주입합니다. cert cache 히트면 이 단계를 건너뜁니다. - Session A 완료 → 평문 루프 진입 (DPU) — 클라이언트 핸드셰이크가 Finished까지 도달하면 양 방향
SSL_read()/SSL_write()루프가 열립니다. 이때 읽어낸 평문은 원서버로 바로 재암호화하지 않고 memif로 호스트에 먼저 넘깁니다. - Plain TCP forward (Host) — 호스트 VPP/애플리케이션이 memif로 수신한 바이트에 DLP/ICAP/IDS를 적용합니다. 통과 판정이면 역방향 memif로 돌려보내고, 차단이면 RST 메타를 DPU로 보냅니다.
- 재암호화 → Tx (DPU) — 호스트에서 돌아온 바이트를
SSL_write()(클라이언트 역할)로 원서버 방향에 흘려 보내면 DPU의 AES-GCM 가속기가 레코드를 생성해 업링크로 송출합니다. 반대 방향(Origin→Client)은 대칭적으로 동작합니다.
6.5.2 DPU 측 골격 — VCL 세션 + OpenSSL BIO
VCL은 "POSIX socket과 비슷하지만 VPP의 세션 계층 위에서 동작하는" 사용자 라이브러리입니다. 그 자체에는 TLS 기능이 없으므로, OpenSSL을 얹으려면 VCL 전용 BIO를 만들어 SSL_set_bio()로 주입하는 것이 정석입니다. BIO의 read/write 콜백이 각각 vppcom_session_read()와 vppcom_session_write()를 호출하도록 구현하면, OpenSSL은 평범한 소켓에 붙은 것처럼 동작합니다.
/* dpu_ssli.c — DPU 측 VCL+OpenSSL MITM 골격
* 빌드: gcc dpu_ssli.c -o dpu_ssli -lvppcom -lssl -lcrypto
* 실행: LD_LIBRARY_PATH=... VCL_CONFIG=/etc/vpp/vcl.conf ./dpu_ssli
* 교육용 골격 — 동시성(epoll), 오류 처리, 정책 훅은 단순화.
*/
#include <vppcom.h>
#include <openssl/ssl.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <string.h>
/* 1) VCL 세션을 OpenSSL BIO로 감싼다 ------------------------------*/
static int vcl_bio_write(BIO *b, const char *buf, int len) {
int sh = (int)(intptr_t)BIO_get_data(b);
int r = vppcom_session_write(sh, (void*)buf, len);
BIO_clear_retry_flags(b);
if (r == VPPCOM_EAGAIN) { BIO_set_retry_write(b); return -1; }
return r;
}
static int vcl_bio_read(BIO *b, char *buf, int len) {
int sh = (int)(intptr_t)BIO_get_data(b);
int r = vppcom_session_read(sh, buf, len);
BIO_clear_retry_flags(b);
if (r == VPPCOM_EAGAIN) { BIO_set_retry_read(b); return -1; }
return r;
}
static long vcl_bio_ctrl(BIO *b, int cmd, long n, void *p) {
if (cmd == BIO_CTRL_FLUSH) return 1;
return 0;
}
static BIO_METHOD *vcl_bio_method(void) {
static BIO_METHOD *m;
if (!m) {
m = BIO_meth_new(BIO_TYPE_SOURCE_SINK, "vcl");
BIO_meth_set_write(m, vcl_bio_write);
BIO_meth_set_read(m, vcl_bio_read);
BIO_meth_set_ctrl(m, vcl_bio_ctrl);
}
return m;
}
static BIO *vcl_bio_new(int session_handle) {
BIO *b = BIO_new(vcl_bio_method());
BIO_set_data(b, (void*)(intptr_t)session_handle);
BIO_set_init(b, 1);
return b;
}
/* 2) SNI 콜백 — ClientHello에서 호스트 이름을 뽑고 leaf 캐시를 조회 -*/
typedef struct mitm_ctx { char sni[256]; int up_sh; SSL *up_ssl; } mitm_ctx_t;
static int sni_cb(SSL *s, int *al, void *arg) {
mitm_ctx_t *c = SSL_get_ex_data(s, 0);
const char *name = SSL_get_servername(s, TLSEXT_NAMETYPE_host_name);
if (!name) return SSL_TLSEXT_ERR_NOACK;
snprintf(c->sni, sizeof c->sni, "%s", name);
/* 3) 업스트림 연결: VCL connect + OpenSSL client 핸드셰이크 */
c->up_sh = vppcom_session_create(VPPCOM_PROTO_TCP, 0);
vppcom_endpt_t ep = { .is_ip4 = 1, .port = htons(443) };
resolve(name, ep.ip); // DNS는 호스트/외부 리졸버 위임
vppcom_session_connect(c->up_sh, &ep);
SSL_CTX *cli = SSL_CTX_new(TLS_client_method());
SSL_CTX_set_verify(cli, SSL_VERIFY_PEER, NULL);
SSL_CTX_set_default_verify_paths(cli);
c->up_ssl = SSL_new(cli);
SSL_set_tlsext_host_name(c->up_ssl, name);
SSL_set_bio(c->up_ssl, vcl_bio_new(c->up_sh), vcl_bio_new(c->up_sh));
if (SSL_connect(c->up_ssl) <= 0) { *al = SSL_AD_INTERNAL_ERROR; return SSL_TLSEXT_ERR_ALERT_FATAL; }
/* 4) 원서버 leaf → SignLeaf → 클라이언트 방향 인증서 교체 */
X509 *up_leaf = SSL_get_peer_certificate(c->up_ssl);
EVP_PKEY *leaf_key; X509 *leaf;
hsm_sign_leaf_cached(name, up_leaf, &leaf_key, &leaf); // HSM 호출
SSL_use_certificate(s, leaf);
SSL_use_PrivateKey(s, leaf_key);
X509_free(up_leaf);
return SSL_TLSEXT_ERR_OK;
}
/* 5) 세션 하나를 관리하는 양방향 릴레이 — 평문은 memif로 --------*/
static int memif_sh; // 호스트와 연결된 memif 세션 핸들 (사전 생성)
static void run_session(int client_sh) {
SSL_CTX *srv = SSL_CTX_new(TLS_server_method());
SSL_CTX_set_tlsext_servername_callback(srv, sni_cb);
mitm_ctx_t ctx = {0};
SSL *a = SSL_new(srv);
SSL_set_ex_data(a, 0, &ctx);
SSL_set_bio(a, vcl_bio_new(client_sh), vcl_bio_new(client_sh));
if (SSL_accept(a) <= 0) return; // 핸드셰이크 실패 → drop
/* 양방향 펌프: 평문은 반드시 호스트를 거쳐 돌아온다 */
unsigned char buf[16 * 1024];
for (;;) {
int n;
n = SSL_read(a, buf, sizeof buf); // client → plaintext
if (n > 0) memif_send(memif_sh, client_sh, DIR_A2B, buf, n);
int m = memif_recv(memif_sh, client_sh, DIR_B2A, buf, sizeof buf);
if (m > 0) SSL_write(a, buf, m); // 호스트→client 재암호
n = SSL_read(ctx.up_ssl, buf, sizeof buf); // origin → plaintext
if (n > 0) memif_send(memif_sh, client_sh, DIR_B2A, buf, n);
m = memif_recv(memif_sh, client_sh, DIR_A2B, buf, sizeof buf);
if (m > 0) SSL_write(ctx.up_ssl, buf, m); // 호스트→origin 재암호
}
}
int main(void) {
vppcom_app_create("dpu-ssli");
int listen_sh = vppcom_session_create(VPPCOM_PROTO_TCP, 0);
vppcom_endpt_t ep = { .is_ip4 = 1, .port = htons(443) }; // 0.0.0.0:443
vppcom_session_bind(listen_sh, &ep);
vppcom_session_listen(listen_sh, 1024);
memif_sh = memif_open("/var/run/vpp/ssli-pipe.sock");
for (;;) {
int client_sh = vppcom_session_accept(listen_sh, NULL, 0);
spawn(run_session, client_sh); // 워커 스레드/이벤트 루프
}
}
코드 설명
- BIO 래퍼 (vcl_bio_*)OpenSSL은 소켓 FD 대신 BIO 추상화를 통해 I/O를 합니다. VCL 세션은 커널 FD가 아니므로 BIO를 직접 만들어 read/write를
vppcom_session_*으로 연결합니다.VPPCOM_EAGAIN을 OpenSSL의BIO_set_retry_*로 매핑(Mapping)해야SSL_ERROR_WANT_READ/WRITE가 정상 동작합니다. 이 매핑이 누락되면 핸드셰이크가 무한 루프를 돕니다. - sni_cb — SNI 콜백OpenSSL 서버 측에서 ClientHello의
server_name확장을 처리하는 표준 지점입니다. 이 콜백 안에서 업스트림 연결·핸드셰이크·leaf 재서명까지 모두 끝내야, 돌아 나왔을 때 OpenSSL이 Session A의 ServerHello에 곧바로 바뀐 인증서를 담을 수 있습니다. 콜백 내부에서 블로킹 업스트림 연결이 일어난다는 점이 설계의 핵심이며, 실전에서는 이 구간을 이벤트 루프(Event Loop)로 비동기화해야 동시 연결 수가 늡니다. - 업스트림 VCL connect + OpenSSL clientDPU 내부에서 또 하나의 VCL 세션(Session B)을 열고, 그 위에 OpenSSL 클라이언트 SSL*을 올립니다. 업링크 포트로 나가는 진짜 TLS 핸드셰이크이며,
SSL_VERIFY_PEER를 끄면 안 됩니다. 이 검증을 완화하면 SSL Inspection이 그대로 MitM 공격 도구가 됩니다. - hsm_sign_leaf_cached(SNI, 업스트림 leaf fingerprint) → (leaf_key, leaf_cert)를 반환하는 캐시 계층입니다. 히트 시에는 HSM 호출 없이 바로 리턴해야 하며, 미스 시에만 HSM에 서명을 요청합니다. 이 캐시의 히트율이 핸드셰이크 TPS를 지배합니다. 상세 설계는 10번 소절(동적 leaf 인증서)을 그대로 사용할 수 있습니다.
- run_session — 양방향 펌프SSL_read로 복호한 평문을 즉시 원서버로 보내지 않고 memif로 호스트에 먼저 넘기는 것이 왕복 모델의 정체성입니다. 호스트에서 되돌아온 바이트만 반대편 SSL_write로 재암호화합니다. 실전 구현에서는 이 루프를 방향별 fifo + epoll로 나눠, 한 방향의 지연이 다른 방향을 막지 않도록 해야 합니다.
- memif_send/memif_recv의 conn_idmemif는 하나의 shared-memory 파이프이므로, 호스트 측이 "지금 받은 바이트가 어느 SSL 세션의 것인가"를 알아야 합니다. 따라서 memif 프레임 앞에 작은 메타 헤더(
{conn_id, dir, len})를 얹어 전송합니다. conn_id는 DPU 측에서 세션 생성 시 할당하고, 호스트의 세션 맵에 같은 값이 들어 있어야 양쪽이 동기화됩니다.
6.5.3 Host 측 골격 — Plain TCP Forwarder
호스트 쪽은 "memif에서 평문 바이트를 받아, 검사한 뒤, 다시 memif로 돌려보내는" 한 쌍의 루프만 있으면 됩니다. 여기서 OpenSSL은 필요 없고, VCL도 memif와 결합된 최소 기능만 쓰면 되므로 코드가 훨씬 단순합니다.
/* host_fwd.c — 호스트 측 plain TCP 포워더/검사기
* DPU에서 넘어온 평문에 DLP/IDS를 적용한 뒤 그대로 돌려보낸다. */
#include <vppcom.h>
#include "ssli_proto.h" // {conn_id, dir, len} 메타 헤더 정의
typedef struct session_state {
uint64_t conn_id;
char sni[256];
enum { ALLOW, BLOCK, QUARANTINE } verdict;
} session_state_t;
static session_state_t *lookup_or_create(uint64_t conn_id);
static void on_memif_frame(const ssli_hdr_t *h, const uint8_t *plain, size_t len) {
session_state_t *s = lookup_or_create(h->conn_id);
/* 1) L7 훅 — DLP/IDS/카테고리 필터링 순차 적용 */
if (!dlp_scan(plain, len, s)) { s->verdict = BLOCK; goto send_rst; }
if (!ids_match(plain, len, s)) { s->verdict = BLOCK; goto send_rst; }
if (!category_allow(s->sni)) { s->verdict = BLOCK; goto send_rst; }
/* 2) 통과 → 같은 바이트를 같은 방향으로 되돌려 DPU 재암호화 */
ssli_hdr_t out = { .conn_id = h->conn_id, .dir = h->dir, .len = len };
memif_send_hdr_and_payload(&out, plain, len);
return;
send_rst:
/* 3) 차단 → DPU에 RST 지시, 로그/SIEM 전송 */
ssli_ctrl_t rst = { .conn_id = h->conn_id, .op = SSLI_OP_RST };
memif_send_ctrl(&rst);
siem_log_block(s);
}
주목할 점 세 가지입니다.
- 호스트는 키를 모른다 — 호스트 프로세스는 평문을 보지만 CA 개인키나 leaf 개인키는 전혀 갖지 않습니다. 호스트가 탈취되어도 과거/미래 TLS 세션을 복호화할 능력이 없습니다. 이것이 "키 격리"의 구체적 의미입니다.
- 검사 루프는 단방향 판정만 한다 — 호스트는 통과/차단만 돌려주고, 실제 바이트 조작(예: 응답 헤더 재작성)은 가급적 하지 않습니다. 바이트를 수정하면 DPU 측 SSL_write가 재암호화는 해 주지만 HTTP/2·HTTP/3 프레이밍 오류를 유발하기 쉽습니다. 조작이 꼭 필요하면 별도 L7 게이트웨이를 호스트에 두고, 거기서 HTTP 파서 위에서 수정하는 것이 안전합니다.
- 제어 채널은 RST만으로 충분 — 차단은 "그 conn_id의 SSL을 끊어라"는 한 비트만 DPU에 전달하면 됩니다. 이걸 받은 DPU는 Session A/B 양쪽에
SSL_shutdown()을 호출하고, 필요하면 Alert를 클라이언트에 전달합니다.
6.5.4 한 요청의 바이트 워크 — 누가 언제 무엇을 들고 있는가
HTTPS GET 하나가 이 파이프라인을 지나는 동안 어떤 주체가 어떤 형태의 데이터를 들고 있는지를 표로 정리하면, 왕복 모델의 보안 특성이 직관적으로 보입니다.
| 단계 | 주체 | 손에 쥐는 것 | 평문? | 위험이 노출되면 |
|---|---|---|---|---|
| Rx 수신 | DPU NIC/eSwitch | TLS 암호문 프레임 | 아니오 | 네트워크 스니퍼와 동급 — 의미 없음 |
| ① SSL_accept | DPU OpenSSL server | 세션 키(A 방향) | 일부 메타 | DPU 메모리 탈취 시 해당 세션만 영향 |
| ② SSL_connect | DPU OpenSSL client | 세션 키(B 방향) | 일부 메타 | 동일 |
| ③ SignLeaf | HSM | CA 개인키 | − | HSM 경계 안에서만 존재 → 호스트 탈취와 무관 |
| ④ SSL_read → memif | DPU VPP | 복호화된 평문 | 예 | DPU 메모리에만 존재, 수명 수십 마이크로초 |
| ⑤ memif → Host | memif shared mem | 평문 | 예 | 호스트-DPU 간 PCIe/shared 메모리 — 물리적으로 같은 보드 |
| ⑥ Host 검사 | Host CPU | 평문 | 예 | 호스트 탈취 시 그 순간의 트래픽 평문 노출. 키·과거 트래픽은 안전 |
| ⑦ memif → DPU | memif shared mem | 평문 | 예 | 동일 |
| ⑧ SSL_write → Tx | DPU OpenSSL client | 재암호화 프레임 | 아니오 | 네트워크 스니퍼와 동급 |
이 표가 말하는 보안 특성은 분명합니다. 호스트 탈취의 피해 범위는 "탈취가 지속되는 동안 해당 호스트를 지나는 세션의 평문"으로 한정되며, 과거 트래픽 복호화·CA 위조·새 leaf 발급은 모두 불가능합니다. 반대로 DPU 탈취는 모든 것을 잃지만, DPU는 별도의 보드·별도의 OS·최소 공격면을 갖도록 관리되므로 호스트보다 침해 난이도가 한 단계 높습니다. 이것이 "호스트를 믿지 않는" 설계의 구체적 의미입니다. 1번 소절에서 언급한 B 모드/C 모드의 보안적 근거가 이 표로 설명됩니다.
6.5.5 왕복 모델에서 흔히 빠지는 함정
SSL_ERROR_WANT_WRITE로 부분 송신을 돌려줄 수 있습니다. 이때 다음 루프에서 같은 포인터·같은 길이로 재시도해야 하며, 버퍼를 바꾸면 TLS 레코드 경계가 깨집니다. SSL_MODE_ENABLE_PARTIAL_WRITE/SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER 플래그를 명시적으로 설정해 두세요.
conn_id → 세션 맵이 사라지면서, DPU에서 들어오는 평문을 받을 주인이 없어집니다. 이 경우 DPU는 그 구간을 holddown으로 처리하고 해당 세션들을 일괄 RST해야 합니다. 그렇지 않으면 "평문이 호스트를 거치지 않은 채 재암호화돼 나가는" 감사 위반이 발생합니다.
SSL*는 기본적으로 스레드 안전하지 않습니다. 한 세션의 SSL*는 반드시 같은 워커에서만 다루어야 하고, 세션 핸드오프가 필요하면 SSL_in_init()이 0인 상태에서만 넘겨야 합니다.
pcap trace를 걸어 보고 HTTP 헤더가 보이지 않는지(=암호화 상태) 확인하고, 동시에 호스트의 memif RX 쪽에는 HTTP 헤더가 보이는지(=평문 상태) 확인합니다. 두 조건이 동시에 만족되어야 의도한 파이프라인입니다. 이 감사 절차는 13번 소절의 pcap trace 가이드라인과 동일한 방법을 씁니다.
7. 성능 특성과 운영 포인트
| 관심사 | 호스트 x86 VPP | DPU Arm VPP | 운영 시사점 |
|---|---|---|---|
| 순수 L2/L3 포워딩 | ~100 Mpps / 서버 | ~40~60 Mpps / DPU | DPU Arm은 코어당 성능이 낮아 워커 수로 메움 — 워커 확장 효율을 미리 벤치 |
| TLS 레코드 처리(AEAD) | AES-NI로 ~20 Gbps/코어 | cryptodev로 ~100+ Gbps/DPU | 배치 크기와 doorbell 빈도가 성능을 좌우 — show crypto async status로 큐 깊이 확인 |
| 핸드셰이크 TPS | OpenSSL ECDHE ~3~5k/코어 | Arm OpenSSL ~1~2k/코어 + HSM 왕복 | 핸드셰이크가 병목이면 ECDHE 세션 재개(PSK)·TLS 1.3 0-RTT 활용 |
| 메모리 예산 | 128GB~1TB | 16~64GB | 세션 풀 크기를 보수적으로 — session { event-queue-length ... }와 heapsize 조정 |
| 장애 전파 | VPP crash = 네트워크 다운 | DPU Arm crash = 호스트는 영향 없음(eSwitch가 fallback) | fail-open/fail-close를 eSwitch 플로우 룰로 명시해야 함 — 기본값 확인 필수 |
| 키 거버넌스 | 호스트 파일시스템(Filesystem) | HSM/TPM에 격리 | PKCS#11 URI 사용, 핀 저장 위치와 수명 주기를 감사 문서에 명시 |
- fail-open 기본값 — BlueField의 eSwitch는 VPP가 죽어도 프레임을 그대로 통과시키는 기본 플로우를 가질 수 있습니다. SSL Inspection 용도라면 이 기본값이 "암호화된 트래픽이 검사 없이 새어 나가는" 상태를 의미하므로, 반드시 fail-close 룰을 명시적으로 설치하고 헬스체크와 연동해야 합니다.
- 시간 동기화 — DPU와 호스트의 시계가 어긋나면 인증서 NotBefore/NotAfter 판정이 깨집니다. DPU에서 PTP 또는 chrony를 업링크 스위치와 직접 동기화하세요.
- DPDK/OFED 버전 조합 — mlx5 PMD는 DPDK, OFED, 커널 드라이버의 세 버전이 서로 맞아야 합니다. NVIDIA 문서의 compatibility matrix를 먼저 읽고, VPP가 요구하는 DPDK 최소 버전과 교집합을 잡으세요.
- Hairpin 트래픽 경로 — 호스트 → DPU → 외부가 아니라 VM↔VM 트래픽을 DPU가 처리하게 하려면 eSwitch hairpin 룰이 필요합니다. 이 설정 없이는 "DPU에서 검사한다"는 의도가 반쯤만 실현됩니다.
- 감사 pipe의 양방향화 — 개발 편의로 호스트에서 DPU로 명령을 내리는 채널을 같은 pipe에 얹는 순간, DPU 격리의 가치가 사라집니다. 제어 평면과 데이터 평면은 반드시 분리하세요.
8. 패킷 워크 기초 — 호스트 경로 vs DPU 경로
DPU를 처음 접할 때 가장 헷갈리는 부분이 "같은 패킷이 어디를 거쳐 어디로 가는가"입니다. 먼저 "호스트 CPU에 VPP가 있는 평범한 구성"과 "DPU Arm에 VPP가 있는 구성"을 같은 패킷 하나에 대해 나란히 비교해 보는 것이 이해의 출발점입니다. 두 경로가 왜 다른지만 분명하게 잡으면, 이후의 모든 "오프로드 최적화"는 이 그림의 어느 구간을 짧게 만드느냐의 이야기로 환원됩니다.
9. TLS 1.3 핸드셰이크 단계별 트레이스
SSL Inspection on DPU에서 가장 설명하기 어려운 부분이 핸드셰이크입니다. "클라이언트↔DPU"와 "DPU↔원서버"라는 두 개의 핸드셰이크가 동시에 진행되면서 서로의 결과에 의존하기 때문입니다. 아래는 실제 VPP/OpenSSL 구현을 기준으로 TLS 1.3 한 세션이 시작부터 첫 응용 데이터까지 어떻게 흐르는지를 단계별로 풀어쓴 것입니다.
[클라이언트] [DPU VPP (SSL Inspector)] [원서버]
① ─── TCP SYN ──────────▶ ┌───────────────────────┐
│ dpdk-input │
│ ip4-input/lookup │
│ tcp-input → LISTEN │
│ downstream_tcp │
└───────────────────────┘
② ◀────── SYN/ACK ────────┤ (DPU가 SYN 대신 응답)
③ ─── ClientHello (SNI) ─▶ ┌───────────────────────┐
│ tls-rx 노드 │
│ ClientHello 파싱 │
│ → SNI="api.x.com" │
│ → ALPN="h2" │
└─────────┬─────────────┘
│
│ (a) 업스트림 해석
│ dns 조회 또는 설정된 upstream policy
│ (b) 업스트림 TCP 접속 시작 (비동기)
▼
┌───────────────────────┐
│ upstream_tcp (SYN) ──────────────────▶ ④
│ (DPU 소스 IP로) │
└───────────────────────┘
│ ⑤ ◀── SYN/ACK
│
┌───────────────────────┐
│ upstream_tls 시작 │ ⑥ ────── ClientHello (진짜 SNI) ──▶
│ (OpenSSL ctx A) │ ⑦ ◀── ServerHello + Cert + Fin ──
│ 서버 cert 검증 │
│ (CT / pinning) │
└─────────┬─────────────┘
│
│ (c) 서버 cert OK → 내부 CA로
│ "api.x.com"용 임시 leaf cert 서명
│ (HSM의 PKCS#11 C_Sign)
▼
┌───────────────────────┐
│ downstream_tls 응답 │
│ (OpenSSL ctx B) │
│ ServerHello+임시cert│
└─────────┬─────────────┘
⑧ ◀── ServerHello+Cert ───┤
⑨ ───── Client Finished ─▶ ┌───────────────────────┐
│ 양쪽 모두 Finished │
│ → application key │
│ install (ctx A,B) │
│ → 비동기 crypto 엔진 │
│ 에 세션 등록 │
└───────────────────────┘
⑩ ─── 첫 Application Data ▶ (downstream record)
┌───────────────────────┐
│ crypto-dispatch │
│ → mlx5_crypto decrypt│
│ → 평문 vlib 버퍼 │
│ → http-parse → 정책 │
│ → crypto-dispatch │
│ → mlx5_crypto encrypt│
│ → upstream record │────── Application Data ──▶ ⑪
단계별 설명
- ① TCP 3-way handshake는 DPU가 "대신" 받는다투명 프록시 모드에서 DPU는 원래 서버 IP를 가진 척 클라이언트의 SYN을 받아 SYN/ACK로 응답합니다. 이 delayed binding 덕분에 ClientHello까지 받아 본 뒤에야 실제 업스트림 연결 여부를 결정할 수 있습니다. VPP 세션 계층의
TRANSPORT_TX_TYPE_DGRAM/STREAM구분과 app listener 설정이 이 동작을 지원합니다. - ③ SNI 파싱 후의 분기ClientHello에서 뽑아낸 SNI는 세 가지 갈림길을 만듭니다 — (a) 정책상 bypass 대상(예: 금융), (b) 차단, (c) inspection 대상. inspection 대상일 때만 업스트림 핸드셰이크가 시작됩니다. SNI 파싱은 상태를 거의 갖지 않는 한 번의 스윕이므로 어떤 정책 결정보다도 먼저 실행되도록 노드 순서를 짜는 것이 중요합니다.
- ⑥~⑦ 업스트림 먼저이것이 핵심입니다 — DPU는 클라이언트에게 ServerHello를 돌려주기 전에 먼저 업스트림과 핸드셰이크를 합니다. 이유는 두 가지입니다. 첫째, 원서버 인증서 체인을 검증하지 않고 클라이언트에게 가짜 cert를 제시하는 것은 MitM 공격과 구분되지 않습니다. 둘째, ALPN/cipher 협상 결과를 클라이언트 측 핸드셰이크에 반영하려면 업스트림 결정이 먼저 나와야 합니다. 이 순서를 뒤집으면 "서버는 http/1.1인데 클라이언트에겐 h2로 약속"하는 버그가 발생합니다.
- ⑧ 임시 leaf cert 즉석 서명원서버 cert의 SAN과 만료 같은 속성을 복사한 뒤, 개인키만 DPU가 소유한 별도 키로 바꿔서 내부 CA로 서명합니다. 이 연산이 전체 핸드셰이크 중 가장 비쌉니다 — 공개키 서명 1회 + HSM 왕복 1회. Cert cache를 두는 이유(다음 절)가 여기 있습니다.
- ⑩~⑪ 애플리케이션 데이터는 가속기로핸드셰이크가 끝나고 application traffic secret이 파생되면, VPP는 두 세션의 AEAD 키를 모두 crypto async 엔진(cryptodev)에 세션으로 등록합니다. 이 순간부터 레코드는 Arm 코어를 거치지 않고 cryptodev 배치로 흘러가며, Arm은 "정책 매칭과 복호화 결과의 라우팅(Routing)"만 담당합니다. 일반적으로 이 시점 이후 CPU 사용률이 급격히 떨어지는 것을 대시보드에서 볼 수 있습니다 — 경계 지점은 보통 '첫 application record 이후 1~2 RTT'입니다.
10. 동적 leaf 인증서 생성과 cert cache
SSL Inspection의 숨은 비용은 핸드셰이크마다 cert를 새로 서명하는 것입니다. TLS 1.3 핸드셰이크 한 번당 공개키 서명 한 번을 HSM에 요청하면 HSM이 곧 병목이 됩니다. 이를 피하기 위해 모든 성숙한 SSL Inspection 구현은 (SNI, 업스트림 cert fingerprint) → leaf cert의 캐시를 둡니다. VPP에 플러그인을 직접 올릴 때도 이 구조가 그대로 필요합니다.
/* plugins/ssli/ssli_cert_cache.h — 개념 단순화 버전 */
typedef struct {
u8 sni[256]; /* 클라이언트가 보낸 SNI */
u8 upstream_spki[32]; /* 업스트림 서버 공개키 SHA-256 */
f64 not_after; /* 캐시 만료 (업스트림 cert와 동기) */
X509 *leaf; /* 서명 완료된 DER */
EVP_PKEY *leaf_key; /* leaf에 대응되는 DPU 소유 개인키 */
} ssli_cert_entry_t;
ssli_cert_entry_t *
ssli_cert_lookup_or_mint (const char *sni,
X509 *upstream_cert,
X509 *ca, EVP_PKEY *ca_key_pkcs11)
{
u8 spki_hash[32];
X509_pubkey_digest(upstream_cert, EVP_sha256(), spki_hash, NULL);
/* 1) (sni, spki) 로 해시 조회 — hit이면 공개키 서명 0회 */
ssli_cert_entry_t *e = hash_find(ssli_cc, sni, spki_hash);
if (e && e->not_after > now_secs())
return e;
/* 2) miss → 새 leaf를 만든다. 원서버 cert의 SAN/CN/NotAfter를 복제 */
e = pool_get_zero(ssli_cc->pool);
e->leaf = X509_new();
X509_set_subject_name(e->leaf, X509_get_subject_name(upstream_cert));
X509_add_ext(e->leaf,
X509v3_copy_san(upstream_cert), -1);
X509_set_notAfter(e->leaf, X509_get_notAfter(upstream_cert));
X509_set_issuer_name(e->leaf, X509_get_subject_name(ca));
/* 3) leaf 전용 키는 DPU가 자체 생성 — 업스트림 서버 키가 아님을 보장 */
e->leaf_key = EVP_PKEY_new();
EVP_PKEY_keygen(ctx, &e->leaf_key); /* ECDSA P-256 */
X509_set_pubkey(e->leaf, e->leaf_key);
/* 4) 서명만 HSM에 위임 — ca_key_pkcs11은 pkcs11 engine을 통한 referencing key */
X509_sign(e->leaf, ca_key_pkcs11, EVP_sha256());
memcpy(e->sni, sni, strlen(sni));
memcpy(e->upstream_spki, spki_hash, 32);
e->not_after = X509_get_notAfter_double(upstream_cert);
hash_set(ssli_cc, sni, spki_hash, e);
return e;
}
코드 설명
- 1) 캐시 키 = (SNI, upstream SPKI)SNI 하나만으로 키를 잡으면 "같은 도메인이지만 원서버 cert가 바뀐 경우"를 놓쳐서, 클라이언트가 사실상 낡은 cert로 계속 속는 사태가 벌어집니다. 업스트림 cert의 SubjectPublicKeyInfo 해시를 함께 키에 넣으면, 원서버가 cert를 갱신하는 순간 캐시도 자연스럽게 갱신 경로를 타게 됩니다. 이 사소한 설계가 감사 추적성을 지킵니다.
- 2) 원서버 cert 속성 복제SAN(Subject Alternative Name) 목록, CN, NotAfter를 그대로 가져오는 이유는 클라이언트가 기대하는 hostname·유효기간이 달라지면 브라우저가 경고를 띄우기 때문입니다. 반대로 절대 복사하지 말아야 할 것이 있습니다 — 확장 중 OCSP URL, AIA, 공개키 그 자체. 이 셋을 복사하면 클라이언트가 OCSP를 원서버에 요청하면서 MitM이 들통납니다.
- 3) leaf 전용 키는 DPU가 생성임시 leaf의 개인키는 DPU Arm의 메모리 안에서만 존재합니다. HSM에 넣지 않는 이유는 핸드셰이크마다 HSM 왕복이 발생해 성능이 무너지기 때문입니다. 대신 leaf 개인키는 메모리에만 존재하고 디스크에 절대 쓰이지 않으며, 세션이 끝나면 폐기되어야 합니다. HSM이 지키는 것은 "CA 개인키가 유출되지 않는 것"이고, leaf 개인키의 노출은 한 세션분의 위험으로 한정됩니다.
- 4) 서명만 HSM에 위임OpenSSL의 PKCS#11 엔진은
EVP_PKEY에 "실제 키가 아닌 HSM 참조"를 심어 줍니다.X509_sign()은 내부적으로 private key 오퍼레이션을 그 참조로 디스패치(Dispatch)하므로 PKCS#11C_Sign으로 내려가고, CA 개인키는 호스트/DPU 메모리에 단 한 번도 등장하지 않습니다. 이 한 줄을EVP_PKEY_sign으로 직접 부르면 더 통제력이 좋지만, 코드량이 크게 늘어납니다.
11. 세션 계층 배선 — VPP 코드 관점의 연결점
실제 VPP에서 SSL Inspection 플러그인을 붙이는 위치는 세션 계층(session layer)입니다. src/vnet/session이 제공하는 app listener/segment manager 인프라를 그대로 사용하면, TCP/TLS 프로토콜 기계는 건드리지 않고 "복호화된 바이트스트림을 받는 애플리케이션" 역할로 붙을 수 있습니다.
/* plugins/ssli/ssli_session.c — 핵심 콜백만 */
static session_cb_vft_t ssli_session_cb = {
.session_accept_callback = ssli_accept_cb, /* 클라이언트 세션 accept */
.session_connected_callback = ssli_connected_cb, /* 업스트림 connect 완료 */
.builtin_app_rx_callback = ssli_rx_cb, /* 복호화된 바이트 도착 */
.session_disconnect_callback= ssli_close_cb,
.session_reset_callback = ssli_reset_cb,
};
/* 1) ClientHello에서 SNI가 뽑히는 즉시 업스트림 connect를 건다 */
static int
ssli_accept_cb (session_t *s)
{
ssli_flow_t *f = ssli_flow_new();
f->down_session = session_handle(s);
/* SNI는 TLS plugin이 미리 opaque에 저장해 둠 */
u8 *sni = tls_ctx_get_sni(s);
strncpy(f->sni, (char*)sni, sizeof(f->sni));
/* 정책 평가 — bypass / block / inspect */
if (ssli_policy_bypass(f->sni))
return ssli_handoff_to_splice(s);
/* inspect: 업스트림 비동기 connect 발행 */
vnet_connect_args_t a = {
.app_index = ssli_main.app_index,
.sep_ext = { .is_ip4 = 1, .ip = ssli_resolve(f->sni),
.port = 443, .transport_proto = TRANSPORT_PROTO_TLS },
.api_context= f->id, /* 콜백에 돌려받음 */
};
return vnet_connect(&a);
}
/* 2) 업스트림 TLS가 완성되면 두 세션을 하나의 flow로 바인딩 */
static int
ssli_connected_cb (u32 app_wrk, u32 api_ctx, session_t *s, u32 err)
{
ssli_flow_t *f = ssli_flow_get(api_ctx);
if (err) return ssli_block(f, err);
f->up_session = session_handle(s);
ssli_mint_leaf_cert(f); /* 앞 절의 cert cache 호출 */
ssli_start_downstream_tls(f); /* 이제서야 클라이언트 Server* */
return 0;
}
/* 3) 복호화된 바이트가 들어오면 정책 매칭 후 반대 세션으로 fwd */
static int
ssli_rx_cb (session_t *s)
{
ssli_flow_t *f = ssli_session_to_flow(s);
svm_fifo_t *rx = s->rx_fifo;
u32 n = svm_fifo_max_dequeue_cons(rx);
while (n)
{
u8 chunk[4096];
u32 got = svm_fifo_peek(rx, 0, 4096, chunk);
/* DPI 엔진으로 평가 — 매치 시 로그/차단 */
ssli_dpi_scan(f, chunk, got);
/* 반대쪽 세션의 TX fifo로 전달 — 복사 회피 가능 시 splice */
session_t *peer = session_get_from_handle(
(s == ssli_down(f)) ? f->up_session : f->down_session);
app_worker_enqueue(peer, chunk, got);
svm_fifo_dequeue_drop(rx, got);
n -= got;
}
return 0;
}
코드 설명
- accept_cb 시점에 업스트림 connect전통적 TCP 프록시는 "클라이언트 accept → 업스트림 connect"만 고려하지만, SSL Inspection에서는 SNI가 보여야 의미 있는 결정을 내릴 수 있습니다. 그래서 TLS plugin이 ClientHello를 파싱해
tls_ctx_t->sni에 저장한 직후에 세션 accept가 일어나도록 순서가 조정되어 있습니다. 이 순서를 유지해 주는 것이 VPP host stack을 쓰는 가장 큰 이점 중 하나입니다. - api_context로 두 세션 묶기업스트림 connect는 비동기이므로 완료 시점에 "이 connect가 어느 클라이언트 흐름의 것인지"를 다시 찾아내야 합니다.
api_context에ssli_flow_t의 ID를 넣어 두고connected_cb에서 되돌려받는 이 패턴이 VPP 세션 API의 표준 사용법입니다. - rx_cb에서 peer로 enqueue복호화된 평문 바이트는 이미 AEAD 가속기가 풀어 놓은 결과입니다. rx_cb는 그 바이트에 DPI/정책만 적용하고 반대 세션의 TX fifo에 그대로 밀어 넣습니다. 이 구간에서 VPP는 아무 복호화도 직접 수행하지 않으며, 메모리 복사도 fifo peek→enqueue 한 번뿐입니다. 리소스 사용량이 여기서 크게 갈리므로 "불필요한 복사를 한 번이라도 추가하면" 전체 처리율이 수 Gbps씩 날아갑니다.
- handoff_to_splice (bypass)bypass로 결정된 흐름은 TLS context를 만들지 않고 그대로 TCP splice로 돌립니다. 이렇게 하면 핸드셰이크 리소스와 가속기 큐 슬롯을 아끼고, 금융/헬스케어 같은 민감 트래픽이 의도치 않게 복호화되는 사고도 구조적으로 차단됩니다. 정책 우회의 기본 형태로 반드시 제공해야 할 경로입니다.
12. 그래프 노드 워크스루 — 패킷이 실제로 밟는 노드들
위의 코드와 핸드셰이크를 "VPP 노드 그래프" 기준으로 한 번 더 보는 것이 디버깅에 결정적입니다. show trace에서 실제로 나타나는 노드 이름들을 기준으로, 한 흐름이 방문하는 노드 열을 정리했습니다. 각 노드에서 어떤 상태가 바뀌는지 메모가 붙어 있습니다.
| # | 노드 | 하는 일 | 상태 변화 |
|---|---|---|---|
| 1 | dpdk-input | DPU NIC 큐에서 mbuf 수신 → vlib 버퍼로 변환 | b->current_data = L2 시작 |
| 2 | ethernet-input | EtherType 매칭 → ip4-input로 next | VLAN pop 처리 |
| 3 | ip4-input | 헤더/체크섬(Checksum) 검증 | vnet_buffer(b).ip.fib_index 지정 |
| 4 | ip4-lookup | LPM → load-balance → receive DPO (DPU가 종단) | adj_index에 receive 인덱스 |
| 5 | ip4-local | uRPF, L4 프로토콜 분기 | TCP 플래그 확인 |
| 6 | tcp4-input-nolookup | 5-tuple 세션 조회 → tcp_connection_t | LISTEN → SYN_RCVD |
| 7 | session-queue | accept 이벤트를 app worker로 전달 | session_t 생성, app 콜백 enqueue |
| 8 | tls-rx | ClientHello 파싱, SNI 추출 | tls_ctx_t->sni 채움 |
| 9 | ssli-accept (플러그인) | 정책 평가 → 업스트림 vnet_connect | ssli_flow_t 할당 |
| 10 | tls-async-process | 양측 핸드셰이크 상태 머신 진행 | handshake complete 이벤트 |
| 11 | crypto-dispatch | AEAD op을 cryptodev 큐에 enqueue | in-flight 프레임 생성 |
| 12 | crypto-dequeue | 가속기 완료 결과 수거 → 평문 버퍼 반환 | session rx fifo에 write |
| 13 | ssli-rx (플러그인) | DPI/정책 매칭, 반대 세션으로 enqueue | 감사 로그 작성 |
| 14 | tls-tx + crypto-dispatch | 업스트림 방향 AEAD 암호화 | 업스트림 session의 TX fifo → TCP |
| 15 | tcp4-output → ip4-rewrite → interface-output → dpdk-output | 전송 큐로 내려보내기 | DPU NIC의 TX 디스크립터 |
이 표는 "어디서 무엇이 돌아가는가"에 대한 직접적인 답이기도 합니다. 11·12·14번만 DPU 가속기 영역이고 나머지는 모두 Arm 코어 노드입니다. 따라서 성능 병목은 둘 중 하나에서만 나타납니다 — Arm 그래프가 느리거나(대부분 배치가 깨진 경우), 가속기 큐가 꽉 찬 경우(enqueue/dequeue가 CPU를 잡아먹는 경우). 이 이분법이 튜닝 작업을 단순하게 만듭니다.
13. 관측과 디버깅 — 어디를 어떻게 보는가
# 1) 세션 상태 — 양쪽 세션이 짝지어져 있는지 확인
dpu# show ssli sessions # 플러그인 CLI
flow-id sni down-state up-state policy leaf-cache
42 api.example.com ESTABLISHED ESTABLISHED inspect HIT
43 cdn.example.net ESTABLISHED ESTABLISHED bypass (splice)
# 2) async crypto 큐 — enqueue/dequeue 잔량, 오류 카운터
dpu# show crypto async status
thread 0 dispatch-node active
frames in-flight: 48
enqueued: 120394 dequeued: 120346
errors: 0
# 3) cryptodev 디바이스 레벨 통계
dpu# show dpdk cryptodev
cryptodev 0 mlx5_crypto
qp 0 enqueue 482371 dequeue 482301 err 0 avg_burst 31
# 4) 실제 패킷 추적 — 노드 열로 확인
dpu# clear trace
dpu# trace add dpdk-input 200
dpu# # 테스트 클라이언트에서 curl https://api.example.com
dpu# show trace
Packet 1
01: dpdk-input ...
02: ethernet-input
03: ip4-input
04: ip4-lookup
05: ip4-local
06: tcp4-input-nolookup
07: session-queue
08: tls-rx sni="api.example.com"
09: ssli-accept flow=42 policy=inspect
...
# 5) 감사 채널 구독 (호스트 측 분석기)
host$ socat UNIX-CONNECT:/run/vpp/ssli-audit.sock -
{"ts":..., "sni":"api.example.com", "host":"api.example.com",
"url":"/v1/users", "verdict":"allow", "bytes_down":1843, "bytes_up":206}
# 6) PCAP으로 잘라 보기 — "평문이 DPU 경계를 넘지 않았는지" 검증
dpu# pcap trace rx tx max 1000 intfc uplink0 file /tmp/uplink.pcap
↑ uplink0은 외부 방향 — 여기에 평문이 나타나면 설정 버그
운영 관점 해설
- show ssli sessionsdown-state/up-state가 비대칭으로 멈춰 있는 것이 가장 자주 보는 병리 현상입니다. 예: down=ESTABLISHED인데 up=CONNECTING 상태에서 멈춰 있으면, 업스트림 DNS 해석이나 방화벽(Firewall)에 의해 연결이 막혀 있다는 강력한 단서입니다. leaf-cache 컬럼이
HIT인지MINT인지로 HSM 부하가 즉시 판별됩니다. - show crypto async statusin-flight 프레임 수가 점점 쌓이기만 하고 줄지 않으면 dequeue 쪽이 뒤처지고 있다는 뜻입니다. 보통은
crypto-dequeue가 CPU를 잡지 못하고 있다는 신호이며,corelist-workers에 전용 dequeue 코어를 분리하거나 polling 빈도를 올려야 합니다. - show dpdk cryptodevavg_burst가 1~2에 머물면 배치가 깨진 상태로, 가속기의 본래 성능을 전혀 못 쓰고 있는 징후입니다. 배치가 깨지는 가장 흔한 원인은 "세션 수는 많은데 각 세션이 동기적으로 작은 레코드만 보내는" 패턴 — 이 경우 프레임 가용 기간(
async-frame-timeout)을 약간 늘려서 가속기로 보내기 전에 더 모이도록 해야 합니다. - pcap trace로 평문 검증SSL Inspection 배포에서 반드시 수행해야 할 감사 테스트입니다. 외부 업링크(uplink0)에 pcap을 걸고 기지 트래픽을 흘려 보낸 뒤, pcap에서 Content-Type 같은 HTTP 헤더가 발견되면 설정이 잘못된 것입니다(업링크는 재암호화 이후여야 함). 반대로 감사 pipe(memif) 쪽에는 메타데이터만 나타나야 합니다. 이 "우연한 평문 유출"을 잡는 것이 가장 값싼 보안 통제입니다.
14. 실습 랩 스켈레톤 — 최소 재현 환경
실제로 한 번 재현해 보지 않으면 이 절의 내용이 머리에 남지 않습니다. 아래는 BlueField-2 또는 x86에서 소프트웨어 cryptodev(crypto_aesni_mb 또는 crypto_openssl)로 대체해 돌릴 수 있는 최소 랩 스켈레톤입니다. 실제 DPU가 없어도 동일한 코드 경로를 밟을 수 있기 때문에 학습용으로 먼저 이 구성을 돌려 보시기 바랍니다.
# 토폴로지
#
# [ client ] ──▶ veth0 ──▶ (VPP) ──▶ veth1 ──▶ [ origin nginx ]
# │
# └─▶ memif(ssli-audit.sock) ──▶ [analyzer]
#
# 가짜 DPU 대신 호스트의 리눅스 namespace 두 개를 사용
# 0) namespace와 veth
$ sudo ip netns add cli
$ sudo ip netns add srv
$ sudo ip link add veth-cli type veth peer name veth-vpp-c
$ sudo ip link add veth-srv type veth peer name veth-vpp-s
$ sudo ip link set veth-cli netns cli
$ sudo ip link set veth-srv netns srv
$ sudo ip -n cli addr add 10.0.0.2/24 dev veth-cli
$ sudo ip -n srv addr add 10.0.1.2/24 dev veth-srv
$ sudo ip -n cli link set veth-cli up
$ sudo ip -n srv link set veth-srv up
# 1) VPP 구동 (호스트에서, 단 cryptodev는 SW)
$ sudo vpp unix { interactive } \
dpdk { no-pci vdev crypto_openssl0 } \
plugins { plugin tlsopenssl_plugin.so { enable } }
# 2) 인터페이스 생성과 주소
vpp# create host-interface name veth-vpp-c
vpp# create host-interface name veth-vpp-s
vpp# set interface ip address host-veth-vpp-c 10.0.0.1/24
vpp# set interface ip address host-veth-vpp-s 10.0.1.1/24
vpp# set interface state host-veth-vpp-c up
vpp# set interface state host-veth-vpp-s up
# 3) 테스트 CA 준비 (실습 전용, 절대 운영 환경에 쓰지 마세요)
$ openssl ecparam -genkey -name prime256v1 -out /tmp/ca.key
$ openssl req -x509 -new -key /tmp/ca.key -subj '/CN=SSLI Lab CA' \
-days 30 -out /tmp/ca.pem
# 4) SSL Inspection 활성화 (플러그인이 있는 경우)
vpp# tls ca-cert file /tmp/ca.pem
vpp# tls ca-key file /tmp/ca.key
vpp# ssli listener add 10.0.0.1:443 upstream 10.0.1.2:443 dynamic-cert on
vpp# set crypto async handler aes-128-gcm openssl
vpp# set crypto async dispatch on
# 5) origin에 nginx와 테스트 컨텐츠
$ sudo ip netns exec srv nginx -c /tmp/nginx.conf # TLS 서버
# 6) 클라이언트 테스트
$ sudo ip netns exec cli curl -v --cacert /tmp/ca.pem https://10.0.0.1/
↑ 브라우저 신뢰 체인 검증, CA 주입이 필요한 케이스 확인
# 7) 검증 체크리스트
vpp# show ssli sessions # down/up 둘 다 ESTABLISHED 여야 함
vpp# show trace # ssli-accept, ssli-rx 노드가 보여야 함
vpp# pcap trace rx tx intfc host-veth-vpp-s file /tmp/upstream.pcap
분석: upstream.pcap에는 평문 HTTP 헤더가 없어야 함 (재암호화 검증)
/tmp/ca.* 파일도 함께 삭제하시기 바랍니다.
15. 한 장 요약
DPU에서의 VPP는 "Arm 코어 + 도메인 가속기 + 자체 OS"라는 세 축을 기반으로, 호스트 경험의 대부분을 aarch64로 옮기는 작업입니다. SSL Inspection 시나리오에서는 ① AEAD를 cryptodev로 오프로드하고, ② 핸드셰이크와 정책 매칭은 Arm VPP가 처리하며, ③ CA 개인키는 HSM/TPM에 격리하고, ④ 복호화된 평문은 DPU 경계 밖으로 나가지 않도록 감사 채널을 단방향·메타데이터 한정으로 두는 것이 기본 공식입니다. 이 공식만 지키면 DPU가 제공하는 "호스트 독립적 보안 경계"라는 이점을 그대로 얻을 수 있습니다.
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하시기 바랍니다.
오픈소스 코드 인용 고지
| 프로젝트 | 저작권자 | 라이선스 | 공식 저장소 |
|---|---|---|---|
| VPP (Vector Packet Processing) | FD.io contributors | Apache License 2.0 | github.com/FDio/vpp |
| DPDK (Data Plane Development Kit) | DPDK contributors | BSD 3-Clause License | github.com/DPDK/dpdk |
| OpenSSL | OpenSSL contributors | Apache License 2.0 | github.com/openssl/openssl |
코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.