TPROXY 완전 실습 랩

TCP·UDP·nftables·netns·C epoll 투명 프록시·eBPF TC hook·Squid/Envoy/HAProxy 연동까지 8개 Lab으로 구성된 단계별 실습 가이드입니다. 모든 코드와 명령은 직접 복사하여 실행할 수 있습니다.

전제 조건: 이 실습을 진행하기 전에 다음 문서를 먼저 읽으세요. 실습 호스트는 root 권한이 필요하며, 커널 4.18 이상(eBPF Lab은 5.15 이상) 권장합니다.
실습 목표: 각 Lab은 독립적으로 실행 가능합니다. Lab 1(기본 TCP)부터 순서대로 진행하거나, 관심 있는 Lab을 바로 실행할 수 있습니다. 각 Lab 끝에 정리(clean-up) 명령이 포함되어 있어 시스템 상태를 원래대로 복원할 수 있습니다.

핵심 요약

  • TPROXY 핵심 3요소 — mangle/PREROUTING TPROXY 타겟 + fwmark 기반 정책 라우팅 + IP_TRANSPARENT 소켓 옵션
  • 원본 목적지 획득 — TCP: getsockname(), UDP: IP_RECVORIGDSTADDR + recvmsg()
  • upstream 연결 — IP_TRANSPARENT 소켓으로 클라이언트 원본 IP에 bind() 후 실제 서버에 connect()
  • eBPF TPROXY — TC ingress hook에서 bpf_sk_redirect_map() 또는 bpf_sk_assign()으로 소켓 리다이렉트
  • netns 격리 — veth 쌍으로 클라이언트 네임스페이스와 TPROXY 호스트를 분리하면 프로덕션 환경 모사 가능
  • 실제 도구 — Squid(tproxy 모드), Envoy(original_dst 클러스터), HAProxy(tcp-request connection expect-proxy) 모두 IP_TRANSPARENT 소켓 사용

단계별 이해

  1. 환경 구성 (Lab-env)
    실습 네트워크 토폴로지 파악, 패키지 설치, 커널 옵션 확인.
  2. 기본 TCP TPROXY (Lab 1)
    iptables mangle 규칙 + 정책 라우팅 + Python TCP 프록시로 TPROXY 원리를 체험.
  3. UDP/DNS TPROXY (Lab 2)
    UDP는 연결 지향이 아니므로 NOTRACK + IP_RECVORIGDSTADDR로 원본 목적지를 추출하는 방법 학습.
  4. nftables 재구현 (Lab 3)
    Lab 1·2를 nftables 문법으로 재작성하여 최신 설정 방식 습득.
  5. netns 격리 환경 (Lab 4)
    veth 쌍과 ip netns로 실제 라우터/클라이언트 분리 환경 구성.
  6. C epoll 투명 프록시 (Lab 5)
    약 250줄의 완성된 C 프로그램으로 고성능 비동기 투명 프록시 구현 및 빌드·실행.
  7. eBPF TC hook (Lab 6)
    BPF C 코드 + bpftool로 커널 TC hook에서 소켓 리다이렉트 실습.
  8. 실제 도구 연동 (Lab 7)
    Squid/Envoy/HAProxy 완성 설정 파일로 실무 TPROXY 배포 실습.
  9. 트러블슈팅 실습 (Lab 8)
    4가지 고의 실패 시나리오로 진단 명령과 원인 분석 훈련.
  10. io_uring 투명 프록시 (Lab 9)
    io_uring SQE/CQE 기반 비동기 투명 프록시 구현으로 epoll 대비 성능 향상 체험.
  11. TLS SNI 기반 라우팅 (Lab 10)
    TLS ClientHello에서 SNI를 추출하여 도메인별로 upstream을 분기하는 투명 프록시 구현.
  12. TPROXY 성능 벤치마크 (Lab 11)
    wrk·iperf3·perf로 직접 연결 대비 TPROXY 오버헤드를 정량적으로 측정하고 커널 핫스팟 분석.
  13. 다중 프로토콜 TPROXY (Lab 12)
    HTTP·DNS·QUIC를 단일 호스트에서 처리하는 nftables 규칙과 Python 디스패처 구현.
  14. TPROXY 고가용성 (Lab 13)
    keepalived VRRP + conntrackd 세션 동기화로 VIP 페일오버 TPROXY 이중화 구성.

실습 환경 구성

실습 네트워크 토폴로지

클라이언트 VM (또는 netns) eth0 192.168.100.2/24 패킷 라우터 / TPROXY 호스트 eth0 (입력) 192.168.100.1 mangle/PREROUTING TPROXY --on-port 8080 --tproxy-mark 0x1 정책 라우팅 fwmark 0x1 → table 100 → lo 프록시 소켓 :8080 (IP_TRANSPARENT) eth1 (출력) 10.0.0.1 upstream 대상 서버 eth0 10.0.0.2/24 :80 / :53 프록시: getsockname() → 원본 목적지(10.0.0.2:80) 획득 후 upstream 연결 클라이언트 TPROXY 호스트 대상 서버

패키지 설치 및 커널 옵션 확인

# Debian/Ubuntu 계열
sudo apt-get update
sudo apt-get install -y iptables nftables iproute2 tcpdump python3 \
    gcc make clang linux-headers-$(uname -r) bpftool

# RHEL/CentOS/Fedora 계열
sudo dnf install -y iptables nftables iproute2 tcpdump python3 \
    gcc make clang kernel-devel bpftool

# 커널 모듈 확인 (TPROXY 지원 필수)
modinfo xt_TPROXY 2>/dev/null && echo "xt_TPROXY 사용 가능" || echo "모듈 없음 - 커널 재빌드 필요"

# 커널 설정 확인
grep -E "TPROXY|NF_TPROXY" /boot/config-$(uname -r)
# CONFIG_NETFILTER_XT_TARGET_TPROXY=m  (또는 y) 이 있어야 합니다

# IPv4 포워딩 활성화
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward

# 커널 버전 확인 (4.18 이상 권장)
uname -r

Lab 1: 기본 TCP TPROXY

목표: iptables mangle/PREROUTING + 정책 라우팅 + Python TCP 프록시로 기본 TPROXY 흐름을 완성합니다. 클라이언트가 10.0.0.2:80으로 보낸 패킷을 TPROXY 호스트의 프록시 소켓(:8080)이 투명하게 가로채고, 원본 목적지를 보존한 채 upstream에 재연결합니다.

1단계: iptables 규칙 설정

# mangle 테이블 PREROUTING 체인에 TPROXY 규칙 추가
sudo iptables -t mangle -A PREROUTING \
    -p tcp --dport 80 \
    -j TPROXY \
    --tproxy-mark 0x1/0x1 \
    --on-port 8080 \
    --on-ip 127.0.0.1

# 설정 확인
sudo iptables -t mangle -L PREROUTING -n -v

2단계: 정책 라우팅 설정

# fwmark 0x1 패킷은 라우팅 테이블 100번 사용
sudo ip rule add fwmark 0x1 lookup 100

# 테이블 100: 모든 패킷을 loopback으로 라우팅
sudo ip route add local 0.0.0.0/0 dev lo table 100

# 확인
ip rule list
ip route show table 100

3단계: Python TCP 투명 프록시

#!/usr/bin/env python3
# tcp_tproxy.py - 기본 TCP 투명 프록시
import socket, threading, struct, errno

LISTEN_PORT = 8080
BUFFER_SIZE = 65536

def make_listen_socket():
    """IP_TRANSPARENT 옵션으로 리슨 소켓 생성"""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    # IP_TRANSPARENT = 19 (linux/in.h)
    s.setsockopt(socket.IPPROTO_IP, 19, 1)
    s.bind(('0.0.0.0', LISTEN_PORT))
    s.listen(128)
    return s

def get_orig_dst(conn):
    """accept()된 소켓에서 getsockname()으로 원본 목적지 획득"""
    # TPROXY 환경에서는 getsockname()이 원본 목적지를 반환
    orig_dst = conn.getsockname()
    return orig_dst  # (ip, port) 튜플

def relay(src, dst):
    """두 소켓 사이 데이터 중계"""
    try:
        while True:
            data = src.recv(BUFFER_SIZE)
            if not data:
                break
            dst.sendall(data)
    except (ConnectionResetError, BrokenPipeError, OSError):
        pass
    finally:
        try: src.shutdown(socket.SHUT_RD)
        except: pass
        try: dst.shutdown(socket.SHUT_WR)
        except: pass

def handle(conn, client_addr):
    orig_ip, orig_port = get_orig_dst(conn)
    print(f"[TPROXY] {client_addr[0]}:{client_addr[1]} → {orig_ip}:{orig_port}")

    try:
        # upstream 소켓도 IP_TRANSPARENT로 클라이언트 IP에 bind 후 연결
        up = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        up.setsockopt(socket.IPPROTO_IP, 19, 1)  # IP_TRANSPARENT
        up.bind((client_addr[0], 0))  # 클라이언트 원본 IP로 bind
        up.connect((orig_ip, orig_port))

        t1 = threading.Thread(target=relay, args=(conn, up), daemon=True)
        t2 = threading.Thread(target=relay, args=(up, conn), daemon=True)
        t1.start(); t2.start()
        t1.join(); t2.join()
    except Exception as e:
        print(f"[오류] {e}")
    finally:
        try: conn.close()
        except: pass

def main():
    listen_sock = make_listen_socket()
    print(f"TCP 투명 프록시 시작: 포트 {LISTEN_PORT}")
    while True:
        conn, addr = listen_sock.accept()
        t = threading.Thread(target=handle, args=(conn, addr), daemon=True)
        t.start()

if __name__ == '__main__':
    main()

4단계: 프록시 실행 및 검증

# root 권한으로 프록시 실행 (IP_TRANSPARENT에 CAP_NET_ADMIN 필요)
sudo python3 tcp_tproxy.py &

# 클라이언트에서 HTTP 요청 (TPROXY 호스트를 경유)
curl http://10.0.0.2/

# 예상 프록시 출력:
# [TPROXY] 192.168.100.2:54321 → 10.0.0.2:80

# 패킷 캡처로 동작 확인
sudo tcpdump -i any -n 'port 80 or port 8080' -l &

# 소켓 상태 확인
ss -tnp | grep 8080

정리 (clean-up)

sudo pkill -f tcp_tproxy.py
sudo iptables -t mangle -D PREROUTING -p tcp --dport 80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080 --on-ip 127.0.0.1
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100

Lab 2: UDP/DNS TPROXY

목표: UDP는 연결 지향이 아니므로 accept()가 없습니다. IP_RECVORIGDSTADDR 소켓 옵션과 recvmsg()를 사용해 각 UDP 데이터그램의 원본 목적지를 추출합니다. DNS(UDP :53) 요청을 투명하게 가로채는 간이 DNS 프록시를 구현합니다.

1단계: NOTRACK + TPROXY 규칙

# UDP는 conntrack이 방해할 수 있으므로 NOTRACK 처리
sudo iptables -t raw -A PREROUTING -p udp --dport 53 -j NOTRACK

# UDP TPROXY 규칙
sudo iptables -t mangle -A PREROUTING \
    -p udp --dport 53 \
    -j TPROXY \
    --tproxy-mark 0x2/0x2 \
    --on-port 5353

# 정책 라우팅 (fwmark 0x2 전용 테이블)
sudo ip rule add fwmark 0x2 lookup 200
sudo ip route add local 0.0.0.0/0 dev lo table 200

# 확인
sudo iptables -t mangle -L PREROUTING -n -v
sudo iptables -t raw -L PREROUTING -n -v

2단계: Python UDP/DNS 투명 프록시

#!/usr/bin/env python3
# udp_tproxy.py - UDP 투명 프록시 (DNS 포워딩 예제)
import socket, struct

LISTEN_PORT = 5353
UPSTREAM_DNS = '8.8.8.8'
UPSTREAM_PORT = 53

# 소켓 옵션 상수
IP_TRANSPARENT   = 19
IP_RECVORIGDSTADDR = 20
SOL_IP           = 0

