tech

Cilium, Under the Hood #1: eBPF로 들여다보는 Kubernetes 네트워크의 동작 원리

주의) 이 글은 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라고 설명할 수 있다. 예를 들어 아래와 같은 정책이 있다고 가정한다.

endpointSelector:
  matchLabels:
    app: netshoot
ingress:
- fromEndpoints:
    - matchLabels:
        app: backend

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 등은 이미 설치되어 있다고 가정한다.

환경 세팅

# kind-cilium.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cilium-lab
networking:
  disableDefaultCNI: true # kindnet 끄기 -> Cilium이 CNI 담당
nodes:
  - role: control-plane
  - role: worker
  - role: worker

먼저 위와 같은 yaml을 만든다. disableDefaultCNI: true는 kind가 기본으로 쓰는 kindnet CNI를 끄겠다는 뜻이다. CNI는 이 자리에 Cilium을 설치해서 대신한다.

kind create cluster --config ./kind-cilium.yaml

이 명령어로 kind 쿠버네티스 클러스터를 만든다. 이어서 아래 명령어로 NodePod의 상태를 확인한다.

# 노드 목록 불러오기
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-proxyIPTables/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 프로그램이 실제로 어디에 붙어 있는지 확인한다.

# 커널 네트워크 서브시스템에 부착된 eBPF 프로그램 목록을 나열한다.
kubectl exec -n kube-system ds/cilium -- bpftool net show

xdp:

tc:
eth0(11) tcx/ingress cil_from_netdev prog_id 2851 link_id 207
cilium_net(12) tcx/ingress cil_to_host prog_id 2843 link_id 206
cilium_host(13) tcx/ingress cil_to_host prog_id 2838 link_id 204
cilium_host(13) tcx/egress cil_from_host prog_id 2840 link_id 205
cilium_vxlan(14) tcx/ingress cil_from_overlay prog_id 2796 link_id 202
cilium_vxlan(14) tcx/egress cil_to_overlay prog_id 2797 link_id 203
lxc_health(16) tcx/ingress cil_from_container prog_id 2900 link_id 209
lxc44e1332ddc99(18) tcx/ingress cil_from_container prog_id 3013 link_id 217
lxc7932f9158faa(20) tcx/ingress cil_from_container prog_id 3020 link_id 218
lxcdf38f3bd8367(22) tcx/ingress cil_from_container prog_id 3030 link_id 219
lxc16317f8e8c4a(24) tcx/ingress cil_from_container prog_id 3281 link_id 229

flow_dissector:

netfilter:

bpftool net showxdp, tc, flow_dissector, netfilter 각 계층에 어떤 eBPF 프로그램이 부착됐는지 보여준다. 여기서는 tc 계층의 각 네트워크 인터페이스에 부착된 프로그램을 확인할 수 있다.

  • eth0(11) tcx/ingress cil_from_netdev — 노드의 물리 NIC로, 노드 외부에서 들어오는 패킷의 첫 관문이다. NodePort 트래픽이나 다른 노드에서 온 VXLAN 패킷이 여기로 들어온다. 이름 그대로 tcx 계층에 부착돼 있다.
  • cilium_net(12) tcx/ingress cil_to_hostcilium_hostcilium_net은 호스트 네임스페이스 자신을 위한 veth 쌍이다. cilium_host에는 노드 라우터 IP가 할당된다.
  • cilium_vxlan(14) tcx/ingress cil_from_overlayRouting: Tunnel [vxlan]의 실체다. 다른 노드에서 온 VXLAN 패킷을 풀어(디캡슐) 목적지 파드로 넘긴다.
  • cilium_vxlan(14) tcx/egress cil_to_overlay — 마찬가지로 Routing: Tunnel [vxlan]의 실체다. 다른 노드로 보낼 패킷을 VXLAN으로 캡슐화한다.
  • lxc_health(16) tcx/ingress cil_from_container — endpoint list에서 identity 4번인 reserved:health에 해당하며, 노드마다 있는 cilium-health endpoint의 veth다.
  • 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파드 순서로 동작한다.

lxc16317f8e8c4a 인터페이스가 어느 파드의 것인지 직접 확인해본다.

# cilium-smx67은 worker2에 배포된 pod다.
kubectl exec -n kube-system cilium-smx67 -- cilium endpoint list | grep netshoot

ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value])
847        Disabled           Disabled          15160      k8s:app=netshoot

# cilium-mxrsz는 worker에 배포된 pod다.
kubectl exec -n kube-system cilium-mxrsz -- cilium endpoint list | grep netshoot
ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value])
1487       Disabled           Disabled          15160      k8s:app=netshoot

