Skip to content

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 には誰も居ない。各ノードに配られたルールが、パケットの出ていく瞬間に宛先を書き換える

順に見ていく。

  1. 仮想 IP に実体はない: 誰もその IP で待ち受けていない。ルールが宛先を書き換えるだけ
  2. 振り分けはノードの中で完結する: 中央に集約点がないので、そこが混んだり落ちたりしない
  3. ルールの配布には遅れがある: 制御側が決めた瞬間には変わらない。この遅れが事故の窓になる

① セレクタで宛先を集める

まず、何を宛先にするかを決める部分から作る:

go

// 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 が宛先に入らないことを固定した。

ただし、これは制御側から見た「あるべき宛先」でしかない。実際にパケットの行き先を決めているのは、この一覧ではない。

② 宛先を書き換えるのは各ノード

実際に振り分けを行うのは、ノードごとに置かれたルールだ:

go

// 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 方式が後から用意された。振り分けの結果は同じで、違うのは規模が大きくなったときの重さだけになる。

③ 配布の遅れが事故の窓になる

ルールが各ノードへ配られるものである以上、配り終えるまでの間がある:

go

// 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 を落としても、同じように遅れて反映される。

デモServiceとkube-proxy届いた 0 / 死んだ宛先 0 / 行き場なし 0
t=4
ルールの配布には 3 周期かかる
制御側が見ている「あるべき宛先」
10.0.0.1ClusterIP(実体を持たない仮想の宛先)
web-1 10.1.0.1readyweb-2 10.1.0.2readyweb-3 10.1.0.3ready
Pod をクリックすると ready を切り替えられる
各ノードに配られたルール
node-a
10.0.0.1 → 10.1.0.110.0.0.1 → 10.1.0.210.0.0.1 → 10.1.0.3
node-b
10.0.0.1 → 10.1.0.110.0.0.1 → 10.1.0.210.0.0.1 → 10.1.0.3
ルールは配り終わっている。あるべき宛先と一致
起きたこと
(まだ何も起きていない)

左が制御側の見ている宛先、右が各ノードに実際に配られたルール。仮想 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 への解決は扱わない
  • 論理時刻: 実時間でなく周期で数える

参考資料