Post

🤯 아직도 iptables 늪에서 허우적대시나요? eBPF가 리눅스 커널의 멱살을 잡고 캐리하는 작동 원리

🤯 아직도 iptables 늪에서 허우적대시나요? eBPF가 리눅스 커널의 멱살을 잡고 캐리하는 작동 원리

🔗 Reference Links

🔥 1. Kube-proxy의 비명 소리, 들어보셨나요? (The Hook)

새벽 3시, 온콜(On-call) 알림이 울립니다. “API 서버 응답 지연 발생”. App 로그는 깨끗하고, DB 슬로우 쿼리도 없습니다. 범인은 네트워크. 클러스터 노드에 접속해 iptables-save | wc -l을 치는 순간, 10만 줄이 넘어가는 룰셋을 보며 헛웃음이 나옵니다.

네, 우리가 매일 쓰는 쿠버네티스 kube-proxy의 민낯입니다. 서비스(Service) 하나를 띄울 때마다 추가되는 무수한 iptables 룰들은 리눅스 넷필터(Netfilter)를 통과하며 패킷을 리니어하게(O(N)) 검사합니다. 트래픽이 몰리면? 커널이 패킷 길을 찾느라 CPU를 다 써버리죠.

“리눅스 커널을 수정하지 않고, 커널의 동작을 내 마음대로 조작할 수는 없을까?” 이것이 모든 인프라 엔지니어들의 오랜 갈증이었습니다.

💡 2. eBPF: 리눅스 커널에 상륙한 자바스크립트 (TL;DR)

한 마디로 요약하자면, eBPF는 ‘리눅스 커널을 위한 자바스크립트’입니다.

웹 브라우저가 DOM을 조작하기 위해 자바스크립트 엔진(V8)을 샌드박스 형태로 돌리듯, 리눅스 커널도 시스템 콜, 네트워크 스택, 커널 함수 호출(Kprobes) 이벤트가 발생할 때마다 여러분이 짠 코드를 커널 스페이스에서 직접 실행해 줍니다. 재부팅? 필요 없습니다. 커널 모듈 작성? 안 해도 됩니다. 완전히 고립된 샌드박스 내에서 빛의 속도로 동작하는 커널 확장 플러그인인 셈이죠.

🛠️ 3. Under the Hood: eBPF는 어떻게 커널을 해킹(?)하는가

솔직히 처음 이 아키텍처를 봤을 땐 의구심이 들었습니다. “유저가 짠 코드를 커널 코어에서 돌린다고? 널 포인터 참조 한 번이면 커널 패닉(Kernel Panic)으로 서버가 뻗어버릴 텐데?” 현업에서 리눅스 커널 모듈을 다뤄보신 분들이라면 이 불안감에 100% 공감하실 겁니다.

이 불안감을 해소하고 eBPF를 마법으로 만들어주는 핵심 기믹이 바로 BPF Verifier(검증기)JIT(Just-In-Time) 컴파일러입니다.

작동 파이프라인 심층 해부:

  1. C/Rust 언어 작성: 개발자가 제한된 기능만 허용된 C 언어(혹은 Rust)로 eBPF 프로그램을 작성합니다.
  2. LLVM 컴파일: 이를 x86이나 ARM 같은 아키텍처 종속적인 어셈블리가 아닌, 범용적인 eBPF 전용 바이트코드(Bytecode)로 컴파일합니다.
  3. bpf() 시스템 콜: 유저 스페이스에서 이 바이트코드를 커널로 밀어 넣습니다.
  4. BPF Verifier 🌟 (통곡의 벽): 여기가 아키텍처의 꽃입니다! 커널이 코드를 분석해 무한 루프가 없는지, 분기문이 유효한지, 허가되지 않은 메모리 영역(Out-of-bounds)을 찌르지 않는지 악랄할 정도로 깐깐하게 시뮬레이션하고 검증합니다. 여기서 통과하지 못하면 커널은 코드 적재를 가차 없이 거부합니다.
  5. JIT 컴파일 & 실행: 검증을 통과하면 커널 내부에 있는 JIT 컴파일러가 이를 네이티브 머신 코드로 변환하여 오버헤드 없이 미친 듯한 속도로 실행합니다.

BPF Map (상태 공유의 마법): 이때 eBPF 프로그램은 커널 스페이스에서 돌지만, 그 설정값이나 수집한 메트릭을 유저 스페이스와 어떻게 통신할까요? 바로 BPF Map이라는 Key-Value 자료구조를 사용합니다. 유저 스페이스 앱이 BPF Map에 ‘차단할 IP 목록’을 Update 해두면, 커널 단의 eBPF 프로그램이 패킷을 받을 때마다 이 Map에서 Lookup하여 실시간으로 동작을 결정합니다.

📊 아키텍처 비교: iptables vs eBPF (Cilium)

이론만 들으면 와닿지 않죠. 왜 Kube-proxy 대신 eBPF 기반의 Cilium이 업계 표준이 되고 있는지 표로 정리해 봤습니다.

비교 항목Legacy (iptables / Kube-proxy)Modern (eBPF / Cilium)
패킷 라우팅 복잡도O(N) (룰이 수만 개로 늘어날수록 성능 수직 낙하)O(1) (BPF Map 해시 테이블을 통한 즉각적인 룩업)
패킷 개입 지점TCP/IP 스택을 전부 거치고 메모리 할당 후 Netfilter 처리네트워크 카드(NIC)에서 패킷을 받자마자 XDP 레벨에서 즉시 처리
Context Switch유저/커널 스페이스 간 잦은 전환으로 CPU 오버헤드 큼커널 내부에서 패킷을 조작하고 바로 포워딩하여 오버헤드 Zero
관측성(Observability)IP, 포트 등 제한적인 L4 수준의 패킷 정보만 확인 가능앱 수정 없이 L7(HTTP, gRPC, 쿼리) 레벨의 심도 있는 메트릭 추출

💻 Under the Hood: XDP로 DDoS 트래픽 O(1) 드랍하기

말뿐인 추상적 설명은 질색입니다. 네트워크 카드로 들어오는 악성 패킷을 리눅스 커널이 인지하기도 전에(즉, sk_buff 구조체를 메모리에 할당하기도 전에) 드랍시켜버리는 가장 로우레벨(XDP - eXpress Data Path)의 eBPF C 코드를 보시죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