netshoot 워크로드는 각각 847, 1487 Endpoint를 갖지만 Identity는 둘 다 15160으로 같다. 워크로드는 Identity를 공유할 수 있어도 Endpoint는 워크로드마다 개별로 부여된다는 점을 확인할 수 있다.

kubectl -n kube-system exec cilium-mxrsz -- cilium endpoint get 1487 | grep interface

"container-interface-name": "eth0",
"interface-index": 24,
"interface-name": "lxc16317f8e8c4a",

이 명령어는 cilium-mxrsz 파드가 배포된 노드에서 endpoint 1487의 interface 정보를 조회한다. 결과를 보면 컨테이너 내부 interface는 eth0이고, veth pair로 호스트에 만들어진 interface는 lxc16317f8e8c4a이며 index24다. 즉 파드에서 패킷이 나가면 파드 내부의 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 대체)
  • cilium_lb4_backends : 백엔드 파드 로드밸런싱 테이블(kube-proxy 대체)
  • cilium_ct4 : 커넥션 트랙 테이블

이 밖에도 다양한 맵이 존재하며 cilium 명령어로 직접 조회할 수 있다.

# ipcache eBPF 맵 조회
kubectl exec -n kube-system ds/cilium -- cilium bpf ipcache list

IP PREFIX/ADDRESS   IDENTITY
10.244.2.0/24       identity=2 encryptkey=0 tunnelendpoint=172.18.0.4 flags=hastunnel
0.0.0.0/0           identity=2 encryptkey=0 tunnelendpoint=0.0.0.0 flags=<none>
10.244.1.210/32     identity=1 encryptkey=0 tunnelendpoint=0.0.0.0 flags=<none>
10.244.2.42/32      identity=15160 encryptkey=0 tunnelendpoint=172.18.0.4 flags=hastunnel
10.244.1.182/32     identity=15160 encryptkey=0 tunnelendpoint=0.0.0.0 flags=<none>

앞서 확인한 netshoot 파드의 IP가 ipcache에서도 동일하게 확인된다. 여기서 매핑된 Identity도 함께 볼 수 있는데, 이 명령어로 “이 파드는 어느 노드로 캡슐화해서 보내야 하는가”라는 라우팅 정보를 확인할 수 있다.

# identity 목록 조회
kubectl exec -n kube-system ds/cilium -- cilium identity list

ID      LABELS
1       reserved:host
2       reserved:world
4       reserved:health
6       reserved:remote-node
15160   k8s:app=netshoot

이 Identity가 어떤 라벨로 설정돼 있는지는 cilium identity list 명령어로 확인할 수 있다. 앞서 배포한 netshoot15160 k8s:app=netshoot Identity를 가지며, 그 밖에도 여러 예약어 Identity가 존재한다.

  • reserved:host : 노드 자신
  • reserved:world : 클러스터 외부 전체(정책에서는 외부 인터넷을 가리킴)
  • reserved:remote-node : 다른 노드들
  • reserved:health : 노드별 cilium-health endpoint

10.244.1.18210.244.2.42는 서로 다른 노드의 파드지만 Identity는 둘 다 15160으로 동일하다. 개론에서 설명한 “같은 라벨의 파드는 하나의 Identity를 공유한다”는 원리를 맵에서 직접 확인한 셈이다.

worker 노드에 배포된 netshoot-7b776d8bd4-f6qxd에서 worker2에 배포된 netshoot-7b776d8bd4-hfwlp로 ICMP PING을 보내는 과정을 모니터링해본다. 모니터링은 worker2에서 진행한다.

kubectl -n kube-system exec -it cilium-smx67 -- cilium monitor --related-to 847

이 명령어는 worker2의 cilium(cilium-smx67)에서 endpoint 847과 관련된 이벤트만 보는 중이다.

kubectl exec netshoot-7b776d8bd4-f6qxd -- ping -c 1 10.244.2.42

다른 터미널에서 netshoot-7b776d8bd4-hfwlp로 ping을 한 번 보낸다.

-> endpoint 847 flow 0x0 , identity 15160->15160 state new ifindex lxc6571eaf7f620 orig-ip 10.244.1.182: 10.244.1.182 -> 10.244.2.42 icmp EchoRequest

-> overlay flow 0x0 , identity 15160->15160 state reply ifindex cilium_vxlan orig-ip 0.0.0.0: 10.244.2.42 -> 10.244.1.182 icmp EchoReply

이 로그에서 눈여겨볼 정보는 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 노드의 eth0cil_from_overlay가 VXLAN을 디캡슐 → worker2에 배포된 파드가 EchoReply를 생성해 전송하는 흐름이다.

Identity는 어떻게 전달되는가