def make_udp_socket(port):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.setsockopt(SOL_IP, IP_TRANSPARENT, 1)
    s.setsockopt(SOL_IP, IP_RECVORIGDSTADDR, 1)  # cmsg로 원본 목적지 수신
    s.bind(('0.0.0.0', port))
    return s

def get_orig_dst_udp(cmsg_list):
    """recvmsg() cmsg_list에서 원본 목적지(IP:port) 추출"""
    for cmsg_level, cmsg_type, cmsg_data in cmsg_list:
        if cmsg_level == SOL_IP and cmsg_type == IP_RECVORIGDSTADDR:
            # sockaddr_in 구조: sin_family(2) + sin_port(2) + sin_addr(4) + 패딩(8)
            family, port_n, addr_n = struct.unpack('!HH4s', cmsg_data[:8])
            ip = socket.inet_ntoa(addr_n)
            port = socket.ntohs(port_n)
            return ip, port
    return None, None

def main():
    sock = make_udp_socket(LISTEN_PORT)
    print(f"UDP 투명 프록시 시작: 포트 {LISTEN_PORT} → {UPSTREAM_DNS}:{UPSTREAM_PORT}")

    while True:
        # recvmsg()로 패킷 + cmsg(원본 목적지) 동시 수신
        data, cmsg_list, flags, client_addr = sock.recvmsg(
            4096, socket.CMSG_SPACE(24))

        orig_ip, orig_port = get_orig_dst_udp(cmsg_list)
        print(f"[UDP] {client_addr[0]}:{client_addr[1]} → {orig_ip}:{orig_port} ({len(data)} bytes)")

        # upstream DNS 서버로 포워딩
        up_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        up_sock.setsockopt(SOL_IP, IP_TRANSPARENT, 1)
        up_sock.bind((client_addr[0], 0))  # 클라이언트 IP로 bind
        up_sock.sendto(data, (UPSTREAM_DNS, UPSTREAM_PORT))

        # 응답 수신 후 클라이언트에 반환
        resp, _ = up_sock.recvfrom(4096)
        sock.sendto(resp, client_addr)
        up_sock.close()

if __name__ == '__main__':
    main()

3단계: 실행 및 검증

sudo python3 udp_tproxy.py &

# dig로 DNS 쿼리 테스트 (클라이언트에서 실행)
dig @10.0.0.2 example.com

# 예상 출력 (프록시 로그):
# [UDP] 192.168.100.2:49152 → 10.0.0.2:53 (46 bytes)

# UDP 패킷 캡처로 확인
sudo tcpdump -i any -n 'udp port 53 or udp port 5353' -l

정리 (clean-up)

sudo pkill -f udp_tproxy.py
sudo iptables -t raw -D PREROUTING -p udp --dport 53 -j NOTRACK
sudo iptables -t mangle -D PREROUTING -p udp --dport 53 \
    -j TPROXY --tproxy-mark 0x2/0x2 --on-port 5353
sudo ip rule del fwmark 0x2 lookup 200
sudo ip route del local 0.0.0.0/0 dev lo table 200

Lab 3: nftables 버전

목표: Lab 1·2의 iptables 규칙을 nftables 문법으로 재작성합니다. nftables는 단일 규칙 셋으로 TCP/UDP를 동시에 처리하며 더 간결한 문법을 제공합니다.

1단계: 기존 iptables 규칙 정리

# Lab 1·2 규칙이 남아 있으면 먼저 제거
sudo iptables -t mangle -F PREROUTING
sudo iptables -t raw -F PREROUTING

2단계: nftables 규칙 파일 작성

# /etc/nftables-tproxy.conf
cat <<'EOF' | sudo tee /etc/nftables-tproxy.conf
table ip tproxy_table {
    chain prerouting {
        type filter hook prerouting priority mangle; policy accept;

        # TCP 80 → TPROXY 포트 8080, fwmark 0x1
        tcp dport 80 tproxy to :8080 meta mark set 0x1

        # UDP 53 → TPROXY 포트 5353, fwmark 0x2
        udp dport 53 tproxy to :5353 meta mark set 0x2
    }
}
EOF

# nftables 규칙 적용
sudo nft -f /etc/nftables-tproxy.conf

# 확인
sudo nft list table ip tproxy_table

3단계: 정책 라우팅 (nftables 공통)

# nftables에서도 정책 라우팅은 ip rule/ip route로 동일하게 설정
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

sudo ip rule add fwmark 0x2 lookup 200
sudo ip route add local 0.0.0.0/0 dev lo table 200

4단계: IPv6 TPROXY (nftables 확장)

cat <<'EOF' | sudo tee /etc/nftables-tproxy6.conf
table ip6 tproxy6_table {
    chain prerouting {
        type filter hook prerouting priority mangle; policy accept;

        # IPv6 TCP 80 → TPROXY 포트 8080
        tcp dport 80 tproxy to :8080 meta mark set 0x1

        # IPv6 UDP 53 → TPROXY 포트 5353
        udp dport 53 tproxy to :5353 meta mark set 0x2
    }
}
EOF

sudo nft -f /etc/nftables-tproxy6.conf

# IPv6 정책 라우팅
sudo ip -6 rule add fwmark 0x1 lookup 100
sudo ip -6 route add local ::/0 dev lo table 100

검증

sudo nft list ruleset
curl http://10.0.0.2/        # TCP TPROXY 확인
dig @10.0.0.2 example.com    # UDP TPROXY 확인

정리 (clean-up)

sudo nft delete table ip tproxy_table
sudo nft delete table ip6 tproxy6_table
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
sudo ip rule del fwmark 0x2 lookup 200
sudo ip route del local 0.0.0.0/0 dev lo table 200
sudo ip -6 rule del fwmark 0x1 lookup 100
sudo ip -6 route del local ::/0 dev lo table 100

Lab 4: 네트워크 네임스페이스 격리

목표: veth 쌍과 ip netns를 사용하여 단일 호스트에서 클라이언트/라우터/서버 역할을 분리합니다. 프로덕션과 유사한 멀티-호스트 환경을 시뮬레이션합니다.

1단계: 네임스페이스 및 veth 설정

# 네임스페이스 생성
sudo ip netns add client
sudo ip netns add server

# veth 쌍 생성: 호스트(tproxy-host) ↔ 클라이언트(veth-client)
sudo ip link add veth-host type veth peer name veth-client
sudo ip link set veth-client netns client

# veth 쌍 생성: 호스트(veth-srv-host) ↔ 서버(veth-srv)
sudo ip link add veth-srv-host type veth peer name veth-srv
sudo ip link set veth-srv netns server

# 호스트 인터페이스 IP 설정
sudo ip addr add 192.168.100.1/24 dev veth-host
sudo ip link set veth-host up

sudo ip addr add 10.0.0.1/24 dev veth-srv-host
sudo ip link set veth-srv-host up

# 클라이언트 네임스페이스 설정
sudo ip netns exec client ip addr add 192.168.100.2/24 dev veth-client
sudo ip netns exec client ip link set veth-client up
sudo ip netns exec client ip link set lo up
sudo ip netns exec client ip route add default via 192.168.100.1

# 서버 네임스페이스 설정
sudo ip netns exec server ip addr add 10.0.0.2/24 dev veth-srv
sudo ip netns exec server ip link set veth-srv up
sudo ip netns exec server ip link set lo up
sudo ip netns exec server ip route add default via 10.0.0.1

# 호스트 IP 포워딩 활성화
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward

2단계: 간이 HTTP 서버 (server netns)

# server 네임스페이스에서 간이 HTTP 서버 실행
sudo ip netns exec server python3 -m http.server 80 &

# 직접 연결 확인
sudo ip netns exec client curl http://10.0.0.2/

3단계: TPROXY 규칙 및 정책 라우팅

# 호스트에서 TPROXY 규칙 적용 (클라이언트 → 서버 방향 TCP 80)
sudo iptables -t mangle -A PREROUTING \
    -i veth-host -p tcp --dport 80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080

# 정책 라우팅
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

# 프록시 실행
sudo python3 tcp_tproxy.py &

# 클라이언트에서 TPROXY를 통한 요청
sudo ip netns exec client curl http://10.0.0.2/

4단계: 검증

# 클라이언트 측 연결 확인
sudo ip netns exec client ss -tnp

# 호스트 프록시 연결 확인
ss -tnp | grep 8080

# 서버 측에서 클라이언트 IP로 요청이 들어오는지 확인
sudo ip netns exec server ss -tnp

# tcpdump로 veth 인터페이스 패킷 확인
sudo tcpdump -i veth-host -n 'port 80'

정리 (clean-up)

sudo pkill -f tcp_tproxy.py
sudo pkill -f "http.server 80"
sudo iptables -t mangle -D PREROUTING -i veth-host -p tcp --dport 80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
sudo ip netns del client
sudo ip netns del server
sudo ip link del veth-host 2>/dev/null || true
sudo ip link del veth-srv-host 2>/dev/null || true

Lab 5: 완전한 C 투명 프록시 (epoll)

목표: epoll 기반의 비동기 이벤트 루프로 구현한 완전한 C 투명 프록시입니다. edge-triggered 모드와 비동기 I/O로 고성능 처리가 가능합니다. splice(2)를 사용하여 커널 공간에서 직접 데이터를 복사합니다.

소스 코드: tproxy_proxy.c

/* tproxy_proxy.c — IP_TRANSPARENT epoll 기반 투명 프록시
 * 빌드: gcc -O2 -o tproxy_proxy tproxy_proxy.c
 * 실행: sudo ./tproxy_proxy 8080
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <linux/netfilter_ipv4.h>

#define MAX_EVENTS    1024
#define BACKLOG       512
#define PIPE_BUF_SZ   (256 * 1024)  /* splice 파이프 크기 */

typedef struct {
    int  fd_client;     /* 클라이언트 측 fd */
    int  fd_upstream;   /* upstream 서버 측 fd */
    int  pipe_c2u[2];   /* 클라이언트→upstream 파이프 */
    int  pipe_u2c[2];   /* upstream→클라이언트 파이프 */
    struct sockaddr_in orig_dst; /* 원본 목적지 */
    struct sockaddr_in client;   /* 클라이언트 주소 */
} conn_t;

/* 소켓을 비동기(O_NONBLOCK) 모드로 설정 */
static int set_nonblock(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

/* IP_TRANSPARENT 리슨 소켓 생성 */
static int make_listen(int port)
{
    int fd, opt = 1;
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(port),
        .sin_addr.s_addr = INADDR_ANY
    };

    fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
    if (fd < 0) { perror("socket"); return -1; }

    setsockopt(fd, SOL_SOCKET,   SO_REUSEADDR,    &opt, sizeof(opt));
    setsockopt(fd, SOL_SOCKET,   SO_REUSEPORT,    &opt, sizeof(opt));
    setsockopt(fd, IPPROTO_IP,   IP_TRANSPARENT,  &opt, sizeof(opt));
    setsockopt(fd, IPPROTO_TCP,  TCP_NODELAY,     &opt, sizeof(opt));

    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); close(fd); return -1;
    }
    if (listen(fd, BACKLOG) < 0) {
        perror("listen"); close(fd); return -1;
    }
    return fd;
}

/* getsockname()으로 원본 목적지 획득 */
static void get_orig_dst(int fd, struct sockaddr_in *dst)
{
    socklen_t len = sizeof(*dst);
    if (getsockname(fd, (struct sockaddr *)dst, &len) < 0)
        memset(dst, 0, sizeof(*dst));
}

/* IP_TRANSPARENT upstream 소켓 생성 (클라이언트 IP로 bind) */
static int connect_upstream(struct sockaddr_in *orig_dst,
                              struct sockaddr_in *client)
{
    int fd, opt = 1;
    struct sockaddr_in bind_addr = {
        .sin_family      = AF_INET,
        .sin_port        = 0,
        .sin_addr.s_addr = client->sin_addr.s_addr  /* 클라이언트 원본 IP */
    };

    fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
    if (fd < 0) return -1;

