Skip to content

Operatorパターン

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

この編は調整ループから始まり、以降の章はどれもその変奏だった。ではこの形は、Kubernetes が用意した資源にしか使えないのか。答えは型を1つ足すだけで、その足し方が Operator パターンになる。面白いのは運用の手順そのものを対象にできることだ。手順書を状態の宣言に書き換えると、順序は「まだ埋まっていない差」の判定として現れ、手順が消えてループだけが残る。

この章で作るもの

この編は調整ループから始まった。「Pod は3個であれ」と宣言すれば、コントローラが現状と見比べて差を埋める。以降の章で作ってきたものは、どれもこの形の変奏だった。スケジューラは置き場所の差を、オートスケーラは数の差を、ローリング更新は版の差を埋めていた。

ここで一段上がる。この形は、Kubernetes があらかじめ用意した資源にしか使えないのか。使えるとしたら、何が要るのか。

答えは「型を1つ足すだけ」になる。あるべき姿(Spec)と今の姿(Status)を持つ型を宣言し、その差を埋めるループを書く。それだけで、調整ループが持っていた性質が、そのまま自分のドメインに対して手に入る。宣言的であること、level-triggered であること、冪等であること、自己修復すること。この足し方が Operator パターンだ。

面白いのは、この仕組みが運用の手順そのものを対象にできることだ。「バックアップから復元して、レプリカを繋ぎ直して、リーダーを選び直す」といった手順書は、状態として書き直せる。「復元済みで、メンバーが3台立っていて、リーダーが1台いる」。あとは差を埋める処理を書けば、手順は消えて状態だけが残る。人が真夜中に手順書を追う代わりに、ループが回る。

  手順書として書くと            状態として書くと

  1. バックアップから復元       Spec:  members: 3
  2. メンバーを3台立てる               restoreFrom: backup-07-28
  3. 全部が起動するのを待つ
  4. リーダーを選ぶ             Status: restored / members / leader
  5. 失敗したら手順1から?
                                差を1つ埋める:
  途中で落ちたらどこから?         復元済みでない → 復元する
  すでに2台居たら?                 メンバーが足りない → 1つ作る
  リーダーが落ちたら?              リーダーが居ない → 選ぶ
                                 差が無い → 何もしない
手順を状態に書き換える。順序は手順としてでなく、まだ埋まっていない差の判定として現れる

順に見ていく。

  1. Spec と Status を分ける: 人が書く「あるべき姿」と、コントローラが書き戻す「今の姿」
  2. 手順は差の判定になる: 順序は if の並び順として現れ、手順としては書かれない
  3. 1回に1手だけ: 途中で落ちても、次の呼び出しが現状から再開する

① Spec と Status を分ける

まず、自作の型を作る:

go

// Phase は自作リソースの状態。宣言された姿にどこまで近づいたかを表す。
type Phase int

const (
	Pending   Phase = iota // まだ何も作られていない
	Creating               // 部品を作っている途中
	Restoring              // バックアップから復元している
	Ready                  // 宣言どおりに揃った
	Degraded               // 揃っていたが崩れた
)

func (p Phase) String() string {
	switch p {
	case Pending:
		return "Pending"
	case Creating:
		return "Creating"
	case Restoring:
		return "Restoring"
	case Ready:
		return "Ready"
	case Degraded:
		return "Degraded"
	}
	return "Unknown"
}

// Spec は人が書く「あるべき姿」。手順ではなく状態だけを書く。
type Spec struct {
	Name string
	// Members はクラスタに欲しいメンバー数。
	Members int
	// RestoreFrom が空でなければ、そのバックアップから復元してから始める。
	RestoreFrom string
}

// Status はコントローラが観測して書き戻す「今の姿」。人は書かない。
type Status struct {
	Phase    Phase
	Members  int    // 実際に立ち上がったメンバー数
	Leader   string // 選ばれたリーダー(空なら未選出)
	Restored bool   // 復元が済んだか
}

// Resource は自作の型。Spec(あるべき姿)と Status(今の姿)を持つ。
// この2つを分けることが、宣言的な仕組みの最小の要件になる。
type Resource struct {
	Spec   Spec
	Status Status
}

Spec は人が書く。Status はコントローラが書き戻す。この2つを分けることが、宣言的な仕組みの最小の要件になる。分けないと、「こうしたい」と「こうなっている」が同じ場所に混ざり、どちらが真かが分からなくなる。

Spec に手順が一切書かれていないことにも注目してほしい。RestoreFrom は「復元せよ」という命令ではなく、「このバックアップの内容を持っている状態であれ」という宣言になる。だから2度書いても2度復元されないし、途中で落ちても書き直す必要がない。

Phase は Status 側にあり、宣言された姿にどこまで近づいたかを表す。人はここを書かない。書けたとしても意味がない。現実がどうなっているかは、観測して初めて分かるものだからだ。

