eBPF 목적지 리다이렉트 — connect() syscall을 커널에서 바꿔치기하는 원리
Keploy, Cilium, Istio가 쓰는 투명 프록시의 핵심 메커니즘
Keploy가 "코드 수정 제로"를 실현하는 핵심 트릭이 eBPF 목적지 리다이렉트다. 이건 Keploy만의 기술이 아니다. Cilium(쿠버네티스 CNI), Istio(서비스 메시)도 같은 원리를 쓴다.
왜 이게 가능한가 — syscall의 구조
앱이 네트워크 통신을 할 때 커널에 요청하는 방식은 딱 정해져 있다:
socket()— 소켓 생성connect(fd, addr, addrlen)— 목적지 주소로 연결send()/recv()— 데이터 송수신
connect()의 두 번째 인자 addr에 목적지 IP와 포트가 들어간다. postgres:5432면 {AF_INET, 5432, 10.0.0.5} 같은 구조체가 된다.
eBPF는 이 connect() syscall이 실행되는 바로 그 순간에 훅을 걸 수 있다. 커널이 TCP 핸드셰이크를 시작하기 전에, addr 구조체를 직접 수정할 수 있다.
Keploy의 구현 — 3개의 eBPF 맵
Keploy 소스(pkg/agent/hooks/linux/)를 보면, eBPF 프로그램이 3개의 공유 맵을 사용한다:
clientRegistrationMap — Keploy가 감시할 프로세스(앱)를 등록. PID 기반으로 "이 프로세스의 네트워크만 가로챌 것"이라고 알려준다.
agentRegistrationMap — Keploy 에이전트 자신을 등록. "이 프로세스(프록시)의 트래픽은 건들지 마라" — 무한 루프 방지.
redirectProxyMap — 핵심 맵. Key는 소스 포트, Value는 원래 목적지 정보(DestInfo 구조체). eBPF가 목적지를 바꿔치기할 때 원래 정보를 여기에 저장한다.
리다이렉트 순서
1. 앱: connect(postgres:5432) 호출
2. eBPF connect4 훅 발동
3. clientRegistrationMap 확인 → 이 PID가 감시 대상인가? Yes
4. redirectProxyMap에 저장: {srcPort: 54321} → {ip: 10.0.0.5, port: 5432}
5. addr 구조체를 수정: {10.0.0.5:5432} → {127.0.0.1:16789}
6. 커널이 수정된 addr로 TCP 핸드셰이크 시작
7. 앱은 postgres에 연결된 줄 알지만, 실제로는 로컬 프록시에 연결됨
앱 프로세스의 메모리에는 원래 목적지가 그대로 있다. 커널 레벨에서만 바꿔치기가 일어나니까 앱은 절대 알 수 없다.
프록시가 원래 목적지를 복원하는 방법
Keploy의 투명 프록시(pkg/agent/proxy/)가 연결을 받으면:
- 클라이언트의 소스 포트를 확인 (예: 54321)
GetDestinationInfo(54321)— eBPF 맵에서 원래 목적지 조회- 결과:
{ip: 10.0.0.5, port: 5432}— postgres 서버 - 프록시가 실제 postgres에 연결
- 앱 ↔ 프록시 ↔ postgres 사이의 트래픽을 중계하면서 녹화
이게 "투명 프록시"라 불리는 이유다. 앱도 서버도 프록시의 존재를 모른다.
SockOps — cgroup 레벨 소켓 감시
connect4/6만으로는 부족하다. SockOps eBPF 프로그램은 cgroupv2에 부착되어 컨테이너 안의 모든 소켓 이벤트를 감시한다.
Docker 컨테이너로 앱을 실행하면, 컨테이너의 cgroup에 SockOps가 자동으로 붙는다. 컨테이너 안에서 일어나는 모든 connect, accept, close 등의 소켓 이벤트가 eBPF를 거친다.
무한 루프 방지
여기서 한 가지 문제가 있다. 프록시도 connect()를 호출해서 실제 postgres에 연결한다. 이것도 eBPF에 걸리면? 프록시 → 프록시 → 프록시 → ... 무한 루프.
agentRegistrationMap이 이걸 막는다. Keploy 에이전트(프록시) 프로세스의 PID가 여기에 등록되어 있으면, eBPF가 해당 프로세스의 connect()는 건너뛴다.
기존 프록시 방식과의 차이
mitmproxy, Charles, VCR 같은 기존 도구들은:
HTTP_PROXY환경변수 설정 필요또는 앱에 SDK/라이브러리 설치 필요
또는 DNS를 조작해서 트래픽을 우회
전부 앱이나 환경을 수정해야 한다. eBPF 방식은 커널 레벨에서 동작하니까 앱도 환경도 안 건드린다. keploy record 없이 실행하면 eBPF 훅이 안 붙으니 프록시도 안 뜬다. 완전히 투명하다.
Linux 전용인 이유
eBPF는 Linux 커널의 기능이다. 정확히는 Linux 3.15에서 도입되어 5.x에서 대폭 확장된 커널 내 가상 머신이다. macOS의 XNU 커널, Windows의 NT 커널에는 존재하지 않는다.
macOS에서 개발한다면 Docker Desktop을 쓰면 된다. Docker Desktop은 내부적으로 Linux VM을 돌리고, 그 안에서 eBPF가 작동한다. 다만 Docker로 한 번 더 감싸야 하는 부담이 생긴다.
CI/CD(GitHub Actions, Jenkins 등)에서는 대부분 Linux 러너를 쓰니까 문제없다.
Ruby에서 Go로
eBPF connect4 훅이 connect() syscall 실행 시점에 발동 — addr 구조체의 목적지 IP:port를 프록시 주소로 덮어씀
원래 목적지는 redirectProxyMap(eBPF 공유 맵)에 {소스포트 → 원래 IP:port}로 저장
프록시가 GetDestinationInfo(srcPort)로 원래 목적지 복원 → 실제 서버에 포워딩하면서 녹화
agentRegistrationMap으로 프록시 자신의 connect()는 eBPF가 스킵 — 무한 루프 방지
장점
- ✓ 앱 코드/설정/환경변수 수정 제로 — 커널에서 동작하니까 유저스페이스를 안 건드린다
- ✓ 언어/프레임워크 무관 — 모든 프로세스가 connect() syscall을 쓰니까
단점
- ✗ Linux 커널 5.15+ 필수 — macOS(XNU), Windows(NT)에는 eBPF가 없다
- ✗ 네트워크 소켓 통신만 가로챌 수 있다 — SQLite(파일 I/O), Unix 도메인 소켓(일부)은 대상 외