投稿日:
更新日:

BGP-ECMP で SNAT を回避した負荷分散を構成

Authors

目次

banner.png

はじめに

Kubernetes でリクエストを Pod に負荷分散する際にクライアントの送信元 IP アドレスを保持したいケースがあります。 LoadBalancerNodePort を使用する Service の場合、負荷分散の過程で送信元 IP アドレスが NAT(SNAT:Source NAT)されるため、Pod に到達するパケットからはクライアントの IP アドレスを知ることができません。

Service の External Traffic PolicyLocal に設定すると、外部からのトラフィックは受信したノード上の Pod にのみ転送されるため SNAT は回避できますが、単一ノードにトラフィックが集中するためクラスタワイドな負荷分散ができません。

また、Ingress Controller を利用すると HTTP X-Forwarded-For ヘッダに送信元情報を付加することができますが、L7 LB が前提となるため、L4 LB のような低レベルロードバランサでは有効に機能しません。

ベアメタル Kubernetes でネットワークロードバランサをプロビジョニングするための代表的な OSS に MetalLB があります。 MetalLB には L2 モードと BGP モードの 2 種類の負荷分散方式が用意されています。 多くの場合は前者が利用されますが、後者の BGP モードでは、より柔軟なネットワーク構成や負荷分散の制御が可能になり、送信元 IP アドレスを保持したままリクエストを分散することができます。

今回のブログでは、MetalLB の BGP モードを利用して SNAT を回避しつつ、クラスタワイドな負荷分散を実現する構成について紹介したいと思います。

MetalLB

metallb.png

MetalLB:
Bare-metal cluster operators are left with two lesser tools to bring user traffic into their clusters, “NodePort” and “externalIPs” services. Both of these options have significant downsides for production use, which makes bare-metal clusters second-class citizens in the Kubernetes ecosystem. MetalLB aims to redress this imbalance by offering a network load balancer implementation that integrates with standard network equipment, so that external services on bare-metal clusters also “just work” as much as possible.

MetalLB は Kubernetes ネイティブな SLB(Software Load Balancer)で、ベアメタルクラスタにおいて外部からのアクセスを可能にするための Add-on です。

AWS / GCP / Azure 等のパブリッククラウドでは、type: LoadBalancer の Service マニフェストを適用すると、自動的にパブリック IP アドレスが割り当てられて外部からのアクセスが可能になります。

一方で、オンプレミス環境ではそのような仕組みが用意されておらず、type: LoadBalancer の Service を使用しても、外部からアクセスできる IP アドレスを自動的に割り当てることはできません。

そこで、MetalLB は Service をクラスタ外に公開するために、DHCP で LAN 内のアドレスプールからプライベート IP を払い出します。 その上で外部ネットワークとも接続する場合は、対象のプライベート IP アドレスを NAT して外向きに公開します。

MetalLB には IP アドレスを Service に割り当ててネットワーク上に通知する際に、L2 モードと BGP モードの 2 つの動作モードがあります。

L2 モード

L2 モードを利用した負荷分散については、一部 こちら のブログでも紹介しています。

L2 モード は MetalLB で一般に利用される負荷分散方式で、到達性の確保に ARP(Address Resolution Protocol) または NDP(Neighbor Discovery Protocol) を使用し、ローカルネットワーク上に対象の IP アドレスをどのノードが持っているかを通知します。

これにより、MetalLB は同一 LAN 上で IP アドレスの所有者をエミュレーションし、クラスタ内のノードが外部から直接アクセス可能な LoadBalancer IP を仮想的に持つことができます。

metallb-l2-mode.png

上の例では 192.168.68.230 という IP アドレスを持つ LoadBalancer に対して通信を開始すると、ARP によって node01 の MAC アドレスが返されます。 このように、同一ネットワーク上に存在しない IP アドレスから MAC アドレスを解決する特別な ARP を Proxy ARP と呼びます。

Proxy ARP による L2 通信で、一度 node01 がリクエストを受け取り、その後 kube-proxyiptables によって背後の Pod に転送します。

ここで、実際に ARP レスポンスを返しているのは各ノードで起動する Speaker という MetalLB の Data-Plane コンポーネントです。

network-reachability.png

Speaker Pod のログを見てみると ARP リクエストを受け付けたログが確認できます。

### Speaker Pod のログ
$ kubectl logs -n metallb metallb-speaker-klrfl -f
{"caller":"arp.go:110","interface":"eth0","ip":"192.168.68.230","level":"debug","msg":"got ARP request for service IP, sending response","responseMAC":"dc:a6:32:bf:8b:77","senderIP":"192.168.68.230","senderMAC":"dc:a6:32:bf:8b:a8","ts":"2024-01-19T16:57:14Z"}
### node01(dc:a6:32:bf:8b:77)の tcpdump ログ
$ sudo tcpdump -n -i eth0 arp
01:59:11.152257 ARP, Request who-has 192.168.68.230 tell 192.168.68.110, length 46
01:59:11.152584 ARP, Reply 192.168.68.230 is-at dc:a6:32:bf:8b:77, length 46

また、node01 を確認すると以下のような Chain が追加されていることも確認できます。

### nftables(iptables)の Chain 抜粋
$ sudo nft list ruleset
table ip nat {
    ### Kubernetes Services Chain
	chain KUBE-SERVICES {
		meta l4proto tcp ip daddr 192.168.68.230  tcp dport 80 counter packets 2 bytes 128 jump KUBE-EXT-B632OJDRW4P5YOHD
	}

    ### Kubernetes External Chain
    chain KUBE-EXT-B632OJDRW4P5YOHD {
		counter packets 2 bytes 128 jump KUBE-MARK-MASQ
		counter packets 2 bytes 128 jump KUBE-SVC-B632OJDRW4P5YOHD
	}

    ### Kubernetes Service Chain
	chain KUBE-SVC-B632OJDRW4P5YOHD {
		meta l4proto tcp ip saddr != 10.16.0.0/12 ip daddr 10.1.6.20  tcp dport 80 counter packets 0 bytes 0 jump KUBE-MARK-MASQ
		counter packets 2 bytes 128 jump KUBE-SEP-37JRHLZWAFYMZLYB
	}

    ### Kubernetes Service End-Point Chain
    chain KUBE-SEP-37JRHLZWAFYMZLYB {
		ip saddr 10.16.2.132  counter packets 0 bytes 0 jump KUBE-MARK-MASQ
		meta l4proto tcp   counter packets 2 bytes 128 dnat to 10.16.2.132:80
	}

    ### Kubernetes Mark Masquerade Chain
    chain KUBE-MARK-MASQ {
		counter packets 2 bytes 128 meta mark set mark or 0x4000
	}

    ### Kubernetes Postrouting Chain
    chain KUBE-POSTROUTING {
		meta mark & 0x00004000 != 0x00004000 counter packets 218 bytes 15414 return
		counter packets 2 bytes 128 meta mark set mark xor 0x4000
		counter packets 2 bytes 128 masquerade fully-random
	}

    ### Flannel Postrouting Chain
    chain FLANNEL-POSTRTG {
		meta mark & 0x00004000 == 0x00004000  counter packets 0 bytes 0 return
		ip saddr 10.16.1.0/24 ip daddr 10.16.0.0/12  counter packets 25303 bytes 1546105 return
		ip saddr 10.16.0.0/12 ip daddr 10.16.1.0/24  counter packets 2050 bytes 151037 return
		ip saddr != 10.16.0.0/12 ip daddr 10.16.1.0/24  counter packets 0 bytes 0 return
		ip saddr 10.16.0.0/12 ip daddr != 224.0.0.0/4  counter packets 327 bytes 20914 masquerade fully-random
		ip saddr != 10.16.0.0/12 ip daddr 10.16.0.0/12  counter packets 0 bytes 0 masquerade fully-random
	}
}