② 手順が差の判定になる

調整の本体は、この編の1章目とほとんど同じ形になる:

go

// Action は1回の調整で打った手(観測・説明用)。
type Action struct {
	Kind   string // "restore" / "create" / "elect" / "noop"
	Target string
}

// Operator は自作リソースを現実へ寄せるコントローラ。
//
// 中身は調整ループそのもので、違うのは扱う型だけになる。Pod の数を数える
// 代わりに、復元が済んだか、メンバーが揃ったか、リーダーが居るかを数える。
// 差を埋める手順は、順序のある運用の手順そのものだが、書き方は「今どの差が
// 残っているか」の判定に変わっている。
type Operator struct {
	Log []string
}

// New はコントローラを作る。
func New() *Operator { return &Operator{} }

// Reconcile は宣言と現実を見比べ、差を1つ埋める。
//
// 1回の呼び出しで1手しか打たないのが肝になる。全部を一気にやろうとすると、
// 途中で失敗したときにどこまで進んだかが分からなくなる。1手ずつ打って
// 状態を書き戻せば、次の呼び出しは必ず現状から再開できる。何度呼んでも
// 安全で、途中で落ちても続きから進む。
func (o *Operator) Reconcile(r *Resource, w *World) Action {
	// ① 復元が指定されていて、まだ済んでいないなら、まず復元する。
	// 順序のある手順は、こうして「まだ済んでいない差」として表現できる。
	if r.Spec.RestoreFrom != "" && !r.Status.Restored {
		if !w.backups[r.Spec.RestoreFrom] {
			r.Status.Phase = Degraded
			o.logf("復元元 " + r.Spec.RestoreFrom + " が見つからない")
			return Action{Kind: "noop"}
		}
		r.Status.Restored = true
		r.Status.Phase = Restoring
		o.logf(r.Spec.RestoreFrom + " から復元した")
		return Action{Kind: "restore", Target: r.Spec.RestoreFrom}
	}

	// ② メンバーが足りなければ1つ作る。
	ready := w.readyMembers()
	r.Status.Members = len(ready)
	if len(w.Members()) < r.Spec.Members {
		m := w.create(r.Spec.Name)
		r.Status.Phase = Creating
		o.logf(m.Name + " を作成")
		return Action{Kind: "create", Target: m.Name}
	}

	// ③ 全員が立ち上がるまでは、まだ次へ進まない。
	if len(ready) < r.Spec.Members {
		r.Status.Phase = Creating
		return Action{Kind: "noop"}
	}

	// ④ リーダーが居ないか、居たはずの者が消えていれば選び直す。
	if r.Status.Leader == "" || !o.alive(w, r.Status.Leader) {
		leader := ready[0].Name // 名前順で決定的に選ぶ
		if r.Status.Leader != "" {
			o.logf("リーダー " + r.Status.Leader + " が居なくなった")
		}
		r.Status.Leader = leader
		r.Status.Phase = Creating
		o.logf(leader + " をリーダーに選出")
		return Action{Kind: "elect", Target: leader}
	}

	// ⑤ 差が無い。宣言どおりに揃っている。
	r.Status.Phase = Ready
	return Action{Kind: "noop"}
}

// Run は差が無くなるまで最大 max 回まわす。
func (o *Operator) Run(r *Resource, w *World, max int) {
	for i := 0; i < max; i++ {
		act := o.Reconcile(r, w)
		w.StartPending() // 作ったメンバーが立ち上がる
		if act.Kind == "noop" && r.Status.Phase == Ready {
			return
		}
	}
}

func (o *Operator) alive(w *World, name string) bool {
	m, ok := w.members[name]
	return ok && m.Ready
}

func (o *Operator) logf(msg string) { o.Log = append(o.Log, msg) }

Reconcile は上から順に「この差はまだ残っているか」を見ていく。復元は済んだか。メンバーは足りているか。全員立ち上がったか。リーダーは居るか。埋めるべき差が見つかったら、それを1つ埋めて戻る。

ここで、順序が手順として書かれていないことが効いてくる。「復元してからメンバーを作る」という順序は、復元の判定を先に置いたことで自然に出る。復元が済んでいなければ、そこで戻るのでメンバーは作られない。順序を守るためのフラグや状態機械は要らない。テストで、復元が済むまでメンバーが作られないことを固定した。

この書き方の効き目は、途中で落ちたときに出る。手順書なら「どこまで進んだか」を記録し、再開位置を判断する必要がある。ここでは要らない。次の呼び出しがまた上から差を見ていくので、済んでいる差は飛ばされ、残っている差から再開される。何度呼んでも安全で、揃っていれば何も起こらない。テストで、揃った後の調整が何もしないことと、復元が二度と繰り返されないことを固定した。