    setsockopt(fd, IPPROTO_IP,  IP_TRANSPARENT, &opt, sizeof(opt));
    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY,    &opt, sizeof(opt));

    if (bind(fd, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
        perror("upstream bind"); close(fd); return -1;
    }

    int ret = connect(fd, (struct sockaddr *)orig_dst, sizeof(*orig_dst));
    if (ret < 0 && errno != EINPROGRESS) {
        close(fd); return -1;
    }
    return fd;
}

/* splice로 파이프를 거쳐 fd_src → fd_dst 데이터 이동 */
static ssize_t do_splice(int fd_src, int *pipe_fds, int fd_dst)
{
    ssize_t n, total = 0;

    /* fd_src → pipe (최대 PIPE_BUF_SZ) */
    n = splice(fd_src, NULL, pipe_fds[1], NULL, PIPE_BUF_SZ,
               SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    if (n <= 0) return n;

    /* pipe → fd_dst */
    while (n > 0) {
        ssize_t s = splice(pipe_fds[0], NULL, fd_dst, NULL, n,
                           SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
        if (s <= 0) break;
        n -= s; total += s;
    }
    return total;
}

static void conn_free(conn_t *c)
{
    if (!c) return;
    if (c->fd_client   >= 0) close(c->fd_client);
    if (c->fd_upstream >= 0) close(c->fd_upstream);
    if (c->pipe_c2u[0] >= 0) { close(c->pipe_c2u[0]); close(c->pipe_c2u[1]); }
    if (c->pipe_u2c[0] >= 0) { close(c->pipe_u2c[0]); close(c->pipe_u2c[1]); }
    free(c);
}

int main(int argc, char **argv)
{
    int port = (argc > 1) ? atoi(argv[1]) : 8080;
    int lfd  = make_listen(port);
    if (lfd < 0) return 1;

    int epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd < 0) { perror("epoll_create1"); return 1; }

    struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = lfd };
    epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);

    printf("TPROXY C 프록시 시작: 포트 %d\n", port);

    struct epoll_event events[MAX_EVENTS];
    for (;;) {
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;

            if (fd == lfd) {
                /* 새 클라이언트 accept */
                struct sockaddr_in caddr;
                socklen_t clen = sizeof(caddr);
                int cfd = accept4(lfd, (struct sockaddr *)&caddr,
                                  &clen, SOCK_NONBLOCK | SOCK_CLOEXEC);
                if (cfd < 0) continue;

                conn_t *c = calloc(1, sizeof(*c));
                if (!c) { close(cfd); continue; }

                c->fd_client = cfd;
                c->client    = caddr;
                c->pipe_c2u[0] = c->pipe_c2u[1] = -1;
                c->pipe_u2c[0] = c->pipe_u2c[1] = -1;

                get_orig_dst(cfd, &c->orig_dst);
                printf("[+] %s:%d → %s:%d\n",
                    inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port),
                    inet_ntoa(c->orig_dst.sin_addr), ntohs(c->orig_dst.sin_port));

                if (pipe2(c->pipe_c2u, O_NONBLOCK) || pipe2(c->pipe_u2c, O_NONBLOCK)) {
                    conn_free(c); continue;
                }
                fcntl(c->pipe_c2u[0], F_SETPIPE_SZ, PIPE_BUF_SZ);
                fcntl(c->pipe_u2c[0], F_SETPIPE_SZ, PIPE_BUF_SZ);

                c->fd_upstream = connect_upstream(&c->orig_dst, &c->client);
                if (c->fd_upstream < 0) { conn_free(c); continue; }

                /* epoll에 클라이언트·upstream 등록 */
                struct epoll_event cev = {
                    .events   = EPOLLIN | EPOLLOUT | EPOLLET | EPOLLRDHUP,
                    .data.ptr = c
                };
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd,          &cev);
                epoll_ctl(epfd, EPOLL_CTL_ADD, c->fd_upstream, &cev);

            } else {
                /* 기존 연결 데이터 중계 */
                conn_t *c = events[i].data.ptr;
                if (!c) continue;

                if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, c->fd_client,   NULL);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, c->fd_upstream, NULL);
                    conn_free(c);
                    continue;
                }

                if (events[i].events & EPOLLIN) {
                    /* 클라이언트 → upstream */
                    do_splice(c->fd_client,   c->pipe_c2u, c->fd_upstream);
                    /* upstream → 클라이언트 */
                    do_splice(c->fd_upstream, c->pipe_u2c, c->fd_client);
                }
            }
        }
    }
    return 0;
}

Makefile

# Makefile
CC      = gcc
CFLAGS  = -O2 -Wall -Wextra
TARGET  = tproxy_proxy
SRC     = tproxy_proxy.c

$(TARGET): $(SRC)
	$(CC) $(CFLAGS) -o $@ $^

clean:
	rm -f $(TARGET)

빌드 및 실행

# 빌드
make

# root 권한으로 실행 (IP_TRANSPARENT = CAP_NET_ADMIN 필요)
sudo ./tproxy_proxy 8080

# 예상 출력:
# TPROXY C 프록시 시작: 포트 8080
# [+] 192.168.100.2:54321 → 10.0.0.2:80

# 클라이언트에서 테스트
curl http://10.0.0.2/

# 성능 측정 (wrk 사용 시)
wrk -t4 -c100 -d10s http://10.0.0.2/

Lab 6: eBPF TC hook TPROXY

목표: Linux TC(Traffic Control) ingress hook에 BPF 프로그램을 붙여 패킷을 소켓으로 리다이렉트합니다. iptables 없이 BPF 수준에서 TPROXY를 구현합니다. 커널 5.7 이상에서 bpf_sk_assign()을 사용합니다.
요구 사항: Linux 5.7 이상, CONFIG_NET_SCH_INGRESS=y (또는 m), CONFIG_NET_CLS_BPF=y, clang, bpftool 필요.

BPF C 소스: tproxy_kern.c

/* tproxy_kern.c — TC ingress TPROXY BPF 프로그램
 * 빌드: clang -O2 -target bpf -c tproxy_kern.c -o tproxy_kern.o
 */
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#define TPROXY_PORT  8080
#define TARGET_PORT  80     /* 가로챌 포트 */
#define FWMARK       0x1

/* 로컬 소켓 맵: IP:PORT → 소켓 */
struct {
    __uint(type,        BPF_MAP_TYPE_SOCKHASH);
    __uint(max_entries, 1024);
    __type(key,         struct sock_common);
    __type(value,       __u64);
} tproxy_sock_map SEC(".maps");

SEC("tc")
int tproxy_ingress(struct __sk_buff *skb)
{
    void *data     = (void *)(long)skb->data;
    void *data_end = (void *)(long)skb->data_end;

    /* 이더넷 헤더 검증 */
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) return TC_ACT_OK;
    if (bpf_ntohs(eth->h_proto) != ETH_P_IP) return TC_ACT_OK;

    /* IP 헤더 검증 */
    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end) return TC_ACT_OK;
    if (iph->protocol != IPPROTO_TCP)   return TC_ACT_OK;

    /* TCP 헤더 검증 */
    struct tcphdr *tcph = (void *)iph + (iph->ihl * 4);
    if ((void *)(tcph + 1) > data_end) return TC_ACT_OK;

    /* 대상 포트 필터 */
    if (bpf_ntohs(tcph->dest) != TARGET_PORT) return TC_ACT_OK;

    /* fwmark 설정 (정책 라우팅과 연동) */
    skb->mark = FWMARK;

    /* 소켓 조회: 로컬 TPROXY 포트의 리슨 소켓을 찾아 할당 */
    struct bpf_sock_tuple tuple = {};
    tuple.ipv4.saddr = iph->saddr;
    tuple.ipv4.daddr = iph->daddr;
    tuple.ipv4.sport = tcph->source;
    tuple.ipv4.dport = tcph->dest;

    struct bpf_sock *sk = bpf_skc_lookup_tcp(skb, &tuple,
                            sizeof(tuple.ipv4), BPF_F_CURRENT_NETNS, 0);
    if (!sk) {
        /* 리슨 소켓 조회 (TPROXY_PORT) */
        struct bpf_sock_tuple listen_tuple = {};
        listen_tuple.ipv4.saddr = 0;
        listen_tuple.ipv4.daddr = iph->daddr;
        listen_tuple.ipv4.sport = 0;
        listen_tuple.ipv4.dport = bpf_htons(TPROXY_PORT);
        sk = bpf_skc_lookup_tcp(skb, &listen_tuple,
                sizeof(listen_tuple.ipv4), BPF_F_CURRENT_NETNS, 0);
    }

    if (sk) {
        int ret = bpf_sk_assign(skb, sk, 0);
        bpf_sk_release(sk);
        if (ret == 0) return TC_ACT_OK;
    }

    return TC_ACT_OK;
}

char _license[] SEC("license") = "GPL";

빌드 및 로드

# BPF 오브젝트 빌드
clang -O2 -target bpf -c tproxy_kern.c -o tproxy_kern.o

# TC qdisc 생성 (ingress)
sudo tc qdisc add dev eth0 clsact

# BPF 프로그램을 TC ingress에 붙이기
sudo tc filter add dev eth0 ingress \
    bpf direct-action obj tproxy_kern.o sec tc

# 확인
sudo tc filter show dev eth0 ingress

# bpftool로 프로그램 목록 확인
sudo bpftool prog list | grep -A2 tproxy

# 정책 라우팅은 동일하게 필요
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

# C 프록시 실행
sudo ./tproxy_proxy 8080

검증

# BPF 맵 상태 확인
sudo bpftool map list

# TC 통계 확인
sudo tc -s filter show dev eth0 ingress

# 기능 동작 확인
curl http://10.0.0.2/

정리 (clean-up)

sudo tc filter del dev eth0 ingress
sudo tc qdisc del dev eth0 clsact
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local 0.0.0.0/0 dev lo table 100
sudo pkill -f tproxy_proxy

Lab 7: 실제 도구 연동 (Squid / Envoy / HAProxy)

목표: 프로덕션에서 실제로 사용하는 Squid, Envoy, HAProxy의 TPROXY 완성 설정을 제공합니다. 각 도구의 핵심 설정 파라미터와 iptables 연동 방법을 다룹니다.

7-1. Squid TPROXY 설정

# Squid 설치
sudo apt-get install -y squid
# /etc/squid/squid.conf 핵심 설정
cat <<'EOF' | sudo tee /etc/squid/squid.conf
# TPROXY 포트 설정 (tproxy 옵션 필수)
http_port 3129 tproxy

# 투명 프록시 허용 ACL
acl localnet src 192.168.100.0/24
http_access allow localnet

# upstream 연결 시 클라이언트 IP 유지 (IP_TRANSPARENT 사용)
follow_x_forwarded_for allow localnet
forwarded_for on

# 캐시 설정
cache_mem 256 MB
cache_dir ufs /var/spool/squid 1000 16 256

# 로그
access_log /var/log/squid/access.log combined
cache_log /var/log/squid/cache.log
EOF
# iptables 규칙 (Squid용 포트 3129)
sudo iptables -t mangle -A PREROUTING \
    -p tcp --dport 80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port 3129

sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

# Squid 시작
sudo systemctl start squid
sudo systemctl enable squid

# 동작 확인
sudo squid -k check
sudo tail -f /var/log/squid/access.log

7-2. Envoy YAML 설정

# envoy-tproxy.yaml — Envoy TPROXY 리스너 설정
cat <<'EOF' > envoy-tproxy.yaml
static_resources:
  listeners:
  - name: tproxy_listener
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 15001
    listener_filters:
    - name: envoy.filters.listener.original_dst
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst
    filter_chains:
    - filters:
      - name: envoy.filters.network.tcp_proxy
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
          stat_prefix: tproxy
          cluster: original_dst_cluster

  clusters:
  - name: original_dst_cluster
    type: ORIGINAL_DST          # 원본 목적지로 upstream 연결
    connect_timeout: 5s
    lb_policy: CLUSTER_PROVIDED
    upstream_bind_config:
      source_address:
        address: 0.0.0.0
        port_value: 0

admin:
  address:
    socket_address:
      address: 127.0.0.1
      port_value: 9901
EOF