それぞれ次のような Chain が追加されます。

KUBE-SERVICES

Kubernetes Services Chain は、サービス全体(ClusterIP / NodePort / LoadBalancer)のトラフィックを処理します。

外部からのリクエスト、もしくは Pod 内 / ノード上からのトラフィックが最初にマッチするトップレベルの Chain で、全てのリクエストに対して適用されます。 その後、Service IP(ClusterIP)や External IP、NodePort 等に対応した各個別の Chain へジャンプします。

KUBE-EXT-xxx

Kubernetes External Chain は、主に External IP(NodePort / LoadBalancer IP)を利用する Service へのトラフィックを処理します。

xxx の部分には Service 毎に一意なハッシュ識別子が付与され、該当するトラフィックに対してのみ適用されます。

主に、SNAT をマークして、対応する内部 Chain KUBE-SVC-xxx にジャンプします。

KUBE-SVC-xxx

Kubernetes Service Chain は、特定の ClusterIP に対応するトラフィックを処理します。

xxx の部分には Service 毎に一意なハッシュ識別子が付与され、該当するトラフィックに対してのみ適用されます。

特定の Service IP / Port に対するバックエンド Pod へのルーティングを、ランダムもしくはラウンドロビンによって決定します。 この時、条件によって SNAT が必要か確認し、必要なら KUBE-MARK-MASQ にジャンプします。

$ kubectl get svc -n nginx -o wide
NAME        TYPE           CLUSTER-IP   EXTERNAL-IP      PORT(S)        AGE    SELECTOR
nginx-svc   LoadBalancer   10.1.6.20    192.168.68.230   80:32749/TCP   150d   app=nginx-pod

KUBE-SEP-xxx

Kubernetes Service End-Point Chain は、トラフィックを特定の Pod IP / Port に DNAT します。

xxx の部分には Service 毎に一意なハッシュ識別子が付与され、該当するトラフィックに対してのみ適用されます。

また、Hairpin アクセス検出時の SNAT にも対応します。

$ kubectl get pod -n nginx nginx-deployment-649fcbf54f-sgc27 -o wide
NAME                                READY   STATUS    RESTARTS       AGE    IP            NODE     NOMINATED NODE   READINESS GATES
nginx-deployment-649fcbf54f-sgc27   1/1     Running   12 (10d ago)   150d   10.16.2.132   node02   <none>           <none>

Hairpin(U-Turn)アクセス とは、Pod が自分自身と同じノード上の Service(ClusterIP や LoadBalancer IP)にアクセスし、再び同じノード内の同じ Pod に戻るようなアクセス形態を指します。

例えば、パケットの送信元 IP アドレスが Pod IP(10.16.2.132)の場合に KUBE-SVC-xxx で DNAT されると、場合によっては宛先として自身の Pod IP(10.16.2.132)が選出される可能性があります。 この場合、送信元も宛先も同じ IP アドレスのパケットになります。 これにより、カーネルのネットワークスタックが混乱し、正常に TCP コネクションが確立できなくなったり、パケットがループ・破棄されたりすることがあります。

kube-proxy はこの問題を認識しており、Hairpin アクセスの場合は SNAT によって送信元 IP をノードの IP アドレスに変換します。 これにより、あたかも外部からリクエストが来たかのように見せかけることで、前述の問題を解決します。参考

hairpin-access.png

KUBE-MARK-MASQ

Kubernetes Mark-for-Masquerade Chain は、マークを利用して後続の Chain で SNAT を指示します。

具体的には、Postrouting で使用するマーク(0x4000)を付与します。 後続の処理でこのマークが付いたトラフィックのみが SNAT の対象となります。

KUBE-POSTROUTING

Kubernetes Postrouting Chain は、NAT テーブルの Postrouting Chain で、KUBE-MARK-MASQ でマーク(0x4000)が付与された Egress 通信を SNAT します。 マークが無い場合は、そのままトラフィックを通過させます。

FLANNEL-POSTRTG

Flannel Postrouting Chain は、Flannel が追加する Postrouting Chain で、トラフィックを SNAT します。

flannel.png

Flannel は、クラスタ内の Pod ネットワークを構築するための CNI(Container Network Interface) プラグラインで、Pod 間での VXLAN ベースのオーバーレイネットワーク通信を可能にします。 これにより、異なるノード間の Pod が直接通信できるようになります。

このルールは kube-proxy が生成した Chain の評価を終えた後、SNAT が必要な場合のみ、Postrouting で処理します。

### KUBE-MARK-MASQ のマスカレードマーク(0x4000)が付与されたパケットには手を出さない
meta mark & 0x00004000 == 0x00004000  counter packets 0 bytes 0 return

### クラスタ内の Pod 間通信は SNAT はしない
ip saddr 10.16.1.0/24 ip daddr 10.16.0.0/12  counter packets 25303 bytes 1546105 return
ip saddr 10.16.0.0/12 ip daddr 10.16.1.0/24  counter packets 2050 bytes 151037 return

### kube-proxy ですでにマークがついていることを前提に Flannel での再 SNAT はしない
ip saddr != 10.16.0.0/12 ip daddr 10.16.1.0/24  counter packets 0 bytes 0 return

### Pod からインターネットへの Egress 通信に SNAT を適用(マルチキャスト通信は除く)
ip saddr 10.16.0.0/12 ip daddr != 224.0.0.0/4  counter packets 327 bytes 20914 masquerade fully-random

### インターネットから Pod への Ingress 通信に SNAT を適用 👈 ※ これによってクライアントの送信元 IP アドレスが分からなくなる
ip saddr != 10.16.0.0/12 ip daddr 10.16.0.0/12  counter packets 0 bytes 0 masquerade fully-random
  • externalTrafficPolicy: Cluster(デフォルト)の場合
    • 各ノードに到達したパケットが kube-proxy の iptables で DNAT された後、Flannel の NIC IP で SNAT して Pod に転送される
  • externalTrafficPolicy: Local の場合
    • 各ノードに到達したパケットが kube-proxy の iptables で DNAT された後、SNAT することなく、そのノード上の Pod に直接転送される
    • LoadBalancer からのリクエストはローカルノードの Pod のみにしかルーティングされないため SNAT が不要になり、Pod からクライアントの送信元 IP を確認することができる
### Flannel の NIC IP アドレス の確認
$ ip a
5: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UNKNOWN group default
    link/ether 62:fe:17:01:07:83 brd ff:ff:ff:ff:ff:ff
    inet 10.16.1.0/32 scope global flannel.1
       valid_lft forever preferred_lft forever

今回の場合は、node01(192.168.68.201)がリクエストを受け取り、kube-proxy によって対象 Pod(10.16.2.132)に宛先 IP アドレスを DNAT しています。

また、対象 Pod は node02(192.168.68.202)に存在するため、Flannel CNI の IP アドレス(10.16.1.0)で SNAT した後、VXLAN オーバーレイネットワークを介して node02 の Pod に転送される挙動となります。

