Serviceとkube-proxy
実装:
orchestration/service// 実行:go test ./orchestration/service/
Pod は消えては生まれ、そのたびに IP が変わる。呼ぶ側が相手の IP を覚えるやり方は成り立たない。だから変わらない宛先を1つ用意する。それが Service だが、その仮想 IP に対応する実体はどこにもない。あるのは各ノードに配られた書き換えルールだけで、振り分けはパケットが出ていく瞬間にノードの中で起こる。配り終えるまでの遅れが、あの落ちるリクエストの正体になる。
この章で作るもの
ここまで、Pod は簡単に消えては生まれるものとして扱ってきた。調整ループが落ちたぶんを作り直し、ローリング更新が入れ替え、Cluster Autoscalerが集約のために移す。そのたびに、Pod の IP は変わる。
すると、呼ぶ側が困る。相手の IP を覚えて呼ぶ、という素朴なやり方が成り立たない。覚えた瞬間から古くなる。だから、変わらない宛先を1つ用意する。それが Service で、ClusterIP という仮想の IP を持つ。呼ぶ側はその IP だけを知っていればよく、後ろで Pod が何度入れ替わっても影響を受けない。
ここまでは、素直な間接参照の話に見える。面白いのはその先で、この仮想 IP に対応する実体がどこにも無いことだ。
制御側(あるべき宛先) 各ノード(実際のルール)
Service web node-a: 10.0.0.1 → 10.1.0.1
ClusterIP 10.0.0.1 10.0.0.1 → 10.1.0.2
selector app=web
│ ラベルが合い node-b: 10.0.0.1 → 10.1.0.1
│ ready な Pod 10.0.0.1 → 10.1.0.2
▼
10.1.0.1 / 10.1.0.2 ← 配る(遅れがある) ←┘
node-a から 10.0.0.1 宛にパケットを出す
→ node-a 自身のルールで 10.1.0.1 に書き換わって出ていく
→ 10.0.0.1 で待ち受けている者はどこにも居ない順に見ていく。
- 仮想 IP に実体はない: 誰もその IP で待ち受けていない。ルールが宛先を書き換えるだけ
- 振り分けはノードの中で完結する: 中央に集約点がないので、そこが混んだり落ちたりしない
- ルールの配布には遅れがある: 制御側が決めた瞬間には変わらない。この遅れが事故の窓になる
① セレクタで宛先を集める
まず、何を宛先にするかを決める部分から作る:
// Endpoints は制御側から見た宛先一覧を返す。セレクタのラベルが合い、かつ
// ready な Pod の IP を名前順に並べたもの。
//
// ここは「あるべき宛先」であって、各ノードが今持っているルールとは別物になる。
// この2つがずれている間が、パケットの落ちる窓になる。
func (c *Cluster) Endpoints(svcName string) []string {
s, ok := c.svcs[svcName]
if !ok {
return nil
}
var names []string
for name, p := range c.pods {
if p.Ready && p.matches(s.selector) {
names = append(names, name)
}
}
sort.Strings(names)
ips := make([]string, 0, len(names))
for _, n := range names {
ips = append(ips, c.pods[n].IP)
}
return ips
}
// publish は今あるべき宛先を計算し、各ノードへの配布を予定する。
// 反映は Propagation のぶん先になる。制御側が決めた瞬間には変わらない。
func (c *Cluster) publish() {
for _, s := range c.svcs {
want := c.Endpoints(s.Name)
for _, n := range c.nodes {
c.queue = append(c.queue, delivery{
at: c.now + c.cfg.Propagation, node: n, clusterIP: s.ClusterIP, backends: want,
})
}
}
}Endpoints は、セレクタのラベルが合い、かつ ready な Pod の IP を返す。Pod の名前を1つずつ登録するのではなく、条件を書いて集める形になっているのが大事なところだ。Pod が入れ替わっても、新しい Pod が同じラベルを持っていれば自動的に宛先に加わる。誰かが登録し直す必要がない。
ready を条件に含めているので、ヘルスチェックの readiness がそのままここに効く。readiness を落とすと宛先から外れ、戻すと復帰する。前章で「転送先から外すだけ」と言っていたのは、この一覧から抜けることを指していた。テストで、ラベルが違う Pod と ready でない Pod が宛先に入らないことを固定した。
ただし、これは制御側から見た「あるべき宛先」でしかない。実際にパケットの行き先を決めているのは、この一覧ではない。
② 宛先を書き換えるのは各ノード
実際に振り分けを行うのは、ノードごとに置かれたルールだ:
// Node は1台のマシン。kube-proxy が書いたルールを持つ。
//
// rules が「この仮想 IP 宛は、この実 IP のどれかへ書き換えよ」という表になる。
// これがノードごとに存在することが肝で、振り分けはパケットが出ていく
// ノードの中で完結する。中央に集約点がない。
type Node struct {
Name string
rules map[string][]string // ClusterIP → 実 IP の並び
rr map[string]int // 振り分けの順番(ノードごとに独立)
}
// NewNode はルールを持たないノードを作る。
func NewNode(name string) *Node {
return &Node{Name: name, rules: map[string][]string{}, rr: map[string]int{}}
}
// Rules は clusterIP 宛のルール(実 IP の並び)を返す。
func (n *Node) Rules(clusterIP string) []string {
return append([]string(nil), n.rules[clusterIP]...)
}
// RuleCount はこのノードが持つルールの本数を返す。
// 実物の iptables 方式では、この本数が Service と Pod の積で増えていく。
func (n *Node) RuleCount() int {
c := 0
for _, ips := range n.rules {
c += len(ips)
}
return c
}
// Route は clusterIP 宛のパケットの宛先を、このノードのルールから選ぶ。
// ルールが無ければ書き換えようがないので、パケットは行き場を失う。
func (n *Node) Route(clusterIP string) (string, bool) {
ips := n.rules[clusterIP]
if len(ips) == 0 {
return "", false
}
ip := ips[n.rr[clusterIP]%len(ips)]
n.rr[clusterIP]++
return ip, true
}Node が持つ rules が、「この仮想 IP 宛は、この実 IP のどれかへ書き換えよ」という表になる。Route はその表から1つ選ぶ。これがノードごとに存在することが、この設計の骨格になっている。
素朴に考えると、仮想 IP の後ろにロードバランサを1台置きたくなる。だがそうすると、全通信がそこを通る。混めば全体が遅くなり、落ちれば全体が止まる。Kubernetes はそうせず、書き換えのルールを全ノードに配ってしまう。パケットは、出ていくノードの中で宛先を書き換えられて、そのまま相手へ直行する。経由するものが何もない。テストで、順番を数えるカウンタもノードごとに独立していて、選び方がノード間でずれることを固定した。ずれてよい。全体として散っていればよく、足並みを揃える必要がない。
RuleCount を用意したのは、この方式の代償を見るためだ。ルールは Service の数と宛先の数の積で増える。Service が 1000 個、それぞれ 10 個の Pod を持てば、各ノードに 1 万本のルールが並ぶ。実物の iptables 方式はルールを上から順に見ていくので、この規模になると更新も評価も重くなる。これは実際に問題になり、専用の表引きを使う IPVS 方式が後から用意された。振り分けの結果は同じで、違うのは規模が大きくなったときの重さだけになる。
③ 配布の遅れが事故の窓になる
ルールが各ノードへ配られるものである以上、配り終えるまでの間がある:
// Tick は時刻を1つ進め、配布の時刻に達したルールを各ノードへ書き込む。
func (c *Cluster) Tick() {
c.now++
var rest []delivery
for _, d := range c.queue {
if c.now < d.at {
rest = append(rest, d)
continue
}
d.node.rules[d.clusterIP] = d.backends
if len(d.backends) == 0 {
delete(d.node.rules, d.clusterIP)
}
}
c.queue = rest
}
// Send は node から clusterIP 宛にパケットを1つ出す。
//
// 宛先を決めるのは、制御側の一覧ではなく、そのノードが今持っているルールだ。
// ルールが古ければ、もう受けられない相手へ書き換えられる。届かない。
func (c *Cluster) Send(node *Node, clusterIP string) bool {
ip, ok := node.Route(clusterIP)
if !ok {
c.Dropped++
c.logf(node.Name + " に " + clusterIP + " のルールがない。行き場を失う")
return false
}
if p := c.podByIP(ip); p == nil || !p.Ready {
c.Blackholed++
c.logf(node.Name + " のルールが " + ip + " を指しているが、そこはもう受けられない")
return false
}
c.Sent++
return true
}Tick が、予定の時刻に達したルールを各ノードへ書き込む。それまで、各ノードは古いルールで振り分け続ける。Send が宛先を決めるのに使うのは、制御側の一覧ではなく、そのノードが今持っているルールだ。
だから、Pod を消しても、ルールが配り終わるまでは、その Pod の IP がルールに残っている。その間に出したパケットは、もう居ない相手へ書き換えられる。届かない。テストで、削除の直後に出したパケットが消えた宛先へ飛び、配り終えた後は飛ばなくなることを固定した。
これが、終了処理の章で Propagation と呼んでいたものの正体になる。あの章では「転送先一覧は制御側の持ち物で、現実に遅れる」と説明したが、なぜ遅れるのかは踏み込まなかった。答えは、一覧が1箇所にあるのではなく、全ノードに配られるものだからだ。配る相手が増えるほど、行き渡るまでの時間は延びる。そして、こちらから短くする手段はない。だから preStop で待つ、という対処になっていた。
動かす
下のデモは、制御側の見ている宛先と、各ノードに配られたルールを並べて見る。「Podを消す」を押してから周期を進めずにパケットを送ると、まだ古いルールが残っているので、消えたはずの宛先へ飛ぶ。周期を進めてルールが行き渡ると、飛ばなくなる。Pod をクリックして ready を落としても、同じように遅れて反映される。
左が制御側の見ている宛先、右が各ノードに実際に配られたルール。仮想 IP で待ち受けている者はどこにもおらず、 パケットが出ていく瞬間に、そのノードのルールで宛先が書き換わる。「Podを消す」を押してから周期を進めずに パケットを送ると、まだ古いルールが残っているので、消えたはずの宛先へ飛ぶ。これが終了処理の章で 「転送先一覧が現実に遅れる」と呼んだものの正体になる。
設計の観点
- 間接参照が変化を吸収する: 変わるもの(Pod の IP)と、変わらないもの(ClusterIP)を分ける。呼ぶ側は変わらない側だけを知る。名前解決やポインタと同じ、ごく古い手筋がここでも効いている
- 集約点を作らない: 全ノードにルールを配ると、通信の途中に何も挟まらない。混雑も単一障害点も生まれない。代わりに、配る手間と遅れを引き受ける
- 条件で集める: 宛先を名前で登録すると、入れ替わるたびに登録し直しが要る。ラベルの条件で集めれば、新しい Pod が勝手に加わる。調整ループが現状を数え直すのと同じ発想
- 遅れは消せない、隠すしかない: 配布の遅れは分散である以上どうしても残る。だから preStop のように、遅れを吸収する側で対処する
- 規模が方式を変える: iptables 方式は素直だが、ルールが積で増える。IPVS はそこを表引きに置き換える。振る舞いは同じで、変わるのは規模への耐性だけ
- DNS はさらに上の層: 実際には
webという名前を DNS が ClusterIP に解決する。名前 → 仮想 IP → 実 IP と、間接参照が二段になっている
対照と実例
| 中央にロードバランサを置く | ノードにルールを配る | |
|---|---|---|
| 通信の経路 | 必ず経由する | 直行する |
| 混雑 | そこが詰まる | 詰まる場所がない |
| 障害 | 落ちると全滅 | ノードごとに独立 |
| 更新 | 1箇所を直す | 全ノードへ配る |
| 代償 | 単一障害点 | 配布の遅れ |
裏どり:
- Service と ClusterIP: 仮想 IP には誰も待ち受けておらず、各ノードのルールで書き換えられること
- kube-proxy の iptables モード: 確率で分岐するルールの連鎖として実装され、ルール数が Service と宛先の積で増えること
- IPVS モード: 表引きで振り分けることで、規模が大きいときの更新と評価の重さを避ける
- EndpointSlice: 宛先一覧を分割して配ることで、1つの Service に宛先が多いときの更新量を抑える仕組み
簡略化したこと
- ポート番号なし: 実物は Service のポートと Pod のポートを対応づける。ここは宛先 IP だけ
- 振り分けは順番: 実物の iptables 方式は確率で選ぶ。決定性を優先して順番にした
- セッションアフィニティなし: 同じ相手を同じ Pod へ固定する設定は扱わない
- ClusterIP のみ: NodePort / LoadBalancer / Headless は扱わない
- DNS なし: 名前から ClusterIP への解決は扱わない
- 論理時刻: 実時間でなく周期で数える
参考資料
- Service — ClusterIP とセレクタ
- Virtual IPs and Service Proxies — kube-proxy の各モードと、ルールの形
- EndpointSlices — 宛先一覧を分割して配る
- 実装: orchestration/service