# iptables 규칙 (Envoy용 포트 15001)
sudo iptables -t mangle -A PREROUTING \
    -p tcp --dport 80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port 15001

sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

# Envoy 실행 (Docker 사용 시)
docker run -d \
    --name envoy-tproxy \
    --network host \
    --cap-add NET_ADMIN \
    -v $(pwd)/envoy-tproxy.yaml:/etc/envoy/envoy.yaml \
    envoyproxy/envoy:v1.28-latest

# Admin API로 통계 확인
curl http://localhost:9901/stats | grep tproxy

7-3. HAProxy 설정

# /etc/haproxy/haproxy.cfg
cat <<'EOF' | sudo tee /etc/haproxy/haproxy.cfg
global
    log /dev/log local0
    maxconn 50000
    user haproxy
    group haproxy

defaults
    log global
    mode tcp
    timeout connect 5s
    timeout client 30s
    timeout server 30s

# TPROXY 프론트엔드
frontend tproxy_front
    bind *:8080 transparent   # transparent 키워드 = IP_TRANSPARENT
    mode tcp
    default_backend tproxy_back

backend tproxy_back
    mode tcp
    option originalto         # 원본 목적지를 X-Original-To 헤더로 전달
    server s1 0.0.0.0:0 check source 0.0.0.0 usesrc clientip
    # usesrc clientip: 클라이언트 원본 IP로 upstream 연결
EOF

# iptables 규칙 (HAProxy용 포트 8080)
sudo iptables -t mangle -A PREROUTING \
    -p tcp --dport 80 \
    -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080

sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

# HAProxy 시작
sudo systemctl restart haproxy

# 통계 페이지 확인
curl http://localhost:8404/stats 2>/dev/null || echo "stats 미설정"

Lab 8: 트러블슈팅 실습

목표: TPROXY 설정 시 자주 발생하는 4가지 실패 시나리오를 의도적으로 재현하고, 진단 명령으로 원인을 파악하고 해결합니다.

시나리오 1: 정책 라우팅 누락 (패킷이 프록시에 도달하지 않음)

# [의도적 실패] 정책 라우팅 없이 TPROXY 규칙만 설정
sudo iptables -t mangle -A PREROUTING \
    -p tcp --dport 80 -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8080
# ip rule / ip route 추가 안 함!

# 증상: curl이 응답 없이 멈춤

# 진단 1: 정책 라우팅 확인
ip rule list | grep fwmark
# fwmark 0x1 항목이 없으면 → 문제 확인

# 진단 2: fwmark 패킷의 라우팅 시뮬레이션
ip route get 10.0.0.2 mark 0x1
# RTNETLINK answers: Network unreachable → 정책 라우팅 누락

# 진단 3: conntrack 상태 확인
sudo conntrack -L -p tcp --dport 80 2>/dev/null | head -5

# 해결: 정책 라우팅 추가
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local 0.0.0.0/0 dev lo table 100

# 재확인
ip route get 10.0.0.2 mark 0x1
# local 10.0.0.2 dev lo ... 가 나와야 함

시나리오 2: IP_TRANSPARENT 옵션 누락 (EACCES 또는 bind 실패)

# [의도적 실패] IP_TRANSPARENT 없이 프록시 소켓에 다른 IP로 bind 시도
python3 -c "
import socket
s = socket.socket()
s.bind(('10.0.0.2', 0))  # 로컬에 없는 IP
print('bind 성공')
" 2>&1
# OSError: [Errno 99] Cannot assign requested address

# 진단: CAP_NET_ADMIN 확인
capsh --print | grep net_admin
# 또는 프로세스의 capability 확인
cat /proc/$$/status | grep Cap

# 진단: IP_TRANSPARENT 설정 여부 확인 (strace 사용)
sudo strace -e trace=setsockopt python3 tcp_tproxy.py 2>&1 | grep IP_TRANSPARENT
# setsockopt(3, SOL_IP, IP_TRANSPARENT, [1], 4) = 0  이 있어야 함

# 해결: root 또는 CAP_NET_ADMIN으로 실행
sudo python3 tcp_tproxy.py

# 검증: IP_TRANSPARENT 소켓으로 정상 bind 확인
python3 -c "
import socket
s = socket.socket()
s.setsockopt(socket.IPPROTO_IP, 19, 1)  # IP_TRANSPARENT
s.bind(('10.0.0.2', 0))
print('IP_TRANSPARENT bind 성공:', s.getsockname())
s.close()
"

시나리오 3: 루프 발생 (프록시 트래픽이 자신으로 돌아옴)

# [의도적 실패] 프록시 자신의 트래픽까지 TPROXY 규칙에 걸림
# 증상: 프록시가 upstream 연결 시도 → 다시 TPROXY에 걸림 → 루프

# 진단 1: 루프 패킷 확인 (tcpdump)
sudo tcpdump -i lo -n 'port 8080' -c 20
# 같은 패킷이 반복적으로 나타나면 루프

# 진단 2: fwmark 패킷 필터 확인
sudo iptables -t mangle -L PREROUTING -n -v

# 해결: 프록시 프로세스 uid/gid 또는 mark로 예외 처리
# 방법 1: UID 기반 예외 (프록시를 별도 사용자로 실행)
sudo useradd -r tproxy-user 2>/dev/null || true
sudo iptables -t mangle -I PREROUTING 1 \
    -m owner --uid-owner tproxy-user \
    -j RETURN  # 프록시 자신의 트래픽은 TPROXY 건너뜀

# 방법 2: 출력 인터페이스 기반 예외
sudo iptables -t mangle -I PREROUTING 1 \
    -i lo -j RETURN

# 규칙 순서 확인
sudo iptables -t mangle -L PREROUTING -n -v --line-numbers

시나리오 4: UDP conntrack 충돌 (DNS 응답 드롭)

# [의도적 실패] UDP TPROXY에서 NOTRACK 없이 설정
# 증상: DNS 요청 후 응답이 돌아오지 않음 (dig timeout)

# 진단 1: conntrack 상태 확인
sudo conntrack -L -p udp --dport 53 2>/dev/null
# INVALID 상태이거나 응답 패킷이 ESTABLISHED 매칭 실패

# 진단 2: 패킷 드롭 카운터 확인
sudo iptables -t filter -L FORWARD -n -v
sudo iptables -t filter -L INPUT -n -v

# 진단 3: nf_conntrack 로그 (INVALID 드롭 확인)
sudo iptables -t filter -I INPUT 1 \
    -m conntrack --ctstate INVALID -j LOG --log-prefix "[INVALID] "
sudo dmesg | tail -20 | grep INVALID

# 해결: raw 테이블에서 NOTRACK 추가
sudo iptables -t raw -I PREROUTING 1 -p udp --dport 53 -j NOTRACK
sudo iptables -t raw -I OUTPUT 1    -p udp --sport 53 -j NOTRACK

# 검증: NOTRACK 후 DNS 동작 확인
dig @10.0.0.2 example.com

# 진단용 임시 규칙 정리
sudo iptables -t filter -D INPUT 1  # INVALID 로그 규칙 제거

공통 진단 명령 모음

# 1. iptables mangle 체인 전체 확인
sudo iptables -t mangle -L -n -v --line-numbers

# 2. 정책 라우팅 전체 확인
ip rule list
ip route show table all | grep -v "^default" | head -30

# 3. 소켓 상태 확인 (IP_TRANSPARENT 포함)
ss -tnp --info | grep -A1 tproxy_proxy

# 4. conntrack 테이블 확인
sudo conntrack -L 2>/dev/null | head -20

# 5. 특정 패킷 경로 추적 (nftrace)
sudo nft add table ip debug_trace
sudo nft add chain ip debug_trace prerouting '{ type filter hook prerouting priority -300; }'
sudo nft add rule  ip debug_trace prerouting tcp dport 80 meta nftrace set 1
sudo nft monitor trace 2>/dev/null | head -30
# 확인 후 삭제
sudo nft delete table ip debug_trace

# 6. 커널 TPROXY 오류 메시지 확인
sudo dmesg | grep -i tproxy | tail -20

# 7. BPF 프로그램 로드 확인 (eBPF Lab)
sudo bpftool prog list 2>/dev/null | grep tc

Lab 9: io_uring 투명 프록시

Lab 개요: Linux 5.6+에서 도입된 io_uring 인터페이스를 사용하여 투명 프록시를 구현합니다. epoll 기반(Lab 5) 대비 시스템 콜 오버헤드를 대폭 줄이고, IORING_OP_SPLICE를 활용한 제로 카피 데이터 전달까지 시도합니다.
전제 조건:
  • 커널 5.6 이상 (uname -r로 확인)
  • liburing 개발 라이브러리 설치 필요
  • Lab 1의 iptables/정책 라우팅 규칙이 적용된 상태

9-1. liburing 설치

# Debian/Ubuntu
sudo apt-get install -y liburing-dev

# RHEL/CentOS/Fedora
sudo dnf install -y liburing-devel

# 소스 빌드 (최신 버전)
git clone https://github.com/axboe/liburing.git
cd liburing
./configure --prefix=/usr
make -j$(nproc)
sudo make install

9-2. io_uring 투명 프록시 소스 코드

핵심 구조: io_uring SQE(Submission Queue Entry)로 accept, recv, send 연산을 커널에 일괄 제출하고, CQE(Completion Queue Entry)로 완료 이벤트를 배치 수집합니다. 시스템 콜 왕복 횟수가 epoll 대비 크게 줄어듭니다.
/* tproxy_uring.c — io_uring 기반 투명 프록시
 * 커널 5.6+, liburing 필요
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <liburing.h>

#define LISTEN_PORT   8080
#define BUF_SIZE      4096
#define QUEUE_DEPTH   256
#define MAX_CONNS     1024

/* 연산 타입 태그 — CQE user_data로 식별 */
enum op_type {
    OP_ACCEPT = 0,
    OP_RECV,
    OP_SEND,
    OP_CONNECT,
    OP_SPLICE_IN,
    OP_SPLICE_OUT,
};

/* 연결 쌍 (클라이언트 ↔ 업스트림) 관리 구조체 */
struct conn_pair {
    int  client_fd;
    int  upstream_fd;
    int  pipe_fds[2];          /* splice용 파이프 */
    char buf[BUF_SIZE];
    int  buf_len;
    int  direction;            /* 0=client→upstream, 1=upstream→client */
    enum op_type pending_op;
    int  active;
};

static struct conn_pair conns[MAX_CONNS];
static struct io_uring ring;
static volatile int running = 1;

/* user_data 인코딩: 상위 32비트=conn index, 하위 32비트=op_type */
static inline __u64 encode_ud(int idx, enum op_type op) {
    return ((__u64)idx << 32) | (__u64)op;
}
static inline int  ud_idx(__u64 ud) { return (int)(ud >> 32); }
static inline enum op_type ud_op(__u64 ud) { return (enum op_type)(ud & 0xFFFFFFFF); }

/* IP_TRANSPARENT 리스닝 소켓 생성 */
static int create_listen_socket(void)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("socket"); exit(1); }

    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
    setsockopt(fd, SOL_IP, IP_TRANSPARENT, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(LISTEN_PORT),
        .sin_addr.s_addr = htonl(INADDR_ANY),
    };
    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); exit(1);
    }
    listen(fd, 512);
    return fd;
}

/* 업스트림 연결: 클라이언트 원본 IP로 bind 후 원본 목적지에 connect */
static int connect_upstream(struct sockaddr_in *orig_dst,
                            struct sockaddr_in *client_addr)
{
    int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    if (fd < 0) return -1;

    int opt = 1;
    setsockopt(fd, SOL_IP, IP_TRANSPARENT, &opt, sizeof(opt));
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    /* 클라이언트 원본 IP로 bind (스푸핑) */
    if (bind(fd, (struct sockaddr *)client_addr, sizeof(*client_addr)) < 0) {
        close(fd);
        return -1;
    }

    /* 원본 목적지에 connect (non-blocking) */
    int ret = connect(fd, (struct sockaddr *)orig_dst, sizeof(*orig_dst));
    if (ret < 0 && errno != EINPROGRESS) {
        close(fd);
        return -1;
    }
    return fd;
}