monitor 로그를 보면 어떤 Identity에서 수신했고 어떤 Identity로 전송했는지 알 수 있다. 서로 다른 노드를 하나의 네트워크망으로 연결하는 방식에는 Underlay와 Overlay 두 가지가 있다. kindnet은 기본적으로 Underlay 방식을 쓰지만, 지금 배포한 Cilium CNI는 Overlay Network를 구성한다. 이 Overlay Network에서는 서로 다른 노드가 VXLAN 통신으로 패킷을 주고받으며, 이때 VXLAN의 VNI 필드에 Identity 값을 담아 전송한다. 이 내용을 직접 확인해본다.

docker exec cilium-lab-worker2 bash -c "apt update && apt install tcpdump net-tools -y"

docker exec cilium-lab-worker2 tcpdump -i eth0 -n -v udp port 8472 -c 4 -X

worker2 노드에 tcpdumpnet-tools를 설치하고 VXLAN 패킷을 캡처한다. 새 터미널을 열어 worker2에 배포된 파드로 패킷을 보낸다.

kubectl exec netshoot-7b776d8bd4-f6qxd -- ping -c 1 10.244.2.42
tcpdump: listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
13:24:02.898286 IP (tos 0x0, ttl 64, id 47173, offset 0, flags [none], proto UDP (17), length 134)
    172.18.0.3.53143 > 172.18.0.4.8472: OTV, flags [I] (0x08), overlay 0, instance 15160
IP (tos 0x0, ttl 64, id 17056, offset 0, flags [DF], proto ICMP (1), length 84)
    10.244.1.182 > 10.244.2.42: ICMP echo request, id 3, seq 1, length 64
    0x0000:  4500 0086 b845 0000 4011 69f6 ac12 0003  E....E..@.i.....
    0x0010:  ac12 0004 cf97 2118 0072 0000 0800 0000  ......!..r......
    0x0020:  003b 3800 562e 4db8 a408 9263 a177 d84d  .;8.V.M....c.w.M
    0x0030:  0800 4500 0054 42a0 4000 4001 de41 0af4  ..E..TB.@.@..A..
    0x0040:  01b6 0af4 022a 0800 155d 0003 0001 72ad  .....*...]....r.
    0x0050:  686a 0000 0000 3bb4 0d00 0000 0000 1011  hj....;.........
    0x0060:  1213 1415 1617 1819 1a1b 1c1d 1e1f 2021  ...............!
    0x0070:  2223 2425 2627 2829 2a2b 2c2d 2e2f 3031  "#$%&'()*+,-./01
    0x0080:  3233 3435 3637                           234567

13:24:02.898448 IP (tos 0x0, ttl 64, id 18887, offset 0, flags [none], proto UDP (17), length 134)
    172.18.0.4.53143 > 172.18.0.3.8472: OTV, flags [I] (0x08), overlay 0, instance 15160
IP (tos 0x0, ttl 64, id 12604, offset 0, flags [none], proto ICMP (1), length 84)
    10.244.2.42 > 10.244.1.182: ICMP echo reply, id 3, seq 1, length 64
    0x0000:  4500 0086 49c7 0000 4011 d874 ac12 0004  E...I...@..t....
    0x0010:  ac12 0003 cf97 2118 0072 0000 0800 0000  ......!..r......
    0x0020:  003b 3800 526f 9d02 2317 4e36 b785 b744  .;8.Ro..#.N6...D
    0x0030:  0800 4500 0054 313c 0000 4001 2fa6 0af4  ..E..T1<..@./...
    0x0040:  022a 0af4 01b6 0000 1d5d 0003 0001 72ad  .*.......]....r.
    0x0050:  686a 0000 0000 3bb4 0d00 0000 0000 1011  hj....;.........
    0x0060:  1213 1415 1617 1819 1a1b 1c1d 1e1f 2021  ...............!
    0x0070:  2223 2425 2627 2829 2a2b 2c2d 2e2f 3031  "#$%&'()*+,-./01
    0x0080:  3233 3435 3637                           23456

이렇게 패킷을 캡처해서 볼 수 있다. 바깥 헤더는 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 IPAM 모드에서 Cilium Operator가 CiliumNode에 PodCIDR을 할당하는 구조도
Cluster Scope 모드에서는 Cilium Operator가 CiliumNode 리소스를 통해 노드별 PodCIDR을 직접 관리한다.

Kubernetes Host Scope

클러스터의 각 노드에 개별적으로 주소 할당이 위임된다. IP 주소는 쿠버네티스가 각 노드에 부여한 범위 안에서 할당된다. 즉 쿠버네티스 자체가 노드별 CIDR을 정하는 방식으로, 구체적으로는 kube-controller-manager--allocate-node-cidrs 옵션이 각 노드 오브젝트의 .spec.PodCIDR에 /24를 기록하면 cilium-agent가 이 값을 읽어 자기 노드의 파드 IP를 나눠준다. 할당 주체는 쿠버네티스다.

Kubernetes Host Scope IPAM 모드에서 노드 오브젝트의 podCIDR을 기준으로 IP를 할당하는 구조도
Kubernetes Host Scope 모드에서는 쿠버네티스 노드 오브젝트에 기록된 podCIDR을 cilium-agent가 읽어 IP를 할당한다.

Multi-Pool

멀티풀 IPAM 모드는 사용자가 정의한 워크로드 주석과 노드 레이블에 따라 여러 개의 서로 다른 IPAM 풀에서 PodCIDR을 할당할 수 있도록 지원한다.

Multi-Pool IPAM 모드에서 워크로드별로 서로 다른 IP 풀을 요청하고 할당받는 구조도
Multi-Pool 모드에서는 파드나 네임스페이스 단위로 지정한 풀에서 개별적으로 IP를 요청하고 할당받는다.
# 각 노드에 할당된 pod CIDR 확인
kubectl get ciliumnode -o custom-columns='NODE:.metadata.name,POD_CIDRS:.spec.ipam.podCIDRs,NODE_IP:.spec.addresses'

NODE                       POD_CIDRS         NODE_IP
cilium-lab-control-plane   [10.244.0.0/24]   [map[ip:172.18.0.2 type:InternalIP] map[ip:10.244.0.133 type:CiliumInternalIP]]
cilium-lab-worker          [10.244.1.0/24]   [map[ip:172.18.0.3 type:InternalIP] map[ip:10.244.1.210 type:CiliumInternalIP]]
cilium-lab-worker2         [10.244.2.0/24]   [map[ip:172.18.0.4 type:InternalIP] map[ip:10.244.2.243 type:CiliumInternalIP]]

각 노드의 POD_CIDRS를 보면 control-plane10.244.0.0/24, cilium-lab-worker10.244.1.0/24, cilium-lab-worker210.244.2.0/24를 사용하고 있다.

kubectl get ciliumnode cilium-lab-worker -o yaml | grep -A 30 "spec:"

이 명령어로 cilium-lab-worker의 IPAM 정보를 좀 더 상세히 확인할 수 있다.

spec:
  addresses:
  - ip: 172.18.0.3
    type: InternalIP
  - ip: 10.244.1.210
    type: CiliumInternalIP
  alibaba-cloud: {}
  azure: {}
  bootid: c37ca258-6783-4afa-ba89-58a71d8a6e0c
  encryption: {}
  eni: {}
  health:
    ipv4: 10.244.1.152
  ingress: {}
  ipam:
    podCIDRs:
    - 10.244.1.0/24
    pools: {}
kubectl -n kube-system get cm cilium-config -o yaml | grep -iE "^  ipam|cluster-pool"

ipam: kubernetes
ipam-cilium-node-update-rate: 15s

이 명령어로 현재 클러스터가 어떤 IPAM 모드를 쓰는지 확인할 수 있다. 지금은 ipam: kubernetes인데, 이를 Cluster Scope 모드로 바꾸려면 공식 문서에서도 마이그레이션보다는 클러스터를 다시 만드는 방법을 권장하고 있다.

# IPAM을 Cluster Scope 모드로 설치
cilium install --version 1.19.5 \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}' \
  --set ipam.operator.clusterPoolIPv4MaskSize=24

실습을 위해 기존 클러스터를 삭제하고 다시 만든다.

kind delete cluster -n cilium-lab
kind create cluster --config ./kind/kind-cilium.yaml

# IPAM을 Cluster Scope 모드로 설치
cilium install --version 1.19.5 \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}' \
  --set ipam.operator.clusterPoolIPv4MaskSize=24

# 정상 상태까지 대기
cilium status --wait
kubectl -n kube-system get cm cilium-config -o yaml | grep -iE "^  ipam|cluster-pool"

cluster-pool-ipv4-cidr: 10.244.0.0/16
cluster-pool-ipv4-mask-size: "24"
ipam: cluster-pool
ipam-cilium-node-update-rate: 15s

이것으로 IPAM 모드를 Cluster Scope, 즉 cluster-pool 모드로 전환했다.

  • cluster-pool-ipv4-cidr : 전체 IP 풀이다.
  • cluster-pool-ipv4-mask-size : 노드당 잘라줄 서브넷 크기다(기본값 24, 노드당 254개 파드 IP를 보유할 수 있다). 값을 바꾸면 노드당 254개 이상의 파드 IP를 할당할 수도 있다.

IPAM spec을 좀 더 상세히 확인해본다.

