주의) 이 글은 eBPF 자체의 동작 원리나 쿠버네티스 자체의 동작 원리를 설명하는 글이 아니다.
Cilium은 쿠버네티스를 위한 eBPF 기반 네트워킹·보안·가시성(Observability) 플랫폼이다. 쿠버네티스에 배포된 파드는 저마다 IP를 가진 채로 죽고 다시 뜨기를 반복한다. 그때마다 IP가 바뀌고, 배포 한 번에 수십 개의 IP가 사라지고 새로 생기기 때문에 전통적인 제어 규칙인 IPTables는 그때마다 재작성이 필요하다. 이는 성능 저하로 이어질 수 있다. 더 큰 문제는 수시로 변하는 IP만으로는 “이 IP가 어떤 워크로드인가”를 정의하기 어렵다는 점이다.
Cilium은 이 문제를 IP 대신 Identity라는 개념으로 풀어낸다. Identity는 IP 주소가 아니라 파드(Pod)의 쿠버네티스 라벨(Label, 예: app=frontend)을 기반으로 부여되는 숫자 형식의 보안 식별자다. Cilium은 이 Identity 값을 기준으로 정책을 만들고 평가한다. 즉 파드가 100개로 늘어나도 정책 자체는 그대로 유지된다. 이를 정책으로 표현하면 다음과 같다. app=frontend Identity가 app=backend Identity와 통신 가능/불가능. 파드의 IP가 바뀌어도 라벨이 유지되면 같은 Identity를 사용하므로 정책을 수정할 필요가 없다. 이러한 Identity는 클러스터 내에서 전역으로 관리되는 값이다.
Identity 외에 추가로 알아야 할 개념이 Endpoint다. 컨테이너가 동일한 IP 주소를 공유하는 파드 단위를 Cilium에서는 Endpoint라고 부른다. Cilium은 클러스터 노드마다 개별 Endpoint ID를 할당하고 관리한다.
정리하면, 정책의 표현은 Identity 기준으로 만들고, 그 정책이 실제로 적용되는 대상은 Endpoint라고 설명할 수 있다. 예를 들어 아래와 같은 정책이 있다고 가정한다.
Cilium은 app: backend 라벨을 Identity로 변환한다. 즉 내부적으로는 Identity(12345, app=backend) -> Identity(6789, app=netshoot) 통신을 허용으로 처리하는 방식이다.
전통적인 쿠버네티스의 패킷 처리는 커널의 IPTables를 거친다. IPTables는 규칙 체인에 패킷이 도달하면 체인을 선형적으로 탐색해 판단하는데, 이는 대규모 서비스에서 병목이 될 수 있다. Cilium은 기존 IPTables 사용을 걷어내고 eBPF 프로그램을 커널의 특정 지점에 부착해 패킷을 처리한다. 이 부분은 뒤에서 다시 자세히 살펴본다. 내부 트래픽은 cgroup/connect를 후킹해서 처리하고, 외부 트래픽은 TC 혹은 TCX 계층을 후킹(eBPF 프로그램 부착)해서 처리한다. 그 결과 커널 네트워크 스택을 이른 시점에 종료시키고, IPTables처럼 선형적으로 탐색하는 구간도 사라져 속도 면에서 개선을 이뤘다. 즉 Cilium은 Pod to Pod 통신뿐 아니라 기존 kube-proxy가 제공하던 Service to Pod 기능까지 함께 수행한다.
앞으로 Cilium을 설명할 때 IP, Endpoint, Identity라는 세 가지 개념이 자주 등장하므로 헷갈리지 않도록 미리 정의하고 넘어간다.
Identity: 무엇인가(정체). 라벨에서 유도되며 클러스터 전역에서 같은 의미를 갖는다. 같은 라벨을 가진 파드 100개는 하나의 Identity를 공유한다.
Endpoint: 이 노드 위의 워크로드 하나(노드 로컬 개념). 파드가 뜨면 그 노드에 Endpoint가 생기고 번호가 붙는다.
IP: 어디에 있는가(위치). 파드마다 다르고 자주 바뀐다.
이번 Cilium 시리즈에서 쿠버네티스에 Cilium을 설치하는 과정 자체는 공식 문서에 잘 정리되어 있어 비교적 쉽게 따라갈 수 있다. 다만 설치를 마친 뒤 “datapath는 실제로 어떻게 동작하는가, 정책은 어떤 방식으로 적용되는가, 더 나아가 SNAT 모드와 DSR은 무엇이 다른가, 멀티클러스터는 어떻게 구성하는가”라는 질문 앞에서는 선뜻 답하기 어려운 경우가 많다.
이번 글에서는 Cilium의 DataPath가 어떻게 동작하는지 짚어보고, East-West 통신과 North-South 통신, Hubble을 거쳐 최종적으로는 ClusterMesh로 멀티클러스터 환경을 구축하는 것까지 실습을 통해 익히는 것을 목표로 한다.
이 실습 환경은 macOS를 기준으로 작성했지만 Linux에서도 동일하게 동작한다. 다만 본문에 등장하는 shell script는 환경에 따라 호환되지 않을 수 있다.
실습에 필요한 kind, docker 등은 이미 설치되어 있다고 가정한다.
먼저 위와 같은 yaml을 만든다. disableDefaultCNI: true는 kind가 기본으로 쓰는 kindnet CNI를 끄겠다는 뜻이다. CNI는 이 자리에 Cilium을 설치해서 대신한다.
kind create cluster --config ./kind-cilium.yaml
이 명령어로 kind 쿠버네티스 클러스터를 만든다. 이어서 아래 명령어로 Node와 Pod의 상태를 확인한다.
# 노드 목록 불러오기
kubectl get nodes
NAME STATUS ROLES AGE VERSION
cilium-lab-control-plane NotReady control-plane 14s v1.34.0
cilium-lab-worker NotReady <none> 5s v1.34.0
cilium-lab-worker2 NotReady <none> 5s v1.34.0
# 파드 목록 불러오기
kubectl get pods -A
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-66bc5c9577-699pt 0/1 Pending 0 13s
kube-system coredns-66bc5c9577-wlwmx 0/1 Pending 0 13s
kube-system etcd-cilium-lab-control-plane 1/1 Running 0 19s
kube-system kube-apiserver-cilium-lab-control-plane 1/1 Running 0 19s
kube-system kube-controller-manager-cilium-lab-control-plane 1/1 Running 0 19s
kube-system kube-proxy-5q4kw 1/1 Running 0 13s
kube-system kube-proxy-ljps8 1/1 Running 0 12s
kube-system kube-proxy-vprd4 1/1 Running 0 12s
kube-system kube-scheduler-cilium-lab-control-plane 1/1 Running 0 19s
local-path-storage local-path-provisioner-7b8c8ddbd6-6jwr7 0/1 Pending 0 13s
coredns 파드가 Pending 상태에 머물러 있어 노드 상태 역시 NotReady로 나타난다. 아래 명령어로 Cilium을 설치한다.
cilium install --version 1.19.5
cilium status --wait
다시 kubectl get nodes를 실행하면 노드 상태가 Ready로 바뀐 것을 확인할 수 있다. 이것으로 첫 번째 Cilium 설치를 마쳤다. 이제 무엇이 어떻게 설치됐는지 좀 더 자세히 들여다본다.
kubectl exec -n kube-system ds/cilium -- cilium status --verbose | head -50
KVStore: Disabled
KubeProxyReplacement: False
Cilium: Ok 1.19.5 (v1.19.5-20eaccfe)
NodeMonitor: Listening for events on 8 CPUs with 64x4096 of shared memory
Cilium health daemon: Ok
IPAM: IPv4: 5/254 allocated from 10.244.1.0/24,
Allocated addresses:
10.244.1.152 (health)
10.244.1.153 (kube-system/coredns-66bc5c9577-699pt)
10.244.1.201 (local-path-storage/local-path-provisioner-7b8c8ddbd6-6jwr7)
10.244.1.210 (router)
10.244.1.65 (kube-system/coredns-66bc5c9577-wlwmx)
Routing: Network: Tunnel [vxlan] Host: Legacy
Attach Mode: TCX
Device Mode: veth
Masquerading: IPTables [IPv4: Enabled, IPv6: Disabled]
KubeProxyReplacement: False — kube-proxy가 대체됐는지 여부다. 이 시리즈 뒤쪽에서 kube-proxy를 Cilium으로 대체하는 실습을 진행한다.
Routing: Network: Tunnel [vxlan] Host: Legacy — 기본값인 Tunnel VXLAN 방식이다. worker 노드의 파드가 다른 노드의 파드로 패킷을 보내면 Cilium이 원본 패킷 전체를 VXLAN(UDP 8472)으로 감싸 노드 간에 전송하고, 목적지 노드에서 이를 다시 풀어낸다. Legacy는 host routing이 아직 eBPF 가속 모드가 아니라는 뜻이다.
Attach Mode: TCX — eBPF 프로그램이 tcx 계층에 부착됐다는 뜻이다. 리눅스 커널 6.6 이상에서 도입된 새 Attach API이며, 레거시 방식은 tc다.
Device Mode: veth — 파드 연결에 veth 쌍을 사용한다는 뜻이다(최신 Cilium은 netkit도 지원한다). 이후 등장하는 lxcXXXXXX 인터페이스가 이 veth의 호스트 쪽이다.
Masquerading: IPTables [IPv4: Enabled] — 파드가 클러스터 밖으로 나갈 때 소스 IP를 노드 IP로 바꾸는 SNAT를 아직 IPTables가 담당하고 있다는 뜻이다. 이 부분도 뒤에서 eBPF로 바꾼다.
다음으로 넘어가기 전에 짚고 갈 개념이 하나 있다. CNI는 pod to pod 네트워크 자체를 만들어주는 역할을 한다. 파드가 뜰 때 veth 쌍을 만들고 IP를 할당하며 라우팅과 캡슐화를 세팅해, 파드 A가 파드 B의 IP로 직접 통신할 수 있게 하는 것이 CNI의 일이다. 반면 kube-proxy는 파드끼리 이미 통신 가능하다는 전제 위에서, Service라는 가상 IP 추상화를 실제 파드 IP로 변환하는 일만 담당한다. 지금 이 클러스터의 kube-proxy는 IPTables/IPVS 기반으로 DNAT를 수행하고 있다. 이 부분을 뒤에서 eBPF로 바꾸는 실습을 진행할 예정이므로, 지금 단계의 kube-proxy는 크게 신경 쓰지 않아도 된다.
여기서 끝내면 아쉬우니 실제로 Pod를 배포해 테스트까지 이어간다. 앞서 개론에서 “eBPF 프로그램을 커널 훅에 부착한다”고 언급했는데, 그 프로그램들이 실제로 어디에 붙어 있는지 확인해본다.
kubectl create deployment netshoot --image=nicolaka/netshoot -- sleep 3600
kubectl scale deployment netshoot --replicas=2
# 배포된 netshoot 확인
kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
netshoot-7b776d8bd4-f6qxd 1/1 Running 0 6m11s 10.244.1.182 cilium-lab-worker
netshoot-7b776d8bd4-hfwlp 1/1 Running 0 6m11s 10.244.2.42 cilium-lab-worker2
각종 네트워크 도구가 담긴 netshoot 디플로이먼트를 배포하고 replicas를 2로 설정해 파드 2개를 띄운다. 이어서 eBPF 프로그램이 실제로 어디에 붙어 있는지 확인한다.
lxc16317f8e8c4a(24) tcx/ingress cil_from_container — 파드 veth 중 호스트 쪽 인터페이스로, 파드에서 나온 패킷을 처리하는 eBPF 프로그램이 붙어 있다.
파드가 패킷을 보내는 datapath는 파드 → lxcXXXXXX(cil_from_container) → 같은 노드라면 목적지 lxcXXXXXX로, 다른 노드라면 cilium_vxlan(cil_to_overlay로 VXLAN 캡슐화) → eth0 밖으로 흐르는 순서다.
/* Attachment/entry point is ingress for veth.
* It corresponds to packets leaving the container.
*/
__section_entry
int cil_from_container(struct __ctx_buff *ctx)
반대로 파드가 패킷을 받는 datapath는 eth0(cil_from_netdev) → cilium_vxlan(cil_from_overlay, VXLAN 디캡슐) → 목적지 lxcXXXXXX → 파드 순서로 동작한다.
이 명령어는 cilium-mxrsz 파드가 배포된 노드에서 endpoint 1487의 interface 정보를 조회한다. 결과를 보면 컨테이너 내부 interface는 eth0이고, veth pair로 호스트에 만들어진 interface는 lxc16317f8e8c4a이며 index는 24다. 즉 파드에서 패킷이 나가면 파드 내부의 eth0을 거쳐 veth pair인 lxc16317f8e8c4a로 주입되고, 이 시점에 cil_from_container eBPF 프로그램이 트리거된다.
eBPF Program & MAP
eBPF 프로그램이 어느 지점에 붙어 있는지는 확인했다. 이 프로그램들은 패킷이 흐를 때 트리거되어 정해진 기준에 따라 패킷을 처리하는데, 그 기준이 되는 정보는 eBPF 맵에 저장된다.
eBPF 프로그램은 이벤트로 트리거되는 코드일 뿐 상태를 직접 저장하지 않는다. 대신 eBPF 맵(Map)이라는 커널 자료구조를 읽고 쓴다. 이 맵은 커널 스페이스와 유저 스페이스 양쪽에서 접근할 수 있다. Identity, 서비스 및 백엔드 목록, 연결 상태 같은 데이터가 여기에 기록되고, 커널에 부착된 eBPF 프로그램은 패킷을 처리할 때 이 맵을 참조한다.
cilium_ipcache : 모든 IP → Identity 매핑
cilium_lb4_services : 서비스 로드밸런싱 테이블(kube-proxy 대체)
이 로그에서 눈여겨볼 정보는 identity 15160->15160(어떤 Identity 간의 통신인가), state new(새로운 연결), ifindex lxc6571eaf7f620(이 인터페이스에서 캡처됨), ifindex cilium_vxlan(곧 cil_to_overlay가 이를 VXLAN으로 캡슐화해 다른 노드로 넘길 예정)이다.
정리하면, worker 노드의 파드가 EchoRequest를 전송 → lxc16317f8e8c4a(cil_from_container) → 다른 노드로 가야 하므로 cil_to_overlay가 VXLAN으로 캡슐화 → eth0 → 물리망 → worker2 노드의 eth0 → cil_from_overlay가 VXLAN을 디캡슐 → worker2에 배포된 파드가 EchoReply를 생성해 전송하는 흐름이다.
Identity는 어떻게 전달되는가
monitor 로그를 보면 어떤 Identity에서 수신했고 어떤 Identity로 전송했는지 알 수 있다. 서로 다른 노드를 하나의 네트워크망으로 연결하는 방식에는 Underlay와 Overlay 두 가지가 있다. kindnet은 기본적으로 Underlay 방식을 쓰지만, 지금 배포한 Cilium CNI는 Overlay Network를 구성한다. 이 Overlay Network에서는 서로 다른 노드가 VXLAN 통신으로 패킷을 주고받으며, 이때 VXLAN의 VNI 필드에 Identity 값을 담아 전송한다. 이 내용을 직접 확인해본다.
이렇게 패킷을 캡처해서 볼 수 있다. 바깥 헤더는 172.18.0.3 → 172.18.0.4(노드 IP 간 통신)이고, 그 안에 10.244.1.182 → 10.244.2.42가 캡슐화돼 들어 있다. tcpdump가 이 패킷을 VXLAN이 아니라 OTV 프로토콜로 해석한 이유는, 표준 VXLAN 포트가 4789인 데 반해 Cilium은 8472를 사용하기 때문이다. VXLAN의 VNI 필드 위치인 0x0020: 003b 38 부분에 Identity 값이 실려 있는 것도 확인할 수 있다.
VXLAN의 구조는 바깥 IP 헤더(20바이트) + UDP 헤더(8바이트) + VXLAN 헤더(8바이트)로 이뤄진다. 즉 노드 간 VXLAN 통신으로 패킷이 전달되는 과정을 직접 캡처로 확인한 사례다.
Tip) Overlay Network는 UDP인데도 패킷이 손실되어도 괜찮은 이유가 있다. VXLAN을 주고받는 노드들은 실질적으로 End to End에서 패킷을 주고받는 구조가 아니기 때문에, 유실이 발생해도 처음 패킷을 보낸 종단에서 재전송을 담당한다. 다만 이는 TCP에 한정된 이야기다.
IP Address Management (IPAM)
IP 주소 관리(IPAM)는 Cilium이 관리하는 네트워크 엔드포인트(컨테이너 등)에 IP 주소를 할당하고 관리하는 역할을 한다. 대표적으로 Kubernetes Host Scope, Cluster Scope, Multi-Pool 세 가지 모드가 있으며, 관리 주체나 범위에 차이가 있을 뿐 공통적으로 IP 할당 방식을 설정한다는 목적은 같다.
Cluster Scope
클러스터 범위 IPAM 모드는 각 노드에 노드별 PodCIDR을 할당하고, 각 노드에서 호스트 범위 할당자로 IP를 나눠준다. 쿠버네티스 리소스로 노드별 PodCIDR을 할당하는 Kubernetes Host Scope와 달리, 이 모드에서는 Cilium 오퍼레이터가 리소스를 통해 노드별 PodCIDR을 직접 관리한다. 즉 Cilium-Operator가 전체 IP 풀을 들고 있다가 노드마다 /24씩 떼어주는 방식이며, 할당 주체는 Cilium이다.
Cluster Scope 모드에서는 Cilium Operator가 CiliumNode 리소스를 통해 노드별 PodCIDR을 직접 관리한다.
Kubernetes Host Scope
클러스터의 각 노드에 개별적으로 주소 할당이 위임된다. IP 주소는 쿠버네티스가 각 노드에 부여한 범위 안에서 할당된다. 즉 쿠버네티스 자체가 노드별 CIDR을 정하는 방식으로, 구체적으로는 kube-controller-manager의 --allocate-node-cidrs 옵션이 각 노드 오브젝트의 .spec.PodCIDR에 /24를 기록하면 cilium-agent가 이 값을 읽어 자기 노드의 파드 IP를 나눠준다. 할당 주체는 쿠버네티스다.
이름 붙은 풀을 여러 개 두고, 파드나 네임스페이스별로 “너는 이 풀에서 IP를 받아라”라고 지정할 수 있다는 것이 핵심이다. pools.allocated를 보면 노드가 어느 풀에서 무엇을 할당받았는지 추적할 수 있다. 특정 네임스페이스에 ipam.cilium.io/ip-pools=mars 같은 Annotation을 달아 풀을 지정하는 방식이다.
이 방식을 쓰면 특정 워크로드에만 외부 라우팅을 허용하는 식의 구성도 가능해진다. 다만 multi-pool 방식은 기존 IPTables 방식과 완전히 호환되지는 않기 때문에, eBPF masquerade를 쓰거나 egressMasqueradeInterfaces 설정을 추가로 해줘야 한다.
Network Policy
지금까지는 주로 Identity와 Endpoint 같은 개념을 익혔다. 이제는 실제로 Identity 기반 네트워크 정책으로 트래픽을 제어하는 실습을 진행한다.
클러스터를 재생성하면서 지워졌던 netshoot deployment를 다시 배포한다.
# netshoot pod 배포
kubectl create deployment netshoot --image=nicolaka/netshoot -- sleep infinity
# ds replicas 2로 설정
kubectl scale deployment netshoot --replicas=2
kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
netshoot-6b9fb7848f-42wlq 1/1 Running 0 54s 10.244.2.194 cilium-lab-worker2
netshoot-6b9fb7848f-6r25s 1/1 Running 0 54s 10.244.0.71 cilium-lab-worker
위와 같은 형식으로 CiliumNetworkPolicy를 만든다. endpointSelector.matchLabels에 해당하는 Endpoint는 ingress.fromEndpoints.matchLabels에 해당하는 Identity로부터만 트래픽을 받을 수 있다는 뜻이다. 이를 deny.yaml로 저장하고 적용한다.
kubectl apply -f deny.yaml
# 적용된 CiliumNetworkPolicy 확인
kubectl get cnp -o wide
NAME AGE VALID
netshoot-deny-ingress 11s True
정책이 정상적으로 적용된 것을 확인했다. 이제 worker 노드의 파드에서 worker2 노드의 파드로 PING을 보내면 어떻게 되는지 확인해본다.
time=2026-07-29T12:45:25.885653625Z level=info msg="Initializing dissection cache..."
Policy verdict log: flow 0x0 local EP ID 162, remote ID 64347, proto 1, ingress, action deny, auth: disabled, match none, 10.244.0.71 -> 10.244.2.194 icmp EchoRequest
xx drop (Policy denied) flow 0x0 to endpoint 162, ifindex 14, file bpf_lxc.c:2405, , identity 64347->64347: 10.244.0.71 -> 10.244.2.194 icmp EchoRequest
로그를 풀어보면 “remote ID 64347 Identity로부터 ingress 요청이 왔지만 action deny로 거절했다. 그 결과 xx drop (Policy denied) to endpoint 162, 즉 정책에 의해 드롭됐고 이 트래픽은 endpoint 162로 향하던 것이었다”는 내용이다. bpf policy map을 조회하면 실제로 어떤 정책 맵을 참조했는지도 확인할 수 있다.
# endpoint 162에 대한 policy eBPF 맵 참조
kubectl exec -n kube-system cilium-wghmx -- cilium bpf policy get 162
POLICY DIRECTION LABELS (source:key[=value]) PORT/PROTO PROXY PORT AUTH TYPE
Allow Ingress reserved:host ANY NONE disabled
Allow Egress ANY ANY NONE disabled
ingress 허용 목록을 보면 ANY는 없고 오직 reserved:host만 허용돼 있다. 즉 자신이 속한 호스트로부터 오는 트래픽만 받겠다는 뜻이다. 반면 밖으로 나가는 egress 트래픽에는 별도의 제한이 없다.
지금까지는 비교적 가벼운 내용을 다뤘다면, 이번 챕터는 조금 더 무거운 내용을 다룬다. 지금까지의 실습에서는 Cilium을 쓰면서도 kube-proxy를 그대로 두고 IPTables를 함께 사용해왔다. 이 구조에서는 Service(ClusterIP)라는 가상 IP, 예를 들어 10.96.X.X 같은 주소를 사용한다. 이 가상 IP를 실제 백엔드 IP로 바꾸는 작업을 IPTables가 담당하는데, 이는 여러 체인으로 구성돼 있고 탐색 방식이 선형적이다. 그래서 체인이 늘어날수록(서비스가 많아질수록) 성능이 떨어진다. Cilium은 이 부분을 cilium-agent와 eBPF로 대체한다. 먼저 기존 kube-proxy와 IPTables가 어떻게 동작하는지부터 확인해본다.
앞서 설명한 대로 kube-proxy는 가상 IP를 파드 IP로 바꿀 때 아래와 같은 IPTables 규칙 체인을 사용한다.
KUBE-SERVICES : 목적지가 ClusterIP인 패킷만 잡아내는 체인이다.
KUBE-SVC-XXXX(서비스별 체인) : 백엔드 중 하나를 확률적으로 고른다. (-m statistic --mode random --probability 0.333...)
KUBE-SEP-XXXX(엔드포인트별 체인) : 실제 파드 IP로 DNAT한다.
netshoot deployment용 Service를 만들고 실제 노드에서 IPTables를 확인해본다.
# netshoot을 가리키는 ClusterIP 서비스 생성
kubectl expose deployment netshoot --port=80 --target-port=80 --name=netshoot-svc
# 방금 만든 서비스 검색
kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 23h
netshoot-svc ClusterIP 10.96.190.56 <none> 80/TCP 9s
목적지가 ClusterIP(10.96.190.56):80인 패킷을 잡아 서비스 전용 체인(KUBE-SVC-RCAX...)으로 보낸다. 서비스 하나당 이런 진입 규칙이 하나씩 생긴다.
2단계
-A KUBE-SVC-RCAXWRJ4Y75BAX5N -m comment --comment "default/netshoot-svc -> 10.244.0.71:80" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-IB7LRUT54SL5PMFQ
-A KUBE-SVC-RCAXWRJ4Y75BAX5N -m comment --comment "default/netshoot-svc -> 10.244.2.194:80" -j KUBE-SEP-B2XTYZ3IMZ7Q3U3U
statistic 모드는 매 연결(또는 새 요청)마다 백엔드를 독립적으로 랜덤 선택하기 때문에 backend affinity가 약하다. 반면 Cilium의 Maglev는 Consistent hashing 기반으로 백엔드를 선택해, 백엔드가 추가되거나 삭제되더라도 대부분의 flow가 기존과 같은 백엔드로 매핑되도록 재매핑을 최소화한다. 다만 첫 번째 패킷은 IPTables 규칙을 거쳐 DNAT되고, 이후 같은 연결의 패킷은 conntrack 정보로 처리된다.
가상의 Cluster IP를 실제 파드 IP(10.244.2.194/32)로 바꾸는 DNAT 과정이다. 즉 서비스(IP)가 백엔드 IP로 변환되는 단계다. 흥미로운 부분은 KUBE-MARK-MASQ로 자기 자신에 대한 규칙이 별도로 존재한다는 점인데, 이는 서비스에 연결한 자신이 다시 자기 자신에게 선택되는 hairpin 상황을 정상적으로 처리하기 위한 것이다. 결국 IPTables는 커널 netfilter가 KUBE-SERVICES → KUBE-SVC → KUBE-SEP → DNAT 순서로 패킷을 처리하는 구조이고, 이 작업이 선형적이기 때문에 시간복잡도는 O(n)이다. cilium-agent와 eBPF는 이를 Socket LB로 대체한다.
kube-proxy 대체하기
앞서 설명한 대로 현재 클러스터에는 여전히 kube-proxy가 설치돼 있다. 이를 제거하고 cilium-agent와 eBPF로 대체한다.
# 쿠버네티스 API 서버 확인
kubectl -n kube-system get cm kubeadm-config -o yaml 2>/dev/null | grep -i "controlPlaneEndpoint"
# cilium helm repo 추가
helm repo add cilium https://helm.cilium.io/ 2>/dev/null; helm repo update
# replacement + eBPF masquerade로 업그레이드
helm upgrade cilium cilium/cilium --version 1.19.5 \
--namespace kube-system \
--reuse-values \
--set kubeProxyReplacement=true \
--set bpf.masquerade=true \
--set k8sServiceHost=cilium-lab-control-plane \
--set k8sServicePort=6443
# cilium 재시작 대기
kubectl -n kube-system rollout restart ds/cilium
kubectl -n kube-system rollout status ds/cilium --timeout=120s
# kube-proxy 제거
kubectl -n kube-system delete ds kube-proxy
kubectl -n kube-system delete cm kube-proxy
# 기존 kube-proxy iptables 규칙 청소 (각 노드에서)
for node in $(kubectl get nodes -o jsonpath='{.items[*].metadata.name}'); do
echo "=== cleaning $node ==="
docker exec $node iptables-save 2>/dev/null | grep -v KUBE | docker exec -i $node iptables-restore 2>/dev/null || echo " (수동 청소 필요할 수 있음)"
done
이 과정으로 cilium을 helm으로 재설치하고, kube-proxy를 제거한 뒤 남아있는 기존 규칙까지 정리할 수 있다. 이제 상태를 다시 확인해본다.
KubeProxyReplacement: True와 Routing: Network: Tunnel [vxlan] Host: BPF를 통해 cilium-agent와 eBPF로 kube-proxy를 완전히 대체한 것을 확인할 수 있다. (Direct Routing)은 Cilium이 노드 NIC(eth0)에 직접 LB 프로그램을 붙여 NodePort와 외부 트래픽을 처리한다는 뜻이고, Masquerading: BPF [eth0] 10.244.0.0/24는 SNAT 대상 대역까지 eBPF가 알고 있다는 뜻이다.
kubectl exec netshoot-6b9fb7848f-6r25s -- ping -c 1 10.244.2.194
PING 10.244.2.194 (10.244.2.194) 56(84) bytes of data.
64 bytes from 10.244.2.194: icmp_seq=1 ttl=63 time=1.38 ms
--- 10.244.2.194 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 1.376/1.376/1.376/0.000 ms
통신도 정상적으로 동작한다. 이렇게 기존 클러스터에서 업그레이드하는 방식도 가능하지만, 처음부터 kube-proxy를 아예 없애고 시작하려면 아래처럼 클러스터를 구성해야 한다.
Cilium은 kube-proxy가 안고 있던 문제를 앞서 설명한 대로 eBPF로 풀어낸다. Cilium은 이를 Socket-Based Loadbalancing이라 부른다. 원리는 비교적 단순하다.
L7 정책이 적용될 때 트래픽은 커널 훅(TC, PRE/POSTROUTING)과 유저스페이스 프록시, Cilium 컴포넌트를 순서대로 거친다.
파드 A가 서비스 IP로 connect 함수를 호출하면, cgroup/connect에 부착된 eBPF 프로그램이 이 호출을 가로챈다. 이 프로그램은 서비스맵과 백엔드맵을 조회해서 연결하려는 서비스 IP가 어떤 백엔드 IP와 연결돼 있는지 확인한 뒤 그 백엔드 IP를 돌려준다. 즉 connect를 호출한 시점에 돌려받은 백엔드 IP로 곧바로 연결할 수 있게 된다.
connect(Service IP)를 요청하면 내부에서 후킹된 eBPF 프로그램이 이를 connect(Backend IP)로 바꿔 실제 백엔드 파드로 연결 요청을 보낸다. 이는 TCP의 사례이고, UDP라면 recvmsg와 sendmsg를 후킹해 처리한다. 그렇다면 getpeername을 호출하면 백엔드 IP가 그대로 나올까. 그렇지 않다. 이때도 후킹된 eBPF 프로그램이 맵을 참조해 처음 연결하려던 서비스 IP를 그대로 돌려준다. 즉 중간에 IPTables를 거치지 않고도 파드에서 파드로 직접 연결되는 구조가 완성된다.
앞서 언급한 서비스맵을 직접 확인해본다.
# Cilium의 서비스 LB 테이블 (kube-proxy iptables의 eBPF 대체물)
kubectl -n kube-system exec ds/cilium -- cilium service list
ID Frontend Service Type Backend
1 10.96.66.12:443/TCP ClusterIP 1 => 172.18.0.4:4244/TCP (active)
6 10.96.57.113:80/TCP ClusterIP 1 => 10.0.0.203:80/TCP (active)
2 => 10.0.2.143:80/TCP (active)
Frontend는 서비스 IP, Backend는 연결된 파드 IP를 의미한다. 좀 더 raw하게 실제 eBPF 맵을 보고 싶다면 아래 명령어로 확인할 수 있다.
여기서 (0)은 마스터 엔트리로 이 서비스에 백엔드가 몇 개 있는지를 나타낸다. (6)은 REVNAT_ID로 서비스 고유 ID다. 다만 이 값이 실제로 쓰이는 경우는 socket LB가 아니라 tc 계층에서 DNAT가 발생하는 경로다. socket LB는 패킷 주소 자체를 바꾸지 않으므로 되돌릴 것이 없고, 실제로 이 경로의 conntrack 엔트리는 RevNAT=0이다. REVNAT_ID는 다음 편에서 다룰 NodePort나 외부 트래픽에서 응답 소스를 프론트엔드로 복원할 때 사용된다.
지금까지는 socket LB에 대한 이론적인 설명이었다. 실제로도 그렇게 동작하는지 tcpdump로 패킷을 캡처해서 검증해본다.
kubectl get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE
netshoot-7b776d8bd4-nrh8b 1/1 Running 0 55m 10.0.2.143 cilium-lab-worker
netshoot-7b776d8bd4-wlsk9 1/1 Running 0 55m 10.0.0.203 cilium-lab-worker2
kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 71m
netshoot-svc ClusterIP 10.96.57.113 <none> 80/TCP 58m
cilium-lab-worker에 배포된 netshoot-7b776d8bd4-nrh8b에서 서비스 IP로 접근해 cilium-lab-worker2에 배포된 netshoot-7b776d8bd4-wlsk9로 패킷이 가는 과정을 확인한다. cilium-lab-worker2에서는 tcpdump로 패킷을 캡처한다.
먼저 양쪽 파드에 80번 포트로 열리는 웹서버를 띄운다.
for p in $(kubectl get pod -l app=netshoot -o jsonpath='{.items[*].metadata.name}'); do
kubectl exec $p -- sh -c "nohup python3 -m http.server 80 >/dev/null 2>&1 &"
done
sleep 2
kubectl exec netshoot-7b776d8bd4-wlsk9 -- tcpdump -i eth0 -n "tcp port 80"
# 패킷 캡처 결과
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
15:38:03.867911 IP 10.0.2.143.40988 > 10.0.0.203.80: Flags [S], seq 2127037688, win 65430, options [mss 65430,sackOK,TS val 4145815494 ecr 0,nop,wscale 7], length 0
15:38:03.868799 IP 10.0.0.203.80 > 10.0.2.143.40988: Flags [S.], seq 656770761, ack 2127037689, win 65418, options [mss 65430,sackOK,TS val 3620275175 ecr 4145815494,nop,wscale 7], length 0
15:38:03.868823 IP 10.0.2.143.40988 > 10.0.0.203.80: Flags [.], ack 1, win 512, options [nop,nop,TS val 4145815495 ecr 3620275175], length 0
15:38:03.869493 IP 10.0.2.143.40988 > 10.0.0.203.80: Flags [P.], seq 1:77, ack 1, win 512, options [nop,nop,TS val 4145815496 ecr 3620275175], length 76: HTTP: GET / HTTP/1.1
15:38:03.869661 IP 10.0.0.203.80 > 10.0.2.143.40988: Flags [.], ack 77, win 511, options [nop,nop,TS val 3620275176 ecr 4145815496], length 0
15:38:03.878526 IP 10.0.0.203.80 > 10.0.2.143.40988: Flags [P.], seq 1:156, ack 77, win 512, options [nop,nop,TS val 3620275185 ecr 4145815496], length 155: HTTP: HTTP/1.0 200 OK
15:38:03.878550 IP 10.0.2.143.40988 > 10.0.0.203.80: Flags [.], ack 156, win 511, options [nop,nop,TS val 4145815505 ecr 3620275185], length 0
15:38:03.878742 IP 10.0.0.203.80 > 10.0.2.143.40988: Flags [
worker1 노드에서는 분명 서비스 IP(10.96.57.113)로 curl을 실행했지만, 캡처된 패킷에는 10.0.2.143.40988 > 10.0.0.203.80처럼 서비스 IP가 등장하지 않고 백엔드 IP만 보인다. 이는 앞서 설명한 cgroup/connect 후킹 단계에서 이미 서비스 IP가 백엔드 IP로 대체됐다는 것을 패킷으로 직접 보여주는 결과다. 정리하면 Cilium socket LB는 다음과 같은 순서로 동작한다.
connect(10.96.57.113:80) — 애플리케이션이 요청한다.
cgroup에 붙은 eBPF가 이 connect() 시스템콜을 가로챈다.
eBPF 맵을 조회한다: 10.96.57.113:80의 백엔드는 무엇인가.
답을 받는다: 10.0.0.203:80.
소켓에 기록될 목적지를 아예 바꿔치기한다: 10.0.0.203:80.
커널이 소켓에 “목적지 = 10.0.0.203:80″을 기록한다.
패킷이 처음부터 백엔드를 향해 생성된다(출발: 10.0.2.143.40988 > 10.0.0.203.80).
별도의 DNAT 과정이 없다.
Socket LB eBPF 소스코드 확인
tcpdump로 패킷을 캡처해 실제 동작을 확인했다면, 이번에는 cilium의 eBPF 소스코드를 기준으로 분석해본다.
__sock4_xlate_fwd 함수는 요청된 서비스 IP를 기준으로 백엔드 IP를 고르는 실질적인 함수다. ctx_dst_port(ctx)는 목적지 포트(예: 80), ctx_protocol은 프로토콜(예: TCP), ctx->user_ip4는 목적지 ClusterIP(예: 10.96.193.104)에 해당한다. orig_key = key는 socknat 기록용으로, 원본 값을 저장해두고 앱이 요청한 목적지를 읽어 LB 키를 만드는 역할을 한다.
sock4_update_revnat 함수는 원래 ClusterIP를 키로 RevNAT을 세팅한다. sock_local_cookie 함수로 소켓 고유 식별자(쿠키)를 뽑아 백엔드 IP·포트와 결합해 키를 만들고, 값으로는 클러스터 IP(원래 목적지)를 저장한다. getpeername이나 SNAT에서 ClusterIP를 다시 꺼내와야 할 때 이 값이 쓰인다. rev_nat_index를 함께 저장하는 이유는 이 소켓이 어느 Service(REVNAT ID X)를 거쳤는지 추적하기 위해서다.
socknat 맵이 필요한 이유는, “이 소켓은 원래 ClusterIP X로 연결하려 했다”는 정보를 기억해뒀다가 getpeername() 같은 호출에서 되돌려주기 위해서다. IP:PORT 조합으로는 connect 시점에 소스 포트가 아직 정해지지 않아 부족하고, 프로세스 ID로는 한 프로세스가 여러 소켓을 열 수 있는 데다 파일 디스크립터도 프로세스마다 독립적이라 유일성을 보장할 수 없다. 그래서 64비트 정수인 소켓 쿠키가 유일한 식별값으로 쓰인다.
여기까지가 Cilium의 기본적인 동작 원리다. 가장 중요했던 지점은 내부 통신(East-West)에서 Cilium이 IPTables 없이 어떻게 서비스 IP를 파드 IP로 바꾸는가였는데, 이를 Socket-Based LoadBalancer, 즉 eBPF로 풀어냈다는 것을 확인했다.
이번 글에서는 내부 인클러스터 통신인 East-West 통신뿐 아니라 외부 클라이언트 트래픽을 처리하는 North-South 통신(SNAT vs DSR), Cilium Hubble을 통한 관측 가능성 확인, 멀티클러스터 통신 구축까지 다루는 것을 목표로 삼았지만, 글이 길어져 이 내용은 다음 편으로 넘긴다. Cilium은 IPTables 같은 기존 구조에서 벗어나 eBPF로 다양한 시도를 이어가고 있는 오픈소스다. 그 가운데는 XDP를 활용해 로드밸런서까지 구축할 수 있을 만큼 강력하면서도 자유로운 확장성을 가진 영역도 있다.