調整ループ(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 が変わる → また見比べる(ループ)順に見ていく。
- 宣言的: 「3 個であってほしい」という状態を宣言する。手順(どう 3 個にするか)は書かない
- level-triggered: イベントに反応せず、毎回まるごと現状を数え直して差を埋める。取りこぼしに強い
- 1 ループが全部やる: 作成・スケール・障害回復が、同じ「差を数えて埋める」処理に集約される
① observed state: 今ある状態
まず、調整の対象になる世界を作る。クラスタは Pod の集まりだ。Pod は Kubernetes の実行単位で、1つ以上のコンテナをひとまとめにして起動・停止する箱になる。各 Pod は状態(Pending / Running / Failed)を持つ。これが observed state、つまり「今まさにある状態」だ:
// 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(生きている数)を見比べ、差を埋める操作を出す。足りなければ作り、多ければ消す:
// 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 の強さも確かめてほしい。
人は「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 をどのノードに置くかは次章(スケジューラ)
参考資料
- Kubernetes: Controllers — 調整ループの公式解説
- Level Triggering and Reconciliation in Kubernetes (Hausenblas/Schimanski) — level-triggered 設計の背景
- Kubebuilder Book — Reconciler の実装パターン
- 実装: orchestration/reconcile