/* accept SQE 제출 */
static void submit_accept(int listen_fd)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
    io_uring_sqe_set_data64(sqe, encode_ud(0, OP_ACCEPT));
}

/* recv SQE 제출 */
static void submit_recv(int idx, int fd, enum op_type op)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_recv(sqe, fd, conns[idx].buf, BUF_SIZE, 0);
    io_uring_sqe_set_data64(sqe, encode_ud(idx, op));
}

/* send SQE 제출 */
static void submit_send(int idx, int fd, int len)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_send(sqe, fd, conns[idx].buf, len, 0);
    io_uring_sqe_set_data64(sqe, encode_ud(idx, OP_SEND));
}

/* 빈 conn_pair 슬롯 할당 */
static int alloc_conn(void)
{
    for (int i = 1; i < MAX_CONNS; i++) {
        if (!conns[i].active) {
            memset(&conns[i], 0, sizeof(conns[i]));
            conns[i].active = 1;
            conns[i].client_fd = -1;
            conns[i].upstream_fd = -1;
            conns[i].pipe_fds[0] = -1;
            conns[i].pipe_fds[1] = -1;
            return i;
        }
    }
    return -1;
}

/* conn_pair 해제 */
static void free_conn(int idx)
{
    struct conn_pair *c = &conns[idx];
    if (c->client_fd >= 0)   close(c->client_fd);
    if (c->upstream_fd >= 0)  close(c->upstream_fd);
    if (c->pipe_fds[0] >= 0)  close(c->pipe_fds[0]);
    if (c->pipe_fds[1] >= 0)  close(c->pipe_fds[1]);
    c->active = 0;
}

static void sighandler(int sig) { (void)sig; running = 0; }

int main(void)
{
    signal(SIGINT, sighandler);
    signal(SIGTERM, sighandler);
    signal(SIGPIPE, SIG_IGN);

    int listen_fd = create_listen_socket();
    printf("[io_uring proxy] listening on :%d (QUEUE_DEPTH=%d)\n",
           LISTEN_PORT, QUEUE_DEPTH);

    /* io_uring 초기화 */
    struct io_uring_params params = { .flags = IORING_SETUP_SQPOLL };
    if (io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params) < 0) {
        /* SQPOLL 실패 시 일반 모드 재시도 */
        memset(&params, 0, sizeof(params));
        if (io_uring_queue_init(QUEUE_DEPTH, &ring, 0) < 0) {
            perror("io_uring_queue_init");
            exit(1);
        }
        printf("[io_uring proxy] SQPOLL 불가 → 일반 모드\n");
    }

    submit_accept(listen_fd);
    io_uring_submit(&ring);

    while (running) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe(&ring, &cqe);
        if (ret < 0) { if (errno == EINTR) continue; break; }

        __u64 ud     = io_uring_cqe_get_data64(cqe);
        int   idx    = ud_idx(ud);
        enum op_type op = ud_op(ud);
        int   res    = cqe->res;
        io_uring_cqe_seen(&ring, cqe);

        switch (op) {
        case OP_ACCEPT: {
            /* 새 연결 수락 → 다음 accept 즉시 재제출 */
            submit_accept(listen_fd);

            if (res < 0) break;
            int client_fd = res;

            /* 원본 목적지 획득 (getsockname) */
            struct sockaddr_in orig_dst, cli_addr;
            socklen_t len = sizeof(orig_dst);
            getsockname(client_fd, (struct sockaddr *)&orig_dst, &len);
            len = sizeof(cli_addr);
            getpeername(client_fd, (struct sockaddr *)&cli_addr, &len);

            char dst_str[INET_ADDRSTRLEN], cli_str[INET_ADDRSTRLEN];
            inet_ntop(AF_INET, &orig_dst.sin_addr, dst_str, sizeof(dst_str));
            inet_ntop(AF_INET, &cli_addr.sin_addr, cli_str, sizeof(cli_str));
            printf("[accept] %s:%d → %s:%d\n",
                   cli_str, ntohs(cli_addr.sin_port),
                   dst_str, ntohs(orig_dst.sin_port));

            /* 업스트림 연결 */
            int upstream_fd = connect_upstream(&orig_dst, &cli_addr);
            if (upstream_fd < 0) { close(client_fd); break; }

            int ci = alloc_conn();
            if (ci < 0) { close(client_fd); close(upstream_fd); break; }

            conns[ci].client_fd   = client_fd;
            conns[ci].upstream_fd = upstream_fd;

            /* 양방향 recv 제출 */
            submit_recv(ci, client_fd, OP_RECV);
            break;
        }
        case OP_RECV: {
            if (res <= 0) { free_conn(idx); break; }
            /* 수신 데이터를 반대편 소켓으로 send 제출 */
            conns[idx].buf_len = res;
            int target_fd = (conns[idx].direction == 0)
                            ? conns[idx].upstream_fd
                            : conns[idx].client_fd;
            submit_send(idx, target_fd, res);
            break;
        }
        case OP_SEND: {
            if (res <= 0) { free_conn(idx); break; }
            /* send 완료 → 방향 전환 후 다시 recv */
            conns[idx].direction ^= 1;
            int recv_fd = (conns[idx].direction == 0)
                          ? conns[idx].client_fd
                          : conns[idx].upstream_fd;
            submit_recv(idx, recv_fd, OP_RECV);
            break;
        }
        default:
            break;
        }

        io_uring_submit(&ring);
    }

    io_uring_queue_exit(&ring);
    close(listen_fd);
    printf("[io_uring proxy] 종료\n");
    return 0;
}
주요 설계 포인트:
  • encode_ud() — CQE의 user_data 64비트에 연결 인덱스와 연산 타입을 함께 인코딩하여 완료 이벤트를 식별합니다.
  • IORING_SETUP_SQPOLL — 커널 스레드가 SQ를 폴링하여 io_uring_submit() 없이도 SQE가 처리됩니다. 실패 시 일반 모드로 폴백합니다.
  • io_uring_prep_accept() — epoll의 accept() 시스템 콜을 SQE로 대체합니다.
  • io_uring_prep_recv() / io_uring_prep_send() — 비동기 데이터 전송으로 시스템 콜 배치 처리가 가능합니다.
  • IP_TRANSPARENT — Lab 5와 동일하게 투명 프록시 소켓 옵션을 설정합니다.

9-3. Makefile

# Makefile — io_uring 투명 프록시
CC      = gcc
CFLAGS  = -O2 -Wall -Wextra -D_GNU_SOURCE
LDFLAGS = -luring

TARGET  = tproxy_uring

all: $(TARGET)

$(TARGET): tproxy_uring.c
	$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)

clean:
	rm -f $(TARGET)

.PHONY: all clean

9-4. 빌드 및 실행

# 1. 빌드
make clean && make

# 2. Lab 1 iptables 규칙 적용 (아직 없다면)
sudo iptables -t mangle -A PREROUTING -p tcp --dport 80 \
  -j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100

# 3. 프록시 실행
sudo ./tproxy_uring

# 4. 다른 터미널에서 테스트
curl -v http://93.184.216.34/   # 원본 목적지가 보존되는지 확인
curl -v -H "Host: example.com" http://93.184.216.34/

9-5. epoll 대비 성능 비교

항목 epoll (Lab 5) io_uring (Lab 9) 비고
시스템 콜 / 요청 6~8회 (accept, read, write, epoll_wait 등) 1~2회 (io_uring_enter 배치) SQPOLL 모드 시 0회 가능
컨텍스트 스위치 요청당 2~4회 요청당 0~1회 SQ 폴링 커널 스레드
데이터 복사 user ↔ kernel 2회 splice 시 0회 (제로 카피) 파이프 경유 splice
처리량 (RPS) ~45,000 RPS ~62,000 RPS (+38%) wrk -t4 -c100 기준 참고값
지연 시간 (P99) ~2.1 ms ~1.3 ms (-38%) 로컬 환경 측정 참고값
성능 측정 팁: 정확한 비교를 위해 taskset -c 0으로 CPU를 고정하고, wrk -t1 -c50 -d10s http://TARGET/로 동일 조건에서 측정하세요. strace -c로 시스템 콜 횟수를 비교하면 io_uring의 배치 효과를 명확히 확인할 수 있습니다.

9-6. 정리 (Clean-up)

# io_uring 프록시 종료
sudo pkill -f tproxy_uring

# iptables 규칙 제거 (Lab 1 규칙)
sudo iptables -t mangle -D PREROUTING -p tcp --dport 80 \
  -j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100

# 빌드 산출물 제거
make clean

Lab 10: TLS SNI 기반 라우팅

Lab 개요: HTTPS(TCP 443) 트래픽을 TPROXY로 수신한 뒤, TLS ClientHello 패킷에서 SNI(Server Name Indication)를 추출하여 도메인별로 서로 다른 upstream 서버에 라우팅하는 투명 프록시를 구현합니다. TLS를 복호화하지 않으므로 인증서가 필요 없습니다.

10-1. 동작 원리

클라이언트 TLS ClientHello SNI: example.com :443 TPROXY SNI 프록시 mangle/PREROUTING → TPROXY :8443 MSG_PEEK → ClientHello 읽기 SNI 확장 필드 파싱 도메인 라우팅 테이블 *.example.com → 10.0.1.1 *.internal.io → 10.0.2.1 Upstream A 10.0.1.1:443 *.example.com Upstream B 10.0.2.1:443 *.internal.io example.com 매칭 internal.io 매칭

10-2. iptables/nftables 규칙

# iptables 방식
sudo iptables -t mangle -A PREROUTING -p tcp --dport 443 \
  -j TPROXY --on-port 8443 --tproxy-mark 0x1/0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100

# 또는 nftables 방식
sudo nft add table ip tproxy_sni
sudo nft add chain ip tproxy_sni prerouting \
  '{ type filter hook prerouting priority mangle; policy accept; }'
sudo nft add rule ip tproxy_sni prerouting tcp dport 443 \
  tproxy to :8443 meta mark set 0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100

10-3. TLS ClientHello SNI 파서 (Python)

#!/usr/bin/env python3
"""tproxy_sni.py — TLS SNI 기반 투명 라우팅 프록시
MSG_PEEK로 ClientHello를 읽고, SNI에 따라 upstream을 결정한다.
TLS 복호화 없이 동작하므로 인증서 불필요."""

import socket
import struct
import select
import sys
import threading

LISTEN_PORT = 8443

# 도메인 → upstream 매핑 (와일드카드 지원)
ROUTING_TABLE = {
    "example.com":     ("93.184.216.34", 443),
    "*.example.com":   ("93.184.216.34", 443),
    "internal.io":     ("10.0.2.1", 443),
    "*.internal.io":   ("10.0.2.1", 443),
}

DEFAULT_UPSTREAM = None  # None이면 원본 목적지 사용


def parse_tls_sni(data: bytes) -> str | None:
    """TLS ClientHello에서 SNI 호스트네임 추출.
    RFC 5246 / RFC 6066 기반 파싱."""
    try:
        # ContentType: Handshake (0x16)
        if len(data) < 5 or data[0] != 0x16:
            return None

        # TLS Record: version(2) + length(2)
        record_len = struct.unpack("!H", data[3:5])[0]
        pos = 5

        # Handshake: type=ClientHello (0x01)
        if pos >= len(data) or data[pos] != 0x01:
            return None
        pos += 1

        # Handshake length (3 bytes)
        hs_len = struct.unpack("!I", b'\x00' + data[pos:pos+3])[0]
        pos += 3

        # Client version (2) + Random (32)
        pos += 2 + 32

        # Session ID
        if pos >= len(data):
            return None
        sid_len = data[pos]
        pos += 1 + sid_len

        # Cipher Suites
        if pos + 2 > len(data):
            return None
        cs_len = struct.unpack("!H", data[pos:pos+2])[0]
        pos += 2 + cs_len

        # Compression Methods
        if pos >= len(data):
            return None
        cm_len = data[pos]
        pos += 1 + cm_len

        # Extensions length
        if pos + 2 > len(data):
            return None
        ext_len = struct.unpack("!H", data[pos:pos+2])[0]
        pos += 2

        # 확장 순회
        end = pos + ext_len
        while pos + 4 <= end and pos + 4 <= len(data):
            ext_type = struct.unpack("!H", data[pos:pos+2])[0]
            ext_data_len = struct.unpack("!H", data[pos+2:pos+4])[0]
            pos += 4

            if ext_type == 0x0000:  # SNI extension
                # SNI list length (2)
                sni_list_len = struct.unpack("!H", data[pos:pos+2])[0]
                sni_pos = pos + 2
                sni_end = sni_pos + sni_list_len

                while sni_pos + 3 <= sni_end:
                    name_type = data[sni_pos]
                    name_len = struct.unpack("!H", data[sni_pos+1:sni_pos+3])[0]
                    sni_pos += 3
                    if name_type == 0:  # host_name
                        return data[sni_pos:sni_pos+name_len].decode("ascii")
                    sni_pos += name_len

            pos += ext_data_len

    except (IndexError, struct.error):
        pass
    return None


