🎲

k8s Serviceの負荷分散は本当にランダムなのか?iptablesのOSSコードから裏取りしてみた

に公開

はじめに

Kubernetes(k8s)を使っていると、当たり前のように Service リソースを作成します。「Serviceを作れば、L4レベルで複数のPodにいい感じに負荷分散される」――これはk8sの基礎知識として誰もがなんとなくふわっと知っています。

しかし、ふと疑問に思いました。

「『いい感じに』って、具体的にどうやってるんだ?」

Serviceの実体は、Nginxのようなプロキシプロセスではありません。単なる仮想IP(ClusterIP)です。実体がないのに、どうやってパケットを振り分けているのでしょうか?ラウンドロビン?完全ランダム?状態は持っているのか?

気になったので、Minikube環境で iptables の設定を覗き、さらにKubernetesのOSSコード(Go言語)を読んで、そのロジックを 「裏取り」 してみました。

本記事では、k8sのネットワークの深淵である kube-proxyiptables の確率計算ロジックについて解説します。

1. iptablesの確認

まずは手元のMinikube環境で、実際にどのような設定が投入されているかを確認しました。
k8sのService(ClusterIP)への通信は、各ノード上で稼働する kube-proxy によって iptables のルールとして書き込まれます。

・検証環境

  • Minikube (Docker driver)
  • Kubernetes v1.34.0

minikubeのstart

minikube start
  1. まず、実験体3つのPodを持つDeploymentを用意する。
kubectl create deployment nginx-demo --image=nginx --replicas=3

Serviceを作ってIPを割り当てる

kubectl expose deployment nginx-demo --port=80
  1. そのServiceの「チェーン名」を探す
    Minikubeの中に入って、今作った nginx-demo のチェーン名(ハッシュ化された名前)を探す。
minikube ssh

中に入ったらチェーン名の確認。

sudo iptables -t nat -L KUBE-SERVICES | grep nginx-demo
KUBE-SVC-XXXXXXXXXXXXXXXX  tcp  --  anywhere             10.100.75.1          /* default/nginx-demo cluster IP */

判明した名前を元にiptablesが見られるはずです。

sudo iptables -t nat -L KUBE-SVC-XXXXXXXXXXXXXXXX # ↑で確認した名前(追記:なぜかぼかしましたが,ただのハッシュ化なので一意だったかもしれないです...)
Chain KUBE-SVC-XXXXXXXXXXXXXXXX (1 references)
target prot opt source destination
KUBE-SEP-A ... -m statistic --mode random --probability 0.3333333333
KUBE-SEP-B ... -m statistic --mode random --probability 0.5000000000
KUBE-SEP-C ...

--mode random --probability 0.333...

ここに答えがありました。k8sは iptablesの statistic モジュールを使い、確率的にパケットをDNAT(宛先書き換え)していたのです。

しかし、ここで新たな疑問が湧きます。 バックエンドのPodが3台ある場合、単純に考えれば「全員 33%」で良さそうなものです。なぜ2つ目のルールは 「0.5(50%)」 なのでしょうか?

2. ソースコード:Goで書かれた確率計算

この謎を解くために、GitHub上の kubernetes/kubernetes リポジトリを調査しました。 kube-proxy の実装である pkg/proxy/iptables/proxier.go 付近を捜索すると、まさにその数字を作り出している関数を発見しました。

https://github.com/kubernetes/kubernetes/blob/master/pkg/proxy/iptables/proxier.go

computeProbability関数

// kubernetes/pkg/proxy/iptables/proxier.go (※バージョンにより行数は異なります)

func computeProbability(n int) string {
    return fmt.Sprintf("%0.10f", 1.0/float64(n))
}

たった1行のシンプルな関数ですが、ここに全てのロジックが詰まっています。 引数 n は 「残りのエンドポイント(Pod)数」 です。計算式は 1.0 / n。

つまり、Podが3台ある場合 (Pod A, Pod B, Pod C)、iptablesのルールは上から順に評価されるため、以下のようなロジックで生成されていたのです。

「1/n」の確率連鎖ロジック
Pod A (残り3台):
計算: 1/3 = 33.3%
iptables: 33%の確率でPod Aへ。外れたら次へ(残りパケットは全体の67%)。