自己修復も、書かなくても付いてくる。リーダーが落ちたとき、リーダー障害を検知する処理はどこにも無い。だが次の調整が「リーダーは居るか」を見たとき、居ないので選び直す。落ちたメンバーも同じで、数が足りないので作られる。テストで、全メンバーが落ちた状態からも揃うことを固定した。

③ 1回に1手だけ打つ

Reconcile が1回に1つの差しか埋めないのは、意図的な制約になる。全部を一気にやろうとすると、途中で失敗したときにどこまで進んだかが分からなくなる。1手打って状態を書き戻せば、次の呼び出しは必ず現状から再開できる。テストで、1回の呼び出しでメンバーが1つしか作られないことを固定した。

これは効率が悪いように見える。3台作るのに3回まわす必要がある。だが、この編を通して繰り返し出てきた形でもある。調整ループも、ローリング更新も、StatefulSetも、1周期に1手しか打たなかった。速く終わらせることより、いつ中断されても壊れないことを優先している。

差を埋められないときの扱いも決めておく。復元元が見つからなければ、Degraded にして先へ進まない。ここで無理にメンバーだけ作ると、空のクラスタが立ち上がってしまう。埋められない差があるなら、そこで止まって状態に書き残す。人が見て気づけるようにするのが、この段でできることになる。テストで、復元元が無いときにメンバーが作られないことを固定した。

動かす

下のデモは、Spec と Status を並べて、1回ずつ差が埋まる様子を見る。復元、メンバー作成、リーダー選出の順に進むが、その順序はコードのどこにも手順として書かれていない。リーダーを落とすと、次の調整が選び直す。バックアップを「見つからない」に切り替えると、そこで止まって Degraded になる。

デモOperatorパターンPending ・ 0/3 メンバー
欲しいメンバー数234バックアップある見つからない
Spec — 人が書く「あるべき姿」
members3
restoreFrombackup-2026-07-28
手順は書かない。状態だけを書く
Status — コントローラが書き戻す「今の姿」
phasePending
members0
leader(未選出)
restoredfalse
現実のメンバー(なし)
この回の手: まだ調整していない
調整が打った手
(まだ調整していない)

左の Spec には手順でなく状態しか書いていない。「1回調整する」を押すたびに、コントローラは Spec と現実を 見比べて、まだ埋まっていない差を1つだけ埋める。復元が先で、次にメンバー、最後にリーダーという順序は、 手順として書かれているのではなく「どの差がまだ残っているか」の判定として現れる。だからリーダーを落とすと、 障害イベントを購読していないのに次の調整で選び直される。調整ループの章と同じ性質が、自分の型に対して そのまま手に入っている。

設計の観点

  • 手順書は状態に書き換えられる: 「AしてBしてC」は「Aが済み、Bが済み、Cが済んだ状態」と、そこへの差の判定に分解できる。分解できたものは自動化でき、できないものは人が判断すべきものとして残る
  • 順序は判定の並び順として現れる: 状態機械やフラグで順序を管理しない。上から差を見ていけば、埋められる差だけが埋まる
  • 中断に強い形を選ぶ: 1回1手は効率と引き換えに、いつ落ちても続きから進める性質を買っている。運用の自動化では、速さより中断耐性のほうが効く
  • 埋められない差は止めて記録する: 無理に進めると中途半端な状態ができる。止まって状態に書き残すほうが、人が気づける
  • 書ける範囲を型が決める: Spec に書けるものしか宣言できない。何を型にするかが、そのまま何を自動化できるかになる
  • 調整ループとの関係: 中身は同じで、違うのは扱う型だけ。この編の1章目が、そのまま最後の章の土台になっている

対照と実例

手順書(runbook)Operator
書くものやることの順序あるべき状態
途中で落ちたら再開位置を人が判断次の調整が現状から再開
2回実行すると二重に実行される差が無いので何もしない
障害が起きたら別の手順書を引く同じループが埋め直す
実行するのは人(真夜中に)ループ(常時)

裏どり:

  • Operator パターン: 自作のリソース型と、それを調整するコントローラの組み合わせ
  • CustomResourceDefinition: API サーバに型を登録すると、標準の資源と同じように扱えるようになる
  • controller-runtime の Reconciler: Reconcile(req) (Result, error) の1メソッドが本章の Reconcile にあたる
  • Spec と Status の分離: 標準の資源も同じ形をとる。人が書く側と、コントローラが書き戻す側を分けるのが規約になっている

簡略化したこと

  • CRD の登録なし: 実物は API サーバに型を登録し、kubectl から扱えるようになる。ここは Go の型のみ
  • watch なし: 実物は informer が変更を検知して reconcile を呼ぶ。ここは手で呼ぶ
  • エラーと再試行なし: 実物は失敗を返すとバックオフして再度キューに入る
  • 所有関係なし: 実物は作った部品に所有者を記録し、親が消えると連鎖して消える
  • 1つのリソースのみ: 実物は同じ型の複数のインスタンスを並行に調整する

参考資料