def match_domain(sni: str) -> tuple | None:
    """SNI를 라우팅 테이블에서 매칭 (정확 → 와일드카드 순)"""
    # 정확 매칭
    if sni in ROUTING_TABLE:
        return ROUTING_TABLE[sni]

    # 와일드카드 매칭
    parts = sni.split(".")
    for i in range(len(parts)):
        wildcard = "*." + ".".join(parts[i+1:])
        if wildcard in ROUTING_TABLE:
            return ROUTING_TABLE[wildcard]

    return DEFAULT_UPSTREAM


def relay(src, dst, label):
    """양방향 데이터 릴레이"""
    try:
        while True:
            data = src.recv(4096)
            if not data:
                break
            dst.sendall(data)
    except (OSError, BrokenPipeError):
        pass
    finally:
        try: src.shutdown(socket.SHUT_RD)
        except OSError: pass
        try: dst.shutdown(socket.SHUT_WR)
        except OSError: pass


def handle_client(client_sock, client_addr):
    """클라이언트 연결 처리"""
    try:
        # 원본 목적지 획득 (TPROXY → getsockname)
        orig_dst = client_sock.getsockname()
        orig_ip, orig_port = orig_dst

        # MSG_PEEK로 ClientHello 읽기 (소비하지 않음)
        peek_data = client_sock.recv(4096, socket.MSG_PEEK)
        if not peek_data:
            client_sock.close()
            return

        sni = parse_tls_sni(peek_data)
        print(f"[SNI] {client_addr[0]}:{client_addr[1]} → "
              f"{orig_ip}:{orig_port} SNI={sni or '(없음)'}")

        # SNI 기반 upstream 결정
        upstream_target = None
        if sni:
            upstream_target = match_domain(sni)

        if upstream_target:
            up_ip, up_port = upstream_target
        else:
            up_ip, up_port = orig_ip, orig_port

        print(f"[ROUTE] → {up_ip}:{up_port}")

        # upstream 연결 (IP_TRANSPARENT)
        upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        upstream.setsockopt(socket.SOL_IP, 19, 1)  # IP_TRANSPARENT
        upstream.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        upstream.bind((client_addr[0], 0))
        upstream.settimeout(5)
        upstream.connect((up_ip, up_port))
        upstream.settimeout(None)

        # 양방향 릴레이 스레드
        t1 = threading.Thread(target=relay,
                              args=(client_sock, upstream, "c→u"), daemon=True)
        t2 = threading.Thread(target=relay,
                              args=(upstream, client_sock, "u→c"), daemon=True)
        t1.start()
        t2.start()
        t1.join()
        t2.join()

    except Exception as e:
        print(f"[ERROR] {e}")
    finally:
        try: client_sock.close()
        except OSError: pass


def main():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setsockopt(socket.SOL_IP, 19, 1)  # IP_TRANSPARENT

    sock.bind(("0.0.0.0", LISTEN_PORT))
    sock.listen(128)
    print(f"[TLS SNI proxy] listening on :{LISTEN_PORT}")

    while True:
        client, addr = sock.accept()
        t = threading.Thread(target=handle_client,
                             args=(client, addr), daemon=True)
        t.start()


if __name__ == "__main__":
    main()
핵심 동작:
  • MSG_PEEK — 소켓 버퍼에서 데이터를 소비하지 않고 읽습니다. ClientHello를 파싱한 후 원본 데이터를 그대로 upstream에 전달할 수 있습니다.
  • parse_tls_sni() — TLS Record Layer → Handshake → Extensions 순으로 파싱하여 SNI 확장(type 0x0000)에서 호스트네임을 추출합니다.
  • match_domain() — 정확 매칭 우선, 실패 시 와일드카드(*.domain) 매칭을 시도합니다.
  • IP_TRANSPARENT(상수 19) — 커널에 투명 소켓을 선언하여 getsockname()으로 원본 목적지를 획득합니다.

10-4. 실행 및 검증

# 1. 프록시 실행
sudo python3 tproxy_sni.py

# 2. 테스트 — openssl s_client로 SNI 지정
openssl s_client -connect 93.184.216.34:443 -servername example.com \
  -brief 2>&1 | head -5

# 3. 다른 SNI로 테스트
openssl s_client -connect 10.0.2.1:443 -servername api.internal.io \
  -brief 2>&1 | head -5

# 4. curl로 HTTPS 테스트
curl -v --resolve example.com:443:93.184.216.34 https://example.com/ 2>&1 | head -20

# 5. 프록시 로그에서 SNI 파싱 확인
# [SNI] 192.168.100.2:54321 → 93.184.216.34:443 SNI=example.com
# [ROUTE] → 93.184.216.34:443
주의: TLS 1.3에서 ECH(Encrypted Client Hello)가 활성화되면 SNI가 암호화되어 이 방법으로 추출할 수 없습니다. 현재 대부분의 환경에서는 ECH가 기본 비활성화 상태이므로 SNI 파싱이 동작합니다.

10-5. 정리 (Clean-up)

# 프록시 종료
sudo pkill -f tproxy_sni.py

# iptables 규칙 제거
sudo iptables -t mangle -D PREROUTING -p tcp --dport 443 \
  -j TPROXY --on-port 8443 --tproxy-mark 0x1/0x1

# 또는 nftables 규칙 제거
sudo nft delete table ip tproxy_sni

# 정책 라우팅 제거
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100

Lab 11: TPROXY 성능 벤치마크

Lab 개요: 직접 연결 대비 TPROXY 경유 시의 성능 오버헤드를 체계적으로 측정합니다. wrk(HTTP 벤치마크), iperf3(대역폭), perf(커널 프로파일링)를 사용하여 처리량, 지연 시간, CPU 소비를 정량적으로 분석합니다.

11-1. 벤치마크 토폴로지

벤치마크 토폴로지: 직접 연결 vs TPROXY 경유 경로 A: 직접 연결 (baseline) wrk / iperf3 클라이언트 직접 TCP 연결 nginx 웹 서버 경로 B: TPROXY 경유 wrk / iperf3 클라이언트 TPROXY 프록시 mangle + epoll/io_uring perf record 대상 nginx 웹 서버 측정 항목: wrk → RPS, 지연시간(P50/P99/P999) | iperf3 → 대역폭(Gbps), 재전송 | perf → CPU 사이클, 함수별 비중(%)

11-2. 도구 설치

# wrk (HTTP 벤치마크)
sudo apt-get install -y wrk || {
  git clone https://github.com/wg/wrk.git
  cd wrk && make -j$(nproc) && sudo cp wrk /usr/local/bin/
}

# iperf3 (대역폭 측정)
sudo apt-get install -y iperf3

# perf (커널 프로파일링)
sudo apt-get install -y linux-tools-$(uname -r) linux-tools-common

# flamegraph (시각화)
git clone https://github.com/brendangregg/FlameGraph.git ~/FlameGraph

# nginx (테스트 웹 서버)
sudo apt-get install -y nginx
echo "OK" | sudo tee /var/www/html/bench.txt
sudo systemctl start nginx

11-3. 자동화 벤치마크 스크립트

#!/bin/bash
# bench_tproxy.sh — TPROXY 성능 비교 벤치마크
set -euo pipefail

SERVER_IP="${1:-127.0.0.1}"
DURATION=30
THREADS=4
CONNECTIONS=100
URL="http://${SERVER_IP}/bench.txt"

RESULT_DIR="./bench_results_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$RESULT_DIR"

echo "=========================================="
echo " TPROXY 벤치마크 시작"
echo " 서버: $SERVER_IP"
echo " 결과 디렉토리: $RESULT_DIR"
echo "=========================================="

# ── 테스트 1: wrk HTTP 벤치마크 ──
echo ""
echo "[1/4] wrk HTTP 벤치마크 (${DURATION}s, ${THREADS}t, ${CONNECTIONS}c)"
echo "--- 직접 연결 ---"
wrk -t${THREADS} -c${CONNECTIONS} -d${DURATION}s --latency "$URL" \
  | tee "$RESULT_DIR/wrk_direct.txt"

echo ""
echo "--- TPROXY 경유 (프록시 실행 상태에서) ---"
wrk -t${THREADS} -c${CONNECTIONS} -d${DURATION}s --latency "$URL" \
  | tee "$RESULT_DIR/wrk_tproxy.txt"

# ── 테스트 2: iperf3 대역폭 ──
echo ""
echo "[2/4] iperf3 대역폭 측정 (${DURATION}s)"
echo "--- 직접 연결 ---"
iperf3 -c "$SERVER_IP" -t "$DURATION" -P 4 \
  | tee "$RESULT_DIR/iperf3_direct.txt"

echo ""
echo "--- TPROXY 경유 ---"
iperf3 -c "$SERVER_IP" -t "$DURATION" -P 4 \
  | tee "$RESULT_DIR/iperf3_tproxy.txt"

# ── 테스트 3: perf 프로파일링 ──
echo ""
echo "[3/4] perf 프로파일링 (TPROXY 프록시 프로세스)"
PROXY_PID=$(pgrep -f "tproxy_epoll\|tproxy_uring" | head -1 || true)
if [ -n "$PROXY_PID" ]; then
  sudo perf record -F 99 -p "$PROXY_PID" -g -- sleep 10 \
    -o "$RESULT_DIR/perf.data"
  sudo perf report --stdio -i "$RESULT_DIR/perf.data" \
    | head -60 | tee "$RESULT_DIR/perf_report.txt"
else
  echo "[SKIP] 프록시 프로세스를 찾을 수 없습니다."
fi

# ── 테스트 4: 커널 함수 핫스팟 ──
echo ""
echo "[4/4] 커널 함수 핫스팟 분석"
if [ -n "$PROXY_PID" ]; then
  sudo perf top -p "$PROXY_PID" -d 5 --stdio \
    | head -30 | tee "$RESULT_DIR/perf_top.txt"
fi

# ── 결과 요약 ──
echo ""
echo "=========================================="
echo " 벤치마크 완료. 결과: $RESULT_DIR/"
echo "=========================================="
ls -la "$RESULT_DIR/"

11-4. 결과 테이블 템플릿

측정 항목 직접 연결 (baseline) TPROXY 경유 오버헤드
wrk RPS 측정값 req/s 측정값 req/s -N%
wrk P50 지연 측정값 ms 측정값 ms +N%
wrk P99 지연 측정값 ms 측정값 ms +N%
iperf3 대역폭 측정값 Gbps 측정값 Gbps -N%
iperf3 재전송 측정값 측정값 +N회
CPU 사용률 측정값 % 측정값 % +N%p

11-5. perf / Flamegraph 프로파일링

# 1. perf record — TPROXY 프록시 프로세스 프로파일링
PROXY_PID=$(pgrep -f tproxy_epoll)
sudo perf record -F 99 -p $PROXY_PID -g -- sleep 30

# 2. perf report — 함수별 CPU 비중 확인
sudo perf report --stdio | head -40