kubectl get ciliumnode cilium-lab-worker -o yaml | grep -A 30 "spec:"
spec:
  addresses:
  - ip: 172.18.0.2
    type: InternalIP
  - ip: 10.244.0.152
    type: CiliumInternalIP
  alibaba-cloud: {}
  azure: {}
  bootid: c37ca258-6783-4afa-ba89-58a71d8a6e0c
  encryption: {}
  eni: {}
  health:
    ipv4: 10.244.0.163
  ingress: {}
  ipam:
    podCIDRs:
    - 10.244.0.0/24
    pools: {}
status:
  alibaba-cloud: {}
  azure: {}
  eni: {}
  ipam:
    operator-status: {}

이번 실습에서는 다루지 않지만, multi-pool 모드까지 운용해보고 싶다면 아래 명령어로 설치할 수 있다.

cilium install --version 1.19.5 \
  --set ipam.mode=multi-pool \
  --set kubeProxyReplacement=true \
  --set bpf.masquerade=true \
  --set ipam.operator.autoCreateCiliumPodIPPools.default.ipv4.cidrs='{10.100.0.0/16}' \
  --set ipam.operator.autoCreateCiliumPodIPPools.default.ipv4.maskSize=24

이렇게 설치하면 아래처럼 풀 구조를 확인할 수 있다.

kubectl get ciliumnode -o yaml | grep -A12 "ipam:"

ipam:
  pools:
    allocated:
    - cidrs:
      - 10.100.0.0/24
      pool: default
    requested:
    - needed:
        ipv4-addrs: 16
      pool: default
- apiVersion: cilium.io/v2
kind: CiliumNode
metadata:
--
ipam:
  pools:
    allocated:
    - cidrs:
      - 10.100.1.0/24
      pool: default
    requested:
    - needed:
        ipv4-addrs: 16
      pool: default

multi-pool은 왜 존재하는가 — 왜 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

배포한 파드 정보를 확인하고, 각각의 endpoint와 identity도 확인한다.

# worker에 배포된 cilium-xrxqf
kubectl exec -n kube-system cilium-xrxqf -- cilium endpoint list

ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value])
1324       Disabled           Disabled          64347      k8s:app=netshoot

# worker2에 배포된 cilium-wghmx
kubectl exec -n kube-system cilium-wghmx -- cilium endpoint list

ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value])
162        Disabled           Disabled          64347      k8s:app=netshoot

각각 endpoint는 1324, 162이고 Identity는 둘 다 64347이다.

# deny.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: netshoot-deny-ingress
spec:
  endpointSelector:
    matchLabels:
      app: netshoot # identity 64347을 만드는 라벨
  ingress:
    - fromEndpoints:
      - matchLabels:
          app: nothing

위와 같은 형식으로 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을 보내면 어떻게 되는지 확인해본다.

# worker2의 파드로 전송
kubectl exec netshoot-6b9fb7848f-6r25s -- ping -c 1 10.244.2.194
# worker2의 cilium-wghmx에서 monitor 명령어 실행. 162는 해당 노드에 배포된 파드(netshoot)의 endpoint
kubectl exec -n kube-system cilium-wghmx -- cilium monitor --related-to 162

이번에도 cilium monitor 명령어로 DROP 관련 이벤트를 확인해본다.

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 트래픽에는 별도의 제한이 없다.

# CiliumNetworkPolicy로 등록한 리소스 삭제
kubectl delete cnp netshoot-deny-ingress

실습이 끝나면 정책을 삭제한다. 여기까지가 Identity 기반 네트워크 정책 실습이다.

Kube-Proxy Replacement & Load Balancing

지금까지는 비교적 가벼운 내용을 다뤘다면, 이번 챕터는 조금 더 무거운 내용을 다룬다. 지금까지의 실습에서는 Cilium을 쓰면서도 kube-proxy를 그대로 두고 IPTables를 함께 사용해왔다. 이 구조에서는 Service(ClusterIP)라는 가상 IP, 예를 들어 10.96.X.X 같은 주소를 사용한다. 이 가상 IP를 실제 백엔드 IP로 바꾸는 작업을 IPTables가 담당하는데, 이는 여러 체인으로 구성돼 있고 탐색 방식이 선형적이다. 그래서 체인이 늘어날수록(서비스가 많아질수록) 성능이 떨어진다. Cilium은 이 부분을 cilium-agent와 eBPF로 대체한다. 먼저 기존 kube-proxyIPTables가 어떻게 동작하는지부터 확인해본다.

앞서 설명한 대로 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

가상 IP 10.96.190.56을 가진 서비스가 만들어졌다.

docker exec cilium-lab-worker iptables-save -t nat 2>/dev/null | grep -iE "10.96.190.56|netshoot-svc" | head -20

-A KUBE-SEP-B2XTYZ3IMZ7Q3U3U -s 10.244.2.194/32 -m comment --comment "default/netshoot-svc" -j KUBE-MARK-MASQ
-A KUBE-SEP-B2XTYZ3IMZ7Q3U3U -p tcp -m comment --comment "default/netshoot-svc" -m tcp -j DNAT --to-destination 10.244.2.194:80
-A KUBE-SEP-IB7LRUT54SL5PMFQ -s 10.244.0.71/32 -m comment --comment "default/netshoot-svc" -j KUBE-MARK-MASQ
-A KUBE-SEP-IB7LRUT54SL5PMFQ -p tcp -m comment --comment "default/netshoot-svc" -m tcp -j DNAT --to-destination 10.244.0.71:80
-A KUBE-SERVICES -d 10.96.190.56/32 -p tcp -m comment --comment "default/netshoot-svc cluster IP" -m tcp --dport 80 -j KUBE-SVC-RCAXWRJ4Y75BAX5N
-A KUBE-SVC-RCAXWRJ4Y75BAX5N ! -s 10.244.0.0/16 -d 10.96.190.56/32 -p tcp -m comment --comment "default/netshoot-svc cluster IP" -m tcp --dport 80 -j KUBE-MARK-MASQ
-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

생성된 서비스 IP를 기준으로 관련 IPTables를 뽑아보면 위와 같은 체인이 형성된다. 패킷이 ClusterIP 10.96.190.56에 도착했을 때를 가정하고 하나씩 짚어본다.

1단계

-A KUBE-SERVICES -d 10.96.190.56/32 -p tcp -m comment --comment "default/netshoot-svc cluster IP" -m tcp --dport 80 -j KUBE-SVC-RCAXWRJ4Y75BAX5N

목적지가 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 정보로 처리된다.

3단계

-A KUBE-SEP-B2XTYZ3IMZ7Q3U3U -s 10.244.2.194/32 -m comment --comment "default/netshoot-svc" -j KUBE-MARK-MASQ

-A KUBE-SEP-B2XTYZ3IMZ7Q3U3U -p tcp -m comment --comment "default/netshoot-svc" -m tcp -j DNAT --to-destination 10.244.2.194:80

가상의 Cluster IP를 실제 파드 IP(10.244.2.194/32)로 바꾸는 DNAT 과정이다. 즉 서비스(IP)가 백엔드 IP로 변환되는 단계다. 흥미로운 부분은 KUBE-MARK-MASQ로 자기 자신에 대한 규칙이 별도로 존재한다는 점인데, 이는 서비스에 연결한 자신이 다시 자기 자신에게 선택되는 hairpin 상황을 정상적으로 처리하기 위한 것이다. 결국 IPTables는 커널 netfilter가 KUBE-SERVICESKUBE-SVCKUBE-SEPDNAT 순서로 패킷을 처리하는 구조이고, 이 작업이 선형적이기 때문에 시간복잡도는 O(n)이다. cilium-agent와 eBPF는 이를 Socket LB로 대체한다.

kube-proxy 대체하기

앞서 설명한 대로 현재 클러스터에는 여전히 kube-proxy가 설치돼 있다. 이를 제거하고 cilium-agenteBPF로 대체한다.

# 쿠버네티스 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를 제거한 뒤 남아있는 기존 규칙까지 정리할 수 있다. 이제 상태를 다시 확인해본다.

kubectl -n kube-system exec ds/cilium -- cilium status --verbose | grep -iE "KubeProxyReplacement|Routing|Masquerading"

KubeProxyReplacement:   True   [eth0    172.18.0.4 fc00:f853:ccd:e793::4 fe80::e4db:6aff:fe0f:c6a9 (Direct Routing)]
Routing:                Network: Tunnel [vxlan]   Host: BPF
Masquerading:           BPF   [eth0]   10.244.2.0/24  [IPv4: Enabled, IPv6: Disabled]
KubeProxyReplacement Details:
  Devices:              eth0    172.18.0.4 fc00:f853:ccd:e793::4 fe80::e4db:6aff:fe0f:c6a9 (Direct Routing)
  IPv4 masquerading agent       16384
  IPv6 masquerading agent       16384

KubeProxyReplacement: TrueRouting: 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를 아예 없애고 시작하려면 아래처럼 클러스터를 구성해야 한다.

# kind-ebpf.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cilium-lab
networking:
  disableDefaultCNI: true
  kubeProxyMode: none     # kube-proxy 삭제
nodes:
  - role: control-plane
  - role: worker
  - role: worker
kind delete cluster -n cilium-lab
kind create cluster --config ./kind-ebpf.yaml

위 yaml로 클러스터를 다시 만든다.

# helm으로 cilium 배포
helm upgrade --install cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set bpf.masquerade=true \
  --set k8sServiceHost=cilium-lab-control-plane \
  --set k8sServicePort=6443

# 정상설치까지 대기
cilium status --wait

# netshoot 디플로이먼트 및 서비스 배포
kubectl create deployment netshoot --image=nicolaka/netshoot -- sleep 3600
kubectl scale deployment netshoot --replicas=2
kubectl expose deployment netshoot --port=80 --target-port=80 --name=netshoot-svc

helm으로 cilium을 배포하고 netshoot도 앞서와 동일하게 배포한다.

Cilium은 kube-proxy가 안고 있던 문제를 앞서 설명한 대로 eBPF로 풀어낸다. Cilium은 이를 Socket-Based Loadbalancing이라 부른다. 원리는 비교적 단순하다.

Cilium 1.13 Datapath 기준 L7 Egress/Ingress 정책이 커널 훅과 Cilium 컴포넌트를 거치는 흐름도
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라면 recvmsgsendmsg를 후킹해 처리한다. 그렇다면 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 맵을 보고 싶다면 아래 명령어로 확인할 수 있다.

SERVICE ADDRESS           BACKEND ADDRESS (REVNAT_ID) (SLOT)
10.96.57.113:0/ANY (0)    0.0.0.0:0 (0) (0) [ClusterIP, non-routable]
10.96.57.113:80/TCP (1)   10.0.0.203:80/TCP (6) (1)
10.96.57.113:80/TCP (2)   10.0.2.143:80/TCP (6) (2)

여기서 (0)은 마스터 엔트리로 이 서비스에 백엔드가 몇 개 있는지를 나타낸다. (6)은 REVNAT_ID로 서비스 고유 ID다. 다만 이 값이 실제로 쓰이는 경우는 socket LB가 아니라 tc 계층에서 DNAT가 발생하는 경로다. socket LB는 패킷 주소 자체를 바꾸지 않으므로 되돌릴 것이 없고, 실제로 이 경로의 conntrack 엔트리는 RevNAT=0이다. REVNAT_ID는 다음 편에서 다룰 NodePort나 외부 트래픽에서 응답 소스를 프론트엔드로 복원할 때 사용된다.

정리하면, 파드 안에서 curl http://10.96.57.113을 실행하면 서비스맵과 백엔드맵을 조회해 커널에 아래처럼 요청하는 구조다.

connect(소켓, 목적지 = 10.0.0.203:80)

이 시점에 커널은 소켓 구조체를 만든다.

TCPDUMP로 검증하기

지금까지는 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-nrh8b -- curl netshoot-svc:80
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는 다음과 같은 순서로 동작한다.

  1. connect(10.96.57.113:80) — 애플리케이션이 요청한다.
  2. cgroup에 붙은 eBPF가 이 connect() 시스템콜을 가로챈다.
  3. eBPF 맵을 조회한다: 10.96.57.113:80의 백엔드는 무엇인가.
  4. 답을 받는다: 10.0.0.203:80.
  5. 소켓에 기록될 목적지를 아예 바꿔치기한다: 10.0.0.203:80.
  6. 커널이 소켓에 “목적지 = 10.0.0.203:80″을 기록한다.
  7. 패킷이 처음부터 백엔드를 향해 생성된다(출발: 10.0.2.143.40988 > 10.0.0.203.80).
  8. 별도의 DNAT 과정이 없다.

Socket LB eBPF 소스코드 확인

tcpdump로 패킷을 캡처해 실제 동작을 확인했다면, 이번에는 cilium의 eBPF 소스코드를 기준으로 분석해본다.

버전은 1.19.5를 기준으로 진행한다.

/* bpf_sock.c */
__section("cgroup/connect4")
int cil_sock4_connect(struct bpf_sock_addr *ctx)
{
[...]
    err = __sock4_xlate_fwd(ctx, ctx, false, true);
[...]
}

cil_sock4_connect 프로그램은 cgroup/connect4 syscall을 후킹해서 eBPF 프로그램을 붙인다. 이때 실제로 트리거되는 함수는 __sock4_xlate_fwd다. 이 함수는 크기가 제법 크기 때문에 중요한 부분만 나눠서 살펴본다.

static __always_inline int __sock4_xlate_fwd(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full,
                         const bool udp_only,
                         const bool is_connect)
{
    const bool in_hostns = ctx_in_hostns(ctx_full, &id.client_cookie);
    const struct lb4_backend *backend;
    const struct lb4_service *svc;
    __u16 dst_port = ctx_dst_port(ctx);
    __u8 protocol = ctx_protocol(ctx);
    __u32 dst_ip = ctx->user_ip4;
    struct lb4_key key = {
        .address    = dst_ip,
        .dport      = dst_port,
        .proto      = protocol,
    }, orig_key = key;

  [...]
}