実際に、externalTrafficPolicy: Cluster の Service を利用する Pod の受信パケットを確認すると、送信元 IP アドレスが Flannel の NIC IP アドレス(10.16.1.0)で SNAT されていることが分かります。

### 192.168.68.230 に対してリクエストを送信
$ curl -I 192.168.68.230
HTTP/1.1 200 OK
Server: nginx/1.27.5
Date: Sat, 20 Jan 2024 18:53:56 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Mon, 15 Jan 2024 12:01:11 GMT
Connection: keep-alive
ETag: "67ff9c07-267"
Accept-Ranges: bytes
root@nginx-deployment-649fcbf54f-sgc27:/$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP group default
    link/ether b6:fa:8b:97:24:81 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.16.2.132/24 brd 10.16.2.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::b4fa:8bff:fe97:2481/64 scope link
       valid_lft forever preferred_lft forever

root@nginx-deployment-649fcbf54f-sgc27:/$ tcpdump -i eth0
### TCP Three-Way Handshake
18:48:32.946040 IP 10.16.1.0.44844 > nginx-deployment-649fcbf54f-sgc27.80: Flags [S], seq 3262481042, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1193186584 ecr 0,sackOK,eol], length 0
18:48:32.946090 IP nginx-deployment-649fcbf54f-sgc27.80 > 10.16.1.0.44844: Flags [S.], seq 1274636909, ack 3262481043, win 64308, options [mss 1410,sackOK,TS val 971138469 ecr 1193186584,nop,wscale 7], length 0
18:48:32.949858 IP 10.16.1.0.44844 > nginx-deployment-649fcbf54f-sgc27.80: Flags [.], ack 1, win 2054, options [nop,nop,TS val 1193186594 ecr 971138469], length 0

### HTTP Request / Response(GET Method)
18:48:32.949976 IP 10.16.1.0.44844 > nginx-deployment-649fcbf54f-sgc27.80: Flags [P.], seq 1:78, ack 1, win 2054, options [nop,nop,TS val 1193186594 ecr 971138469], length 77: HTTP: GET / HTTP/1.1
18:48:32.950005 IP nginx-deployment-649fcbf54f-sgc27.80 > 10.16.1.0.44844: Flags [.], ack 78, win 502, options [nop,nop,TS val 971138473 ecr 1193186594], length 0
18:48:32.950616 IP nginx-deployment-649fcbf54f-sgc27.80 > 10.16.1.0.44844: Flags [P.], seq 1:239, ack 78, win 502, options [nop,nop,TS val 971138473 ecr 1193186594], length 238: HTTP: HTTP/1.1 200 OK
18:48:32.950890 IP nginx-deployment-649fcbf54f-sgc27.80 > 10.16.1.0.44844: Flags [P.], seq 239:854, ack 78, win 502, options [nop,nop,TS val 971138474 ecr 1193186594], length 615: HTTP
18:48:32.958603 IP 10.16.1.0.44844 > nginx-deployment-649fcbf54f-sgc27.80: Flags [.], ack 854, win 2041, options [nop,nop,TS val 1193186603 ecr 971138474], length 0

### TCP Connection Close
18:48:32.959317 IP 10.16.1.0.44844 > nginx-deployment-649fcbf54f-sgc27.80: Flags [F.], seq 78, ack 854, win 2048, options [nop,nop,TS val 1193186603 ecr 971138474], length 0
18:48:32.959435 IP nginx-deployment-649fcbf54f-sgc27.80 > 10.16.1.0.44844: Flags [F.], seq 854, ack 79, win 502, options [nop,nop,TS val 971138482 ecr 1193186603], length 0
18:48:32.962756 IP 10.16.1.0.44844 > nginx-deployment-649fcbf54f-sgc27.80: Flags [.], ack 855, win 2048, options [nop,nop,TS val 1193186607 ecr 971138482], length 0

これらのフローを図に起こすと次のようになり、クライアントからのリクエストは、クラスタネットワーク内で適切な IP アドレスに変換され、最終的に Pod に到達します。

kube-proxy-chain.png

L2 モードの注意点

前述の動作から察する通り、L2 モードはロードバランサと言いつつも、Service の IP を持ったノードがダウンした場合に、別のノードに切り替えるフェールオーバで冗長化しています。 L2 モード利用時は、常に単一ノードにトラフィックが集中するため単一障害点(SPOF:Single Point of Failure)とりなり、負荷分散の観点であまり効果的ではありません。

また、実際に負荷分散しているのは MetalLB ではなく kube-proxy(iptables)ということになります。

BGP モード

BGP モード は各ノードがクラスタの前段に配置されたネットワーク上のルータと BGP ピアリングを組み、対象 Pod が存在するノードを広報することで到達性を確保します。

BGP モードでは、ECMP(Equal Cost Multi-Path)ルーティング で複数のノードにリクエストを分散するクラスタワイドな負荷分散となります。 このため、単一ノードがリクエストを受け取る L2 モードと比較して、ノード間での負荷分散が可能なため、冗長性・分散性が高く、柔軟な制御も可能になります。

負荷分散の正確な動作は BGP ルータによって異なりますが、基本的にはパケットハッシュによって通信毎に特定のノードに振り分けます。 パケットハッシュとは、ネットワーク上を流れるパケットの属性を元に、ハッシュ関数を使って値を割り当てる仕組みです。

BGP の ECMP では、パケットのフィールドの一部をシード(キー)として使用し、接続先を決定します。 シードが同じ値であれば接続先も同じになります。

3 タプルハッシュ

  • 「プロトコル」「送信元 IP」「宛先 IP」をシードとして使用
  • 2 つの一意の IP 間の全てのパケットが同じノードに送信される

5 タプルハッシュ

  • 「プロトコル」「送信元 IP」「送信元ポート」「宛先 IP」「宛先ポート」をシードとして使用
  • 単一クライアントから送信される異なるリクエストをクラスタ全体に分散する
metallb-bgp-mode.png

BGP ルータが、宛先 IP に対して各ノードを Next Hop として指定することで負荷分散を実現しています。

上の例では、172.16.7.0/24 ネットワーク上に存在するノードが Next Hop となり、パケットハッシュに基づいて ECMP で順に振り分けられます。

BGP モードの注意点

BGP モードを選択すると標準のルータを使用できるメリットがありますが、接続先のノードや Pod が停止した際に、全ての接続が切断される点に注意が必要です。

BGP ルータはハッシュを用いてステートレスに負荷分散しますが、クラスタからノードを削除したり、ノードアップグレード等の度に再起動したりするとハッシュが更新されるため、既存のコネクションは全て切断されます。

対応策としては以下のような方法があります。

  • BGP ルータで安定した ECMP ハッシュアルゴリズム(resilient ECMP または resilient LAG)を利用する
    • 既存コネクションへの影響を最小限に抑える
  • 戦略的なノードアップグレードの実施
    • ノードを停止する場合は夜間に行う
    • 起動する Kubernetes の Servie と Pod を別の IP で起動して DNS を用いて向き先を変更し、流入が無くなったノードから停止する
  • Service を Ingress Controller の背後に配置する
    • L7 LB を利用する場合は、ルータ → Ingress → Service とすることで、Ingress に変更が無ければコネクション切断は発生しない

また、BGP モードは MetalLB 単体で動作するわけではないため、必ず BGP を喋れるルータが必要になります。 このため、BGP のピア設定や経路制御等、Kubernetes とは別でネットワーク領域の知識が必要になります。

管理・運用・制御の観点では L2 モードを遥かに上回る複雑さがあるため導入難易度は高いと言えます。

External Traffic Policy

External Traffic Policy は、Service に到達したトラフィックを、どのノードや Pod に転送するかを制御する機能です。

Service のデフォルトの External Traffic Policy は Cluster となっているため、どのような方法で負荷分散したとしても最終的には kube-proxy(iptables)によって Pod 間の負荷分散が発生します。

その結果、リクエスト元の IP アドレスが常に SNAT されることになり、Pod に到達したパケットからはクライアントの送信元 IP アドレスを確認することができません。

クライアント IP アドレスを取得するには、External Traffic Policy を Local に設定します。 これにより、kube-proxy による負荷分散の際に SNAT されないため、Pod に着信したパケットからクライアントの送信元 IP アドレスを確認することができます。

L2 モードの課題

L2 モードで External Traffic Policy Local を使用すると、一つのノードで全てのトラフィックを処理しなければならず、単一障害点を抱えるため可用性の観点で課題があります。

l2-mode-external-traffic-policy-local.png

BGP モードの利点

BGP モードはパケットハッシュに基づき、各ノードに ECMP でトラフィックを分散させます。 つまり External Traffic Policy を Local に設定した場合も、ノードレベルの負荷分散が効いているため、単一障害点を回避しつつクラスタ内の Pod に均等に負荷分散することが可能です。

これにより、高度な負荷分散と可用性の機能を維持しつつ、クライアントの送信元 IP アドレスも保持することが可能 です。

bgp-mode-external-traffic-policy-local.png

BGP-ECMP を利用した負荷分散

MetalLB BGP モードを利用して負荷分散を検証してみます。

BGP モードを利用する場合、別途 BGP ルータを準備する必要がありますが、今回は SOHO(Small Office / Home Office)ネットワークで検証するため仮想環境を構築し、ソフトウェアルータとして VyOS を利用します。

vyos.png

VyOS は OSS として公開されている NOS(Network Operating System)で、商用ルータやファイアウォールアプライアンスと同等の機能を Linux 環境上で実現できます。

また、仮想マシンイメージ も用意されているため、KVM を利用して容易に環境構築をすることができます。

vyos-image.png

検証環境

Kubernetes クラスタと VyOS は Vagrant を利用して KVM 上に構築します。

実際に BGP を利用する場合、クラスタが接続する BGP ルータと、その対向ルータを準備して各々でピアリングを組みますが、今回は VyOS とクラスタの間にのみピアリングを設定し、クライアントからのリクエストが VyOS の外部インターフェースに向くようにスタティックルートを構成することで、擬似的に経路広報を再現します。

仮想マシン

  • Control Plane(Master Node 1 台)
スペック
仮想マシンVagrant v2.2.19 / libvirt v8.0.0 / qemu v6.2.0
OSUbuntu 20.04.6 (Focal Fossa) 64 bit
CPUIntel(R) i9-11900K @3.50GHz 8cores 16threads
アーキテクチャamd64
RAM32 GiB DDR4-3200
  • Data Plane(Worker Node 3 台)
スペック
仮想マシンVagrant v2.2.19 / libvirt v8.0.0 / qemu v6.2.0
OSUbuntu 20.04.6 (Focal Fossa) 64 bit
CPUIntel(R) i9-13900 @5.60GHz 24cores 32threads
アーキテクチャamd64
RAM24 GiB DDR4-3200
  • BGP Router
スペック
仮想マシンVagrant v2.2.19 / libvirt v8.0.0 / qemu v6.2.0
OSVyOS 1.5 (Circinus) 64 bit
CPUIntel(R) i9-11900K @3.50GHz 8cores 8threads
アーキテクチャamd64
RAM4 GiB DDR4-3200

ネットワーク構成

設定内容
Peer ネットワークアドレス10.10.0.0/20
BGP ルータの IP172.16.7.5
BGP ルータの外部 IP10.10.0.115
BGP ルータの AS 番号64512
BGP ルータの ID1.1.1.1
設定内容
ネットワークアドレス172.16.7.0/24
Service IP 範囲172.16.7.192/26
(62 個確保)
node01 の IP172.16.7.11
node01 MetalLB の AS 番号64522
node02 の IP172.16.7.12
node02 MetalLB の AS 番号64522
node03 の IP172.16.7.13
node03 MetalLB の AS 番号64522
local-area-network-bgp-ecmp-load-balancing.png

仮想マシンの構築

事前に Vagrant プラグインをホストマシンにインストールしておきます。

$ vagrant plugin install vagrant-libvirt
$ vagrant plugin install vagrant-vyos

以下の Vagrantfile でゲスト VM を起動し、クラスタを構築します。

Vagrantfile
# frozen_string_literal: true

# -*- mode: ruby -*-
# vi: set ft=ruby :

# rubocop:disable Metrics/BlockLength
Vagrant.configure('2') do |config|
  config.vm.boot_timeout = 600

  config.vm.define :master do |master|
    define_machine_spec master, '8', '32768'
    master.vm.hostname = 'master'
    master.vm.box = 'generic/ubuntu2004'
    master.vm.network :private_network,
                      ip: '172.16.7.10',
                      netmask: '255.255.255.0',
                      auto_config: true
  end

  config.vm.define :node01 do |node01|
    define_machine_spec node01, '8', '24576'
    node01.vm.hostname = 'node01'
    node01.vm.box = 'generic/ubuntu2004'
    node01.vm.network :private_network,
                      ip: '172.16.7.11',
                      netmask: '255.255.255.0',
                      auto_config: true
  end

  config.vm.define :node02 do |node02|
    define_machine_spec node02, '8', '24576'
    node02.vm.hostname = 'node02'
    node02.vm.box = 'generic/ubuntu2004'
    node02.vm.network :private_network,
                      ip: '172.16.7.12',
                      netmask: '255.255.255.0',
                      auto_config: true
  end

  config.vm.define :node03 do |node03|
    define_machine_spec node03, '8', '24576'
    node03.vm.hostname = 'node03'
    node03.vm.box = 'generic/ubuntu2004'
    node03.vm.network :private_network,
                      ip: '172.16.7.13',
                      netmask: '255.255.255.0',
                      auto_config: true
  end

  config.vm.define :router do |router|
    define_machine_spec router, '1', '4096'
    router.vm.hostname = 'router'
    router.vm.box = 'higebu/vyos'
    router.vm.network :private_network,
                      ip: '172.16.7.5',
                      netmask: '255.255.255.0',
                      auto_config: true
    router.vm.network :public_network,
                      ip: '10.10.0.115',
                      netmask: '255.255.240.0',
                      bridge: 'enp3s0',
                      dev: 'enp3s0'
  end

  ## Define macros
  def define_machine_spec(name, use_cpu, use_memory)
    name.vm.provider :libvirt do |libvirt|
      libvirt.driver = 'qemu'
      libvirt.cpus = use_cpu
      libvirt.memory = use_memory
    end
  end
end
# rubocop:enable Metrics/BlockLength
$ vagrant status
Current machine states:

master                    running (libvirt)
node01                    running (libvirt)
node02                    running (libvirt)
node03                    running (libvirt)
router                    running (libvirt)