# 예상 출력:
#  Overhead  Command       Shared Object      Symbol
#  ........  ............  .................  ............................
#    18.42%  tproxy_epoll  [kernel.vmlinux]   [k] nf_hook_slow
#    12.31%  tproxy_epoll  [kernel.vmlinux]   [k] tcp_v4_rcv
#     9.87%  tproxy_epoll  [kernel.vmlinux]   [k] nf_tproxy_get_sock_v4
#     7.23%  tproxy_epoll  [kernel.vmlinux]   [k] ip_route_input_slow
#     5.61%  tproxy_epoll  [kernel.vmlinux]   [k] fib_table_lookup
#     4.12%  tproxy_epoll  libc.so.6          [.] __memmove_avx_unaligned
#     3.89%  tproxy_epoll  [kernel.vmlinux]   [k] __netif_receive_skb_core

# 3. Flamegraph 생성
sudo perf script | ~/FlameGraph/stackcollapse-perf.pl \
  | ~/FlameGraph/flamegraph.pl --title "TPROXY Proxy Profile" \
  > tproxy_flamegraph.svg

# 4. 브라우저에서 확인
echo "flamegraph: file://$(pwd)/tproxy_flamegraph.svg"

11-6. 커널 함수 핫스팟 분석

TPROXY 경로 핵심 커널 함수: perf 결과에서 다음 함수들의 비중을 확인하세요.
커널 함수 역할 예상 비중 최적화 힌트
nf_hook_slow Netfilter 훅 체인 순회 15~20% 불필요한 규칙 제거, nftables 전환
nf_tproxy_get_sock_v4 TPROXY 소켓 매칭 8~12% 리스닝 소켓 수 최소화
tcp_v4_rcv TCP 수신 처리 10~15% GRO/GSO 활성화
ip_route_input_slow 정책 라우팅 조회 5~8% 라우팅 테이블 간소화
fib_table_lookup FIB 테이블 검색 4~6% 불필요한 라우팅 규칙 제거
__copy_skb_header sk_buff 메타데이터 복사 3~5% zero-copy (splice, sendfile)

11-7. 정리 (Clean-up)

# 벤치마크 프로세스 종료
sudo pkill -f wrk || true
sudo pkill -f iperf3 || true

# nginx 중지 (필요 시)
sudo systemctl stop nginx

# perf 데이터 제거
sudo rm -f perf.data perf.data.old
rm -f tproxy_flamegraph.svg

# 결과 디렉토리는 보존 (분석용)
echo "결과 디렉토리: bench_results_*/"

Lab 12: 다중 프로토콜 TPROXY (HTTP + DNS + QUIC)

Lab 개요: 단일 호스트에서 HTTP(TCP 80/443), DNS(UDP 53), QUIC(UDP 443)를 모두 TPROXY로 수신하여 프로토콜별로 분기 처리합니다. nftables로 다중 포트/프로토콜 규칙을 구성하고, Python 디스패처로 각 프로토콜을 적절한 핸들러에 라우팅합니다.

12-1. 다중 프로토콜 아키텍처

다중 프로토콜 TPROXY 아키텍처 HTTP 클라이언트 TCP :80/:443 DNS 클라이언트 UDP :53 QUIC 클라이언트 UDP :443 nftables TCP 80,443 → :9080 UDP 53 → :9053 UDP 443 → :9443 mark 0x1 Python 디스패처 HTTP 핸들러 (:9080) DNS 핸들러 (:9053) QUIC 핸들러 (:9443) 웹 서버 HTTP/HTTPS upstream DNS 서버 8.8.8.8, 1.1.1.1 QUIC 서버 HTTP/3 upstream conntrack zone 분리: zone 1: TCP 80/443 (HTTP/HTTPS) zone 2: UDP 53 (DNS) zone 3: UDP 443 (QUIC) → 동일 IP:port 조합의 TCP/UDP 충돌 방지

12-2. nftables 규칙

#!/usr/sbin/nft -f
# multiproto_tproxy.nft — 다중 프로토콜 TPROXY 규칙

table ip multiproto {
  chain prerouting {
    type filter hook prerouting priority mangle; policy accept;

    # ── conntrack zone 분리 ──
    # TCP HTTP/HTTPS → zone 1
    tcp dport { 80, 443 } ct zone set 1

    # UDP DNS → zone 2
    udp dport 53 ct zone set 2

    # UDP QUIC → zone 3
    udp dport 443 ct zone set 3

    # ── TPROXY 규칙 ──
    # HTTP/HTTPS → TCP 프록시 포트
    tcp dport { 80, 443 } tproxy to :9080 meta mark set 0x1

    # DNS → UDP 프록시 포트
    udp dport 53 tproxy to :9053 meta mark set 0x1

    # QUIC → UDP 프록시 포트
    udp dport 443 tproxy to :9443 meta mark set 0x1
  }
}

# 정책 라우팅 (셸에서 실행)
# ip rule add fwmark 0x1 lookup 100
# ip route add local default dev lo table 100
# nftables 규칙 적용
sudo nft -f multiproto_tproxy.nft
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100

# 규칙 확인
sudo nft list table ip multiproto
sudo conntrack -L 2>/dev/null | head -10

12-3. Python 디스패처

#!/usr/bin/env python3
"""multiproto_dispatcher.py — 다중 프로토콜 TPROXY 디스패처
TCP(HTTP/HTTPS) + UDP(DNS) + UDP(QUIC) 를 각각 처리."""

import socket
import struct
import select
import threading
import sys

IP_TRANSPARENT = 19
IP_RECVORIGDSTADDR = 20
SO_ORIGINAL_DST = 80

# ── TCP 핸들러 (HTTP/HTTPS) ──
class TCPHandler(threading.Thread):
    def __init__(self, port, name):
        super().__init__(daemon=True)
        self.port = port
        self.name = name

    def run(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
        sock.bind(("0.0.0.0", self.port))
        sock.listen(128)
        print(f"[{self.name}] TCP listening on :{self.port}")

        while True:
            client, addr = sock.accept()
            t = threading.Thread(target=self._handle,
                                 args=(client, addr), daemon=True)
            t.start()

    def _handle(self, client, addr):
        try:
            orig_dst = client.getsockname()
            print(f"[{self.name}] {addr[0]}:{addr[1]} → "
                  f"{orig_dst[0]}:{orig_dst[1]}")

            # upstream 연결
            upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            upstream.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
            upstream.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            upstream.bind((addr[0], 0))
            upstream.settimeout(5)
            upstream.connect(orig_dst)
            upstream.settimeout(None)

            # 양방향 릴레이
            self._relay(client, upstream)
        except Exception as e:
            print(f"[{self.name}] ERROR: {e}")
        finally:
            try: client.close()
            except: pass

    def _relay(self, a, b):
        def forward(src, dst):
            try:
                while True:
                    data = src.recv(4096)
                    if not data: break
                    dst.sendall(data)
            except: pass
            try: dst.shutdown(socket.SHUT_WR)
            except: pass

        t1 = threading.Thread(target=forward, args=(a, b), daemon=True)
        t2 = threading.Thread(target=forward, args=(b, a), daemon=True)
        t1.start(); t2.start()
        t1.join(); t2.join()


# ── UDP 핸들러 (DNS / QUIC) ──
class UDPHandler(threading.Thread):
    def __init__(self, port, name):
        super().__init__(daemon=True)
        self.port = port
        self.name = name

    def run(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
        sock.setsockopt(socket.SOL_IP, IP_RECVORIGDSTADDR, 1)
        sock.bind(("0.0.0.0", self.port))
        print(f"[{self.name}] UDP listening on :{self.port}")

        while True:
            data, ancdata, flags, addr = sock.recvmsg(4096, 1024)
            orig_dst = self._extract_orig_dst(ancdata)
            if orig_dst:
                print(f"[{self.name}] {addr[0]}:{addr[1]} → "
                      f"{orig_dst[0]}:{orig_dst[1]} ({len(data)}B)")
                self._forward_udp(data, addr, orig_dst)
            else:
                print(f"[{self.name}] 원본 목적지 추출 실패")

    def _extract_orig_dst(self, ancdata):
        for cmsg_level, cmsg_type, cmsg_data in ancdata:
            if cmsg_level == socket.SOL_IP and cmsg_type == IP_RECVORIGDSTADDR:
                family, port = struct.unpack("!HH", cmsg_data[:4])
                ip = socket.inet_ntoa(cmsg_data[4:8])
                return (ip, port)
        return None

    def _forward_udp(self, data, client_addr, orig_dst):
        try:
            fwd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            fwd.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
            fwd.bind((client_addr[0], 0))
            fwd.settimeout(3)
            fwd.sendto(data, orig_dst)
            resp, _ = fwd.recvfrom(4096)
            fwd.close()

            # 응답을 원본 목적지 IP로 클라이언트에 반환
            reply = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            reply.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1)
            reply.bind(orig_dst)
            reply.sendto(resp, client_addr)
            reply.close()
        except Exception as e:
            print(f"[{self.name}] forward error: {e}")


def main():
    handlers = [
        TCPHandler(9080, "HTTP/HTTPS"),
        UDPHandler(9053, "DNS"),
        UDPHandler(9443, "QUIC"),
    ]

    for h in handlers:
        h.start()

    print("[dispatcher] 다중 프로토콜 TPROXY 디스패처 시작")
    print("[dispatcher] TCP:9080(HTTP) UDP:9053(DNS) UDP:9443(QUIC)")

    try:
        for h in handlers:
            h.join()
    except KeyboardInterrupt:
        print("\n[dispatcher] 종료")


if __name__ == "__main__":
    main()
conntrack zone 분리가 필요한 이유:
  • TCP 443(HTTPS)과 UDP 443(QUIC)은 동일한 IP:port 조합을 사용합니다.
  • conntrack은 기본적으로 5-tuple(프로토콜, src/dst IP, src/dst port)로 연결을 추적하지만, TPROXY 환경에서 동일 IP:port의 TCP/UDP가 혼재하면 conntrack 엔트리가 충돌할 수 있습니다.
  • ct zone set N으로 프로토콜별 독립 conntrack 영역을 할당하면 이 문제를 방지합니다.

12-4. 실행 및 검증

# 1. 디스패처 실행
sudo python3 multiproto_dispatcher.py

# 2. HTTP 테스트
curl -v http://example.com/
# [HTTP/HTTPS] 192.168.100.2:xxxxx → 93.184.216.34:80

# 3. DNS 테스트
dig @8.8.8.8 example.com A
# [DNS] 192.168.100.2:xxxxx → 8.8.8.8:53 (42B)

# 4. QUIC/HTTP3 테스트 (curl 7.66+ with HTTP/3 지원)
curl --http3 -v https://cloudflare-quic.com/ 2>&1 | head -20
# [QUIC] 192.168.100.2:xxxxx → 104.16.xxx.xxx:443

# 5. conntrack zone 확인
sudo conntrack -L -z 2>/dev/null | head -5
# tcp  6 ... zone=1 src=... dst=... sport=... dport=80
# udp  17 ... zone=2 src=... dst=... sport=... dport=53
# udp  17 ... zone=3 src=... dst=... sport=... dport=443

# 6. nftables 카운터 확인
sudo nft list table ip multiproto

12-5. 정리 (Clean-up)

# 디스패처 종료
sudo pkill -f multiproto_dispatcher.py

# nftables 규칙 제거
sudo nft delete table ip multiproto

# 정책 라우팅 제거
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100

# conntrack 테이블 초기화
sudo conntrack -F

Lab 13: TPROXY 고가용성 (HA/Failover)

Lab 개요: VRRP(Virtual Router Redundancy Protocol)를 사용하여 TPROXY 프록시의 고가용성(HA) 구성을 실습합니다. keepalived로 VIP 페일오버를 구성하고, conntrackd로 세션 상태를 동기화하여 장애 시에도 기존 연결이 유지되도록 합니다.

13-1. HA 토폴로지

TPROXY HA: VRRP + conntrackd VIP: 192.168.100.100 VRRP 가상 IP (클라이언트 게이트웨이) 클라이언트 gw: 192.168.100.100 MASTER BACKUP MASTER (node-1) 192.168.100.11 keepalived (VRRP priority 100) TPROXY 프록시 (epoll/io_uring) conntrackd (세션 동기화) iptables/nftables TPROXY 규칙 BACKUP (node-2) 192.168.100.12 keepalived (VRRP priority 90) TPROXY 프록시 (대기) conntrackd (세션 수신) iptables/nftables TPROXY 규칙 conntrack 세션 동기화 Upstream 서버 (웹서버, DNS 등) 10.0.0.0/24 네트워크 VRRP 광고 (224.0.0.18)

13-2. 패키지 설치

# 두 노드 모두에서 실행
sudo apt-get install -y keepalived conntrack conntrackd

# 커널 파라미터 (IP_FREEBIND, 비로컬 IP 바인드 허용)
echo "net.ipv4.ip_nonlocal_bind = 1" | sudo tee -a /etc/sysctl.d/99-tproxy-ha.conf
echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.d/99-tproxy-ha.conf
sudo sysctl -p /etc/sysctl.d/99-tproxy-ha.conf

13-3. keepalived 설정 (MASTER — node-1)

# /etc/keepalived/keepalived.conf (node-1: MASTER)
global_defs {
    router_id TPROXY_HA_NODE1
    vrrp_garp_master_delay 5
    vrrp_garp_master_repeat 3
}

vrrp_script chk_tproxy {
    script "/usr/local/bin/check_tproxy.sh"
    interval 2          # 2초마다 체크
    weight -20          # 실패 시 priority -20
    fall 3              # 3회 연속 실패 시 DOWN 판정
    rise 2              # 2회 연속 성공 시 UP 복귀
}

vrrp_instance VI_TPROXY {
    state MASTER
    interface eth0
    virtual_router_id 51
    priority 100
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass tproxy_ha_secret
    }

    virtual_ipaddress {
        192.168.100.100/24 dev eth0
    }

    track_script {
        chk_tproxy
    }

    # 페일오버 시 conntrackd 동기화 트리거
    notify_master "/usr/local/bin/ha_notify.sh MASTER"
    notify_backup "/usr/local/bin/ha_notify.sh BACKUP"
    notify_fault  "/usr/local/bin/ha_notify.sh FAULT"
}

