kube-vipとCiliumで作る、IPアドレスを節約したK8sロードバランサー
この記事は jsys Advent Calendar 2025 14日目の記事です。
執筆時点で、1つ前の記事は appare45さんの「Go言語でCの構造体を読みたい」であり、次は「GOlang・バトル」が予定されています。意図せずGo言語にまつわる記事に挟まれる形となりました。
本記事の内容は、Go言語や学園祭実行委員会とは特に関係ありませんが、昨年のKubernetesの記事からの繋がりということで、引き続き読んでいただけると幸いです。
はじめに
パブリッククラウドでKubernetesを使っていると、type: LoadBalancerなServiceを作れば、数秒後にはグローバルIPが割り当てられて外部公開できます。しかし、オンプレミス環境ではtype: LoadBalancerのServiceを使えるようにするのが意外と面倒です。
オンプレでtype: LoadBalancerを作るのが難しい理由
オンプレミスでLoadBalancerを実現しようとすると、いくつかの選択肢があります。
MetalLB (L2モード) は最もメジャーな選択肢です。しかし、L2モードはGratuitous ARPを使ってVIPを広報するため、大規模環境ではARPストームが気になります。また、フェイルオーバー時にはARPキャッシュの更新を待つ必要があり、切り替え時間が読みにくいという問題があります。
BGPモード(MetalLBやCiliumのBGP機能)は本格的で優れた解決策ですが、BGP対応ルーターが必要になります。物理的にBGP機能のあるルーターを用意するか、VyOSやFRRoutingで自前構築することが挙げられますが、結局そのL3ルータ自体が障害点になってしまいます。
そしてもう一つ、見落とされがちな問題があります。IPアドレスの枯渇です。
IPアドレス枯渇問題
一般的なKubernetesクラスタでは、各ノードに管理用IPを振り、さらにServiceを外部公開するためのIPプールも同じセグメントに用意します。ノードが3台なら3個のIP、Serviceが10個なら10個のIP、合計13個のIPが必要になります。
いくらプライベートIPとはいえ、他のワークロードもある場合にKubernetesのノードを増やすたびに、Service公開用のIPセグメントまで消費していくのは非効率です。
この記事での解決策
本記事では、以下の3つのアプローチを組み合わせることで、これらの課題を解決します。
- Dual NIC構成: クラスタノード同士の通信を行う管理用NICと、サービス公開用NICを物理的に分離
- IPアドレスを節約: 公開用NICには、ノード自体に固有のIPを振らない(Unnumberedな運用)
- Cilium + kube-vip: 外部にルーターを使わず、K8sクラスタ内で完結できるスケーラブルなHA構成を実現
「kube-vip」と聞くと、コントロールプレーンのVIP(kube-apiのVIP)を思い浮かべる方が多いかもしれません。しかし、kube-vipにはService LoadBalancer機能も備わっています。本記事では、kube-vipをService LBとして活用する方法に焦点を当てます。
アーキテクチャ
物理構成
今回の検証環境は、Proxmox VE上に構築した3ノードのRKE2クラスタです。 OSにはFlatcar Container Linuxを使用しています。
この内容は、基本的にベアメタルのUbuntuやRocky Linux、またはKubeadmで構築したピュアなKubernetesなど、他の環境でも応用可能だと考えています。
特に、Flatcar Container Linuxのsysext-bakeryを使用してRKE2クラスタを構築すると、約5分でデプロイ可能な状態まで構築できるため、最近のお気に入りです。
┌─────────────────────────────────────────────────────────────────┐
│ Physical Network │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ server-1 │ │ agent-1 │ │ agent-2 │ │
│ │ (Control) │ │ (Worker) │ │ (Worker) │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ eth0: 管理用 │ │ eth0: 管理用 │ │ eth0: 管理用 │ │
│ │ 10.0.1.11 │ │ 10.0.1.12 │ │ 10.0.1.13 │ │
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
│ │ eth1: 公開用 │ │ eth1: 公開用 │ │ eth1: 公開用 │ │
│ │ (IPなし) │ │ (IPなし) │ │ (IPなし) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ k8snet (管理用ネットワーク) │
│ Gateway: 10.0.1.1 │
│ VIP: 10.0.1.10 (API Server) │
│ │
│ ┌────────────────┼────────────────┐ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ vmbr0 (公開用ネットワーク) │
│ 192.168.111.0/24 │
│ VIP: 192.168.111.30 (Service LB用) │
│ │
└─────────────────────────────────────────────────────────────────┘
各ノードには2つのNICが搭載されています。
NIC 1 (eth0) - 管理/内部用
- クラスタ間通信用のネットワーク
- 各ノードに固有のIPアドレスを割り当て(10.0.1.11〜13)
- デフォルトゲートウェイはこちらに設定(10.0.1.1)
- API ServerへのアクセスもこのNIC経由
- ProxmoxのSDN機能で構築したオーバーレイ・ネットワーク
NIC 2 (eth1) - 公開用
- ケーブルは接続されているが、OSプロビジョニング時にIPアドレスを設定しない
- 外部からサービスへのアクセス専用
- kube-vipが動的にVIPを割り当てる
- Proxmoxのブリッジが設定されている
この構成のポイントは、eth1に固定IPを振らないことです。ノードを100台追加しても、公開用ネットワークのIP消費はゼロで、VIPサービスのIPのみで済みます。
OS側の設定
今回はOSにFlatcar Container Linuxを使っているので、ignition内でsystemd-networkdの定義を入れてeth1を「IPなし・リンクアップ状態」に設定します。
[Match]
Name=eth1
[Network]
DHCP=no
この設定により、eth1はリンクアップしますがIPアドレスは持ちません。通常、IPを持たないインターフェースは通信に使えませんが、kube-vipがこのNICにIPを設定します。
kube-vipの役割
kube-vipは、リーダー選出されたノードのeth1に対して、VIP(Service IP)をip addr addコマンドで動的に割り当てます。この瞬間、eth1は通信可能なインターフェースとして機能するようになります。
フェイルオーバー時には、新しいリーダーが同様にVIPを割り当て、Gratuitous ARPで広報します。BGPモードと異なり、ルーターに追加の設定変更をすることなくスケールアウトできます。
非対称ルーティングを回避したい
ここまでの説明を聞くと、「なぜわざわざCiliumが必要なのか?」と思われるかもしれません。FlannelやCalicoでも良さそうに見えます。
しかし、実はKubernetes固有の厄介な問題があります。それが非対称ルーティングです。
何が起きるのか
kube-proxy環境下の一般的なK8sクラスターとして下記のシナリオを考えてみます。
- 外部クライアントが
192.168.111.20:80(eth1のVIP)にHTTPリクエストを送信 - パケットはeth1で受信され、Podが処理する
- Podがレスポンスを返そうとする
- ここで問題発生: Linuxカーネルは「外部クライアントのIPはルーティングテーブルにないため、デフォルトゲートウェイに送るべき!」と判断
- カーネルはデフォルトゲートウェイの設定されているeth0からパケットを送出しようとする
- 行きはeth1、帰りはeth0という非対称ルーティングが発生
- 非対称ルーティングの場合、正しくパケットがルーティングされていてもファイアーウォールやルーターによってパケットが破棄されてしまう。
この問題はip ruleを使ったSource Based Routing(ポリシールーティング)を個別に設定すれば解決できます。しかし、Kubernetesでは動的にPodが作成・削除され、VIPも変化するため、静的なルーティングルールでは対応しきれません。
Cilium (kube-proxy-replacementモード) の場合
ここでCiliumをkube-proxy-replacement(旧: strict)モードでの動作を紹介します。このモードのときのCiliumは、外部から受け取ったパケットをeBPFを使って独自にパケットを追跡します。
CiliumのeBPFプログラムは以下の動作を行います。
- コネクション追跡(Conntrack): パケットが入ってきたインターフェース(eth1)を記憶
- ルーティングテーブルのバイパス: Linuxのルーティングテーブルを迂回
- 強制的な送出: 記憶したインターフェース(eth1)からレスポンスを送出
パケットがPod内に届く部分まではほとんど同じですが、Podから外に出るときにノードのルーティングを通さず、Cilium自身がパケットを追跡しているため正しいインターフェース(eth1)からパケットを放出できます。
これにより、複雑なip rule設定が一切不要になります。Kubernetesのマニフェストだけでネットワークを完結させることができます。
パケットの流れの比較
上記2つの流れを比較すると下記の図のようになります。
試していないので、実際のところは分かりませんがCalicoのkube-proxy置き換えモードでも同様の動きをしてくれるはずです。
検証環境でのCilium設定
実際の設定を見てみます。RKE2ではHelmChartConfigを使ってCiliumの設定をカスタマイズします。
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-cilium
namespace: kube-system
spec:
valuesContent: |
kubeProxyReplacement: true
k8sServiceHost: "127.0.0.1"
k8sServicePort: 6443
# DSRモードでSNATを回避
loadBalancer:
mode: dsr
dsrDispatch: geneve
# DSRにはGeneveトンネルが必要
tunnelProtocol: geneve
kubeProxyReplacement: true
kube-proxyを完全に置き換え、eBPFでパケット処理を行います。これが非対称ルーティング回避の鍵となります。
loadBalancer.mode: dsr
Direct Server Return(DSR)モードを有効化します。通常のSNATモードでは、レスポンスがCiliumの分散システムを経由して別ノードから送出されることがあります。DSRモードでは、パケットを受信したノードから直接レスポンスを返すため、クライアントのソースIPが正しく認識されます。
ちなみに、DSRモードではなくデフォルトのSNATモードでも通信自体は可能です。
しかし、この場合、Ciliumの分散機能により、パケットを受信したノードと、実際に処理を行うノードが異なると、パケット転送(ノード間転送)が発生します。その結果、サーバーアプリケーション側からは、クライアントの実際のIPアドレスではなく、受信ノードのIPアドレスがソースIPとして認識されます。これは、同一ノード内で処理が完結する場合はソースIPが正しくクライアントIPになるのに対し、ラウンドロビンなどで他ノードに処理が割り振られた場合に、ソースIPがパケットを受信したノードのIPに書き換えられてしまうためです。上記の問題があるため、この構成においては、DSRモードにしています。
tunnelProtocol: geneve
DSRモードを使用する場合、Geneveトンネルが必要です。デフォルトのVXLANでは動作しません。
このDSRモードとGeneveトンネルモードは、サイボウズのクラウド基盤で起きた問題を解決するために実装された機能だったりします。
副次的なものになりますが、すごく助かりました
なぜ「Cilium L2 Announcement」や「MetalLBのL2モード」を使わないのか?
Cilium単体でもL2広報機能(L2 Announcement Policy)があります。同じようにMetalLBのL2モードも広く使われています。では、なぜkube-vipを使うのか疑問に思った方も多いのではないでしょうか
「Proxy-ARP」の仕組み
CiliumのL2 AnnouncementとMetalLBのL2モードは、どちらもProxy-ARP方式を採用しています。ARPリクエストに対して「そのIPはここにあるよ」と代理応答しますが、実際にインターフェースにIPアドレスを追加しません。
今回の構成では、eth1は「IPを持たないインターフェース」です。Proxy-ARP方式では、eth1に届くはずのパケットをどのインターフェースで受け取るかが曖昧になります。
一方、kube-vipはip addr addコマンドと同様にVIPを物理的なNICに割り当てます。これにより、eth1は正式なIPアドレスを持つインターフェースとして確立され、パケットの送受信先が可能となります。
ProxyARPだけで十分な場合もある
単一NICで、管理用ネットワークと同じセグメントにVIPを置くだけなら、Cilium L2 AnnouncementやMetalLBで十分です。設定もシンプルで、kube-vipを追加する必要はありません。
しかし、今回のように「ノード間通信とサービス用のネットワークが別のサブネット」という要件がある場合、kube-vipのIPを割り当てる方式である必要があります。
実装のポイント
kube-vip の設定(DaemonSet)
kube-vipの設定で最も重要なのはvip_interfaceの指定です。
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: kube-vip-svc
namespace: kube-system
spec:
chart: kube-vip
repo: https://kube-vip.github.io/helm-charts
version: "0.9.1"
targetNamespace: kube-vip-svc
valuesContent: |
env:
vip_interface: "eth1" # ← 公開用NICを明示的に指定
vip_arp: true # Gratuitous ARPを送信
cp_enable: false # コントロールプレーンVIP機能は無効化
svc_election: true # Service LBのリーダー選出を有効化
svc_enable: true # Service LBを有効化
vip_leaderelection: true # リーダー選出を有効化
vip_leasename: "plndr-svc-lock" # リース名(複数kube-vip時は一意に)
prometheus_server: ":2112" # prometheusのエンドポイント(複数kube-vip時は一意に)
nameOverride: "kube-vip-svc"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
- matchExpressions:
- key: node-role.kubernetes.io/master
operator: DoesNotExist
vip_interfaceを空文字にすると、kube-vipはデフォルトルートがあるインターフェース(eth0)を使おうとします。これでは公開用ネットワークにVIPが作られません。
複数kube-vipの共存
この記事ではスコープ外としますが、実際にはコントロールプレーンの冗長化のために複数のkube-vipインスタンスを動かしています。
- 内部用kube-apiのVIP(10.0.1.10): eth0上でKube-api Server用VIPを提供
- 外部用kube-apiのIP(192.168.111.30): eth1上でクラスタ外部のネットワークからkube-api Serverへ接続するためのIPを提供
- Service LB用(192.168.111.20-30): eth1上でService LoadBalancer用のIPプールを提供
複数のkube-vipを共存させる場合、以下の点に注意が必要です。
-
vip_leasenameを一意にする(デフォルトはplndr-cp-lock) - 別々のNamespaceにデプロイすると管理しやすい
- Prometheusのポートが衝突しないよう
prometheus_serverを変更
# 外部用kube-vipの設定例
env:
vip_leasename: "plndr-cp-external-lock"
prometheus_server: ":2113" # デフォルトの:2112と被らないように
これ以外のネットワークにもサービスLBを設置したくなった場合は、各ノードにNICを増やすだけで実現できます。
kube-vip-cloud-providerでIPを自動で割当
kube-vipには、Service LoadBalancer (ServiceLB) 用の簡易的なIPアドレス管理(IPAM)機能として、kube-vip-cloud-providerが用意されています。
これをデプロイすることで、type: LoadBalancerのServiceを作成するだけで、自動的にIPアドレスが割り当てられます。
もちろんServiceのアノテーションにkube-vip.io/loadbalancerIPs=192.168.111.20のようにIPアドレスを直接指定するだけでも十分機能しますが、cloud-providerを利用すると、アノテーションなしで設定された範囲から自動でIPが設定されます。これにより、パブリッククラウドのService LBのような使い勝手になります。
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: kube-vip-svc
namespace: kube-system
spec:
targetNamespace: kube-vip-svc
createNamespace: true
version: 0.9.1
chart: kube-vip
repo: https://kube-vip.github.io/helm-charts
valuesContent: |-
env:
vip_arp: "true"
cp_enable: "false" # コントロールプレーンVIPは無効
svc_election: "true" # Service単位でリーダー選出
svc_enable: "true" # Service LB機能を有効化
prometheus_server: ":2114"
nameOverride: "kube-vip-svc"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
- matchExpressions:
- key: node-role.kubernetes.io/master
operator: DoesNotExist
---
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: kube-vip-cloud-provider
namespace: kube-system
spec:
targetNamespace: kube-vip-svc
createNamespace: true
version: 0.2.9
chart: kube-vip-cloud-provider
repo: https://kube-vip.github.io/helm-charts
valuesContent: |-
cm:
data:
allow-share-global: "true"
# rangeよりcidrの方が優先される
# cidr-global: "192.168.111.0/24"
range-global: "192.168.111.20-192.168.111.30"
interface-global: "eth1"
kube-vip側の設定
| パラメータ | 値 | 説明 |
|---|---|---|
cp_enable |
false | コントロールプレーンVIPは別インスタンスで管理 |
svc_enable |
true | Service LoadBalancer機能を有効化 |
svc_election |
true | Service単位でリーダー選出 |
cloud-provider側の設定
| パラメータ | 値 | 説明 |
|---|---|---|
range-global |
192.168.111.20-192.168.111.30 | IPプールの範囲 |
interface-global |
eth1 | VIPを割り当てるインターフェース |
allow-share-global |
true | 同一IPを複数Serviceで共有可能 |
interface-global: eth1が重要で、この設定によりService用のVIPが公開用NIC(eth1)に割り当てられます。
ここまで設定すると、下記のように超シンプルにサービスを定義するしてデプロイするだけです。
apiVersion: v1
kind: Service
metadata:
name: my-web-app
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 8080
selector:
app: my-web-app
これだけで、192.168.9.26が自動的に割り当てられます。
$ kubectl get svc my-web-app
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-web-app LoadBalancer 10.43.12.34 192.168.111.20 80:31234/TCP 5s
IPプールの設定オプション
cloud-providerは柔軟なIPプール設定をサポートしています。
# CIDR形式(範囲より優先される)
cidr-global: "192.168.111.0/24"
# 範囲形式
range-global: "192.168.111.20-192.168.111.30"
# Namespace単位での設定も可能
cidr-production: "192.168.10.0/24"
range-staging: "192.168.11.1-192.168.11.10"
Worker Nodeへの配置
Service LB用のkube-vipは、affinityでWorkerノードにのみ配置しています。
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
これにより、Control Plane用のkube-vip(API Server VIP)とService LB用のkube-vipが分離され、役割が明確になります。
Cilium Helm設定の全体像
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-cilium
namespace: kube-system
spec:
valuesContent: |
# kube-proxyの置換設定
kubeProxyReplacement: true
k8sServiceHost: "127.0.0.1"
k8sServicePort: 6443
# DSRモード(SNAT回避)
loadBalancer:
mode: dsr
dsrDispatch: geneve
# Geneveトンネル(DSR必須)
tunnelProtocol: geneve
# API Serverへの接続設定(kubeProxyReplacement有効時はこの設定が必須)
# 「CPノードのIP」と「内部用kube-apiのVIP IP」を設定していて、複数CPノードがある場合は全ノードのIPを列挙する必要がある。
k8s:
apiServerURLs: "https://10.0.1.10:6443 https://10.0.1.11:6443"
DSRモードの動作確認
DSRが正しく動作しているかは、Hubbleやcilium CLIで確認できます。
# cilium CLIでService一覧を確認
kubectl exec -n kube-system ds/cilium -- cilium service list
# Hubble flowsでパケットの流れを確認
kubectl exec -n kube-system ds/cilium -- hubble observe --verdict FORWARDED
DSRが効いていれば、実際にLoadBalancer Serviceで動作しているデプロイメントへのアクセス時に、ソースIPがクライアントの本当のIPアドレスになっているはずです。
セキュリティと運用の注意点
見落としがちな問題
kube-vipがVIPを割り当てると、そのIPアドレスはノードのローカルIPとして扱われます。これは意図した動作ですが、セキュリティ上のリスクを生みます。
例えば、sshdがデフォルトで0.0.0.0:22をListenしている場合、公開用VIP経由でSSH接続できてしまいます。NodePortサービスも同様です。
┌─────────────────────────────────────────────────────────────┐
│ リスクのある状態 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 外部クライアント │
│ │ │
│ ▼ ssh root@192.168.111.20 │
│ ┌─────────────────────────────────────────┐ │
│ │ ノード │ │
│ │ eth1: 192.168.111.20 (VIP) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ sshd (0.0.0.0:22) ← アクセス可能! │ │
│ │ NodePort (0.0.0.0:30000-32767) │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
対策1: sshdのListenAddressを制限
/etc/ssh/sshd_configで、sshdを管理用NICのIPにバインドします。
# /etc/ssh/sshd_config
ListenAddress 10.0.1.11 # eth0のIPのみ
対策2: nftablesでポートアクセスを制限
検証環境では、nftablesを使ってeth1からのSSHアクセスをブロックしています。
table ip filter {
chain INPUT {
type filter hook input priority 0;
iifname != "eth0" tcp dport 22 drop
}
}
この設定により、eth0以外のインターフェースからの22番ポートへのアクセスが全て破棄されます。
対策3: Cilium Network Policy
より細かい制御が必要な場合は、CiliumNetworkPolicyを使います。
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: deny-external-to-nodeport
spec:
endpointSelector: {}
ingressDeny:
- fromEntities:
- world
toPorts:
- ports:
- port: "30000"
endPort: "32767"
protocol: TCP
まとめ
過去にkube-proxy環境で同様の構成を試みたことがあったのですが、非対称ルーティングが原因で理論上は可能なはずなのに疎通できないというもどかしい状況に陥っていました。当時はip ruleでSource Based Routingを設定しようとしたものの、Kubernetesの動的な環境では管理が煩雑になりすぎて断念した経緯があります。
今回、CiliumのeBPFによるコネクション追跡機能のおかげで、その問題をスマートに解決できました。kube-vip-cloud-providerと組み合わせることで、type: LoadBalancerを作るだけでIPが自動割り当てされるクラウドライクな体験も実現できています。
この構成であれば、BGPルーターなしでIPアドレスを節約しながら、スケーラブルなLoadBalancer環境を構築できます。自宅ラボや小規模なオンプレミス環境で「LoadBalancerが欲しいけど、機材やIPに余裕がない」という方は、ぜひ試してみてはいかがでしょうか。
Discussion