Cilium - BGP ControlPlane

Gyullbb·2025년 8월 16일
0

K8S

목록 보기
18/22

BGP ControlPlane

앞선 글에서 살펴본 것 처럼, 서로 다른 네트워크 대역 간 통신을 위해 라우터를 거쳐야 하는 환경에서는 노드가 많아질수록 수동 라우트 설정이 비효율적이라는 문제가 발생한다.
이러한 한계를 해결하기 위한 대표적인 방법에는 Overlay 네트워크와 BGP를 통한 동적 라우팅이 있는데, 이번 글에서는 BGP(Border Gateway Protocol)를 활용한 주소 알리기 방식에 대해 살펴본다.

실습 환경은 다음과 같다.

Kubernetes 클러스터 노드

  • k8s-ctr(IP: 192.168.10.100, podCIDR: 172.20.0.0/24)
  • k8s-w1(IP: 192.168.10.101, podCIDR: 172.20.1.0/24)
  • k8s-w0(IP: 192.168.20.100, podCIDR: 172.20.2.0/24)

Router 노드

  • router(IP: 192.168.10.200)

autoDirectNodeRoutes = false

  • 노드 별 PodCIDR 라우팅이 없음.

기본 환경 통신 테스트

현재 cilium의 설정이 autoDirectNodeRoutes=false로 podCIDR에 대해 Node 라우팅이 걸려있지 않기 때문에 노드 내의 파드들 끼리만 통신이 가능하다.

# 노드 별 PodCIDR 라우팅이 없음.
(|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
172.20.0.0/24 via 172.20.0.167 dev cilium_host proto kernel src 172.20.0.167
172.20.0.167 dev cilium_host proto kernel scope link
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.100
192.168.20.0/24 via 192.168.10.200 dev eth1 proto static

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get endpointslices -l app=webpod
NAME           ADDRESSTYPE   PORTS   ENDPOINTS                             AGE
webpod-2zb7b   IPv4          80      172.20.0.28,172.20.1.76,172.20.2.68   21s

# 노드 내에 있는 pod와만 통신이 가능함.
(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c 'while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo "---" ; sleep 1; done'
---
---
---
---
---
---
Hostname: webpod-697b545f57-qmxbk
---
---
Hostname: webpod-697b545f57-qmxbk
---
^Ccommand terminated with exit code 130

Cilium BGP Control Plane

Cilium은 BGP Control Plane 기능을 통해 클러스터 내 노드들이 Pod CIDR, Service IP와 같은 네트워크 정보를 외부 라우터에 동적으로 광고하도록 지원한다. 이를 위해 여러 CRD를 제공하며, 각각의 역할은 다음과 같다.

  1. CiliumBGPClusterConfig

클러스터 차원에서 적용되는 BGP 인스턴스 및 피어(peer) 설정을 정의한다.
이를 통해 특정 BGP 구성을 여러 노드에 일괄적으로 적용할 수 있으며, 노드마다 동일한 피어링 정책을 손쉽게 유지할 수 있다.

  1. CiliumBGPPeerConfig

여러 피어에 공통적으로 적용할 수 있는 BGP 피어링 설정 집합을 정의한다.
예를 들어 hold time, keepalive 주기, eBGP multihop 등 반복적으로 쓰이는 설정을 모듈화하여 재사용할 수 있다.

  1. CiliumBGPAdvertisement

어떤 요소를 BGP 라우팅 테이블에 주입할지를 정의한다.
Pod CIDR, Service CIDR, 혹은 LoadBalancer IP 범위와 같은 네트워크 대역을 외부 라우터에 알리도록 설정할 수 있다.

  1. CiliumBGPNodeConfigOverride

특정 노드에 한정하여 적용하는 세부적인 BGP 설정을 정의한다.

FRR

Cilium BGP Control Plane을 통해 쿠버네티스 노드가 Pod CIDR이나 Service IP 대역을 외부로 광고하려면, 이를 받아줄 BGP 피어가 필요하다. 일반적인 데이터센터나 가상화 환경에서는 L3 라우터가 그 역할을 담당하며, 라우터는 클러스터 노드로부터 BGP 업데이트를 받아 외부 네트워크로의 경로를 전파한다.

FRR(FRRouting)은 리눅스에서 동작하는 라우팅 소프트웨어 오픈소스로,
BGP, OSPF, IS-IS, RIP 등 다양한 라우팅 프로토콜을 지원하며, 커널 라우팅 테이블과 연동되어 동적으로 학습한 경로를 시스템 전반에 반영할 수 있다.

FRR은 다음과 같은 기능을 수행한다.

실습

(1) frr 설정

우선 bgp 광고를 위해 router노드에 frr설정을 주입한다.

root@router:~# cat /etc/frr/frr.conf
frr version 8.4.4
frr defaults traditional
hostname router
log syslog informational
no ipv6 forwarding
service integrated-vtysh-config
!
router bgp 65000
 bgp router-id 192.168.10.200
 no bgp ebgp-requires-policy
 bgp graceful-restart
 bgp bestpath as-path multipath-relax
 neighbor CILIUM peer-group
 neighbor CILIUM remote-as external
 neighbor 192.168.10.100 peer-group CILIUM
 neighbor 192.168.10.101 peer-group CILIUM
 neighbor 192.168.20.100 peer-group CILIUM
 !
 address-family ipv4 unicast
  network 10.10.1.0/24
  maximum-paths 4
 exit-address-family
exit
!

(2) Cilium BGP Control Plane 설정

Cilium에서 노드를 bgp대상으로 인지하고, podCIDR을 외부에 광고할 수 있도록 CR을 배포한다.

# Cilium에서 bgp대상을 인지하도록 Node에 라벨 설정
kubectl label nodes k8s-ctr k8s-w0 k8s-w1 enable-bgp=true

# Config Cilium BGP
# --------------------------------------------------------
# CiliumBGPAdvertisement
# - 어떤 네트워크 프리픽스를 BGP를 통해 광고할지를 정의한다.
# - 여기서는 각 노드에 할당된 PodCIDR을 외부 라우터에 알리도록 설정한다.
# --------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisements
  labels:
    advertise: bgp              # 이후 PeerConfig에서 matchLabels로 참조 가능
spec:
  advertisements:
    - advertisementType: "PodCIDR"   # 각 노드의 PodCIDR을 광고하도록 지정
---
# --------------------------------------------------------
# CiliumBGPPeerConfig
# - BGP 피어링 시 사용되는 공통 설정을 정의한다.
# --------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumBGPPeerConfig
metadata:
  name: cilium-peer
spec:
  timers:
    holdTimeSeconds: 9          # 피어와 세션이 끊겼다고 간주하기까지의 시간
    keepAliveTimeSeconds: 3     # 피어에 keepalive 메시지를 보내는 주기
  ebgpMultihop: 2               # eBGP 피어링 시 허용할 홉 수 (기본은 1)
  gracefulRestart:
    enabled: true               # Graceful Restart 활성화
    restartTimeSeconds: 15      # 세션 복구 시 재학습을 기다리는 시간
  families:
    - afi: ipv4                
      safi: unicast             
      advertisements:
        matchLabels:            # 어떤 광고 리소스를 적용할지 라벨 기반으로 매칭
          advertise: "bgp"      # 위의 CiliumBGPAdvertisement와 연결됨
---
# --------------------------------------------------------
# CiliumBGPClusterConfig
# - 클러스터 단위의 BGP 인스턴스를 정의한다.
# - 특정 노드 셀렉터를 통해 어느 노드에 적용할지 지정할 수 있다.
# --------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumBGPClusterConfig
metadata:
  name: cilium-bgp
spec:
  nodeSelector:
    matchLabels:
      "enable-bgp": "true"      # enable-bgp=true 라벨이 붙은 노드에만 적용
  bgpInstances:
  - name: "instance-65001"      # BGP 인스턴스 이름 
    localASN: 65001             # 로컬 노드(쿠버네티스 노드)의 ASN 번호
    peers:
    - name: "tor-switch"        # 피어 이름 
      peerASN: 65000            # 라우터(피어)의 ASN 번호
      peerAddress: 192.168.10.200  # 라우터의 IP 주소
      peerConfigRef:
        name: "cilium-peer"     # 위에서 정의한 CiliumBGPPeerConfig 참조

(3)설정 이후 통신 확인

Router 노드에서 확인해보면, Cilium이 각 노드의 PodCIDR을 BGP를 통해 광고하였고, 라우터는 이를 수신하여 라우팅 테이블에 반영한 것을 확인할 수 있다.

  1. 라우팅 테이블 확인
root@router:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.10.1.0/24 dev loop1 proto kernel scope link src 10.10.1.200
10.10.2.0/24 dev loop2 proto kernel scope link src 10.10.2.200
172.20.0.0/24 nhid 32 via 192.168.10.100 dev eth1 proto bgp metric 20
172.20.1.0/24 nhid 30 via 192.168.10.101 dev eth1 proto bgp metric 20
172.20.2.0/24 nhid 31 via 192.168.20.100 dev eth2 proto bgp metric 20
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.200
192.168.20.0/24 dev eth2 proto kernel scope link src 192.168.20.200

172.20.0.0/24, 172.20.1.0/24, 172.20.2.0/24 대역이 BGP(proto bgp) 경로로 추가된 것을 확인할 수 있다.
이는 각각 컨트롤 플레인 노드(k8s-ctr), 워커 노드(k8s-w0, k8s-w1)의 PodCIDR이다.

  1. BGP 세션 상태 확인
root@router:~# vtysh -c 'show ip bgp summary'

Neighbor        V   AS     MsgRcvd   MsgSent   Up/Down State/PfxRcd
192.168.10.100  4  65001        76        79  00:03:38            1
192.168.10.101  4  65001        76        79  00:03:38            1
192.168.20.100  4  65001        76        79  00:03:38            1

총 3개의 노드(65001 ASN) 와 BGP 세션이 맺어진 것을 확인할 수 있다.
State/PfxRcd 값이 1인 것은 각 노드로부터 1개의 프리픽스(PodCIDR)를 수신했음을 의미한다.

  1. BGP 라우팅 테이블 확인
root@router:~# vtysh -c 'show ip bgp'
*> 10.10.1.0/24     0.0.0.0                  0         32768 i
*> 172.20.0.0/24    192.168.10.100                         0 65001 i
*> 172.20.1.0/24    192.168.10.101                         0 65001 i
*> 172.20.2.0/24    192.168.20.100                         0 65001 i

172.20.0.0/24, 172.20.1.0/24, 172.20.2.0/24 프리픽스가 노드별 IP(192.168.x.x)를 NextHop으로 하여 등록되어 있다. 즉, 라우터가 Cilium 노드로부터 PodCIDR을 정상적으로 학습한 상태이다.

  1. Cilium 측 광고 상태 확인
(|HomeLab:N/A) root@k8s-ctr:~# cilium bgp routes
Node      VRouter   Prefix          NextHop   Age      Attrs
k8s-ctr   65001     172.20.0.0/24   0.0.0.0   5m49s    [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w0    65001     172.20.2.0/24   0.0.0.0   15m48s   [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w1    65001     172.20.1.0/24   0.0.0.0   15m48s   [{Origin: i} {Nexthop: 0.0.0.0}]

각 노드가 자신의 PodCIDR을 BGP 경로로 광고(advertise) 하고 있음을 보여주며, 이는 라우터에서 확인한 결과와 일치한다.

하지만 여전히 curl pod에서 web pod로 통신을 시도해보면 동일 노드 내의 pod와만 통신이 가능하다.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c 'while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo "---" ; sleep 1; done'
---
---
---
---
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-phntn
---
---

이는 Cilium이 클러스터 내에서 podCIDR 대역에 대해 노드 대역을 자동으로 라우팅 테이블에 추가하지 않기 때문이다.

Cilium이 물리 네트워크 경로를 라우팅 테이블에 추가하지 않는 이유

Cilium은 기본적으로 모든 노드가 서로 L3 reachable 하다는 가정을 깔고 있다.
그렇기 때문에 BGP 광고는 PodCIDR을 외부(라우터)로 알리는 역할만 하고, 물리 네트워크 경로 자체를 해결해주지는 않는다.
노드 IP 대역 자체가 서로 통신 가능한 상태여야 Cilium이 광고한 PodCIDR도 의미가 생긴다.

따라서 Cilium BGP 광고를 활용할 때에는 물리 라우터가 각 노드가 속한 서브넷을 상호 라우팅할 수 있도록 설정이 되어야 한다.

만약 물리 네트워크 레벨에서 라우팅을 보장하기 어렵다면, VXLAN, Geneve 같은 오버레이 터널링 방식을 택해야 한다.
이 경우 노드 간 직접 라우팅이 불가능해도 Pod 트래픽을 캡슐화하여 전달할 수 있다.

Cilium BGP 통신 정리

  • Direct Routing 모드: 노드 간 PodCIDR 통신이 VXLAN/Geneve 터널 없이, L2/L3 직접 경로를 통해 전달됨.
  • autoDirectNodeRoutes=false: Cilium Agent가 커널에 PodCIDR route를 자동 추가하지 않음.
  1. BGP설정을 Reconcile하며 CiliumNode의 PodCIDR 정보 실시간 반영 및 광고
//pkg/bgpv1/manager/manager.go
func (m *BGPRouterManager) reconcileBGPConfig(ctx context.Context,
	sc *instance.ServerWithConfig,
	newc *v2alpha1.CiliumBGPVirtualRouter,
	ciliumNode *v2.CiliumNode) error {
...
	for _, r := range m.Reconcilers {
    //BGP 설정을 Reconcile하며 CiliumNode의 podCIDR 정보를 실시간 저장
		if err := r.Reconcile(ctx, reconciler.ReconcileParams{
			CurrentServer: sc,
			DesiredConfig: newc,
			CiliumNode:    ciliumNode,
		}); err != nil {
			return fmt.Errorf("reconciliation of virtual router with local ASN %v failed: %w", newc.LocalASN, err)
		}
	}
...
}

//pkg/bgpv1/manager/reconciler/pod_cidr.go
func (r *ExportPodCIDRReconciler) Reconcile(ctx context.Context, p ReconcileParams) error {
...
  advertisements, err := exportAdvertisementsReconciler(&advertisementsReconcilerParams{
		logger:    r.Logger,
		ctx:       ctx,
		name:      "pod CIDR",
		component: "exportPodCIDRReconciler",
		enabled:   *p.DesiredConfig.ExportPodCIDR,

		sc:   p.CurrentServer,
		newc: p.DesiredConfig,

		currentAdvertisements: r.getMetadata(p.CurrentServer),
		toAdvertise:           toAdvertise,
	})
...
  // 광고해야 할 CiliumNode의 podCIDR 정보를 실시간 저장
	r.storeMetadata(p.CurrentServer, advertisements)
	return nil
}

//cf) 별도로 NextHop이 지정되어있지 않다면 0.0.0.0 반환
// NextHopFromPathAttributes returns the next hop address determined by the list of provided BGP path attributes.
func NextHopFromPathAttributes(pathAttributes []bgppacket.PathAttributeInterface) string {
	for _, a := range pathAttributes {
		switch attr := a.(type) {
		case *bgppacket.PathAttributeNextHop:
			return attr.Value.String()
		case *bgppacket.PathAttributeMpReachNLRI:
			return attr.Nexthop.String()
		}
	}
	return "0.0.0.0"
}
  1. cilium bgp map 상태 확인
(|HomeLab:N/A) root@k8s-ctr:~# cilium bgp peers
Node      Local AS   Peer AS   Peer Address     Session State   Uptime      Family         Received   Advertised
k8s-ctr   65001      65000     192.168.10.200   established     37h44m51s   ipv4/unicast   5          3
k8s-w0    65001      65000     192.168.10.200   established     37h44m48s   ipv4/unicast   5          3
k8s-w1    65001      65000     192.168.10.200   established     37h44m48s   ipv4/unicast   5          3

(|HomeLab:N/A) root@k8s-ctr:~# cilium bgp routes
(Defaulting to `available ipv4 unicast` routes, please see help for more options)

Node      VRouter   Prefix          NextHop   Age         Attrs
k8s-ctr   65001     172.16.1.1/32   0.0.0.0   37h23m15s   [{Origin: i} {Nexthop: 0.0.0.0}]
          65001     172.20.0.0/24   0.0.0.0   38h3m45s    [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w0    65001     172.16.1.1/32   0.0.0.0   37h23m14s   [{Origin: i} {Nexthop: 0.0.0.0}]
          65001     172.20.2.0/24   0.0.0.0   38h13m42s   [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w1    65001     172.16.1.1/32   0.0.0.0   37h23m13s   [{Origin: i} {Nexthop: 0.0.0.0}]
          65001     172.20.1.0/24   0.0.0.0   38h13m42s   [{Origin: i} {Nexthop: 0.0.0.0}]
  1. k8s-ctr 즉, 172.20.0.0/24에서 패킷을 보낸다고 가정을 했을 때, cilium은 bgp routes에 저장된 Prefix, Nexthop을 확인한다.
    이 때, NextHop은 0.0.0.0으로 지정되어 있는데, k8s-ctr 노드의 라우팅 테이블을 확인하면 eth0으로 빠져나가게 된다.
(|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100

해당 실습에서 실제 통신이 되게 위해서는 eth1로 빠져나가 podCIDR BGP 라우트 정보가 있는 route노드로 향해야 하기 때문에 강제적으로 podCIDR대역에 대해 route 노드로 향하는 물리 라우트를 추가해주어야 한다.

(|HomeLab:N/A) root@k8s-ctr:~# ip route add 172.20.0.0/16 via 192.168.10.200
root@k8s-w0:~# ip route add 172.20.0.0/16 via 192.168.20.200
root@k8s-w1:~# ip route add 172.20.0.0/16 via 192.168.10.200

라우트를 추가한 이후 정상적으로 통신이 되는 것을 확인할 수 있다.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c 'while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo "---" ; sleep 1; done'
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-hhjqw
---
Hostname: webpod-697b545f57-8d2xs
---
Hostname: webpod-697b545f57-8d2xs
---
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-hhjqw
---

결론적으로는 아래와 같이 정리할 수 있다.

  • autoDirectNodeRoutes=false 환경에서는, PodCIDR 광고만으로는 실제 노드 간 통신이 보장되지 않음.
  • 라우팅 테이블을 통해 PodCIDR 대역 → 실제 물리 노드 경로를 명시적으로 지정해야 함.

Service IP advertisement

PodCIDR을 BGP로 광고하듯, Kubernetes 서비스의 IP도 BGP를 통해 외부 라우터에 광고할 수 있다.

  • External IP: 외부에서 접근 가능한 서비스 IP
  • Cluster IP: 클러스터 내부에서만 사용하는 서비스 IP

둘 다 필요에 따라 BGP를 통해 광고 가능하며, 이를 통해 외부 네트워크에서도 서비스 접근이 가능해진다.

서비스 IP를 광고하기 전에 알아둬야 할 개념으로 Traffic Policy가 있는데, Traffic Policy는 서비스 트래픽을 어떻게 분산할지 결정하는 설정이다.

External Traffic Policy

  • Cluster
    외부에서 들어오는 트래픽을 클러스터 전체의 서비스 Pod로 분산한다.
  • Local
    외부 트래픽은 해당 노드의 서비스 Pod로만 전달된다.

Internal Traffic Policy

  • Cluster
    클러스터 내부 Pod → 서비스 트래픽을 전체 Pod로 분산한다.
  • Local
    내부 트래픽은 같은 노드의 Pod로만 전달된다.

서비스 IP를 광고할 때는 External / Internal Traffic Policy에 따라 어떤 Pod로 트래픽이 전달될지가 달라지게 된다. 각 상황에 대해 알아본다.

External IP

External IP + External Traffic Policy (Cluster)

Service를 LoadBalancer타입으로 설정하고, External IP 설정을 한다.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP   21h
webpod       ClusterIP   10.96.252.129   <none>        80/TCP    8h

(|HomeLab:N/A) root@k8s-ctr:~# cat << EOF | kubectl apply -f -
apiVersion: "cilium.io/v2"
kind: CiliumLoadBalancerIPPool
metadata:
  name: "cilium-pool"
spec:
  allowFirstLastIPs: "No"
  blocks:
  - cidr: "172.16.1.0/24"
EOF
ciliumloadbalancerippool.cilium.io/cilium-pool created

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get ippool
NAME          DISABLED   CONFLICTING   IPS AVAILABLE   AGE
cilium-pool   false      False         254             7s

(|HomeLab:N/A) root@k8s-ctr:~# kubectl patch svc webpod -p '{"spec": {"type": "LoadBalancer"}}'
service/webpod patched

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
kubernetes   ClusterIP      10.96.0.1       <none>        443/TCP        21h
webpod       LoadBalancer   10.96.252.129   172.16.1.1    80:31726/TCP   8h

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get ippool
NAME          DISABLED   CONFLICTING   IPS AVAILABLE   AGE
cilium-pool   false      False         253             16s

LB IP를 BGP로 광고하기 위해 adviertisementType를 service, LoadBalancerIP로 지정하여 CiliumBGPAdvertisements CR을 배포한다.

cat << EOF | kubectl apply -f -
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisements-lb-exip-webpod
  labels:
    advertise: bgp
spec:
  advertisements:
    - advertisementType: "Service"
      service:
        addresses:
          - LoadBalancerIP
      selector:             
        matchExpressions:
          - { key: app, operator: In, values: [ webpod ] }
EOF

(|HomeLab:N/A) root@k8s-ctr:~# kubectl describe svc webpod | grep 'Traffic Policy'
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bgp route-policies
VRouter   Policy Name                                             Type     Match Peers         Match Families   Match Prefixes (Min..Max Len)   RIB Action   Path Actions
65001     allow-local                                             import                                                                        accept
65001     tor-switch-ipv4-PodCIDR                                 export   192.168.10.200/32                    172.20.1.0/24 (24..24)          accept
65001     tor-switch-ipv4-Service-webpod-default-LoadBalancerIP   export   192.168.10.200/32                    172.16.1.1/32 (32..32)          accept

root@router:~# sudo vtysh -c 'show ip bgp 172.16.1.1/32'
BGP routing table entry for 172.16.1.1/32, version 5
Paths: (3 available, best #1, table default)
  Advertised to non peer-group peers:
  192.168.10.100 192.168.10.101 192.168.20.100
  65001
    192.168.10.100 from 192.168.10.100 (192.168.10.100)
      Origin IGP, valid, external, multipath, best (Router ID)
      Last update: Fri Aug 15 06:41:04 2025
  65001
    192.168.10.101 from 192.168.10.101 (192.168.10.101)
      Origin IGP, valid, external, multipath
      Last update: Fri Aug 15 06:41:04 2025
  65001
    192.168.20.100 from 192.168.20.100 (192.168.20.100)
      Origin IGP, valid, external, multipath
      Last update: Fri Aug 15 06:41:04 2025

LB IP를 통해 통신 테스트를 수행해본다. 통신 방식을 명확하게 확인하기 위해 webpod replicas수를 하나 줄인 후 외부 router에서 LB IP를 호출해본다.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl scale deployment webpod --replicas 2
deployment.apps/webpod scaled
(|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS      AGE   IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   1 (39h ago)   47h   172.20.0.247   k8s-ctr   <none>           <none>
webpod-697b545f57-8d2xs   1/1     Running   0             47h   172.20.2.68    k8s-w0    <none>           <none>
webpod-697b545f57-hhjqw   1/1     Running   0             47h   172.20.1.76    k8s-w1    <none>           <none>

(|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'
root@k8s-w0:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'
root@k8s-w1:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'

root@router:~# LBIP=172.16.1.1
root@router:~# curl -s $LBIP

호출 시 Pod는 k8s-w0, k8s-w1에만 떠있는 상태에서, 일부 패킷은 k8s-w0와, k8s-w1으로 동시에 패킷을 받는 경우가 있음을 알 수 있다.
이는 External Traffic Policy가 Cluster로 설정되었기 때문이다.

동작 방식

1) External Traffic Policy = Cluster일 경우, nodeSelector 조건에 맞는 모든 노드는 LB의 ExternalIP를 광고한다.

//pkg/bgpv1/manager/reconciler/service.go
func (r *ServiceReconciler) fullReconciliation(ctx context.Context, p ReconcileParams, pathRefs pathReferencesMap) error {
...
  //해당 노드에서 광고할 수 있는 서비스 endpoint가 존재하는 서비스 즉, 해당 노드에서 External Policy Type = Local로 통신이 가능한 서비스를 구분한다.
	ls, err := r.populateLocalServices(p.CiliumNode.Name)
	if err != nil {
		return err
	}
	for _, svc := range toReconcile {
		if err := r.reconcileService(ctx, p.CurrentServer, p.DesiredConfig, svc, ls, pathRefs); err != nil {
			return fmt.Errorf("failed to reconcile service %s/%s: %w", svc.Namespace, svc.Name, err)
		}
	}
...
	return nil
}

//Service를 Reconcile하는 함수
func (r *ServiceReconciler) reconcileService(ctx context.Context, sc *instance.ServerWithConfig, newc *v2alpha1api.CiliumBGPVirtualRouter, svc *slim_corev1.Service, ls localServices, pathRefs pathReferencesMap) error {
  //주어진 서비스에서 route되어야 하는 목록을 반환한다.
	desiredRoutes, err := r.svcDesiredRoutes(newc, svc, ls)
...
	return r.reconcileServiceRoutes(ctx, sc, svc, desiredRoutes, pathRefs)
}

//Service 광고 방식에 따라 최종 광고해야하는 route 목록을 반환한다.
func (r *ServiceReconciler) svcDesiredRoutes(newc *v2alpha1api.CiliumBGPVirtualRouter, svc *slim_corev1.Service, ls localServices) ([]netip.Prefix, error) {
...
	var desiredRoutes []netip.Prefix
	for _, svcAdv := range newc.ServiceAdvertisements {
		switch svcAdv {
		case v2alpha1api.BGPLoadBalancerIPAddr:
			desiredRoutes = append(desiredRoutes, r.lbSvcDesiredRoutes(svc, ls)...)
		case v2alpha1api.BGPClusterIPAddr:
			desiredRoutes = append(desiredRoutes, r.clusterIPDesiredRoutes(svc, ls)...)
    // 해당 케이스를 타게 된다.
		case v2alpha1api.BGPExternalIPAddr:
			desiredRoutes = append(desiredRoutes, r.externalIPDesiredRoutes(svc, ls)...)
		}
	}
}

아래 externalIPDesiredRoutes로 인해 각 Node의 Cilium-agent에서 LB의 ExternalIP를 광고한다.

func (r *ServiceReconciler) externalIPDesiredRoutes(svc *slim_corev1.Service, ls localServices) []netip.Prefix {
	var desiredRoutes []netip.Prefix
...
	for _, extIP := range svc.Spec.ExternalIPs {
		if extIP == "" {
			continue
		}
		addr, err := netip.ParseAddr(extIP)
		if err != nil {
			continue
		}
		desiredRoutes = append(desiredRoutes, netip.PrefixFrom(addr, addr.BitLen()))
	}
	return desiredRoutes
}

2) 외부 라우터가 광고된 External IP를 보고, 패킷을 노드로 전달.
이 때, 라우터는 Pod가 어느 노드에 있는지 모르는 상태이며, External IP의 BGP 광고를 보고 트래픽을 전달하는 상황.

3) Cilium LB가 패킷을 받음.

4) Cilium agent의 Service List의 Backend 인지

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg service list
ID   Frontend               Service Type   Backend
...
16   172.16.1.1:80/TCP      LoadBalancer   1 => 172.20.1.76:80/TCP (active)
                                           2 => 172.20.2.68:80/TCP (active)

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 172.16.1.1
172.16.1.1:80/TCP (1)          172.20.1.76:80/TCP (16) (1)
172.16.1.1:80/TCP (2)          172.20.2.68:80/TCP (16) (2)
172.16.1.1:80/TCP (0)          0.0.0.0:0 (16) (0) [LoadBalancer]                                          

5) bpf ipcache map을 기반으로 Pod가 떠있는 노드로 패킷 전달

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf ipcache list
IP PREFIX/ADDRESS   IDENTITY
172.20.1.76/32      identity=28377 encryptkey=0 tunnelendpoint=0.0.0.0 flags=<none>
172.20.2.68/32      identity=28377 encryptkey=0 tunnelendpoint=192.168.20.100 flags=hastunnel
...

(|HomeLab:N/A) root@k8s-ctr:~# ip route get 172.20.1.76
172.20.1.76 via 192.168.10.200 dev eth1 src 192.168.10.100 uid 0
    cache

(|HomeLab:N/A) root@k8s-ctr:~# ip route get 172.20.2.68
172.20.2.68 via 192.168.10.200 dev eth1 src 192.168.10.100 uid 0
    cache

External IP + External Traffic Policy (Local)

이번에는 서비스의 External Traffic Policy를 Local로 변경한 후 통신을 확인해본다.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl patch service webpod -p '{"spec":{"externalTrafficPolicy":"Local"}}'
service/webpod patched

External Traffic Policy를 local로 바꾸면 Router 노드에서의 bgp 경로도 Pod가 떠있는 노드만으로 변경됨을 알 수 있다.

# External Traffic Policy = Cluster
root@router:~# ip -c route
...
172.16.1.1 nhid 101 proto bgp metric 20
	nexthop via 192.168.10.100 dev eth1 weight 1
	nexthop via 192.168.20.100 dev eth2 weight 1
	nexthop via 192.168.10.101 dev eth1 weight 1

# External Traffic Policy = Local
root@router:~# ip -c route
...
172.16.1.1 nhid 105 proto bgp metric 20
	nexthop via 192.168.20.100 dev eth2 weight 1
	nexthop via 192.168.10.101 dev eth1 weight 1

이번에도 위와 동일하게 webpod가 k8s-w0, k8s-w1에만 떠있는 상태에서 LB IP를 호출해본다.

(|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'
root@k8s-w0:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'
root@k8s-w1:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'

root@router:~# LBIP=172.16.1.1
root@router:~# curl -s $LBIP

이번에는 Pod가 떠있는 노드 중 한쪽 노드로만 통신이 진행됨을 알 수 있다.

동작 방식

1) cilium에서 service가 External Policy Type = Local일 경우 Pod가 뜬 노드만 ExternalIP를 광고한다.

//pkg/bgpv1/manager/reconciler/service.go
func (r *ServiceReconciler) externalIPDesiredRoutes(svc *slim_corev1.Service, ls localServices) []netip.Prefix {
	var desiredRoutes []netip.Prefix
	// externalTrafficPolicy == Local 이며 endpoint 파드가 떠있지 않은 노드 같은 경우는 desiredRoutes를 빈 값으로 반환한다. 
  // 즉, externalTrafficPolicy == Local인 상황에서 endpoint파드가 떠있는 노드만 스스로를 광고한다.
	if svc.Spec.ExternalTrafficPolicy == slim_corev1.ServiceExternalTrafficPolicyLocal &&
		!hasLocalEndpoints(svc, ls) {
		return desiredRoutes
	}

  // externalTrafficPolicy == Local 이지만 endpoint 파드가 있는 노드는 스스로를 External IP로 광고한다.
	for _, extIP := range svc.Spec.ExternalIPs {
		if extIP == "" {
			continue
		}
		addr, err := netip.ParseAddr(extIP)
		if err != nil {
			continue
		}
		desiredRoutes = append(desiredRoutes, netip.PrefixFrom(addr, addr.BitLen()))
	}
	return desiredRoutes  
...
}

2) 외부 라우터가 광고된 External IP를 보고, 패킷을 노드로 전달힘.
이 때, Pod가 뜬 노드만 광고됨.

3) Pod가 뜬 노드에 패킷이 들어옴.

4) Cilium LB에서 Backend 확인

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg service list
ID   Frontend               Service Type   Backend
...
16   172.16.1.1:80/TCP      LoadBalancer   1 => 172.20.1.76:80/TCP (active)
                                           2 => 172.20.2.68:80/TCP (active)

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 172.16.1.1
172.16.1.1:80/TCP (1)          172.20.1.76:80/TCP (16) (1)
172.16.1.1:80/TCP (2)          172.20.2.68:80/TCP (16) (2)
172.16.1.1:80/TCP (0)          0.0.0.0:0 (16) (0) [LoadBalancer]                                          

5) Cilium에서 service가 External Policy Type이 Local일 경우 해당 노드에 존재하는 Pod만 Backend로 선택
ipcache/map 기반으로 실제 Pod IP와 노드 매핑을 확인한 뒤 Local Pod로만 NAT/DNAT 적용

ECMP Hash Policy

리눅스 커널은 기본적으로 L3(목적지 IP 기반) 해시를 사용한다. 보다 정교한 부하분산을 원하면 L4 해시 (IP + 포트) 기반으로 설정해야 한다.

(|HomeLab:N/A) root@k8s-ctr:~# sysctl net.ipv4.fib_multipath_hash_policy
net.ipv4.fib_multipath_hash_policy = 0
(|HomeLab:N/A) root@k8s-ctr:~# sysctl -w net.ipv4.fib_multipath_hash_policy=1
net.ipv4.fib_multipath_hash_policy = 1

이후 외부 router노드에서 LB IP로 통신을 시도하면 Pod가 떠있는 두 노드로 부하분산이 정상적으로 이뤄짐을 확인할 수 있다.

Cluster IP

이번에는 ExternalIP가 아닌 Cluster IP를 광고해보자.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl edit ciliumbgpadvertisement
...
  spec:
    advertisements:
    - advertisementType: Service
      selector:
        matchExpressions:
        - key: app
          operator: In
          values:
          - webpod
      service:
        addresses:
        - ClusterIP
...

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc webpod -o wide
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE    SELECTOR
webpod   LoadBalancer   10.96.252.129   172.16.1.1    80:31726/TCP   2d3h   app=webpod

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -- cilium service list
ID   Frontend               Service Type   Backend
...
12   10.96.252.129:80/TCP    ClusterIP     1 => 172.20.0.103:80/TCP (active)
                                           2 => 172.20.1.80:80/TCP (active)
                                           3 => 172.20.1.81:80/TCP (active)

외부 router의 bgp설정을 확인하면 아래와 같이 Cluster IP 광고로 변경된 것을 확인할 수 있다.

root@router:~# ip -c route
...
10.96.252.129 nhid 117 proto bgp metric 20
	nexthop via 192.168.10.100 dev eth1 weight 1
	nexthop via 192.168.20.100 dev eth2 weight 1
	nexthop via 192.168.10.101 dev eth1 weight 1

Cluster IP + Internal Traffic Policy (Cluster)

외부 router에서 바로 Cluster IP로 통신을 시도하면 통신이 되지 않는다.

root@router:~# curl -s $LBIP
^C

이는 원래 clusterIP의 목적이 클러스터 내부 통신을 위한 것이기 때문이다. bpf.lbExternalClusterIP=true 설정을 추가하여 내부 IP인 clusterIP도 외부에서 통신이 가능하도록 설정을 할 수 있다.

(|HomeLab:N/A) root@k8s-ctr:~# helm upgrade cilium cilium/cilium --version 1.18.0 --namespace kube-system --reuse-values --set  bpf.lbExternalClusterIP=true
Release "cilium" has been upgraded. Happy Helming!
NAME: cilium
LAST DEPLOYED: Sun Aug 17 02:53:00 2025
NAMESPACE: kube-system
STATUS: deployed
REVISION: 2
TEST SUITE: None
NOTES:
You have successfully installed Cilium with Hubble Relay and Hubble UI.

Your release version is 1.18.0.

For any further help, visit https://docs.cilium.io/en/v1.18/gettinghelp\

(|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system rollout restart ds/cilium
daemonset.apps/cilium restarted

bpf lb list를 통해 ClusterIP를 살펴보면 bpf.lbExternalClusterIP=true 설정 전에는 ClusterIP가 non-routable이지만, 설정 적용 이후에는 non-routable이 사라진 것을 볼 수 있다.

# 설정 적용 전
(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 10.96.252.129
10.96.252.129:80/TCP (0)        0.0.0.0:0 (11) (0) [ClusterIP, non-routable]
10.96.252.129:80/TCP (2)        172.20.1.80:80/TCP (11) (2)
10.96.252.129:80/TCP (1)        172.20.0.103:80/TCP (11) (1)
10.96.252.129:80/TCP (3)        172.20.1.81:80/TCP (11) (3)

# 설정 적용 후
(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 10.96.252.129
10.96.252.129:80/TCP (1)        172.20.0.103:80/TCP (12) (1)
10.96.252.129:80/TCP (2)        172.20.1.80:80/TCP (12) (2)
10.96.252.129:80/TCP (0)        0.0.0.0:0 (12) (0) [ClusterIP]
10.96.252.129:80/TCP (3)        172.20.1.81:80/TCP (12) (3)

외부 router에서 통신을 확인해보면, clusterIP로도 외부 통신이 가능한 것을 확인할 수 있다.

root@router:~# curl -s $LBIP
Hostname: webpod-697b545f57-hcqv2
IP: 127.0.0.1
IP: ::1
IP: 172.20.0.103
IP: fe80::7c3e:18ff:fe18:9940
RemoteAddr: 192.168.10.200:48558
GET / HTTP/1.1
Host: 10.96.252.129
User-Agent: curl/8.5.0
Accept: */*

앞선 테스트 처럼 pod의 개수를 2개로 줄인 후 통신을 확인해본다. pod는 k8s-ctr과 k8s-w1에 떠있다.

(|HomeLab:N/A) root@k8s-ctr:~# kubectl scale deployment/webpod --replicas=2
deployment.apps/webpod scaled

(|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          49m   172.20.0.23    k8s-ctr   <none>           <none>
webpod-697b545f57-dm2hg   1/1     Running   0          49m   172.20.1.80    k8s-w1    <none>           <none>
webpod-697b545f57-hcqv2   1/1     Running   0          49m   172.20.0.103   k8s-ctr   <none>           <none>

해당 테스트 또한 위 External Traffic Policy (Cluster)와 동일하게 일부 패킷은 k8s-ctr과, k8s-w1으로 동시에 패킷을 받는 경우가 생기게 된다.

k8s-ctr에서 패킷 덤프를 떠서 확인해본다.

(|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -w /tmp/dsr.pcap
tcpdump: listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
^C139 packets captured
141 packets received by filter
0 packets dropped by kernel

(1) Router 에서 Cilium BGP Peer인 모든 노드로 전달

(2) InternalTrafficPolicy:Cluster 서비스로, 여러 파드 중 (k8s-w1)의 파드를 대상으로 요청을 전달

(3) 해당 요청이 (k8s-ctr)을 통해 들어와 (k8s-w1)로 전달

(4) (k8s-w1)노드의 파드가 요청을 처리하고 응답 리턴을 위해서, NAT를 수행했던 노드(k8s-ctr)로 다시 전달

(5) 외부 인입을 받아서 NAT를 수행했던 연결 정보를 확인해서, Reverse NAT를 수행해서 최종 응답을 리턴

Cluster IP + Internal Traffic Policy (Local)

(|HomeLab:N/A) root@k8s-ctr:~# kubectl patch service webpod -p '{"spec":{"internalTrafficPolicy":"Local"}}'

External Traffic Policy를 local로 바꾸면 Router 노드에서의 bgp 경로도 Pod가 떠있는 노드만으로 변경이 된다.

root@router:~# ip -c route
...
10.96.122.24 nhid 77 proto bgp metric 20
	nexthop via 192.168.10.101 dev eth1 weight 1
	nexthop via 192.168.10.100 dev eth1 weight 1

webpod가 k8s-ctr, k8s-w0에 떠있는 상태에서 LB IP를 호출해본다.

(|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'
root@k8s-w0:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'
root@k8s-w1:~# tcpdump -i eth1 -A -s 0 -nn 'tcp port 80'

root@router:~# LBIP=10.96.252.129
root@router:~# curl -s $LBIP

동작 방식

1) cilium에서 service가 Internal Policy Type = Local일 경우 Pod가 뜬 노드만 ClusterIP를 광고한다.

func (r *ServiceReconciler) clusterIPDesiredRoutes(svc *slim_corev1.Service, ls localServices) []netip.Prefix {
	var desiredRoutes []netip.Prefix
	// InternalTrafficPolicy == Local 이며 endpoint 파드가 떠있지 않은 노드 같은 경우는 desiredRoutes를 빈 값으로 반환한다. 
  // 즉, InternalTrafficPolicy == Local인 상황에서 endpoint파드가 떠있는 노드만 스스로를 광고한다.
	if svc.Spec.InternalTrafficPolicy != nil && *svc.Spec.InternalTrafficPolicy == slim_corev1.ServiceInternalTrafficPolicyLocal &&
		!hasLocalEndpoints(svc, ls) {
		return desiredRoutes
	}

  //ClusterIP를 확인한다.
	if svc.Spec.ClusterIP == "" || len(svc.Spec.ClusterIPs) == 0 || svc.Spec.ClusterIP == corev1.ClusterIPNone {
		return desiredRoutes
	}
	ips := sets.New[string]()
	if svc.Spec.ClusterIP != "" {
		ips.Insert(svc.Spec.ClusterIP)
	}
  
  //ClusterIP를 광고할 수 있도록 desiredRotues에 추가한다.
	for _, clusterIP := range svc.Spec.ClusterIPs {
		if clusterIP == "" || clusterIP == corev1.ClusterIPNone {
			continue
		}
		ips.Insert(clusterIP)
	}
...
	return desiredRoutes
}

2) BPF map에는 Local Pod만 Backend로 등록

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 10.96.122.24
10.96.122.24:80/TCP (1)          172.20.0.103:80/TCP (12) (1)
10.96.122.24:80/TCP (0)          0.0.0.0:0 (12) (0) [ClusterIP, InternalLocal]

(|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf ipcache list | grep 14076
172.20.0.103/32     identity=14076 encryptkey=0 tunnelendpoint=0.0.0.0 flags=<none>
172.20.1.80/32      identity=14076 encryptkey=0 tunnelendpoint=192.168.10.101 flags=hastunnel

3) router에서 ClusterIP를 호출하면 모든 트래픽이 k8s-ctr에 있는 pod로 향한다.

  • 예상 원인
    bpf ipcache list에서 Pod IP의 tunnelendpoint=0.0.0.0 이어야 로컬 Pod로 인지가 되기 때문에 k8s-ctr에 있는 pod만 backend로 인지되는 것으로 보인다.

k8s-ctr에서 패킷 덤프를 떠서 확인해본다.

(|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -w /tmp/dsr2.pcap
tcpdump: listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
^C139 packets captured
141 packets received by filter
0 packets dropped by kernel

(1) Router 에서 Cilium BGP Peer인 노드로 전달 (pod가 떠있는 노드 대상)

(2) InternalTrafficPolicy:Local 서비스 기준으로, 유효한 backend는 k8s-ctr위의 Pod 뿐

(3) 해당 노드의 파드가 요청을 처리하고 최종 응답을 리턴

0개의 댓글