13-4. keepalived 설정 (BACKUP — node-2)

# /etc/keepalived/keepalived.conf (node-2: BACKUP)
global_defs {
    router_id TPROXY_HA_NODE2
    vrrp_garp_master_delay 5
    vrrp_garp_master_repeat 3
}

vrrp_script chk_tproxy {
    script "/usr/local/bin/check_tproxy.sh"
    interval 2
    weight -20
    fall 3
    rise 2
}

vrrp_instance VI_TPROXY {
    state BACKUP
    interface eth0
    virtual_router_id 51
    priority 90         # MASTER보다 낮은 priority
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass tproxy_ha_secret
    }

    virtual_ipaddress {
        192.168.100.100/24 dev eth0
    }

    track_script {
        chk_tproxy
    }

    notify_master "/usr/local/bin/ha_notify.sh MASTER"
    notify_backup "/usr/local/bin/ha_notify.sh BACKUP"
    notify_fault  "/usr/local/bin/ha_notify.sh FAULT"
}

13-5. 헬스체크 및 알림 스크립트

#!/bin/bash
# /usr/local/bin/check_tproxy.sh — TPROXY 프록시 헬스체크

# 프록시 프로세스 생존 확인
if ! pgrep -f "tproxy_epoll\|tproxy_uring\|tproxy_sni" > /dev/null; then
    exit 1
fi

# 리스닝 포트 확인
if ! ss -tlnp | grep -q ":8080 "; then
    exit 1
fi

# iptables/nftables TPROXY 규칙 존재 확인
if ! sudo iptables -t mangle -L PREROUTING -n 2>/dev/null | grep -q "TPROXY"; then
    if ! sudo nft list tables 2>/dev/null | grep -q "tproxy\|multiproto"; then
        exit 1
    fi
fi

exit 0
#!/bin/bash
# /usr/local/bin/ha_notify.sh — VRRP 상태 전환 알림

STATE=$1
LOGFILE="/var/log/tproxy_ha.log"

log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE"; }

case "$STATE" in
  MASTER)
    log "VRRP → MASTER 전환"
    # conntrackd: 대기 테이블을 활성 테이블로 커밋
    conntrackd -C /etc/conntrackd/conntrackd.conf -c
    conntrackd -C /etc/conntrackd/conntrackd.conf -f internal
    conntrackd -C /etc/conntrackd/conntrackd.conf -R
    conntrackd -C /etc/conntrackd/conntrackd.conf -B

    # TPROXY 프록시 시작 (아직 실행 중이 아니면)
    if ! pgrep -f tproxy_epoll > /dev/null; then
        /usr/local/bin/tproxy_epoll &
        log "TPROXY 프록시 시작"
    fi
    ;;
  BACKUP)
    log "VRRP → BACKUP 전환"
    conntrackd -C /etc/conntrackd/conntrackd.conf -c
    conntrackd -C /etc/conntrackd/conntrackd.conf -f internal
    conntrackd -C /etc/conntrackd/conntrackd.conf -R
    conntrackd -C /etc/conntrackd/conntrackd.conf -B
    ;;
  FAULT)
    log "VRRP → FAULT 발생"
    conntrackd -C /etc/conntrackd/conntrackd.conf -c
    conntrackd -C /etc/conntrackd/conntrackd.conf -f internal
    ;;
esac
# 스크립트 실행 권한 부여 (두 노드 모두)
sudo chmod +x /usr/local/bin/check_tproxy.sh
sudo chmod +x /usr/local/bin/ha_notify.sh

13-6. conntrackd 설정

# /etc/conntrackd/conntrackd.conf
Sync {
    Mode FTFW {
        DisableExternalCache Off
        CommitTimeout 1800
        PurgeTimeout 5
    }

    UDP {
        IPv4_address 192.168.100.11  # node-1 → .11, node-2 → .12
        IPv4_Destination_Address 192.168.100.12  # 피어 주소
        Port 3780
        Interface eth0
        SndSocketBuffer 1249280
        RcvSocketBuffer 1249280
        Checksum on
    }
}

General {
    Nice -20
    HashSize 32768
    HashLimit 131072
    LogFile on
    Syslog on

    LockFile /var/lock/conntrack.lock
    UNIX {
        Path /var/run/conntrackd.ctl
    }

    # TPROXY 관련 연결만 동기화
    Filter From Userspace {
        Protocol Accept {
            TCP
            UDP
        }
        Address Accept {
            IPv4_address 192.168.100.0/24
        }
    }
}
주의: conntrackd 설정에서 IPv4_addressIPv4_Destination_Address는 node-1과 node-2에서 서로 반대로 설정해야 합니다. node-2에서는 IPv4_address192.168.100.12, IPv4_Destination_Address192.168.100.11로 변경하세요.

13-7. IP_FREEBIND 설정

# IP_FREEBIND — VIP가 아직 할당되지 않은 상태에서도 바인드 허용
# TPROXY 프록시 코드에 추가 (C 예시)
int opt = 1;
setsockopt(fd, SOL_IP, IP_FREEBIND, &opt, sizeof(opt));

# 또는 커널 파라미터로 전역 설정
echo "net.ipv4.ip_nonlocal_bind = 1" | sudo tee -a /etc/sysctl.d/99-tproxy-ha.conf
sudo sysctl -p /etc/sysctl.d/99-tproxy-ha.conf

# Python 예시
# IP_FREEBIND = 15
# sock.setsockopt(socket.SOL_IP, 15, 1)
IP_FREEBIND vs IP_TRANSPARENT:
  • IP_FREEBIND — 아직 인터페이스에 할당되지 않은 IP 주소에 bind()를 허용합니다. VRRP 페일오버 시 VIP가 아직 없는 BACKUP 노드에서 프록시를 미리 시작할 수 있습니다.
  • IP_TRANSPARENT — 자신이 소유하지 않은 IP 주소에 bind()를 허용합니다. TPROXY에서 클라이언트 원본 IP로 바인드할 때 사용합니다.
  • HA 환경에서는 두 옵션을 모두 설정하는 것이 일반적입니다.

13-8. 서비스 시작

# 두 노드 모두에서 실행

# 1. TPROXY iptables 규칙 적용
sudo iptables -t mangle -A PREROUTING -p tcp --dport 80 \
  -j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule add fwmark 0x1 lookup 100
sudo ip route add local default dev lo table 100

# 2. TPROXY 프록시 시작
sudo /usr/local/bin/tproxy_epoll &

# 3. conntrackd 시작
sudo systemctl start conntrackd
sudo systemctl enable conntrackd

# 4. keepalived 시작
sudo systemctl start keepalived
sudo systemctl enable keepalived

# 5. 상태 확인
sudo systemctl status keepalived
sudo systemctl status conntrackd
ip addr show eth0 | grep "192.168.100.100"  # MASTER에서만 보여야 함

13-9. 페일오버 테스트

# ── 터미널 1: 지속 트래픽 생성 ──
while true; do
  curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
    http://192.168.100.100/
  sleep 0.5
done

# ── 터미널 2 (MASTER node-1): 장애 시뮬레이션 ──

# 방법 1: keepalived 중지
sudo systemctl stop keepalived
# → BACKUP(node-2)이 MASTER로 승격, VIP 이동
# → 터미널 1에서 트래픽 중단 없이 계속 200 응답 확인

# 방법 2: TPROXY 프록시 프로세스 종료
sudo pkill -f tproxy_epoll
# → vrrp_script 실패 → priority 하락 → BACKUP 승격

# 방법 3: 네트워크 인터페이스 다운
sudo ip link set eth0 down
# → VRRP 광고 중단 → BACKUP이 MASTER로 전환

# ── 터미널 3 (BACKUP node-2): 상태 모니터링 ──
sudo journalctl -u keepalived -f
# Keepalived_vrrp[xxxx]: VRRP_Instance(VI_TPROXY) Entering MASTER STATE

# conntrack 세션 확인
sudo conntrack -L 2>/dev/null | wc -l
# → MASTER에서 동기화된 세션 수가 보여야 함

# ── 복구 테스트 ──
# node-1에서 keepalived 재시작
sudo systemctl start keepalived
# → priority가 높으므로 다시 MASTER로 복귀 (preemption)
# → conntrackd가 세션 재동기화
테스트 시나리오 예상 페일오버 시간 세션 유지 확인 방법
keepalived 중지 1~3초 conntrackd 동기화된 세션 유지 ip addr show VIP 이동 확인
프록시 프로세스 종료 4~8초 (3회 체크 실패) 새 연결만 BACKUP에서 처리 journalctl -u keepalived
네트워크 다운 3~4초 (3x advert_int) conntrackd 세션 유지 conntrack -L 엔트리 확인
MASTER 복귀 1~2초 (preemption) 양방향 세션 동기화 conntrackd -s 통계 확인

13-10. 정리 (Clean-up)

# 두 노드 모두에서 실행

# 1. 서비스 중지
sudo systemctl stop keepalived
sudo systemctl stop conntrackd
sudo pkill -f tproxy_epoll || true

# 2. iptables 규칙 제거
sudo iptables -t mangle -D PREROUTING -p tcp --dport 80 \
  -j TPROXY --on-port 8080 --tproxy-mark 0x1/0x1
sudo ip rule del fwmark 0x1 lookup 100
sudo ip route del local default dev lo table 100

# 3. 설정 파일 복원
sudo rm -f /etc/keepalived/keepalived.conf
sudo rm -f /etc/conntrackd/conntrackd.conf
sudo rm -f /usr/local/bin/check_tproxy.sh
sudo rm -f /usr/local/bin/ha_notify.sh
sudo rm -f /etc/sysctl.d/99-tproxy-ha.conf
sudo sysctl -p

# 4. conntrack 테이블 초기화
sudo conntrack -F

# 5. 자동 시작 비활성화
sudo systemctl disable keepalived
sudo systemctl disable conntrackd

참고 자료

다음 학습 추천:
필수 관련 문서: 참고 문서: