tech

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

이전 시리즈에서는 Cilium의 기본 개념과 Identity, Endpoint, IPAM 등을 쿠버네티스 환경에서 직접 실습했고, Socket LB(East-West Load Balancing)는 TCPDUMP를 기반으로 패킷을 하나하나 분석하며 이해를 진행했다. 다만 내용이 길어지면서 다음 주제인 North-South Load Balancing은 다루지 못했다. 이번 글에서는 North-South Load Balancing을 다루고자 한다.

North-South Load Balancing

지금까지 다룬 내용은 In-Cluster 내 Pod to Pod 통신, 즉 East-West 통신을 기반으로 진행했다. In-Cluster 통신의 경우 출발지 파드가 클러스터가 소유한 노드에 있기 때문에, 출발지 파드가 네트워크 연결을 요청할 때 connect() 함수를 eBPF로 후킹할 수 있었다. 하지만 North-South 통신의 경우 외부 클라이언트로부터 트래픽이 인입되기 때문에 connect() 함수를 후킹할 수 없다. 그렇기에 패킷이 도착하는 목적지 노드의 tc(tcx) 계층에서 DNAT을 수행해야 한다(cil_from_netdev@eth0). 그리고 백엔드가 다른 노드에 있다면 해당 노드로 패킷을 포워딩까지 해야 한다. 응답의 경우 처음 도착했던 노드를 통해서 최초의 외부 클라이언트에게 응답을 제공해야 한다(클라이언트는 요청했던 노드 IP:포트에서 응답을 기다리고 있기 때문이다).

  • North-South Load Balancing: 외부 트래픽을 쿠버네티스 노드 내 서비스로 전달
  • East-West Load Balancing: 내부 서비스 간 로드 밸런싱

North-South 로드밸런싱에는 3가지 모드가 존재한다.

SNAT 모드 (기본)

클러스터 외부 클라이언트에서 발생한 패킷이 노드 A에 도착했다고 가정하자. 이때 전송하려는 백엔드 파드가 노드 B에 있다면, 노드 A는 패킷의 소스를 자신의 IP로 변경(SNAT)한 뒤 노드 B로 전송한다. 응답받은 파드는 다시 노드 B로 전달하고 노드 B는 노드 A로 전달한다. 최종적으로 노드 A가 외부 클라이언트에게 응답한다. ExternalTrafficPolicy가 local이라면 백엔드가 클라이언트의 IP를 볼 수 있지만, 그 외의 경우에는 소스 IP가 노드 A의 IP로 변경되어 있다. 또한 응답이 노드 A를 거치므로 네트워크 홉이 하나 더 생긴다.

DSR (Direct Server Return)

패킷이 노드 A에 도착했을 때 소스를 클라이언트 IP 그대로 유지한 채 노드 B로 전달한다. 전달받은 노드 B는 백엔드 파드에게 전달한다. 전달받은 백엔드 파드는 소스가 클라이언트 IP로 되어 있기 때문에 외부 클라이언트에게 직접 응답을 제공한다. 즉, 노드 A를 거치지 않는다. 장점이라면 클라이언트 IP를 보존할 수 있고, 응답 경로를 단축할 수 있다는 점이다. 다만, 최초 패킷을 받은 노드 A는 원래 목적지(클라이언트의 주소 정보)를 노드 B에게 어떻게든 전달해야 하는 과정이 필수적이다.

Hybrid

TCP는 DSR 방식으로, UDP는 SNAT 방식을 사용함을 의미한다.

kubectl -n kube-system exec ds/cilium -- cilium status --verbose | grep -iA 12 "KubeProxyReplacement Details"

KubeProxyReplacement Details:
  Status:               True
  Socket LB:            Enabled
  Socket LB Tracing:    Enabled
  Socket LB Coverage:   Full
  Devices:              eth0    172.18.0.4 fc00:f853:ccd:e793::4 fe80::4488:54ff:fe83:e798 (Direct Routing)
  Mode:                 SNAT
  Backend Selection:    Random
  Session Affinity:     Enabled
  NAT46/64 Support:     Disabled
  XDP Acceleration:     Disabled
  Services:
  - ClusterIP:      Enabled

현재 클러스터는 SNAT 모드로 동작하고 있음을 확인할 수 있다.

Cilium + eBPF 기반 SNAT 모드에서의 패킷 캡처 확인

cilium-lab-worker(172.18.0.3) 노드에는 netshoot-7b776d8bd4-brh8b 파드가 배포되어 있고 cilium-lab-worker2(172.18.0.4) 노드에는 netshoot-7b776d8bd4-wlsk9 파드가 배포되어 있다. 각각 80번 포트의 웹서버가 동작 중이라고 가정할 때, 외부 도커 컨테이너(클러스터 외부)가 cilium-lab-worker 노드에 요청을 보내 cilium-lab-worker2 노드로 패킷이 전달되어 netshoot-7b776d8bd4-wlsk9 파드로 트래픽이 최종적으로 흐르는 상황이다. 그전에 netshoot Deployment에 NodePort 서비스를 추가한다.

# NodePort 서비스 추가
kubectl expose deployment netshoot --type=NodePort --port=80 --name=netshoot-np

kubectl get svc

NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
kubernetes     ClusterIP   10.96.0.1       <none>        443/TCP        25h
netshoot-np    NodePort    10.96.136.151   <none>        80:31178/TCP   20s
netshoot-svc   ClusterIP   10.96.57.113    <none>        80/TCP         24h

NodePort로 31178을 사용하는 것을 알 수 있다.

# 외부 도커 컨테이너 실행 및 HTTP 요청
docker run -d --name ext-client --network kind nicolaka/netshoot sleep infinity
docker exec ext-client curl http://172.18.0.3:31178

# cilium-lab-worker2에서 tcpdump 캡처 시작
docker exec cilium-lab-worker2 bash -c "apt update && apt install tcpdump -y"
docker exec cilium-lab-worker2 tcpdump -i any -n "tcp and (tcp port 80 or tcp port 31178)"

15:29:56.953692 cilium_vxlan P   IP 10.0.2.37.59516 > 10.0.0.203.80: Flags [S], seq 1729550172, win 65495, options [mss 65495,sackOK,TS val 3966506285 ecr 0,nop,wscale 7], length 0

15:29:56.954004 lxcb816d8186f24 In  IP 10.0.0.203.80 > 10.0.2.37.59516: Flags [S.], seq 1814208142, ack 1729550173, win 65418, options [mss 65430,sackOK,TS val 384236862 ecr 3966506285,nop,wscale 7], length 0

15:29:56.954120 cilium_vxlan Out IP 10.0.0.203.80 > 10.0.2.37.59516: Flags [S.], seq 1814208142, ack 1729550173, win 65418, options [mss 65430,sackOK,TS val 384236862 ecr 3966506285,nop,wscale 7], length 0

tcpdump로 cilium-lab-worker2에서 캡처한 패킷 중 중요 부분만 일부 표시 및 분석을 진행한다.

15:29:56.953692 cilium_vxlan P   IP 10.0.2.37.59516 > 10.0.0.203.80: Flags [S], seq 1729550172, win 65495, options [mss 65495,sackOK,TS val 3966506285 ecr 0,nop,wscale 7], length 0

10.0.2.37 IP는 cilium-lab-worker의 cilium_host 인터페이스다. 이는 노드에서 트래픽이 나가는 지점이기에 해당 IP로 소스가 설정된다. 이 VXLAN 패킷을 cilium_vxlan 인터페이스에서 수신한다. 이를 cilium-lab-worker2 노드의 파드에 트래픽을 전달한다. 여기서 재미있는 점이 존재한다. 만약 cilium에서 kube-proxy가 존재하는 IPTables 상태였다면 노드 입장에서 파드의 veth pair 쌍인 lxcXXXXXX와 eth0 간 통신 과정에서 lxcXXXXXX로 패킷이 나가는 흐름이 캡처됐을 것이다. 하지만 현재는 cilium + eBPF 모드이기 때문에 호스트의 TC(TCX) 계층에서 수신한 패킷이 eBPF 프로그램인 cil_from_overlay에 의하여 내부적으로 bpf_redirect_peer() bpf helper 함수를 호출하여 lxcXXXXXX의 veth pair 인터페이스인 파드의 netns 내 eth0에 패킷을 포워딩한다. 그렇기에 파드의 veth pair(호스트에 설치된 인터페이스) lxcXXXXXX에는 패킷이 흐르지 않아 호스트에서 실행 중인 tcpdump에는 캡처되지 않는다.

15:29:56.954004 lxcb816d8186f24 In  IP 10.0.0.203.80 > 10.0.2.37.59516: Flags [S.], seq 1814208142, ack 1729550173, win 65418, options [mss 65430,sackOK,TS val 384236862 ecr 3966506285,nop,wscale 7], length 0

이는 백엔드 파드의 응답이다. 처음 목적지인 cilium-lab-worker에 응답하는 것을 볼 수 있다. 다만, Out은 안 보이고 lxc In만 보이는 이유는 bpf_redirect_peer() bpf helper 함수가 단방향으로 패킷을 주입하는 함수이기 때문이다. 그렇기에 파드의 응답은 파드 내 netns에 설치된 eth0, 그리고 그 veth pair 쌍인 lxcXXXXXX에는 In으로 캡처된다.

15:29:56.954120 cilium_vxlan Out IP 10.0.0.203.80 > 10.0.2.37.59516: Flags [S.], seq 1814208142, ack 1729550173, win 65418, options [mss 65430,sackOK,TS val 384236862 ecr 3966506285,nop,wscale 7], length 0

이는 VXLAN 회신이다. 이처럼 SNAT 모드라면 요청과 응답이 모두 최초 트래픽의 진입 지점 노드를 통과한다는 것을 알 수 있다.

Cilium + eBPF 기반 DSR 모드에서의 패킷 캡처 확인

이전 SNAT 실습에서 클라이언트 소스 IP가 사라지고 응답 구간에서 노드의 추가 홉이 생기는 것을 볼 수 있었다. 이번에는 이 두 가지 단점을 해결하는 DSR(Direct Server Return) 방식을 확인한다.

IPIP

노드에서 향하는 트래픽을 IP IN IP 형식으로 IP 헤더 안에 IP 헤더를 포함시킨다. 즉, 터널 간 캡슐화를 진행한다. 캡슐화된 패킷은 해당 파드를 호스팅하는 노드로 라우팅되고, Cilium은 터널을 종료하고 외부 헤더를 제거한 후 DNAT를 수행하여 외부 목적지 주소로 식별된 백엔드 파드에 내부 패킷을 전달한다. 다만, Cilium VXLAN 통신에서는 사용할 수 없으며 네이티브 라우팅 환경에서만 사용이 가능하다.

IP Option

클라이언트가 접속했던 최초 진입 노드의 IP, 포트 정보가 IP Option 형태로 저장되어 추가된다. 다만 해당 기능을 사용하려면 네이티브 라우팅을 사용해야 하며 캡슐화 모드에서는 사용할 수 없다.

Geneve

클라이언트가 최초 진입하는 노드의 IP/포트를 Cilium IP Option을 추가하여 처리하면, 일부 데이터 센터 라우터는 IP 옵션을 알 수 없는 패킷을 Layer 2 Slow Path라는 소프트웨어 처리 단계로 전달한다. 또한 IP 옵션이 포함된 패킷의 양이 특정 임계값을 초과하면 해당 패킷을 버리는데, 이는 네트워크 성능에 영향을 미칠 수 있다. Cilium은 이러한 문제를 방지하기 위해 Geneve를 사용한 모드를 제공한다. 이는 라우팅 모드가 네이티브 모드인지 캡슐화 모드인지와 상관없다.

이번 실습에서는 IP Option을 사용해서 DSR 모드를 실습해보는 것으로 진행한다. 클러스터 설치부터 DSR 모드로 설정한다.

# 기존 클러스터 삭제
kind delete cluster -n cilium-lab

# 클러스터 설치 (without kube-proxy)
kind create cluster --config ./kind/kind-cilium.yaml

# DSR 모드 및 ip option DSR 모드로 cilium 설치
helm install cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=cilium-lab-control-plane \
  --set k8sServicePort=6443 \
  --set routingMode=native \
  --set autoDirectNodeRoutes=true \
  --set ipv4NativeRoutingCIDR=10.244.0.0/16 \
  --set loadBalancer.mode=dsr \
  --set loadBalancer.dsrDispatch=opt \
  --set bpf.masquerade=true

# cilium 설치 대기
cilium status --wait

# netshoot 디플로이먼트 파드 설치 및 노드포트 추가
kubectl create deployment netshoot --image=nicolaka/netshoot -- sleep 3600
kubectl scale deployment netshoot --replicas=2
kubectl expose deployment netshoot --type=NodePort --port=80 --name=netshoot-np

# 양쪽에 웹서버 띄우기
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

# nodeport 서비스 포트 확인
kubectl get svc

# DSR 설치 확인
kubectl -n kube-system exec ds/cilium -- cilium status --verbose | grep -iA 12 "KubeProxyReplacement Details"
KubeProxyReplacement Details:
  Status:                True
  Socket LB:             Enabled
  Socket LB Tracing:     Enabled
  Socket LB Coverage:    Full
  Devices:               eth0    172.18.0.4 fc00:f853:ccd:e793::4 fe80::741a:1fff:fedc:4d08 (Direct Routing)
  Mode:                  DSR
    DSR Dispatch Mode:   IP Option/Extension
  Backend Selection:     Random
  Session Affinity:      Enabled
  NAT46/64 Support:      Disabled
  XDP Acceleration:      Disabled
  Services:

DSR 모드로 IP Option을 사용하는 것으로 클러스터 설치가 되었다는 것을 확인할 수 있다. 이제 앞서와 마찬가지로 cilium-lab-worker2에서 tcpdump로 외부 클라이언트로부터 요청한 패킷을 캡처한다.

  • cilium-lab-worker(172.18.0.3)
  • cilium-lab-worker2(172.18.0.4)
  • ext-client(172.18.0.5)
# ext-client(외부 클라이언트)가 cilium-lab-worker 노드에 요청
docker exec ext-client bash -c "nc -zv 172.18.0.3 32619"

# tcpdump 설치
docker exec cilium-lab-worker2 bash -c "apt update && apt install tcpdump -y"

# worker2에 감지된 패킷 캡처
docker exec cilium-lab-worker2 tcpdump -i any -n -v "tcp port 80 or tcp port 32619"

# 결과
tcpdump: data link type LINUX_SLL2
tcpdump: listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
14:53:31.872585 eth0  In  IP (tos 0x0, ttl 63, id 35763, offset 0, flags [DF], proto TCP (6), length 68, options (unknown 154))
    172.18.0.5.46726 > 10.0.0.7.80: Flags [S], cksum 0xb64c (incorrect -> 0x8887), seq 3961816526, win 65495, options [mss 65495,sackOK,TS val 4223527638 ecr 0,nop,wscale 7], length 0
14:53:31.872848 lxcad54fbc1e6bb In  IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    10.0.0.7.80 > 172.18.0.5.46726: Flags [S.], cksum 0xb64c (incorrect -> 0xdfea), seq 3447068812, ack 3961816527, win 65468, options [mss 65480,sackOK,TS val 1535338287 ecr 4223527638,nop,wscale 7], length 0
14:53:31.873011 eth0  Out IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    172.18.0.3.32619 > 172.18.0.5.46726: Flags [S.], cksum 0x585b (incorrect -> 0xbec0), seq 3447068812, ack 3961816527, win 65468, options [mss 65480,sackOK,TS val 1535338287 ecr 4223527638,nop,wscale 7], length 0

위처럼 패킷 덤프를 뜰 수 있다. 하나하나 곱씹어 본다.

14:53:31.872585 eth0  In  IP (tos 0x0, ttl 63, id 35763, offset 0, flags [DF], proto TCP (6), length 68, options (unknown 154))
    172.18.0.5.46726 > 10.0.0.7.80: Flags [S], cksum 0xb64c (incorrect -> 0x8887), seq 3961816526, win 65495, options [mss 65495,sackOK,TS val 4223527638 ecr 0,nop,wscale 7], length 0
14:53:31.872848 lxcad54fbc1e6bb In  IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)

외부 클라이언트(172.18.0.5)의 SYN 패킷이 worker2 백엔드로 이미 DNAT된 채, IP Option(type 154, length 68)을 달고 도착했다. 이때 SNAT과 달리 외부 클라이언트의 소스가 유지된다.

14:53:31.872848 lxcad54fbc1e6bb In  IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    10.0.0.7.80 > 172.18.0.5.46726: Flags [S.], cksum 0xb64c (incorrect -> 0xdfea), seq 3447068812, ack 3961816527, win 65468, options [mss 65480,sackOK,TS val 1535338287 ecr 4223527638,nop,wscale 7], length 0
14:53:31.873011 eth0  Out IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)

백엔드가 SYN-ACK를 응답한다. 소스 IP는 백엔드(10.0.0.7)이고 목적지는 클라이언트(172.18.0.5)이다. 여기까지는 소스가 백엔드 파드 IP로 되어 있다.

14:53:31.873011 eth0  Out IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    172.18.0.3.32619 > 172.18.0.5.46726: Flags [S.], cksum 0x585b (incorrect -> 0xbec0), seq 3447068812, ack 3961816527, win 65468, options [mss 65480,sackOK,TS val 1535338287 ecr 4223527638,nop,wscale 7], length 0

여기서 소스 IP가 cilium-lab-worker로 변경되어서 클라이언트에게 전달된 것을 확인할 수 있다. worker에서 worker2로 패킷을 전달할 때 IP Option에 원래 노드 IP(172.18.0.3.32619)를 추출해서 NAT 엔트리에 저장했고, 이를 백엔드 응답이 나갈 때 eth0(Out) 인터페이스에서 소스를 복원한다.

관측 가능성 (Hubble)

지금까지는 패킷이 왜 DROP되었는지, 왜 이 노드에서 DROP됐는지, 이 패킷의 Identity는 무엇이고 어떤 Identity를 향해 가는지 등을 monitor, bpf 명령어 또는 맵 등을 통해서 확인해왔다. 하지만 이때마다 명령어를 호출하고 조건을 걸고 다른 맵을 살펴보는 것은 운영 환경에서는 불리하게 작용한다. Cilium은 Hubble을 통해 앞서 말한 문제점을 해결하고 관찰 가능성을 제공한다. 즉, Hubble은 Identity·서비스·정책 판정이 붙은 구조화된 플로우로 보여준다.

Hubble의 구성 요소

  • Hubble Server: 각 cilium 에이전트에 내장되어 있다. eBPF 이벤트를 관측 데이터로 변환한다.
  • Hubble Relay: 여러 노드의 Hubble Server를 모아 클러스터 전역 뷰를 제공한다.
  • Hubble UI: 웹 기반 시각화(서비스 맵)를 보여준다.
kubectl -n kube-system exec ds/cilium -- cilium status | grep -i hubble

Hubble:                  Ok              Current/Max Flows: 4095/4095 (100.00%), Flows/s: 5.88   Metrics: Disabled

위는 Hubble을 확인하는 명령어이고 아래 명령어로 설치가 가능하다.

# Hubble + Relay 활성화 (필요시)
helm upgrade cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system --reuse-values \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true

kubectl -n kube-system rollout restart ds/cilium
kubectl -n kube-system rollout status ds/cilium --timeout=120s

# MacOS의 경우
brew install hubble

호스트에도 Hubble을 설치한다.

# hubble relay를 로컬 포트로 포워드
kubectl -n kube-system port-forward hubble-relay-785f7df-fst9d 4245:4245 &

# CLI가 해당 포트로 접속 및 마지막 10건 수집
hubble observe --server localhost:4245 --last 10

Aug  3 14:04:11.925: 10.0.2.178:51188 (host) -> kube-system/hubble-ui-6bb97d8894-zlsmx:8081 (ID:24960) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
Aug  3 14:04:12.605: 172.18.0.4:53516 (remote-node)  172.18.0.4:4240 (remote-node) to-network FORWARDED (TCP Flags: ACK)

hubble observe 명령어를 통해 트래픽 플로우를 확인할 수 있다. 내용을 자세히 살펴보면 (host)와 (ID:24960)이 양쪽의 Identity다. host(예약 Identity)에서 hubble-ui 파드로 가는 것을 볼 수 있다. to-endpoint는 datapath의 어느 지점인지를 나타낸다. 이는 파드로 배달되는 단계이다. FORWARDED는 정책 판정(허용)이다. DROP이면 DROPPED로 뜨게 된다.

  • to-endpoint: 파드로 전달
  • to-stack: 호스트 커널 스택으로 전달
  • to-network: 노드 밖 네트워크로 전달

정책 기반 관찰

다음으로는 정책을 추가하여 관측을 진행한다. 이전에 배포해둔 외부 클라이언트 ext-client(172.18.0.5)가 존재한다고 가정한다.

허용 로그

hubble observe --server localhost:4245 --pod netshoot -f

Aug  3 15:02:19.969: 172.18.0.5:43488 (world) -> default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) to-endpoint FORWARDED (TCP Flags: SYN)
Aug  3 15:02:19.969: 172.18.0.5:43488 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) to-endpoint FORWARDED (TCP Flags: ACK)
Aug  3 15:02:19.969: 172.18.0.5:43488 (world) -> default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Aug  3 15:02:19.980: 172.18.0.5:43488 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
Aug  3 15:02:19.980: 172.18.0.5:43488 (world) -> default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) to-endpoint FORWARDED (TCP Flags: ACK)

라벨 기반 정책 적용

# hubble-deny.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: netshoot-deny
spec:
  endpointSelector:
    matchLabels:
      app: netshoot
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: nothing

위 yaml 파일을 hubble-deny.yaml로 저장하고 정책을 적용한다. 이는 nothing이라는 라벨을 가진 Identity만 정책을 허용한다는 뜻이다.

kubectl apply -f hubble-deny.yaml

hubble observe --server localhost:4245 --pod netshoot -f

Aug  3 15:04:07.076: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)
Aug  3 15:04:07.076: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) Policy denied DROPPED (TCP Flags: SYN)
Aug  3 15:04:08.093: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)
Aug  3 15:04:08.093: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) Policy denied DROPPED (TCP Flags: SYN)
Aug  3 15:04:09.117: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)
Aug  3 15:04:09.117: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) Policy denied DROPPED (TCP Flags: SYN)
Aug  3 15:04:10.141: 172.18.0.5:44074 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)

정책에 의해 DROP된 것을 확인할 수 있다. 지금까지는 이를 cilium monitor로 관측했었는데 hubble로 손쉽게 관측이 가능해졌다.

L3/L4 포트 기반 정책 적용

라벨 기반으로 정책을 적용하는 것뿐만 아니라 L4, L7 등 다양하게 정책을 적용할 수 있다. 다음은 포트 기반으로 클러스터 내 다른 노드로부터만 트래픽을 허용하는 정책을 적용해본다.

# hubble-port.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: netshoot-allow
spec:
  endpointSelector:
    matchLabels:
      app: netshoot
  ingress:
    - fromEntities:
        - remote-node
      toPorts:
        - ports:
            - port: "80"
              protocol: TCP

# 정책 적용
kubectl apply -f hubble-port.yaml

# hubble 모니터링 진행
hubble observe --server localhost:4245 --pod netshoot -f

Aug  3 15:24:05.074: 172.18.0.5:58126 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN DENIED (TCP Flags: SYN)

위처럼 클러스터 내부 노드가 아닌 외부 클라이언트의 연결은 정책에서 DROP된 것을 확인할 수 있다.

L7 기반 정책 적용

eBPF는 커널에서 패킷 헤더(L3/L4)를 본다. 그런데 HTTP 메서드/경로/상태 코드는 TCP 스트림을 재조립해서 파싱해야 알 수 있다. 그렇기에 커널/eBPF에서 처리하기에는 어렵다. 그렇기에 cilium은 L7 트래픽 제어가 필요하면 트래픽을 Envoy 프록시로 우회시킨다. Envoy가 HTTP를 파싱하고 결과를 Hubble L7 flow에 넘긴다.

if (lb4_svc_is_l7_loadbalancer(svc)) {
    ...
    return 0;   /* Let the TC level eBPF datapath redirect to L7 LB. */
}
# hubble-l7.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: netshoot-l7
spec:
  endpointSelector:
    matchLabels:
      app: netshoot
  ingress:
    - fromEntities:
        - world
      toPorts:
        - ports:
            - port: "80"
              protocol: TCP
          rules:
            http:
              - method: "GET"
                path: "/"

위 정책은 GET 메서드로 “/” 경로만 요청을 허용하겠다는 뜻이며, 클러스터 내부 노드가 아니라 외부 클라이언트로부터만 연결을 허용하겠다는 뜻이다.

kubectl apply -f hubble-l7.yaml

hubble observe --server localhost:4245 --pod netshoot -t l7 -f

Aug  3 15:28:49.290: 172.18.0.5:58718 (world) -> default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) http-request FORWARDED (HTTP/1.1 GET http://172.18.0.3:32619/)
Aug  3 15:28:49.300: 172.18.0.5:58718 (world)  default/netshoot-7b776d8bd4-pd2c8:80 (ID:25373) http-request DROPPED (HTTP/1.1 GET http://172.18.0.3:32619/hello)
Aug  3 15:28:54.778: 172.18.0.5:39606 (world) 

위처럼 어떤 경로에 접근했는지, DROP되었는지 확인할 수 있다. 다만 L7 정책을 추가하면 트래픽이 커널에서 유저스페이스(Envoy)로 전달되었다가 다시 돌아오기 때문에 socket LB를 사용하지 못하는 단점이 발생한다(높은 지연과 CPU 사용량).

kubectl -n kube-system exec cilium-qsqpp -- cilium endpoint list | grep netshoot

2613       Enabled            Disabled          25373      k8s:app=netshoot

netshoot 워크로드의 endpoint가 2613인 것을 확인한다.

kubectl -n kube-system exec cilium-qsqpp -- cilium bpf policy get 2613

POLICY   DIRECTION   LABELS (source:key[=value])   PORT/PROTO   PROXY PORT   AUTH TYPE   BYTES   PACKETS   PREFIX   COOKIE
Allow    Ingress     reserved:host                 ANY          NONE         disabled    -       -         0        0
Allow    Ingress     reserved:world                80/TCP       16221        disabled    558     7         24       0
Allow    Ingress     reserved:world-ipv4           80/TCP       16221        disabled    -       -         24       0
Allow    Ingress     reserved:world-ipv6           80/TCP       16221        disabled    -       -         24       0
Allow    Egress      ANY                           ANY          NONE         disabled    -       -         0        0

endpoint의 정책 eBPF 맵을 확인해보면 16221 포트로 프록시 서버가 동작 중인 것을 확인할 수 있다.

Hubble UI

마지막으로 hubble 명령어가 아닌 UI로, 웹페이지에서 트래픽을 모니터링하는 방향으로 진행한다.

kubectl -n kube-system port-forward hubble-ui-6bb97d8894-zlsmx 8081:8081 &

그리고 localhost:8081로 접근해서 namespace를 선택하면 트래픽을 확인할 수 있다.

Hubble UI에서 네임스페이스별 트래픽 흐름을 시각화한 서비스맵 화면
Hubble UI에서 namespace를 선택하면 서비스 간 트래픽 흐름을 시각화된 서비스맵으로 확인할 수 있다.

멀티클러스터 그리고 Cluster Mesh

Cluster Mesh란 여러 쿠버네티스 클러스터를 하나의 네트워크로 묶는 역할을 한다.

파드 간 직접 통신

클러스터 A의 파드가 클러스터 B의 파드 IP로 통신 가능하도록 한다.

글로벌 서비스

같은 이름의 서비스를 양쪽 클러스터에 두면 백엔드가 자동으로 합쳐진다. 로컬 백엔드 장애 발생 시 다른 클러스터에 배포된 백엔드로 페일오버(Failover)된다.

크로스클러스터 정책

클러스터 B의 '서비스만 허용' 같은 Identity 정책 기반 적용이 가능하다.

이번 챕터는 Cilium의 Cluster Mesh를 사용해서 KIND로 배포된 서로 다른 쿠버네티스 클러스터를 하나의 클러스터로 묶는 실습을 진행한다.

해결해야 하는 문제

멀티클러스터를 구축하기 위해서는 해결해야 하는 문제가 존재한다.

  • 라우팅(클러스터 A의 노드가 클러스터 B의 파드 IP로 가는 길을 어떻게 찾을 것인가?)
  • Identity 충돌(클러스터마다 독립적으로 할당되는 Identity를 어떻게 관리할 것인가?)
  • 상태 공유(서로 다른 클러스터가 파드, 서비스 Identity 정보를 어떻게 아는가?)

설계

실제 실습 전에 확인해야 할 사항이 존재한다. 그리고 이를 반영한 클러스터를 아래처럼 구축한다.

  • 고유한 Cluster Name과 Cluster ID가 존재해야 한다.
  • 클러스터마다 서로 다른 Pod CIDR를 가져야 한다.
  • 노드 간 통신이 가능해야 한다.
cluster name  : cluster1     | cluster2
cluster id    : 1            | 2
pod CIDR      : 10.1.0.0/16  | 10.2.0.0/16
service CIDR  : 10.11.0.0/16 | 10.12.0.0/16
# kind-c1.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cluster1
networking:
  disableDefaultCNI: true
  kubeProxyMode: none
  podSubnet: "10.1.0.0/16"
  serviceSubnet: "10.11.0.0/16"
nodes:
  - role: control-plane
  - role: worker
---
# kind-c2.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cluster2
networking:
  disableDefaultCNI: true
  kubeProxyMode: none
  podSubnet: "10.2.0.0/16"
  serviceSubnet: "10.12.0.0/16"
nodes:
  - role: control-plane
  - role: worker
kind create cluster --config kind-c1.yaml
kind create cluster --config kind-c2.yaml

그리고 각 클러스터에 cilium을 설치한다.

# --- cluster1 ---
helm install cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system \
  --kube-context kind-cluster1 \
  --set cluster.name=cluster1 \
  --set cluster.id=1 \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList={10.1.0.0/16} \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=cluster1-control-plane \
  --set k8sServicePort=6443 \
  --set bpf.masquerade=true \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true

  # --- cluster2 ---
helm install cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system \
  --kube-context kind-cluster2 \
  --set cluster.name=cluster2 \
  --set cluster.id=2 \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList={10.2.0.0/16} \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=cluster2-control-plane \
  --set k8sServicePort=6443 \
  --set bpf.masquerade=true \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true

각 클러스터에 cilium이 설치됐지만 아직 clustermesh로 연결된 상태는 아니다. 그렇기에 먼저 연결 전 상태를 확인한다.

kubectl --context kind-cluster1 -n kube-system exec ds/cilium -- cilium status | grep -iE "cluster|kvstore"

# etcd모드가 아닌 CRD모드 사용
KVStore:                 Disabled
Kubernetes APIs:         ["cilium/v2::CiliumCIDRGroup", "cilium/v2::CiliumClusterwideNetworkPolicy", "cilium/v2::CiliumEndpoint", "cilium/v2::CiliumNetworkPolicy", "cilium/v2::CiliumNode", "core/v1::Pods", "networking.k8s.io/v1::NetworkPolicy"]

# 아직 연결된 클러스터가 없음.
ClusterMesh:             0/0 remote clusters ready
Cluster health:          2/2 reachable   (2026-08-09T07:03:08Z)   (Probe interval: 1m36.566274746s)

이제 Identity도 확인해본다.

kubectl --context kind-cluster1 -n kube-system exec ds/cilium -- cilium identity list | head -20

ID       LABELS
1        reserved:host
[...]
84424    k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=kube-system
         k8s:io.cilium.k8s.policy.cluster=cluster1
         k8s:io.cilium.k8s.policy.serviceaccount=coredns

보면 84424 Identity가 생긴 것을 확인할 수 있다. 이는 Cluster ID 값이 적용된 유니크한 Identity 값임을 알 수 있다.

clusterIDShift = NumericIdentityBitlength - clusterIDLen    // 기본 24 - 8 = 16

// cluster ID로부터 identity 범위 계산
return NumericIdentity((1 > uint32(GetClusterIDShift())) & cmtypes.ClusterIDMax

또한 라벨에도 k8s:io.cilium.k8s.policy.cluster=cluster1처럼 클러스터 이름이 적혀 있어, 유니크한 라벨 덕분에 클러스터 내 정책에서 충돌 및 제한으로부터 자유롭다.

fromEndpoints:
  - matchLabels:
      k8s:io.cilium.k8s.policy.cluster: cluster2
      app: frontend        # "cluster2의 frontend만 허용"
kubectl --context kind-cluster1 -n kube-system exec ds/cilium -- cilium bpf ipcache list | head -20

IP PREFIX/ADDRESS   IDENTITY
10.1.0.51/32        identity=4 encryptkey=0 tunnelendpoint=172.18.0.4 flags=hastunnel
10.1.0.147/32       identity=6 encryptkey=0 tunnelendpoint=172.18.0.4 flags=hastunnel
10.1.1.31/32        identity=1 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
10.1.1.96/32        identity=84424 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
10.1.1.155/32       identity=115908 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
10.1.1.163/32       identity=84262 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
172.18.0.3/32       identity=1 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
[...]

cluster1에서 ipcache를 조회했을 때 자신이 관리 중인 IP 목록은 포함되어 있지만, cluster2에 포함된 IP 목록(10.2.0.0/16)은 아직 포함되지 않은 것을 알 수 있다.

Cluster Mesh 배포 및 연결

이제 본격적으로 kind-cluster1과 kind-cluster2, 2개의 쿠버네티스 클러스터를 하나로 묶는 작업을 진행한다.

참고) tls.authMode 값의 기본값은 Migrate다. 이 경우 추가적인 ConfigMap이 필요하기 때문에 여기서는 Legacy로 설정한다.

# 양쪽에 clustermesh-apiserver 활성화
helm upgrade cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system --kube-context kind-cluster1 --reuse-values \
  --set clustermesh.useAPIServer=true \
  --set clustermesh.apiserver.service.type=NodePort \
  --set clustermesh.apiserver.tls.authMode=legacy

helm upgrade cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system --kube-context kind-cluster2 --reuse-values \
  --set clustermesh.useAPIServer=true \
  --set clustermesh.apiserver.service.type=NodePort \
  --set clustermesh.apiserver.tls.authMode=legacy

clustermesh를 배포한다. 그리고 아래 명령어로 클러스터를 연결한다.

cilium clustermesh connect --context kind-cluster1 --destination-context kind-cluster2

Error: Unable to connect cluster: Cilium CA certificates do not match between clusters cluster1 and cluster2. Use --allow-mismatching-ca to allow this by adding remote CAs to the CA bundle

clustermesh로 서로 다른 클러스터를 연결하려면 CA를 공유해야 한다. 공유하기 위한 방법으로 아래 2가지가 존재한다.

  • 공유 CA - 서로 다른 클러스터가 같은 CA를 사용한다.
  • --allow-mismatching-ca 옵션을 사용하여 각각 CA를 유지하면서 상대방 CA를 신뢰 목록에 추가한다.

이 중 1번 방법을 사용해 진행한다.

kubectl --context kind-cluster1 -n kube-system get secret cilium-ca -o yaml > ./ca.yaml

양쪽 클러스터의 CA 값을 명령어로 확인한다. 그리고 cluster1의 CA 값을 ca.yaml로 저장하고, 여기서 resourceVersion, creationTimestamp, annotations 필드를 제거한다.

kubectl --context kind-cluster2 -n kube-system delete secret cilium-ca

# hubble 관련 시크릿도 삭제
kubectl --context kind-cluster2 -n kube-system delete secret \
  hubble-server-certs \
  hubble-relay-client-certs \
  hubble-relay-server-certs \
  hubble-ui-client-certs \
  --ignore-not-found

# clustermesh 쪽도 아직 안 지웠으면 같이
kubectl --context kind-cluster2 -n kube-system delete secret \
  clustermesh-apiserver-server-cert \
  clustermesh-apiserver-admin-cert \
  clustermesh-apiserver-remote-cert \
  clustermesh-apiserver-local-cert \
  --ignore-not-found

# CA 덮어쓰기
kubectl --context kind-cluster2 -n kube-system apply -f ./ca.yaml

# 기존 인증서 시크릿 삭제 → helm이 새 CA로 재생성
kubectl --context kind-cluster2 -n kube-system delete secret \
  clustermesh-apiserver-server-cert \
  clustermesh-apiserver-admin-cert \
  clustermesh-apiserver-remote-cert \
  2>/dev/null

# helm 재적용으로 새 CA 기반 인증서 생성
helm upgrade cilium cilium/cilium --version 1.19.5 \
  --namespace kube-system --kube-context kind-cluster2 --reuse-values

# 재시작
kubectl --context kind-cluster2 -n kube-system rollout restart deployment/clustermesh-apiserver
kubectl --context kind-cluster2 -n kube-system rollout restart ds/cilium
kubectl --context kind-cluster2 -n kube-system rollout status deployment/clustermesh-apiserver --timeout=180s
kubectl --context kind-cluster2 -n kube-system rollout status ds/cilium --timeout=180s

# helm으로 새 CA 기반 재발급 (hubble)
helm upgrade cilium cilium/cilium --version 1.19.5 --namespace kube-system --kube-context kind-cluster2 --reuse-values

# 전부 재시작해서 새 인증서 로드
kubectl --context kind-cluster2 -n kube-system rollout restart ds/cilium
kubectl --context kind-cluster2 -n kube-system rollout restart deployment/hubble-relay
kubectl --context kind-cluster2 -n kube-system rollout restart deployment/clustermesh-apiserver

kubectl --context kind-cluster2 -n kube-system rollout status ds/cilium --timeout=180s
kubectl --context kind-cluster2 -n kube-system rollout status deployment/hubble-relay --timeout=120s

kind-cluster2에서 CA를 지우고 kind-cluster1에서 복제한 CA로 덮어쓴다. 클러스터 간 CA 값이 동일한지 아래 명령어로 확인한 뒤, 다시 clustermesh로 클러스터 간 연결을 진행한다.

# 양쪽 CA 인증서 지문 비교
echo "=== cluster1 CA ==="
kubectl --context kind-cluster1 -n kube-system get secret cilium-ca -o jsonpath='{.data.ca\.crt}' | base64 -d | openssl x509 -noout -fingerprint -sha256
echo "=== cluster2 CA ==="
kubectl --context kind-cluster2 -n kube-system get secret cilium-ca -o jsonpath='{.data.ca\.crt}' | base64 -d | openssl x509 -noout -fingerprint -sha256
cilium clustermesh connect --context kind-cluster1 --destination-context kind-cluster2

ℹ️  Found ClusterMesh service IPs: [172.18.0.5]
ℹ️ Configuring Cilium in cluster kind-cluster1 to connect to cluster kind-cluster2
ℹ️ Configuring Cilium in cluster kind-cluster2 to connect to cluster kind-cluster1
✅ Connected cluster kind-cluster1  kind-cluster2!
kubectl --context kind-cluster1 -n kube-system exec ds/cilium -- cilium status | grep -i clustermesh
kubectl --context kind-cluster2 -n kube-system exec ds/cilium -- cilium status | grep -i clustermesh

ClusterMesh:             1/1 remote clusters ready
ClusterMesh:             1/1 remote clusters ready

두 개의 클러스터가 clustermesh로 연결된 것을 확인할 수 있다. 또한 ipcache도 다시 한번 확인한다.

kubectl --context kind-cluster1 -n kube-system exec ds/cilium -- cilium bpf ipcache list | head -20

IP PREFIX/ADDRESS   IDENTITY
10.1.0.0/24         identity=2 encryptkey=0 tunnelendpoint=172.18.0.4 flags=hastunnel
10.1.1.18/32        identity=93015 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
10.1.1.96/32        identity=84424 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
10.1.1.163/32       identity=84262 encryptkey=0 tunnelendpoint=0.0.0.0 flags=
10.2.0.142/32       identity=4 encryptkey=0 tunnelendpoint=172.18.0.5
10.2.1.40/32        identity=165001 encryptkey=0 tunnelendpoint=172.18.0.7 flags=hastunnel
10.2.1.102/32       identity=135358 encryptkey=0 tunnelendpoint=172.18.0.7 flags=hastunnel

10.2.0.102/32       identity=6 encryptkey=0 tunnelendpoint=172.18.0.5 flags=hastunnel,remotecluster

kind-cluster1의 ipcache에서 kind-cluster2의 파드 IP 목록이 보이는 것을 확인할 수 있다. 추가적으로 remotecluster는 remote-node로, 노드가 원격 클러스터에 속한다는 뜻이다. 그리고 hastunnel + tunnelendpoint는 "저 노드로 VXLAN으로 캡슐화해서 전송하라"는 뜻이다.

통신 확인

연결된 클러스터에 각각 백엔드를 배포하고, 외부 클라이언트에서 nc 명령어로 통신 및 장애 테스트(failover)를 진행한다.

kubectl --context kind-cluster1 create deployment netshoot --image=nicolaka/netshoot -- sleep infinity
kubectl --context kind-cluster1 rollout status deployment/netshoot

# cluster2
kubectl --context kind-cluster2 create deployment netshoot --image=nicolaka/netshoot -- sleep infinity
kubectl --context kind-cluster2 rollout status deployment/netshoot

# IP 확인
kubectl --context kind-cluster1 get pod -l app=netshoot -o wide
kubectl --context kind-cluster2 get pod -l app=netshoot -o wide
  • kind-cluster1: 10.1.1.104
  • kind-cluster2: 10.2.1.103

배포된 파드에 대해 우선 클러스터 간 통신을 진행한다.

kubectl --context kind-cluster1 exec  netshoot-57fdfc7498-xkhh9 -- ping -c 3 10.2.1.103

PING 10.2.1.103 (10.2.1.103) 56(84) bytes of data.
64 bytes from 10.2.1.103: icmp_seq=1 ttl=63 time=0.738 ms
64 bytes from 10.2.1.103: icmp_seq=2 ttl=63 time=0.212 ms

kind-cluster1에서 kind-cluster2로 정상적인 통신을 확인했다. 이제 서비스를 배포하고 외부 클라이언트로의 통신을 확인한다.

# 양쪽에 웹서버 띄우기
for ctx in kind-cluster1 kind-cluster2; do
  for p in $(kubectl --context $ctx get pod -l app=netshoot -o jsonpath='{.items[*].metadata.name}'); do
    kubectl --context $ctx exec $p -- sh -c "nohup python3 -m http.server 80 >/dev/null 2>&1 &" 2>/dev/null
  done
done
sleep 2

# 양쪽에 같은 이름의 서비스 + global annotation
for ctx in kind-cluster1 kind-cluster2; do
  kubectl --context $ctx expose deployment netshoot --port=80 --type=NodePort --name=global-svc
  kubectl --context $ctx annotate service global-svc service.cilium.io/global="true"
done

먼저 웹서버 백엔드를 python3으로 실행한다. 그리고 양쪽에 같은 이름의 서비스를 배포하기 위해서는 service.cilium.io/global="true"라는 Annotation을 붙인다. 이 Annotation을 붙이면 cilium이 해당 서비스를 클러스터 전역 서비스로 인식한다. 즉 다른 클러스터의 같은 이름을 가진 서비스 백엔드를 자신의 LB 맵에 합친다.

kubectl --context kind-cluster1 get svc
kubectl --context kind-cluster2 get svc

# kind-cluster1의 서비스
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)
global-svc   NodePort    10.12.106.229           80:32498/TCP

# kind-cluster2의 서비스
NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
global-svc   NodePort    10.12.254.36           80:31935/TCP

그리고 노드 IP를 확인하면 kind-cluster1은 172.18.0.3, kind-cluster2의 경우 172.18.0.7이다. 외부 클라이언트에서 kind-cluster1의 노드포트로 요청을 전송한다.

docker exec ext-client nc -zv 172.18.0.3 32498

그리고 kind-cluster2에서 cilium monitor로 관련된 백엔드 endpoint를 모니터링한다.

kubectl --context kind-cluster2 exec -n kube-system cilium-pdvgw -- cilium monitor --related-to 230

-> endpoint 230 flow 0x64c148f2 , identity world->136352 state new ifindex lxcebea5111109e orig-ip 10.1.1.31: 10.1.1.31:45596 -> 10.2.1.103:80 tcp SYN
-> overlay flow 0x20c3614 , identity 136352->remote-node state reply ifindex cilium_vxlan orig-ip 0.0.0.0: 10.2.1.103:80 -> 10.1.1.31:45596 tcp SYN, ACK
-> endpoint 230 flow 0x64c148f2 , identity world->136352 state established ifindex lxcebea5111109e orig-ip 10.1.1.31: 10.1.1.31:45596 -> 10.2.1.103:80 tcp ACK
-> endpoint 230 flow 0x64c148f2 , identity world->136352 state established ifindex lxcebea5111109e orig-ip 10.1.1.31: 10.1.1.31:45596 -> 10.2.1.103:80 tcp ACK, FIN
-> overlay flow 0x20c3614 , identity 136352->remote-node state reply ifindex cilium_vxlan orig-ip 0.0.0.0: 10.2.1.103:80 -> 10.1.1.31:45596 tcp ACK, FIN
-> endpoint 230 flow 0x64c148f2 , identity world->136352 state established ifindex lxcebea5111109e orig-ip 10.1.1.31: 10.1.1.31:45596 -> 10.2.1.103:80 tcp ACK

정상적으로 외부 클라이언트는 kind-cluster2로 TCP 패킷을 전송했지만, kind-cluster2가 최종적으로 트래픽을 전달받아 응답한 것을 볼 수 있다.

kubectl --context kind-cluster1 exec -n kube-system ds/cilium -- cilium service list

ID   Frontend                Service Type   Backend
11   0.0.0.0:32498/TCP       NodePort       1 => 10.1.1.104:80/TCP (active)
                                            2 => 10.2.1.103:80/TCP (active)
13   10.12.106.229:80/TCP    ClusterIP      1 => 10.1.1.104:80/TCP (active)
                                            2 => 10.2.1.103:80/TCP (active)

kind-cluster1에서 cilium service list를 실행했을 때, 백엔드 맵에 각 클러스터에 배포된 백엔드 IP가 담겨 있는 것을 알 수 있다.

장애 테스트

kind-cluster1에 배포된 netshoot deployment의 replicas를 0으로 설정한 후 다시 패킷을 전송한다.

kubectl --context kind-cluster1 scale deployment netshoot --replicas=0

docker exec ext-client nc -zv 172.18.0.3 32498

Connection to 172.18.0.3 32498 port [tcp/*] succeeded! 정상적으로 통신이 되는 것을 확인할 수 있다.

kubectl --context kind-cluster1 exec -n kube-system ds/cilium -- cilium service list

ID   Frontend                Service Type   Backend
11   0.0.0.0:32498/TCP       NodePort       1 => 10.2.1.103:80/TCP (active)
13   10.12.106.229:80/TCP    ClusterIP      1 => 10.2.1.103:80/TCP (active)

또한 서비스 맵에서 kind-cluster1에 배포된 백엔드 IP가 제거된 것을 볼 수 있다.

Affinity

추가적으로 로컬/원격 선호도를 Annotation으로 제어할 수 있다. 크로스클러스터의 경우 장애 발생 시 failover할 수 있는 장점이 있지만, 그만큼 네트워크 비용과 높은 레이턴시가 발생할 수 있다. 그렇기에 아래 Annotation으로 특정 상황에서만 크로스클러스터에 배포된 백엔드로 트래픽을 전달할 수 있다.

service.cilium.io/affinity: "local"    # 로컬 백엔드 우선, 없으면 원격
service.cilium.io/affinity: "remote"   # 원격 우선
service.cilium.io/affinity: "none"     # 구분 없이 균등 (기본)
kubectl --context kind-cluster1 annotate service global-svc service.cilium.io/affinity=local --overwrite

여기까지가 cilium clustermesh를 활용한 기본적인 고가용성 멀티클러스터 구축 방법이다. 고가용성뿐만 아니라 공유 서비스, 상태 저장 서비스와 상태 비저장 서비스의 분리 등 여러 방면에서 활용할 수 있다. 특히 공유 서비스의 경우, 각 클러스터 A, B, C끼리는 물리적 경계를 두고 A, B, C 클러스터가 공유하는 클러스터에만 연결을 맺는 방식이다. 이 공유 클러스터는 DNS 쿼리, 로깅 등 공통된 서비스만 모아서 처리할 수 있다.

이 글에서는 이전 시리즈에 이어 외부 클라이언트의 트래픽을 처리하는 North-South 통신(SNAT vs DSR)을 살펴보고, Cilium Hubble로 관측 가능성을 확인했으며, 멀티클러스터 통신 구축까지 다뤄보았다. Cilium은 iptables 같은 기존 레거시 구조에서 벗어나 eBPF를 이용해 다양한 시도를 이어가고 있는 오픈소스다. 그중에는 XDP를 이용해 로드밸런서까지 구축할 수 있을 만큼 강력하면서도 자유로운 확장성을 가진 영역도 있다.

참조문서

김수창 기자

시작은 귀찮지만 중간은 즐거움.


TOP