IT/Kubernets

Cilium kube-proxy replacement, 내부에서는 무슨 일이 벌어지나

gfrog 2026. 6. 17. 21:43
SMALL

 

kube-proxy를 Cilium으로 교체한 지 1년이 좀 넘었다. 처음엔 단순히 "iptables 룰이 많아져서 느려지니까 eBPF로 바꾸자" 정도의 인식이었는데, 운영하면서 보니 동작 모델 자체가 완전히 다르다. ClusterIP 패킷이 어디서 어떻게 처리되는지, 왜 socket-level LB가 그렇게 강조되는지, DSR은 왜 켰을 때 갑자기 응답이 안 오는지 — 이런 걸 제대로 알아야 디버깅이 가능했다.

이번 글은 우리 팀에서 KubeCon EU 2026 직후 사내 발표용으로 정리한 내용을 다시 풀어쓴 거다. 코드 예제보다는 패킷이 흐르는 경로를 추적하는 데 집중했다.

kube-proxy의 한계, 그리고 왜 eBPF가 답이 됐나

kube-proxy iptables 모드는 Service 하나당 여러 개의 chain을 만든다. KUBE-SERVICES → KUBE-SVC-XXX → KUBE-SEP-YYY 같은 식이다. 패킷은 PREROUTING에서 시작해 이 체인들을 선형으로 통과한다. Service가 100개면 그럭저럭이고, 1000개를 넘기면 "이게 정말 O(n)인가?" 싶을 정도로 체감이 온다. 우리 팀 내부 측정으로는 Service 2,000개, Endpoint 8,000개 클러스터에서 패킷당 약 60μs가 룰 매칭에만 쓰였다.

ipvs 모드는 해시 기반이라 O(1) 가깝지만, 여전히 netfilter conntrack을 거치고, Pod IP가 바뀔 때마다 ipvsadm을 거쳐 동기화한다. 갱신 지연이 수백 ms 단위로 튄다.

Cilium kube-proxy replacement는 BPF hash map을 LB 백엔드 자료구조로 쓴다. Service ID와 백엔드 슬롯이 키, 백엔드 IP/포트가 값이다. 갱신은 control plane이 map entry를 in-place로 바꾸는 것뿐이라, 갱신 자체도 빠르고 lookup도 O(1)이다. 여기까지는 모두가 아는 얘기.

ClusterIP 패킷의 진짜 경로

흥미로운 건 패킷이 "어디서 LB 결정이 일어나는가"다. Cilium은 두 가지 모드를 동시에 운용한다.

같은 Pod 안에서 ClusterIP를 호출할 때 — connect(2) syscall 시점에 cgroup eBPF 프로그램(cgroup/connect4)이 끼어든다. 이 훅은 syscall 인자에 들어있는 목적지 주소를 직접 백엔드 IP로 바꿔치기한다. 즉 NAT가 아니라 "처음부터 다른 주소로 연결을 만든다"는 발상이다. 이게 socket-based LB, 줄여서 sock LB다.

이게 왜 중요하냐면, 일반적인 NAT 모드에서는 conntrack 항목이 패킷마다 매칭되어야 하고, return 패킷에서 SNAT 역변환도 필요하다. sock LB는 그 전체 단계가 없다. 소켓이 처음부터 백엔드 주소로 열리므로 return 패킷도 그냥 직접 돌아온다. 커널 네트워크 스택을 한 번도 안 거친다고 봐도 될 정도다.

외부에서 들어오는 패킷이나 호스트 네트워크에서 ClusterIP를 부르는 경우 — connect 훅이 안 걸린다(예: hostNetwork Pod, 외부 트래픽). 이때는 tc(traffic control) eBPF 프로그램이 nic ingress에서 LB 결정을 내린다. 이쪽도 conntrack은 BPF map으로 별도 관리해서, netfilter conntrack을 거의 안 쓴다.

처음 봤을 때 헷갈렸던 게, 같은 Service라도 호출자가 어디 있느냐에 따라 다른 경로로 처리된다는 점이다. 디버깅할 때는 cilium service list만 보면 안 되고, cilium bpf lb list, cilium bpf ct list global 도 같이 확인해야 한다.

DSR이 켜졌을 때 일어나는 일

NodePort나 LoadBalancer Service에서 DSR(Direct Server Return)을 활성화하면, 외부 클라이언트 → 노드 A → 백엔드 Pod(노드 B에 있음) 경로에서 응답이 노드 A를 거치지 않고 노드 B → 클라이언트로 직접 돌아간다. SNAT가 없으니 source IP가 보존된다는 게 핵심 장점이다.

내부적으로는 IP 옵션이나 IPv6 destination option 헤더에 원래 클라이언트 정보를 끼워 넣어서 백엔드로 보낸다. 백엔드 노드에서 eBPF 프로그램이 그 정보를 읽어 응답 패킷의 source를 가짜 VIP로 위장한다. 그래서 클라이언트는 자기가 보낸 VIP에서 응답이 온 것처럼 받는다.

여기서 우리가 한번 크게 당한 적이 있다. 클라우드의 ENI 보안 그룹이 IP 옵션 들어간 패킷을 silently drop했다. 노드 간 트래픽은 멀쩡한데 외부 클라이언트의 응답만 안 돌아오는 증상이었다. 해결책은 DSR 모드를 geneve로 바꾸는 거였다 — IP 옵션 대신 geneve 캡슐화로 메타데이터를 운반한다. Cilium 1.14부터 geneve DSR이 안정화됐고, 우리는 1.16에서 이걸 적용했다.

XDP, 그리고 왜 일부 노드만 빠른가

NodePort 트래픽을 더 빠르게 처리하려고 XDP 모드를 켤 수 있다. XDP는 NIC 드라이버 단계, 즉 skb(socket buffer) 할당 에 패킷을 본다. 여기서 LB 결정을 내리고 다른 노드로 forward하면 그 노드의 커널은 패킷을 아예 보지도 않는다.

문제는 NIC 드라이버가 XDP native 모드를 지원해야 진짜 효과가 나온다는 거다. AWS의 ena 드라이버는 지원하지만, 일부 VM 타입에서는 generic 모드로 fallback돼서 오히려 살짝 느려진 경우도 있었다. 노드 풀별로 bpftool prog list | grep xdp 찍어보면서 native인지 generic인지 확인하는 게 좋다.

운영하면서 알게 된 것들

문서에 잘 안 나오는데 실제로 부딪힌 것들 몇 개.

hostPort Service의 동작이 다르다. kube-proxy 시절엔 iptables PREROUTING DNAT였는데, Cilium에서는 BPF map 기반이라 iptables -t nat -L 에 아무것도 안 보인다. 처음 마이그레이션 직후에 보안팀이 "iptables 룰이 다 사라졌다"고 알람을 띄웠다. 정상이다.

Headless Service(clusterIP: None)는 여전히 DNS만 사용한다. eBPF가 끼어들지 않는다. 가끔 "왜 이 Service만 sock LB가 안 걸리지?" 하다가 알게 됐다.

externalTrafficPolicy: Local은 sock LB랑 궁합이 좀 묘하다. 호스트 네트워크에서 호출되는 경우엔 tc 훅이 Local 정책을 보고 같은 노드 백엔드만 선택하는데, Pod 내부에서 호출되면 connect 훅이 클러스터 전체 백엔드 중에서 고른다. 의도된 동작이지만 트래픽 분포가 비대칭으로 보일 수 있다.

마치며

eBPF 기반 LB는 "iptables가 빠른 자료구조로 바뀐 것"이 아니라, 패킷이 처리되는 시점 자체가 앞당겨진 거다. Service Mesh 사이드카 vs Ambient의 차이와 비슷한 결의 변화다. 한 번 이해하고 나면 cilium-dbg로 BPF map을 직접 들여다보면서 문제를 좁히는 게 의외로 쉬워진다.

내부 동작을 더 파고들고 싶다면 Cilium 공식 docs의 "kube-proxy replacement" 섹션과, isovalent 블로그의 "BPF socket-level load balancing" 글이 진입점으로 좋다. 코드를 직접 보고 싶으면 bpf/bpf_sock.c, bpf/lib/lb.h 부터 시작하면 된다.

다음엔 Cilium의 ClusterMesh 동작 — 여러 클러스터 간 Service를 어떻게 한 BPF map으로 묶는지 — 를 한 번 정리해볼 생각이다.

BIG