TPROXY 완전 실습 랩

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

전제 조건: 이 실습을 진행하기 전에 다음 문서를 먼저 읽으세요. 실습 호스트는 root 권한이 필요하며, 커널 4.18 이상(eBPF Lab은 5.15 이상) 권장합니다.
일상 비유: TPROXY 실습은 도심 교차로를 축소해서 만든 모형 도로와 비슷합니다. 실제 도로를 막지 않고 작은 모형에서 우회, 차선 변경, 신호 실패를 반복 실험하듯이, 각 Lab도 netns와 테스트 프록시로 운영 환경의 패킷(Packet) 흐름을 안전하게 재현합니다.

핵심 요약

  • TPROXY 핵심 3요소 — mangle/PREROUTING TPROXY 타겟 + fwmark 기반 정책 라우팅 + IP_TRANSPARENT 소켓 옵션.
  • 원본 목적지 획득 — TCP는 getsockname(), UDP는 IP_RECVORIGDSTADDR + recvmsg()로 가져옵니다.
  • upstream 연결IP_TRANSPARENT 소켓으로 클라이언트 원본 IP에 bind 후 실제 서버에 connect합니다.
  • eBPF TPROXY — TC ingress에서 bpf_sk_redirect_map()/bpf_sk_assign()으로 소켓을 리다이렉트합니다.
  • 실전 도구 — Squid(tproxy 모드), Envoy(original_dst), HAProxy 모두 IP_TRANSPARENT를 사용합니다.

단계별 이해

  1. 환경 구성과 기본 TCP TPROXY
    네트워크 토폴로지·패키지·커널 옵션을 확인하고 iptables mangle + 정책 라우팅 + Python TCP 프록시로 원리를 체험합니다(Lab-env/Lab 1).
  2. UDP·nftables·netns
    UDP/DNS TPROXY, nftables 재작성, veth 기반 netns 격리까지 설정 방식을 확장합니다(Lab 2·3·4).
  3. C epoll·eBPF 투명 프록시
    epoll 기반 고성능 프록시와 TC hook의 BPF 소켓 리다이렉트를 구현합니다(Lab 5·6).
  4. 실전 도구 연동과 디버깅
    Squid/Envoy/HAProxy 배포와 고의 실패 시나리오 진단 훈련을 병행합니다(Lab 7·8).
  5. 확장 주제
    io_uring 프록시, TLS SNI 라우팅, 성능 벤치마크, 다중 프로토콜, keepalived 고가용성까지 운영 시나리오로 이어갑니다(Lab 9~13).
실습 목표: 각 Lab은 독립적으로 실행 가능합니다. Lab 1(기본 TCP)부터 순서대로 진행하거나, 관심 있는 Lab을 바로 실행할 수 있습니다. 각 Lab 끝에 정리(clean-up) 명령이 포함되어 있어 시스템 상태를 원래대로 복원할 수 있습니다.

실습 환경 구성

실습 네트워크 토폴로지

클라이언트 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으로 보낸 패킷(Packet)을 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를 사용하여 단일 호스트에서 클라이언트/라우터/서버 역할을 분리합니다. 프로덕션과 유사한 멀티-호스트 환경을 시뮬레이션합니다.
client netns veth-client 192.168.100.2/24 ip route default via 192.168.100.1 curl http://10.0.0.2/ veth 쌍 호스트 (TPROXY) veth-host 192.168.100.1 veth-srv-host 10.0.0.1 mangle/PREROUTING TPROXY -i veth-host --on-port 8080 fwmark 0x1 → table 100 → lo 프록시 :8080 (IP_TRANSPARENT) ip_forward = 1 veth 쌍 server netns veth-srv 10.0.0.2/24 ip route default via 10.0.0.1 python3 -m http.server 80 client netns 호스트 (TPROXY) server netns veth 쌍 연결

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

소스 코드: 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()을 사용합니다.
외부 패킷 dst :80 (TCP) eth0 NIC 수신 TC ingress hook tproxy_kern.o (BPF) parse ETH → IP → TCP dport == 80 ? bpf_sk_assign() → :8080 리다이렉트 프록시 소켓 :8080 (IP_TRANSPARENT) getsockname() → 원본 dst upstream 대상 서버 :80 비교: 기존 iptables 경로 패킷 → NIC → netfilter PREROUTING → mangle TPROXY → 정책 라우팅(fwmark) → 프록시 소켓 eBPF TC hook (iptables 대체) 프록시 소켓 대상 서버
요구 사항: 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가지 실패 시나리오를 의도적으로 재현하고, 진단 명령으로 원인을 파악하고 해결합니다.
정상 패킷 흐름 클라이언트 curl :80 mangle TPROXY fwmark 0x1 설정 정책 라우팅 fwmark → table 100 → lo 프록시 :8080 IP_TRANSPARENT bind 대상 서버 :80 실패 지점 (시나리오별) 시나리오 1 정책 라우팅 누락 패킷 미도달 시나리오 2 IP_TRANSPARENT 누락 EACCES / bind 실패 시나리오 3 루프 발생 프록시→자신 재진입 시나리오 4 UDP conntrack 충돌 DNS 응답 드롭 진단 도구 ip rule list / ip route get ss -tnp / capsh --print iptables -L / conntrack -L tcpdump / nft list ruleset 정상 경로 실패 지점 진단 명령

시나리오 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) 대비 시스템 콜(System Call) 오버헤드를 대폭 줄이고, 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 — 커널 스레드(Kernel Thread)가 SQ를 폴링(Polling)하여 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
처리량(Throughput) (RPS) ~45,000 RPS ~62,000 RPS (+38%) wrk -t4 -c100 기준 참고값
지연(Latency) 시간 (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를 복호화(Decryption)하지 않으므로 인증서가 필요 없습니다.

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 — 소켓 버퍼(Buffer)에서 데이터를 소비하지 않고 읽습니다. 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가 암호화(Encryption)되어 이 방법으로 추출할 수 없습니다. 현재 대부분의 환경에서는 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(대역폭(Bandwidth)), perf(커널 프로파일링(Profiling))를 사용하여 처리량, 지연 시간, 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 재전송(Retransmission) 측정값 측정값 +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 디스패처로 각 프로토콜을 적절한 핸들러(Handler)에 라우팅합니다.

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()
코드 설명
  • 핵심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 이동 확인
프록시 프로세스(Process) 종료 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

참고 자료

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