Skip to content

調整ループ(reconciliation)

実装: orchestration/reconcile/ / 実行: go test ./orchestration/reconcile/

Kubernetesは宣言で動く。「Podは3個であれ」と宣言すると、コントローラが現状と見比べて差を埋める。足りなければ作り、多ければ消す。これが調整ループだ。支えているのは宣言的であることと、level-triggeredであることの2つ。イベントに反応せず毎回現状を数え直すので、取りこぼしても必ず追いつく。実装すると、作成・スケール・障害回復を1つのループが等しく扱うことを確かめられる。

この章で作るもの

コンテナで namespace と cgroup による 1 プロセスの隔離を作った。だが本番では、コンテナを何十何百と、複数のマシンに散らして動かし、落ちたら立て直し、負荷に応じて増減させる必要がある。これを担うのがコンテナオーケストレータで、その代表が Kubernetes だ。Kubernetes には多くの部品があるが、その全体を貫く 1 つの発想がある。調整ループ(reconciliation loop)だ。この章は、まずそこから作る。

普通のシステム管理は手続き的だ。「Pod を 1 個起動する」「もう 1 個起動する」と、やることを順に命じる。Kubernetes は逆で、宣言的だ。人が書くのは「あるべき状態(desired state)は Pod 3 個」という状態だけ。どうやって 3 個にするかは書かない。その差を埋めるのがコントローラで、宣言された desired state と、今まさにある observed state を、ひたすら見比べる。3 個であるべきなのに 2 個しかなければ 1 個作る。4 個あれば 1 個消す。この「見比べて差を埋める」を繰り返すのが調整ループだ。作成も、スケールも、障害からの回復も、すべてこの 1 つのループが処理する。

   desired = 3          observed を数える
       │                     │
       ▼                     ▼
   ┌── 見比べる ──┐   足りない → 作る
   │ desired vs   │   多い     → 消す
   │ observed     │   同じ     → 何もしない(冪等)
   └──────┬───────┘

   observed が変わる → また見比べる(ループ)
調整ループ。desired(あるべき数)と observed(今ある数)を毎回見比べ、差を埋める操作を出す。作成・スケール・自己修復がすべて同じ処理に集約される

順に見ていく。

  1. 宣言的: 「3 個であってほしい」という状態を宣言する。手順(どう 3 個にするか)は書かない
  2. level-triggered: イベントに反応せず、毎回まるごと現状を数え直して差を埋める。取りこぼしに強い
  3. 1 ループが全部やる: 作成・スケール・障害回復が、同じ「差を数えて埋める」処理に集約される

① observed state: 今ある状態

まず、調整の対象になる世界を作る。クラスタは Pod の集まりだ。Pod は Kubernetes の実行単位で、1つ以上のコンテナをひとまとめにして起動・停止する箱になる。各 Pod は状態(Pending / Running / Failed)を持つ。これが observed state、つまり「今まさにある状態」だ:

go

// Phase は Pod のライフサイクル状態。
type Phase int

const (
	Pending Phase = iota // 作られたがまだ起動していない
	Running              // 起動して稼働中
	Failed               // 落ちた(ノード障害・クラッシュ)
)

func (p Phase) String() string {
	switch p {
	case Pending:
		return "Pending"
	case Running:
		return "Running"
	case Failed:
		return "Failed"
	}
	return "Unknown"
}

// Pod は 1 つのワークロード実体。observed state を構成する。
type Pod struct {
	Name  string
	Phase Phase
}

// Cluster は実際にある状態(observed state)を保持する世界。
type Cluster struct {
	pods map[string]*Pod
	seq  int
}

// NewCluster は空のクラスタを作る。
func NewCluster() *Cluster { return &Cluster{pods: map[string]*Pod{}} }

// create は Pending の Pod を 1 つ足し、その名前を返す(連番で決定的)。
func (c *Cluster) create() string {
	c.seq++
	name := "pod-" + itoa(c.seq)
	c.pods[name] = &Pod{Name: name, Phase: Pending}
	return name
}

// delete は Pod を消す。
func (c *Cluster) delete(name string) { delete(c.pods, name) }

// Pods は名前順に Pod 一覧を返す(観測用・決定的)。
func (c *Cluster) Pods() []Pod {
	names := make([]string, 0, len(c.pods))
	for n := range c.pods {
		names = append(names, n)
	}
	sort.Strings(names)
	out := make([]Pod, len(names))
	for i, n := range names {
		out[i] = *c.pods[n]
	}
	return out
}

// StartPending は Pending の Pod を Running にする(スケジュール後の起動を模す)。
func (c *Cluster) StartPending() {
	for _, p := range c.pods {
		if p.Phase == Pending {
			p.Phase = Running
		}
	}
}