This environment represents multiple VMs. The VMs are all listed
above with their current state. For more information about a specific
VM, run `vagrant status NAME`.

※ クラスタ自体の構築手順は省きます。

### クラスタコンポーネントの確認
$ kubectl get pod -A -o wide
NAMESPACE     NAME                                  READY   STATUS    RESTARTS        AGE     IP                NODE     NOMINATED NODE   READINESS GATES
flannel       kube-flannel-ds-4nr68                 1/1     Running   0               27s     192.168.121.133   master   <none>           <none>
flannel       kube-flannel-ds-gk6jm                 1/1     Running   0               27s     192.168.121.222   node02   <none>           <none>
flannel       kube-flannel-ds-p2k78                 1/1     Running   0               27s     192.168.121.95    node01   <none>           <none>
flannel       kube-flannel-ds-qbjz8                 1/1     Running   0               27s     192.168.121.245   node03   <none>           <none>
kube-system   coredns-5dd5756b68-bt4w7              1/1     Running   0               27m     10.16.0.2         master   <none>           <none>
kube-system   coredns-5dd5756b68-gxvvs              1/1     Running   0               27m     10.16.0.3         master   <none>           <none>
kube-system   etcd-master                           1/1     Running   0               27m     192.168.121.133   master   <none>           <none>
kube-system   kube-apiserver-master                 1/1     Running   0               27m     192.168.121.133   master   <none>           <none>
kube-system   kube-controller-manager-master        1/1     Running   0               27m     192.168.121.133   master   <none>           <none>
kube-system   kube-proxy-ddcx4                      1/1     Running   0               27m     192.168.121.95    node01   <none>           <none>
kube-system   kube-proxy-hbfxx                      1/1     Running   0               27m     192.168.121.133   master   <none>           <none>
kube-system   kube-proxy-qkx66                      1/1     Running   0               27m     192.168.121.222   node02   <none>           <none>
kube-system   kube-proxy-wwntt                      1/1     Running   0               27m     192.168.121.245   node03   <none>           <none>
kube-system   kube-scheduler-master                 1/1     Running   0               27m     192.168.121.133   master   <none>           <none>

BGP ルータの設定

次に、VyOS に接続します。

$ vagrant ssh router
vyos@router:~$ show version
Version:          VyOS 1.5-rolling-202310120020
Release train:    current

Built by:         [email protected]
Built on:         Thu 12 Oct 2023 01:43 UTC
Build UUID:       bf1ce2c0-f635-47b0-979f-57dacb6b6070
Build commit ID:  c0662f75cd8a54

Architecture:     x86_64
Boot via:         installed image
System type:      QEMU guest

Hardware vendor:  QEMU
Hardware model:   Standard PC (i440FX + PIIX, 1996)
Hardware S/N:
Hardware UUID:    99b96297-b3a6-4532-a2ea-ab355a7bed36

Copyright:        VyOS maintainers and contributors

以下のコマンドを順に実行して BGP ルータおよび AS(Autonomous System) を構成します。 AS は BGP ルーティングにおける個々の独立したネットワーク単位で、各 AS にはグローバルネットワークにおける識別子として一意な ASN(Autonomous System Number) が付与されます。

### ルータ設定モードに切り替える
vyos@router:~$ configure

### 既存設定を削除
vyos@router% delete protocols bgp

### バナー設定
vyos@router% set system login banner post-login "Hello, VyOS router for MetalLB!!"

### BGP プロトコルを設定して ASN 64512 を割り当てる
vyos@router% set protocols bgp system-as 64512

### ASN 64512 を管理する BGP ルータの ID を設定する
vyos@router% set protocols bgp parameters router-id 1.1.1.1

### 10.10.0.0/20 ネットワークを BGP ピア(近隣ルータ)にアドバタイズして他の BGP ピアにこのネットワーク情報を提供する
vyos@router% set protocols bgp address-family ipv4-unicast network 10.10.0.0/20

### BGP ピア(近隣ルータ)として IP アドレス 172.16.7.11 ASN 64522 のルータを指定して BGP セッションを確立する
vyos@router% set protocols bgp neighbor 172.16.7.11 remote-as 64522
vyos@router% set protocols bgp neighbor 172.16.7.11 description "kubernetes-node01"
vyos@router% set protocols bgp neighbor 172.16.7.11 update-source 172.16.7.5
vyos@router% set protocols bgp neighbor 172.16.7.11 address-family ipv4-unicast soft-reconfiguration inbound
vyos@router% set protocols bgp neighbor 172.16.7.11 address-family ipv4-unicast weight 200

### BGP ピア(近隣ルータ)として IP アドレス 172.16.7.12 ASN 64522 のルータを指定して BGP セッションを確立する
vyos@router% set protocols bgp neighbor 172.16.7.12 remote-as 64522
vyos@router% set protocols bgp neighbor 172.16.7.12 description "kubernetes-node02"
vyos@router% set protocols bgp neighbor 172.16.7.12 update-source 172.16.7.5
vyos@router% set protocols bgp neighbor 172.16.7.12 address-family ipv4-unicast soft-reconfiguration inbound
vyos@router% set protocols bgp neighbor 172.16.7.12 address-family ipv4-unicast weight 200

### BGP ピア(近隣ルータ)として IP アドレス 172.16.7.13 ASN 64522 のルータを指定して BGP セッションを確立する
vyos@router% set protocols bgp neighbor 172.16.7.13 remote-as 64522
vyos@router% set protocols bgp neighbor 172.16.7.13 description "kubernetes-node03"
vyos@router% set protocols bgp neighbor 172.16.7.13 update-source 172.16.7.5
vyos@router% set protocols bgp neighbor 172.16.7.13 address-family ipv4-unicast soft-reconfiguration inbound
vyos@router% set protocols bgp neighbor 172.16.7.13 address-family ipv4-unicast weight 200

### 内部 BGP(iBGP)の最大パス数を 2 に設定する(同一 AS 内の異なる経路をルータが学習および使用できる数を制限する)
vyos@router% set protocols bgp address-family ipv4-unicast maximum-paths ibgp 2

### 設定の変更を確定し、ルータに反映させる
vyos@router% commit

### 設定を永続化して、再起動後にも設定が保持されるように保存する
vyos@router% save

### コマンドラインモードに戻る
vyos@router% exit

今回は、VyOS の ASN に 64512 を、各ノードの ASN に 64522 を指定して BGP ピアリングを構成しています。 64512 や 64522 はインターネット上でグローバルにルーティングされない プライベート ASN として予約されています。

これらの設定は、下記の vbash(vyatta-bash) スクリプトを実行することで CaC として管理することもできます。

router-config.sh
#!/bin/vbash

node01_addr="172.16.7.11"
node02_addr="172.16.7.12"
node03_addr="172.16.7.13"

source /opt/vyatta/etc/functions/script-template

configure

delete protocols bgp

set system login banner post-login "Hello, VyOS router for MetalLB!!"

set protocols bgp system-as 64512
set protocols bgp parameters router-id 1.1.1.1
set protocols bgp address-family ipv4-unicast network 10.10.0.0/20

set protocols bgp neighbor ${node01_addr} remote-as 64522
set protocols bgp neighbor ${node01_addr} description "kubernetes-node01"
set protocols bgp neighbor ${node01_addr} update-source 172.16.7.5
set protocols bgp neighbor ${node01_addr} address-family ipv4-unicast soft-reconfiguration inbound
set protocols bgp neighbor ${node01_addr} address-family ipv4-unicast weight 200

set protocols bgp neighbor ${node02_addr} remote-as 64522
set protocols bgp neighbor ${node02_addr} description "kubernetes-node02"
set protocols bgp neighbor ${node02_addr} update-source 172.16.7.5
set protocols bgp neighbor ${node02_addr} address-family ipv4-unicast soft-reconfiguration inbound
set protocols bgp neighbor ${node02_addr} address-family ipv4-unicast weight 200

set protocols bgp neighbor ${node03_addr} remote-as 64522
set protocols bgp neighbor ${node03_addr} description "kubernetes-node03"
set protocols bgp neighbor ${node03_addr} update-source 172.16.7.5
set protocols bgp neighbor ${node03_addr} address-family ipv4-unicast soft-reconfiguration inbound
set protocols bgp neighbor ${node03_addr} address-family ipv4-unicast weight 200

set protocols bgp address-family ipv4-unicast maximum-paths ibgp 2

commit
save

VyOS は設定内容を /config/config.boot に保存します。

$ show ip bgp summary

IPv4 Unicast Summary (VRF default):
BGP router identifier 1.1.1.1, local AS number 64512 vrf-id 0
BGP table version 1
RIB entries 1, using 192 bytes of memory
Peers 3, using 61 KiB of memory

Neighbor        V         AS   MsgRcvd   MsgSent   TblVer  InQ OutQ  Up/Down State/PfxRcd   PfxSnt Desc
172.16.7.11     4      64522         0         0        0    0    0    never       Active        0 kubernetes-node01
172.16.7.12     4      64522         0         0        0    0    0    never       Active        0 kubernetes-node02
172.16.7.13     4      64522         0         0        0    0    0    never       Active        0 kubernetes-node03

Total number of neighbors 3

この時点では、全ての BGP ピア(各ノード)との接続状態が Active ですが、BGP セッションは未だ確立されていない(never)ことが分かります。

BGP で受信したネットワークプレフィックスの一覧を確認します。

$ show ip bgp cidr-only
BGP table version is 1, local router ID is 1.1.1.1, vrf id 0
Default local pref 100, local AS 64512
Status codes:  s suppressed, d damped, h history, * valid, > best, = multipath,
               i internal, r RIB-failure, S Stale, R Removed
Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self
Origin codes:  i - IGP, e - EGP, ? - incomplete
RPKI validation codes: V valid, I invalid, N Not found

    Network          Next Hop            Metric LocPrf Weight Path
 *> 10.10.0.0/20     0.0.0.0                  0         32768 i

Displayed  1 routes and 1 total paths

最初の段階では、自ルータが保有する 10.10.0.0/20 のみを外部に広報している状態です。 VyOS による経路広報先は各ノードに DaemonSet でデプロイされた Speaker Pod になります。

これらの情報は show bgp neighbors で詳細に確認することもできます。

MetalLB の設定

次に、クラスタに MetalLB をインストールして BGP モードを構成します。

MetalLB 自体のインストールは Bitnami の Helm Chart を使用しました。

以下のカスタムリソースを適用することで、MetalLB の BGP モードを有効化し、前で設定した VyOS と BGP ピアリングを組みます。参考

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: network-pool-for-private-cloud
  namespace: metallb
spec:
  addresses:
    - 172.16.7.192/26 # Service に払い出す IP アドレス範囲
  autoAssign: true
  avoidBuggyIPs: true
---
apiVersion: metallb.io/v1beta2
kind: BGPPeer
metadata:
  name: worker-node-group
  namespace: metallb
spec:
  myASN: 64522
  peerASN: 64512 # BGP router's ASN
  peerAddress: 10.10.0.115 # BGP router's external network IPv4
---
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
  name: local
  namespace: metallb
spec:
  ipAddressPools:
    - network-pool-for-private-cloud
  peers:
    - worker-node-group
  communities:
    - 65535:65282 ## RFC1997: NO_ADVERTISE
  aggregationLength: 32
  aggregationLengthV6: 128
  localPref: 100 ## Default: 100

IPAddressPool は、MetalLB が LoadBalancer Service に割り当てる IP アドレスプールを定義します。

$ kubectl get ipaddresspool -n metallb
NAME                             AUTO ASSIGN   AVOID BUGGY IPS   ADDRESSES
network-pool-for-private-cloud   true          true              ["172.16.7.192/26"]

BGPPeer は、MetalLB が BGP 経路上のピアとして接続する近隣の BGP ルータ(VyOS)を定義します。 今回は、ノードから見た時にグローバル IP アドレスとして見えるように VyOS の外部 IP アドレス(10.10.0.115)を指定します。

$ kubectl get bgppeer -n metallb
NAME                ADDRESS       ASN     BFD PROFILE   MULTI HOPS
worker-node-group   10.10.0.115   64512

BGPAdvertisement は、指定した IP アドレスプールを、どのピアに向けて、どのように広報するかを定義します。 今回は Service に割り当てるアドレスプールとして 172.16.7.192/26 を予約しているため、この範囲から払い出された IP アドレスが BGP ルータへ広報されます。

$ kubectl get bgpadvertisement -n metallb
NAME    IPADDRESSPOOLS                       IPADDRESSPOOL SELECTORS   PEERS
local   ["network-pool-for-private-cloud"]                             ["worker-node-group"]

ArgoCD をデプロイするとコンポーネント一覧を視覚的に把握することができます。

  • ワークロード
metallb-workload.png
  • ネットワークリソース
metallb-networking.png

スタティックルートの追加

検証環境では VM のプライベートネットワークを利用しており、対向ルータがクライアントネットワークに存在しないため、クライアントマシンは 172.16.7.0/24 のネットワーク情報を知りません。 そのため、172.16.7.0/24 への通信は VyOS の外側の IP アドレス(10.10.0.115)を経由するようにスタティックルートを追加することで、この IP 範囲対する通信が BGP ルータを通過するように構成します。

### クライアントマシンで実行
$ sudo ip route add 172.16.7.0/24 via 10.10.0.115
netstat -nvr | awk 'NR==1 || NR==2 || /172.16.7.0/'
Kernel IP routing table
Destination     Gateway         Genmask         Flags   MSS Window  irtt Iface
172.16.7.0      10.10.0.115     255.255.255.0   UG        0 0          0 enp4s0

同様に、各ノードに対してもクライアントネットワーク側に BGP ルータを経由して到達できるようにスタティックルートを追加します。 これにより、戻りのトラフィックが VM ネットワーク内の他のルートに吸われることなく、常に BGP ルータを経由するようになります。

### 各ノード(node01 / node02 / node03)で実行
$ sudo ip route add 10.10.0.0/20 via 172.16.7.5
netstat -nvr | awk 'NR==1 || NR==2 || /10.10.0.0/'
Kernel IP routing table
Destination     Gateway         Genmask         Flags   MSS Window  irtt Iface
10.10.0.0       172.16.7.5      255.255.240.0   UG        0 0          0 eth1

※ 実際の運用では、近隣ルータ間で BGP ピアリングによる経路交換を設定することが殆どなので、スタティックルートを追加する必要はありません。

BGP セッションの確認

以上の構成により、クラスタと VyOS の間は eBGP でピアリングが組まれた状態となります。

Speaker Pod が起動し、VyOS との BGP ピアリングが組まれると、BGP セッションが確立されます。

$ show ip bgp summary

IPv4 Unicast Summary (VRF default):
BGP router identifier 1.1.1.1, local AS number 64512 vrf-id 0
BGP table version 7
RIB entries 3, using 576 bytes of memory
Peers 3, using 61 KiB of memory

Neighbor        V         AS   MsgRcvd   MsgSent   TblVer  InQ OutQ  Up/Down State/PfxRcd   PfxSnt Desc
172.16.7.11     4      64522       169       167        7    0    0 01:22:20            1        1 kubernetes-node01
172.16.7.12     4      64522       168       167        7    0    0 01:22:20            0        1 kubernetes-node02
172.16.7.13     4      64522       168       167        7    0    0 01:22:20            0        1 kubernetes-node03

Total number of neighbors 3

送信元 IP アドレスを保持した負荷分散

MetalLB BGP モードを利用した ECMP による負荷分散を確認します。

今回は、クライアントの情報を Pod で確認するために、こちら で準備したカスタムの Nginx イメージを利用します。

server.js
const express = require("express");
const fs = require("fs");

const app = express();
const port = 80;

function getNginxVersion(callback) {
  fs.readFile("/etc/nginx/nginx_version", "utf8", (err, data) => {
    if (err) {
      callback(err);
    } else {
      callback(null, data);
    }
  });
}