Pod B (残り2台):
計算: 1/2 = 50.0%
iptables: 「残ったパケットのうち」 50%の確率でPod Bへ。
全体から見ると 67% * 50% = 33.3% となり、公平になる。

Pod C (残り1台):
計算: 1/1 = 100%
iptables: 残ったパケットを全て回収。

3. なぜこの実装なのか?

この実装から、Kubernetesの設計思想が見えてきます。

  1. ステートレスであること(Stateless)
    もし「ラウンドロビン(順番)」を実装しようとすると、「前回どこに送ったか」という 状態(State/Counter) を保持する必要があります。これは高速なパケット処理においてはロック競合の原因となり、パフォーマンスのボトルネックになります。

「確率(サイコロ)」であれば、過去の記憶は不要です。各パケットに対して独立してサイコロを振るだけで良いため、計算量は O(1) に近く、極めて高速です。(※ただしiptablesのルール評価自体はO(n)なので、Pod数が増えすぎるとIPVSモードが必要になります)

  1. 計算コストの削減とカーネルへの委譲
    コードをさらに読み進めると、この確率計算すらキャッシュしている箇所がありました。
func (proxier *Proxier) precomputeProbabilities(numberOfPrecomputed int) {
    // ...
    for i := len(proxier.precomputedProbabilities); i <= numberOfPrecomputed; i++ {
        proxier.precomputedProbabilities = append(proxier.precomputedProbabilities, computeProbability(i))
    }
}

毎回割り算をするCPUコストすら惜しみ、あらかじめ計算済みの文字列("0.3333333333")を配列に保持しています。この徹底した最適化には美しさすら感じます。

そして、このキャッシュされた確率は、最終的に以下のコードで Linuxカーネルの機能(statisticモジュール) へと引き渡されます。

if i < (numEndpoints - 1) {
    // Each rule is a probabilistic match.
    args = append(args,
        "-m", "statistic",
        "--mode", "random",
        "--probability", proxier.probability(numEndpoints-i))
}
// The final (or only if n == 1) rule is a guaranteed match.
natRules.Write(args, "-j", string(epInfo.ChainName))

つまり、Kubernetes(Go言語)側では「確率の計算(設定値の作成)」までを行い、パケットごとの「実際の抽選」は statistic モジュールを通じてLinuxカーネルに完全委任しているのです。これにより、アプリケーション層でのオーバーヘッドを回避し、カーネルレベルの高速な負荷分散を実現しています。

まとめ

k8sのような複雑で高度に思えるようなものも突き詰めればシンプルな計算に収束しました。

・Serviceによる負荷分散
実体: iptables の statistic モジュールによるDNATルール。
ロジック: 1/n の確率計算による連鎖的なフィルター。
思想: 状態を持たない(Stateless)ことで、高速かつシンプルな分散を実現している。

なぜこうなっている?
もしGoのcomputeProbability関数の中で rand.Float64() とかを使って「サイコロを振る」実装にしていたら、全パケットを一度Goのアプリまで吸い上げないといけない。それだと遅すぎるから、「確率(ルール)」だけを計算して、あとはカーネルに丸投げ。

普段何気なく使っている Service ですが、その裏側にはLinuxカーネルの機能を極限まで使い倒す工夫と、Go言語による泥臭い実装が隠れていました。

Service (L4): カーネル(iptables)レベルでパケットを確率的に散らすだけで爆速でステートレス。
Ingress (L7): NginxなどのアプリケーションがHTTPの中身を見て、「このCookieの人はPod Aへ」といった高度な判断を行い重厚だが賢い。

このように、「単純な仕事はカーネルに(Service)」、「複雑な仕事はアプリに(Ingress)」 と役割を明確に分けているのも、k8sのアーキテクチャの美しい点なのかもしれません。

今回のようなOSカーネルに近い下位レイヤーの知識習得は根源的に揺るがない数学や物理法則を学ぶことに似ているように思えてなりません。GenAIの台頭によりOSSのコードリーディングが驚くほど容易になったのでエージェントとして使うことのほか、人間がより深い技術の理解をする手助けをしてもらうような使い方が初学者にとっては追い風になりうると思います。

GitHubで編集を提案

Discussion