// Fail は name の Pod を Failed にする(ノード障害・クラッシュ)。
func (c *Cluster) Fail(name string) {
	if p, ok := c.pods[name]; ok {
		p.Phase = Failed
	}
}

// alive は生きている(Pending か Running の)Pod 名を名前順で返す。Failed は死。
func (c *Cluster) alive() []string {
	var names []string
	for n, p := range c.pods {
		if p.Phase != Failed {
			names = append(names, n)
		}
	}
	sort.Strings(names)
	return names
}

Pod は作られるとまず Pending(起動待ち)になり、起動すると Running になる。ノードの障害やクラッシュで Failed になる。alive が「生きている Pod」を Pending + Running と数え、Failed を死んだものとして除くのが後で効く。この Cluster は、Kubernetes で言えば API サーバが保持する実際の状態にあたる。コントローラはこの状態を観測し、desired state と突き合わせる。ここでは観測を単純に「今ある Pod を数える」こととした。

② Reconcile: 差を数えて埋める

調整の本体だ。desired(あるべき数)と observed(生きている数)を見比べ、差を埋める操作を出す。足りなければ作り、多ければ消す:

go

// Action は調整で起こした操作(観測・説明用)。
type Action struct {
	Kind string // "create" / "delete"
	Pod  string
}

// Controller は「Pod を desired 個に保つ」責務を持つ(ReplicaSet 相当)。
type Controller struct{ desired int }

// New は目標レプリカ数 desired のコントローラを作る。
func New(desired int) *Controller {
	if desired < 0 {
		desired = 0
	}
	return &Controller{desired: desired}
}

// SetDesired は目標を宣言的に変更する(スケール)。手順でなく「あるべき数」を書く。
func (c *Controller) SetDesired(n int) {
	if n < 0 {
		n = 0
	}
	c.desired = n
}

// Desired は現在の目標数を返す。
func (c *Controller) Desired() int { return c.desired }

// Reconcile は observed state と desired state を見比べ、差を埋める操作を行う。
// この 1 回の呼び出しが、作成・スケール・障害回復のどれも等しく処理する。
// 何度呼んでも収束先は同じ(冪等)。イベントに反応せず、毎回現状を数え直す
// (level-triggered)ので、取りこぼしやコントローラの再起動に強い。
func (c *Controller) Reconcile(cl *Cluster) []Action {
	var actions []Action

	// まず死んだ Pod を掃除する(Failed は生きた数に数えないので、この後で
	// 作り直しの対象になる)。
	for _, p := range cl.Pods() {
		if p.Phase == Failed {
			cl.delete(p.Name)
			actions = append(actions, Action{Kind: "delete", Pod: p.Name})
		}
	}

	alive := cl.alive()
	switch {
	case len(alive) < c.desired:
		// 足りない。差のぶんだけ作る(障害回復もスケールアップもここ)。
		for i := 0; i < c.desired-len(alive); i++ {
			actions = append(actions, Action{Kind: "create", Pod: cl.create()})
		}
	case len(alive) > c.desired:
		// 多い。余りを消す(スケールダウン)。名前順で末尾から。
		excess := alive[c.desired:]
		for _, name := range excess {
			cl.delete(name)
			actions = append(actions, Action{Kind: "delete", Pod: name})
		}
	}
	// 差がなければ actions は空(冪等・何もしない)。
	return actions
}

// Converged は observed が desired に一致し、全 Pod が Running かを返す。
func (c *Controller) Converged(cl *Cluster) bool {
	running := 0
	for _, p := range cl.Pods() {
		if p.Phase == Failed {
			return false
		}
		if p.Phase == Running {
			running++
		}
	}
	return running == c.desired && len(cl.Pods()) == c.desired
}

Reconcile を読むと、やっていることは驚くほど単純だ。まず Failed な Pod を掃除する。次に生きている数を数え、desired より少なければ差のぶん作り、多ければ余りを消す。同じなら何もしない。この単純さが力になる。まず冪等だ。目標に達していれば何度呼んでも何も起こさない。テストで、収束後の Reconcile が no-op であることを固定した。だから調整ループは安心して回し続けられる。無駄に作ったり消したりしない。

そして、この 1 つの関数が 3 つの仕事を兼ねる。空から 3 個作るのも(作成)、目標を 2 から 5 に変えて 3 個足すのも(スケール)、落ちた Pod を数え直して作り直すのも(自己修復)、すべて「生きている数と desired の差を埋める」という同じ処理だ。障害回復のための特別なコードはどこにもない。Failed が生きた数に数えられないので、自然に作り直しの対象になる。テストで、Pod を落とすと次の Reconcile が作り直すこと、目標数を変えるだけで過不足が埋まることを固定した。

③ level-triggered: なぜイベント駆動でないのか