__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 키를 만드는 역할을 한다.

svc = lb4_lookup_service(&key, true);
static __always_inline const struct lb4_service *
__lb4_lookup_service(struct lb4_key *key)
{
    return map_lookup_elem(&cilium_lb4_services_v2, key);
}

cilium bpf lb list로 덤프했던 그 맵을 조회하는 지점이다. 앞서 만든 key로 서비스를 꺼내오며, 이때 사용하는 맵이 cilium_lb4_services_v2다. 이 맵도 cilium 명령어로 직접 확인할 수 있다.

kubectl -n kube-system exec cilium-rnqsj -- cilium map get cilium_lb4_services_v2

Key                       Value                   State   Error
10.96.57.113:80/TCP (1)   9 0[0] (6) [0x0 0x0]
10.96.57.113:80/TCP (2)   10 0[0] (6) [0x0 0x0]
10.96.57.113:80/TCP (0)   0 2[0] (6) [0x0 0x0]

각 엔트리를 보면 0번은 마스터 엔트리이고 1번과 2번이 실제 백엔드 슬롯 엔트리다. Value 값은 뒤이어 확인할 cilium_lb4_backends_v3 맵에서 사용하는 key다.

key.backend_slot = (sock_select_slot(ctx_full) % svc->count) + 1;
backend_slot = __lb4_lookup_backend_slot(&key);

backend_id = backend_slot->backend_id;
backend = __lb4_lookup_backend(backend_id);

sock_select_slot으로 앞서 본 서비스맵에서 백엔드 슬롯을 꺼내온다. 마스터 엔트리는 사용하지 않으므로 +1을 더한다. __lb4_lookup_backend_slot 역시 lb4_backends_v3 맵을 조회한다.

static __always_inline const struct lb4_backend *
__lb4_lookup_backend(__u32 backend_id)
{
    return map_lookup_elem(&cilium_lb4_backends_v3, &backend_id);
}

이 맵도 cilium 커맨드로 조회할 수 있다.

kubectl -n kube-system exec cilium-rnqsj -- cilium map get cilium_lb4_backends_v3

Key   Value              State   Error
10    TCP://10.0.2.143
9     TCP://10.0.0.203
sock4_update_revnat(ctx_full, backend, &orig_key, svc->rev_nat_index)
static __always_inline int sock4_update_revnat(struct bpf_sock_addr *ctx,
                           const struct lb4_backend *backend,
                           const struct lb4_key *orig_key,
                           __u16 rev_nat_id)
{
    key.cookie = sock_local_cookie(ctx);
    key.address = backend->address;
    key.port = backend->port;

    val.address = orig_key->address;
    val.port = orig_key->dport;
    val.rev_nat_index = rev_nat_id;

    tmp = map_lookup_elem(&cilium_lb4_reverse_sk, &key);
    if (!tmp || memcmp(tmp, &val, sizeof(val)))
        ret = map_update_elem(&cilium_lb4_reverse_sk, &key,
                      &val, 0);
    return ret;
}

sock4_update_revnat 함수는 원래 ClusterIP를 키로 RevNAT을 세팅한다. sock_local_cookie 함수로 소켓 고유 식별자(쿠키)를 뽑아 백엔드 IP·포트와 결합해 키를 만들고, 값으로는 클러스터 IP(원래 목적지)를 저장한다. getpeername이나 SNAT에서 ClusterIP를 다시 꺼내와야 할 때 이 값이 쓰인다. rev_nat_index를 함께 저장하는 이유는 이 소켓이 어느 Service(REVNAT ID X)를 거쳤는지 추적하기 위해서다.

kubectl -n kube-system exec -it cilium-rnqsj -- cilium map get cilium_lb4_reverse_sk

[10.0.0.228]:13568, 198569   [10.96.0.10]:13568, 1024
[10.0.0.228]:13568, 198555   [10.96.0.10]:13568, 1024
[10.0.0.141]:13568, 218742   [10.96.0.10]:13568, 1024
[10.0.0.228]:13568, 222857   [10.96.0.10]:13568, 1024
[10.0.0.228]:13568, 198559   [10.96.0.10]:13568, 1024
[10.0.0.228]:13568, 218733   [10.96.0.10]:13568, 1024
[10.0.0.141]:13568, 206550   [10.96.0.10]:13568, 1024
[10.0.0.228]:13568, 202473   [10.96.0.10]:13568, 1024
[10.0.0.228]:13568, 214689   [10.96.0.10]:13568, 1024

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를 활용해 로드밸런서까지 구축할 수 있을 만큼 강력하면서도 자유로운 확장성을 가진 영역도 있다.

참조문서

김수창 기자

오랜만에 재밌는 공부였습니다.


TOP