SEC("xdp")
int drop_malicious_ip(struct xdp_md *ctx) {
    // 실제로는 BPF Map에서 IP를 조회해야 하지만, 원리 이해를 위해 단순화했습니다.
    // 들어오는 모든 패킷을 빛의 속도로 버립니다. (CPU 부하 0에 수렴)
    bpf_printk("Drop packet before kernel even knows it!
");
    return XDP_DROP;
}

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

유저 스페이스에서 이 코드를 커널로 밀어 넣는 Python(BCC 라이브러리 활용) 로더(Loader) 코드는 이렇습니다.

1
2
3
4
5
6
7
8
9
10
from bcc import BPF

# 1. 컴파일된 eBPF C 코드 바이트코드 로드
b = BPF(text=ebpf_c_code)

# 2. XDP 훅에 프로그램 부착 (네트워크 인터페이스 eth0)
b.attach_xdp(dev="eth0", fn=b.get_syscall_fnname("drop_malicious_ip"))

print("🚀 XDP eBPF 프로그램이 커널에 주입되었습니다! 악성 패킷 드랍 모니터링 중...")
b.trace_print()

이 짧은 코드가 컴파일되어 NIC 드라이버 레벨에 꽂히는 순간, 초당 수백만 번의 SYN Flooding 공격도 서버 CPU를 거의 쓰지 않고 방어해 냅니다. 기존 iptables로는 상상도 할 수 없는 아키텍처적 우위죠.

🎯 4. 실전! 현업에서는 어떻게 써먹을까? (Pragmatic Use Cases)

시나리오 A: 대규모 트래픽 스파이크 시의 마이크로서비스 라우팅 블랙 프라이데이 이벤트로 트래픽이 100배 폭증했다고 가정해 봅시다. k8s 파드(Pod)가 1,000개에서 5,000개로 스케일 아웃됩니다. iptables 환경에서는 노드마다 수만 개의 룰이 업데이트되며 전체 네트워크가 락(Lock)에 걸리고 멈칫거립니다. 반면 eBPF를 적용한 클러스터는 노드의 BPF Map(단순한 Key-Value 해시 테이블)에 Pod IP 하나만 O(1)으로 ‘띡’ 추가하고 끝납니다. 트래픽 폭주 중에도 네트워크 지연 시간(Latency) 그래프가 평온하게 일자(Flat)를 유지합니다.

시나리오 B: 레거시 코드 건드리지 않고 분산 트레이싱(Tracing) 달기 “옆 팀이 10년 전에 만든 C++ 레거시 서버에서 HTTP 500 에러가 간헐적으로 나는데, 프로메테우스 메트릭을 달아줄 수 있나요? (소스코드 수정 없이요)” 보통은 불가능하다고 하겠죠. 하지만 eBPF의 uprobes를 사용하면 가능합니다. 유저 스페이스 애플리케이션의 특정 함수(예: HTTP 핸들러) 메모리 주소에 eBPF 훅을 걸어버립니다. 앱 개발자는 코드를 단 한 줄도 수정하지 않았는데, 인프라 엔지니어가 밖에서 HTTP 응답 시간, 상태 코드, gRPC 페이로드 등을 훔쳐와(?) 대시보드에 띄울 수 있습니다.

더 소름 돋는 건, 암호화된 HTTPS 트래픽조차 OpenSSL 라이브러리의 SSL_readSSL_write 함수에 훅을 걸면, 커널이 암호화하기 직전/직후의 평문 데이터를 가로채 로깅할 수 있다는 점입니다. 이것이 ‘Zero-instrumentation’의 진정한 무서움이자 가치입니다.

⚖️ 5. 시니어의 깐깐한 시선: 이면에 숨겨진 Trade-offs

현업에서 구르다 보면 늘 그렇듯 ‘은통알(Silver Bullet)’은 없습니다. 아키텍처가 우수하다고 무작정 도입하기엔 꽤 무거운 트레이드오프들이 존재합니다.

  • BPF Verifier라는 통곡의 벽: 커널 보호를 명목으로 Verifier가 코드를 튕겨낼 때 뱉어내는 에러 메시지(예: R1 type=ctx expected=fp)는 최악의 개발자 경험(DX)을 자랑합니다. 메모리 바운드 체크 로직을 조금만 잘못 짜도 컴파일은 되는데 커널이 적재를 거부하는 환장할 상황을 마주하게 됩니다. 포인터 연산의 자유도가 극도로 제한되기 때문에 숙련된 C 개발자라도 꽤나 애를 먹습니다.
  • 파편화된 커널 버전과 CO-RE의 한계: eBPF 생태계는 ‘Compile Once, Run Everywhere(한 번 컴파일해서 어디서든 실행)’를 외치며 BTF(BPF Type Format)를 도입했습니다. 하지만 RHEL 7(커널 4.x)과 Ubuntu 24.04(커널 6.x) 사이의 구조체 오프셋 차이를 완벽히 극복하는 것은 여전히 험난합니다. 최신 eBPF 기능을 쓰려면 결국 서버 운영체제의 커널 버전을 최신으로 끌어올려야 하는 강력한 선결 조건이 붙습니다.
  • 무서운 벤더 락인(Vendor Lock-in): eBPF 기반의 관측/보안 생태계를 특정 툴(예: Isovalent의 상용 Cilium 기능이나 Datadog의 네트워크 모니터링)에 깊게 의존하게 되면, 추후 커스텀 로직을 넣거나 다른 오픈소스 솔루션으로 마이그레이션할 때 사실상 네트워크 인프라 전체를 뜯어고치는 ‘재건축’ 수준의 비용을 치러야 합니다.

🏁 6. Closing Thoughts: 변화는 이미 시작되었습니다

솔직히 말해서, 대부분의 백엔드나 프론트엔드 개발자가 생태계의 밑바닥인 eBPF C 코드를 직접 짤 일은 앞으로도 거의 없을 겁니다. 이미 앞서 언급한 Cilium, Pixie, Tetragon 같은 훌륭한 추상화 도구들이 생태계를 든든하게 받치고 있으니까요.

하지만 기술의 ‘원리’를 아는 것과 블랙박스로 두는 것은 천지 차이입니다. 인프라가 어떻게 트래픽을 O(1)로 라우팅하는지, 어떻게 내 애플리케이션 코드를 수정하지 않고도 성능 메트릭을 뽑아갈 수 있는지 그 밑바닥의 아키텍처를 이해하는 엔지니어는 위기 상황에서 빛을 발합니다. 원인 모를 트러블슈팅 상황에서 남들이 죄다 iptables 로그나 애플리케이션 로그만 뒤지고 있을 때, 정확히 eBPF 훅 지점이나 커널 사이드의 병목을 의심하고 입체적인 해결책을 찾아낼 수 있기 때문이죠.

Kube-proxy는 이미 은퇴 수순을 밟고 있습니다. 클라우드 네이티브 네트워크와 옵저버빌리티의 패러다임은 Netfilter에서 eBPF로 완전히 넘어왔습니다. 이 거대한 아키텍처적 파도 위에서 여러분의 멘탈 모델도 한 단계 깊이 업데이트해 보시길 강력히 권합니다. 🚀

References

  • https://ebpf.io/
  • https://cilium.io/
  • https://github.com/iovisor/bcc
This post is licensed under CC BY 4.0 by the author.