ここが調整ループの設計の核心だ。素朴には、コントローラを「Pod が死んだ」というイベントに反応させたくなる(edge-triggered)。だが Kubernetes はそうしない。コントローラは、イベントを聞くのでなく、毎回まるごと現状を数え直して desired と比べる(level-triggered)。

なぜか。edge-triggered は取りこぼしに弱い。「Pod が死んだ」イベントを 1 回逃したら、その死は永遠に修復されない。ネットワークの瞬断、コントローラの再起動、イベントの順序入れ替わり。分散システムでイベントを完璧に届けるのは難しく、必ずどこかで取りこぼす。level-triggered なら、イベントを何回逃しても関係ない。次の調整で現状を数え直し、desired と違えば直す。「今どうであるか」だけを見て、「何が起きたか」に依存しない。テストで、3 つの Pod をまとめて落とし、その間 1 度も Reconcile しなくても、たった 1 回の Reconcile が全部を復旧することを固定した。イベントを 3 回逃したのと同じ状況で、それでも収束する。

この堅牢さが、Kubernetes が「宣言した状態にいつか必ず収束する(eventually consistent)」と言える理由だ。コントローラが一時的に落ちても、復帰して次に調整すれば追いつく。宣言的 API と level-triggered な調整ループ。この 2 つが組み合わさって、自己修復するシステムができる。

動かす

下のデモは、目標レプリカ数と実際の Pod を並べて、調整ループが差を埋める様子を見る。Pod をわざと落としたり、目標数を変えたりして「調整」を押すと、同じループが自己修復もスケールもこなす。イベントを溜めてから 1 回調整しても追いつく level-triggered の強さも確かめてほしい。

デモ調整ループ(reconciliation)desired 3 / running 0
desired state(宣言)
3 レプリカ
vs
observed state(現状) — 0 Pod
(Pod なし)
差あり: 調整が必要
直前の調整の操作
(まだ調整していない)

人は「desired = 3」という状態だけを宣言する。調整ループは desired と現状を毎回見比べ、足りなければ作り、 多ければ消し、同じなら何もしない(冪等)。Pod を落としてから調整すると、同じループが作り直す(自己修復)。 複数まとめて落としてから 1 回調整しても、現状を数え直すので全部復旧する(level-triggered)。作成・スケール・ 障害回復が、すべてこの 1 つの「差を埋める」処理に集約されている。

設計の観点

  • 宣言的 API が土台: 手順でなく状態を宣言するから、コントローラは「今の状態から目標へ」を毎回計算できる。手続き的だと途中で失敗したとき、どこまで進んだかの復旧が難しい
  • level-triggered は分散の必然: イベントは取りこぼす前提で設計する。現状を数え直す方式なら、取りこぼしも重複も順序入れ替わりも吸収する。Kubernetes 全体がこの原則で貫かれている
  • 冪等な reconcile: 何度呼んでも安全だから、調整ループは頻繁に・重複して回せる。冪等でないと、二重に作ったり消したりする
  • コントローラは無数にある: ReplicaSet だけでなく、Deployment・Job・Service など、リソースごとに専用のコントローラが同じパターンで走る。Kubernetes は「調整ループの集合」だ
  • watch と informer: 実物は毎回全件を数えるのは重いので、変更を watch してキャッシュ(informer)を保ち、変わったものだけ reconcile を呼ぶ。だが reconcile 自体は常に「現状 vs 目標」で、level-triggered の性質は保たれる

対照と実例

手続き的 / edge-triggered宣言的 / level-triggered
人が書くもの手順(作れ・消せ)状態(3個であれ)
反応の起点イベント現状の数え直し
取りこぼし修復されない次の調整で追いつく
障害回復専用の処理が要る同じループが処理
素朴なスクリプトKubernetes コントローラ

裏どり:

  • Kubernetes: Controllers: 調整ループの公式説明。desired/observed の突き合わせと収束の考え方
  • level-triggered vs edge-triggered (James Bowes 他): Kubernetes が level-triggered を選んだ設計判断の解説。分散環境での堅牢性
  • Operator パターン: 調整ループを自作のリソースに広げる仕組み。同じパターンでアプリ固有の運用を自動化する
  • Reconciler (controller-runtime): 実装フレームワーク。Reconcile(req) (Result, error) の 1 メソッドが本章の Reconcile にあたる

簡略化したこと

  • ReplicaSet 相当のみ: Deployment の revision 管理は扱わない。入れ替えそのものは別章(ローリング更新)
  • 単一コントローラ: 実物は多数のコントローラが並行に走り、共有 API サーバの状態を watch する
  • watch でなく明示 Reconcile: 実物は informer が変更を検知して reconcile を呼ぶ。ここは手で呼ぶ
  • スケジューリングなし: Pod をどのノードに置くかは次章(スケジューラ)

参考資料