app.get("/", (req, res) => {
  getNginxVersion((error, version) => {
    res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
    res.write("Maintained by RenGoto@Pluslab\n");

    // Client info.
    const remoteAddr = req.socket.remoteAddress;
    const splittedRemoteAddr = remoteAddr.split(":");
    const clientAddress = splittedRemoteAddr[splittedRemoteAddr.length - 1];

    const httpMethod = req.method;

    const realPath = req.originalUrl;

    const httpVersion = req.httpVersion;

    const requestUrl = `${req.protocol}://${req.headers.host}${req.originalUrl}`;

    res.write("\nCLIENT VALUES:\n");
    res.write(client_adress=${clientAddress}\n`);
    res.write(`http_method=${httpMethod}\n`);
    res.write(`real_path=${realPath}\n`);
    res.write(`http_version=${httpVersion}\n`);
    res.write(`request_uri=${requestUrl}\n`);

    // Server info.
    const hostName = require("os").hostname();

    res.write("\nSERVER VALUES:\n");
    res.write(`host_name=${hostName}\n`);
    if (error) {
      res.write(`server_version=${error}\n`);
    } else {
      res.write(`server_version=${version}\n`);
    }

    // Header info.
    const headers = req.headers;

    res.write("\nHEADERS RECEIVED:\n");
    res.write(`accept=${headers.accept}\n`);
    res.write(`host=${headers.host}\n`);
    res.write(`user-agent=${headers["user-agent"]}\n`);

    res.end();
  });
});

app.listen(port, () => {
  console.log(`Port ${port}: Server listen...`);
});

カスタムイメージは、Express で簡単なエコーサーバを起動し、アクセス元のクライアント情報、サーバ情報、ヘッダ等を整形してレスポンスとして返す簡易的なものです。

docker-hub-custom-nginx-image.png

以下のマニフェストを適用して Nginx Pod をデプロイします。

nginx.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: nginx
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: nginx
  labels:
    app: nginx-pod
spec:
  selector:
    matchLabels:
      app: nginx-pod
  replicas: 6
  template:
    metadata:
      labels:
        app: nginx-pod
    spec:
      containers:
        - name: nginx-pod
          image: ren1007/custom-nginx:1.2
          imagePullPolicy: Always
          ports:
            - containerPort: 80
          resources:
            limits:
              memory: '512Mi'
              cpu: '0.2'
            requests:
              memory: '256Mi'
              cpu: '0.1'
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-svc
  namespace: nginx
spec:
  type: LoadBalancer
  loadBalancerIP: 172.16.7.192
  externalTrafficPolicy: Local # 単一ノード内の負荷分散に留める
  selector:
    app: nginx-pod
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80

Service の External Traffic Policy を Local に設定することで、ECMP で負荷分散された後はローカルノード内の Pod にルーティングするようにします。 これにより、送信元 IP アドレスを維持しつつ、クラスタワイドな負荷分散を実現します。

$ kubectl get svc -n nginx nginx-svc -o wide
NAME        TYPE           CLUSTER-IP   EXTERNAL-IP    PORT(S)        AGE     SELECTOR
nginx-svc   LoadBalancer   10.1.2.185   172.16.7.192   80:31902/TCP   2m52s   app=nginx-pod
$ kubectl get pod -n nginx -o wide
nginx-deployment-bfc96bb4c-tmmvk   1/1     Running             0          9s    10.16.1.7   node01   <none>           <none>
nginx-deployment-bfc96bb4c-ln429   1/1     Running             0          9s    10.16.1.8   node01   <none>           <none>
nginx-deployment-bfc96bb4c-gfx4x   1/1     Running             0          9s    10.16.2.6   node02   <none>           <none>
nginx-deployment-bfc96bb4c-2s29d   1/1     Running             0          10s   10.16.2.7   node02   <none>           <none>
nginx-deployment-bfc96bb4c-hjfcc   1/1     Running             0          9s    10.16.3.7   node03   <none>           <none>
nginx-deployment-bfc96bb4c-b488f   1/1     Running             0          10s   10.16.3.8   node03   <none>           <none>

クライアントからアクセスしてみると、送信元 IP アドレスが保持されていることが確認できます。

### クライアントマシンの IP アドレス
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether a8:a1:59:85:13:4c brd ff:ff:ff:ff:ff:ff
    inet 10.10.0.106/20 brd 10.10.15.255 scope global noprefixroute enp4s0
       valid_lft forever preferred_lft forever
    inet6 fe80::aaa1:59ff:fe85:134c/64 scope link
       valid_lft forever preferred_lft forever
custom-nginx-access.png

また、実際にリクエストを返したのは node03 にデプロイされた nginx-deployment-bfc96bb4c-b488f という Pod であることが分かります。

$ show ip bgp cidr-only
BGP table version is 14, local router ID is 1.1.1.1, vrf id 0
Default local pref 100, local AS 64512
Status codes:  s suppressed, d damped, h history, * valid, > best, = multipath,
               i internal, r RIB-failure, S Stale, R Removed
Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self
Origin codes:  i - IGP, e - EGP, ? - incomplete
RPKI validation codes: V valid, I invalid, N Not found

    Network          Next Hop            Metric LocPrf Weight Path
 *> 10.10.0.0/20     0.0.0.0                  0         32768 i
 *= 172.16.7.192/32  172.16.7.11                          200 64522 i
 *=                  172.16.7.12                          200 64522 i
 *>                  172.16.7.13                          200 64522 i

BGP ルータを確認すると 172.16.7.192/32 というプレフィックスに対して、各ノードへの経路が学習されており、ECMP 構成になっていることが分かります。

また、現在のベストパスは node03 となっており、これによってクライアントからのリクエストが node03 の Pod にルーティングされたことが分かります。

172.16.7.192/32 というプレフィックスの経路広報は Speaker Pod(ASN:64522)によって行われています。

node03 の Speaker Pod のログを確認してみます。

$ kubectl logs -n metallb metallb-speaker-fzxkn -f
### 経路広報処理を開始
{"caller":"bgp_controller.go:345","event":"updatedAdvertisements","ips":["172.16.7.193"],"level":"info","msg":"making advertisements using BGP","numAds":1,"pool":"network-pool-for-private-cloud","protocol":"bgp","ts":"2024-01-22T10:54:20Z"}
### 172.16.7.192 を BGP ピアへ経路広報
{"caller":"main.go:420","event":"serviceAnnounced","ips":["172.16.7.193"],"level":"info","msg":"service has IP, announcing","pool":"network-pool-for-private-cloud","protocol":"bgp","ts":"2024-01-22T10:54:20Z"}

次に、複数のクライアントマシンを準備してリクエストを送ってみます。

Vagrantfile
# frozen_string_literal: true

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure('2') do |config|
  config.vm.boot_timeout = 600

  ## client01 - client06
  (1..6).each do |i|
    vm_name = format('client%02d', i)
    ip_last_octet = 120 + i # 10.10.0.121 - 10.10.0.121.126

    config.vm.define vm_name.to_sym do |node|
      define_machine_spec(node, 1, 4096)

      node.vm.hostname = vm_name
      node.vm.box = 'generic/ubuntu2204'

      node.vm.network :public_network,
                      ip: "10.10.0.#{ip_last_octet}",
                      netmask: '255.255.240.0',
                      bridge: 'enp4s0',
                      dev: 'enp4s0'
    end
  end
end

## Define macros
def define_machine_spec(name, use_cpu, use_memory)
  name.vm.provider :libvirt do |libvirt|
    libvirt.driver = 'qemu'
    libvirt.cpus = use_cpu
    libvirt.memory = use_memory
  end
end
$ while true; do curl --silent http://172.16.7.192 | egrep 'client_address|host_name'; sleep 1; done
multi-client-load-balancing.png

6 台のクライアントのリクエストは次のように分散されたことが分かります。

  • client02 と client05 → node01 の Pod
    • nginx-deployment-bfc96bb4c-tmmvk
    • nginx-deployment-bfc96bb4c-ln429
  • client01 と client03 → node02 の Pod
    • nginx-deployment-bfc96bb4c-gfx4x
    • nginx-deployment-bfc96bb4c-2s29d
  • client04 と client06 → node03 の Pod
    • nginx-deployment-bfc96bb4c-hjfcc
    • nginx-deployment-bfc96bb4c-b488f

実際に ECMP でノード間で負荷分散しつつ、Pod に着信したパケットから送信元 IP アドレスも保持できていることが確認できました。

bgp-ecmp-load-balancing-packet-flow.png

実際の運用環境

検証環境ではノードとクライアントの両方にスタティックルートを追加して BGP ルータを経由するように指示していますが、実際の運用環境では BGP ピアリングを組んだ近隣ルータ間で経路情報を交換します。

本番環境での理想的な構成は、以下のようになります。

production-network-bgp-ecmp-load-balancing.png

Tips

VyOS コマンドライン

### AS パスが異なる場合においても 2 つの BGP ルートを等しいコストとして扱う
$ set protocols bgp parameters bestpath as-path multipath-relax

### BGP ピア(隣接ルータ)へのセッションに使用する送信元 IPv4 アドレス(またはインターフェース)を指定
$ set protocols bgp neighbor <address|interface> update-source <address|interface>

### BGPピア(隣接ルータ)の入力方向ソフト再構成(soft reconfiguration)を有効化
$ set protocols bgp neighbor <address|interface> address-family <ipv4-unicast|ipv6-unicast> soft-reconfiguration inbound

### 近隣ルートのデフォルトの重み値を指定(ASN の範囲: 1 - 65535)
$ set protocols bgp neighbor <address|interface> address-family <ipv4-unicast|ipv6-unicast> weight <number>
### 全ての BGP ネットワーク経路を表示
$ show ip bgp cidr-only

### 全ての BGP 接続ステータスを表示
$ show <ip|ipv6> bgp summary

### BGP ピア(隣接ルータ)情報を表示
$ show bgp neighbors

### 全ての設定内容を表示
$ show configuration commands

用語説明

  • ソフト再構成(soft reconfiguration)
    • BGP ピアから受信したルート情報を変更を加えずに再評価するための機能
      • 通常、BGP では受信したルート情報をルーティングテーブルに保持してルートの変更が発生した場合にのみ再評価する
      • ソフト再構成を有効にすると受信したルート情報を変更なしに再評価する
    • 指定した BGP ピアで受信ルート情報を再評価するための仕組みを有効にすると、ルート情報の変更をリアルタイムに反映できる
    • ネットワークトラブルシューティングや BGP ルート情報の監視等に役立つ
    • ルート更新の保存にはメモリが使用される
      • 複数のネイバーに対してソフト再構成インバウンドを有効にするとメモリ使用量が増大する可能性がある
  • 自律システム(AS:Autonomous System)
    • インターネットは多くのネットワークやルーターで構成されており、それぞれが管理や運用を行う独立したネットワーク区域を持っている
    • これらの独立したネットワーク区域を AS と呼ぶ
    • 一般的に、AS 内では特定の統一されたポリシーや経路選択アルゴリズムが適用され、AS 間では BGP ルーティングポリシが適用される
  • ルーティングの判断指標
    • AS パス:AS Path Length
      • 送信元 AS から宛先 AS までの経路を示すリスト
      • このリスト(AS パス)にはデータが通過した AS の番号が含まれており、通常はカンマで区切られて表示される
      • AS パスは経路選択プロセスにおいて異なる経路間の優先度を判断する際に使用される
      • 一般的に、最もパス長が 最も短い 通信経路が優先される
    • ローカルプレフィックス:Local Preference
      • BGP 経路選択において使用される優先度を表す値
      • 一般的に、最もローカルプレフィックス長が 最も高い 通信経路が優先される
    • オリジンコード:Origin Code
      • i - IGPe - EGP 等の BGP ルータに流入したパケットのオリジン経路を示すコード
      • BGP 経路のオリジンコードによって、経路の起源がどこから来たかが示される
      • 一般的に、IGP よりも EGP が優先され、EGP よりも不完全コード (? - incomplete) が優先される
    • メトリック値(Metric Value)
      • 通信経路の選択や優先順位付けに使用される値
      • 最適な経路を選択するための指標として使用され、BGP の柔軟性と適応性を高める
      • 一般的に、メトリック値が 最も小さい 通信経路が優先される

まとめ

今回のブログでは、MetalLB の BGP モードを利用して SNAT を回避しつつ、クラスタワイドな負荷分散を実現する構成について紹介しました。

Kubernetes の仕組み上、負荷分散時の SNAT は回避できず、通常であれば Service の External Traffic Policy を Local にすることでこの問題を解決します。

しかし、MetalLB の L2 モードでは一つのノードにトラフィックが集中することで単一障害点になるため、可用性や冗長性の観点で推奨されません。

これに対し、BGP モードではクラスタ前段のルータがトラフィックを ECMP で各ノードに振り分けるため、クラスタワイドな負荷分散が可能です。 このため、BGP モードでは External Traffic Policy を Local に設定しても単一障害点を抱えることなく、スケーラブルなクラスタ運用が実現できます。

BGP モードは L2 モードに比べて構成自体が複雑になりますが、特に大規模なクラスタや高可用性を求める環境、送信元 IP アドレスを保持したいといった特定のケースにも柔軟に対応することが可能です。

参考・引用