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つ作る
リーダーが落ちたら? リーダーが居ない → 選ぶ
差が無い → 何もしない順に見ていく。
- Spec と Status を分ける: 人が書く「あるべき姿」と、コントローラが書き戻す「今の姿」
- 手順は差の判定になる: 順序は if の並び順として現れ、手順としては書かれない
- 1回に1手だけ: 途中で落ちても、次の呼び出しが現状から再開する
① Spec と Status を分ける
まず、自作の型を作る:
// 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章目とほとんど同じ形になる:
// 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 になる。
左の 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つのリソースのみ: 実物は同じ型の複数のインスタンスを並行に調整する
参考資料
- Operator pattern — 自作リソースとコントローラの組み合わせ
- Custom Resources — 型を足すという拡張の形
- Kubebuilder Book — Reconciler の実装パターン
- 実装: orchestration/operator