Skip to content

StatefulSetとPVC

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

ここまでの章は、どの Pod も同じで区別がつかず、どれを消してもよいという前提の上にあった。だがデータベースのクラスタはそうはいかない。1台1台が自分のディスクを持ち、名前で呼ばれ、立ち上げる順序がある。StatefulSet はこの前提を3つ置き換える。序数のついた安定した名前、作る順と消す順の保証、Pod と一対一で結びついたボリューム。得るものと同じだけ、失うものもある。

この章で作るもの

ここまでの章は、ある前提の上に成り立っていた。どの Pod も同じで、区別がつかず、どれを消してもよい。だから調整ループは「3個であれ」とだけ言えばよかったし、ローリング更新はどれから入れ替えても構わなかったし、Cluster Autoscalerは好きな Pod を別のノードへ移せた。

だがデータベースのクラスタは、そうはいかない。3台のうち1台がリーダーで、残りが追随する。各台は自分のディスクを持ち、そこに自分だけのデータがある。消えた1台の代わりに新しい1台を立てても、ディスクが空なら同じものにならない。名前も要る。他のメンバーが「db-1 に繋げ」という設定を持っているなら、作り直した相手も db-1 でなければならない。立ち上げる順序も要る。3台が同時に起動して、それぞれが「自分がリーダーだ」と名乗ったら壊れる。

StatefulSet はこの前提を3つ置き換える。どれも「どの Pod も同じ」を捨てることで得られるもので、そのぶん失うものもある。

   序数   0            1            2
          │            │            │
  Pod   db-0   ──→   db-1   ──→   db-2      作るのは 0 から順に
          │            │            │        (手前が ready になるまで待つ)
  ボリューム│            │            │
       data-db-0   data-db-1   data-db-2

  db-1 を消しても data-db-1 は残る
   → 作り直した db-1 が、また data-db-1 に繋がる
   → 中身がそのまま戻る

  db-1 が ready にならないと db-2 は作られない
   → 順序を守るとは、詰まったら止まるということ
序数が3つのものを束ねる。名前も、作る順も、繋がるボリュームも、すべて序数から決まる

順に見ていく。

  1. 名前は序数から決まる: 作り直しても同じ序数には同じ名前が戻る
  2. 順に作り、逆順に消す: 一度に1つずつ。手前が ready になるまで次へ進まない
  3. ボリュームは Pod より長生きする: Pod が消えても残り、同じ序数に再接続される

① 序数が3つのものを束ねる

まず、序数で結びつく2つを作る:

go

// PVC は Pod に紐づくボリューム。序数と一対一で結びつく。
//
// 肝は、これが Pod より長生きすることだ。Pod が消えても PVC は残り、同じ
// 序数の Pod が作り直されたときに、また同じ PVC が繋がる。データが残るのは
// この寿命の差による。
type PVC struct {
	Name    string
	Ordinal int
	Data    string
}

// Pod は序数で識別されるレプリカ。名前は序数から決まる。
type Pod struct {
	Name    string
	Ordinal int
	Ready   bool
	PVC     string

	broken  bool
	readyAt int
}

PodPVC(PersistentVolumeClaim。Pod が使う永続ボリュームへの要求)も Ordinal を持っていて、名前がそこから決まる。序数 1 の Pod は必ず db-1 で、繋がるボリュームは必ず data-db-1 になる。この対応が固定されていることが、この仕組みの土台になっている。

なぜ名前が大事かというと、分散システムのメンバーは互いを名前で知っているからだ。設定ファイルに「db-0, db-1, db-2 でクラスタを組む」と書いてある。Pod が作り直されるたびに名前が変わったら、その設定を全員に配り直すことになる。序数で決まる名前なら、作り直しても同じ名前が戻るので、誰も設定を変えなくてよい。テストで、消して作り直しても同じ序数には同じ名前が戻ることを固定した。

PVCOrdinal を持たせているのも同じ理由で、そちらは名前でなくデータのためだ。

② 順に作り、逆順に消す

増減は1つずつ、順序を守って進む:

go

// Step は1周期進める。起動を進めてから、順序に従って作る手か消す手を1つ打つ。
//
// 増やすときは序数の小さいほうから、減らすときは大きいほうから。しかも
// 一度に1つずつで、前のものが ready になるまで次へ進まない。この待ちが
// 「1台目が立ち上がってから2台目」という順序を保証する。
func (s *Set) Step() {
	s.now++
	for _, p := range s.pods {
		if !p.Ready && !p.broken && s.now >= p.readyAt {
			p.Ready = true
			s.logf(p.Name + " が ready になった")
		}
	}

	// 減らす: いちばん大きい序数から1つずつ。
	if len(s.pods) > s.cfg.Replicas {
		last := s.maxOrdinal()
		s.DeletePod(last)
		return
	}

	// 増やす: 次に埋めるべき序数は、まだ Pod が無い最小の序数。
	if len(s.pods) < s.cfg.Replicas {
		next := s.nextOrdinal()
		// それより小さい序数が全部 ready でなければ、まだ作らない。
		if !s.allReadyBelow(next) {
			s.logf("序数 " + itoa(next) + " はまだ作らない(手前が ready でない)")
			return
		}
		s.create(next)
	}
}

// Run は最大 max 周期まで進め、揃ったらそこで止める。
func (s *Set) Run(max int) {
	for i := 0; i < max && !s.Converged(); i++ {
		s.Step()
	}
}

// allReadyBelow は ordinal 未満の序数がすべて ready かを返す。
func (s *Set) allReadyBelow(ordinal int) bool {
	for o := 0; o < ordinal; o++ {
		p, ok := s.pods[o]
		if !ok || !p.Ready {
			return false
		}
	}
	return true
}

// create は序数 ordinal の Pod を作り、同じ序数のボリュームを繋ぐ。
// ボリュームが無ければ新しく作り、あればそれを再利用する。
func (s *Set) create(ordinal int) {
	v, existed := s.pvcs[ordinal]
	if !existed {
		v = &PVC{Name: s.pvcName(ordinal), Ordinal: ordinal}
		s.pvcs[ordinal] = v
	}
	p := &Pod{
		Name: s.name(ordinal), Ordinal: ordinal, PVC: v.Name,
		broken: s.broken[ordinal], readyAt: s.now + s.cfg.StartupTicks,
	}
	if s.cfg.StartupTicks == 0 && !p.broken {
		p.Ready = true
	}
	s.pods[ordinal] = p
	if existed {
		s.logf(p.Name + " を作成(既存のボリューム " + v.Name + " を再接続)")
	} else {
		s.logf(p.Name + " を作成(ボリューム " + v.Name + " を新規作成)")
	}
}

Step は1周期に1手しか打たない。増やすときは、まだ Pod が無い最小の序数を埋める。ただし allReadyBelow が真でなければ作らない。つまり、それより小さい序数がすべて ready になっているときだけ次へ進む。減らすときは、いちばん大きい序数から1つずつ消す。

この順序が要るのは、分散システムの立ち上げに順序があるからだ。1台目が起動してクラスタを作り、2台目がそこへ参加し、3台目が続く。全部が同時に起動して、それぞれが「自分が最初だ」と思い込むと、別々のクラスタが3つできる。順に立ち上げれば、後から来たものは必ず既存のクラスタを見つける。

代償もはっきりしている。途中の1つが ready にならないと、それ以降は永久に作られない。ローリング更新では、壊れた版を出しても古い版が残って容量が保たれた。こちらは違う。db-1 が起動しなければ db-2 は存在しないままで、3台のはずのクラスタが2台で止まる。テストで、途中を壊すとそこで止まり、直すと先へ進むことを固定した。

順序を守るとは、詰まったら止まるということでもある。同じことの裏表で、どちらか片方だけを選ぶことはできない。

③ ボリュームは Pod より長生きする

create が、この仕組みでいちばん効いている部分になる。序数のボリュームが既にあれば、それを再利用する。無いときだけ新しく作る。だから Pod を消しても、ボリュームは残ったままになる。同じ序数の Pod が作り直されると、また同じボリュームが繋がり、中身がそのまま戻る。

素朴には、Pod を消したらボリュームも消えてほしい気がする。だが逆で、消えないことがこの仕組みの目的になっている。Pod は落ちるものだ。ノードが死ねば消えるし、更新でも入れ替わる。そのたびにデータが消えるなら、そもそもデータを置けない。Pod とボリュームの寿命を分けて、ボリューム側を長くする。これが「状態を持つ」ということの実装になる。テストで、Pod を消してもデータが残り、作り直すと同じボリュームに繋がることを固定した。

寿命が長いことは、そのまま落とし穴にもなる。目標を 3 から 1 に減らしても、ボリュームは 3 つ残る。使われていないボリュームの費用は払い続ける。戻せばデータも戻るという嬉しさと、放っておくと積み上がるという困りごとが、同じ性質から出ている。テストで、縮小してもボリュームが残り、戻すと同じデータが繋がることを固定した。データが失われるのは、ボリュームを明示的に消したときだけになる。

動かす

下のデモは、序数の順に立ち上がる様子と、ボリュームの寿命を見る。周期を進めると db-0 から順に現れる。「db-1 を壊す」を押すと、db-2 が永久に現れなくなる。ボリュームに何か書いてから Pod だけを消すと、作り直された Pod がまた同じボリュームに繋がる。

デモStatefulSetとPVCready 0 / 目標 3 ・ ボリューム 0
step=0
起動に 2 周期
Pod(序数で識別され、順に立ち上がる)
(なし)
(なし)
(なし)
ボリューム(Pod より長生きする)
(なし)
(なし)
(なし)
まだ揃っていない。手前が ready になるまで次は作られない
起きたこと
(まだ何も起きていない)

周期を進めると db-0 から順に立ち上がる。手前が ready になるまで次は作られないので、db-1 を壊すと db-2 は永久に現れない。ボリュームに何か書いてから「Podを消す」と、Pod だけが消えてボリュームは残り、 作り直された同じ序数の Pod がまた同じボリュームに繋がる。目標を減らしてもボリュームは残るので、 戻せばデータも戻る。データが失われるのは、ボリュームを明示的に消したときだけになる。

設計の観点

  • 同じであることを捨てると、扱いが重くなる: 区別のつかないものは並列に扱えるが、区別のあるものは順序と対応づけが要る。調整ループの「数を合わせるだけ」がここでは通らない
  • 順序保証と可用性は交換: 順に立ち上げれば安全だが、途中で詰まると全体が止まる。並列に立ち上げれば速いが、初期化の競合を自分で解く必要がある
  • 寿命を分けることが状態の実装: Pod は短く、ボリュームは長い。この差だけで「データが残る」が実現している。逆に言えば、寿命を揃えると状態は持てない
  • 消えないことの費用: 安全側の既定(消さない)は、放っておくと積み上がる。定期的に棚卸しする運用がセットで要る
  • 状態は載せないほうが楽: この章の複雑さは、状態を持つことから来ている。データベースをクラスタの外(マネージドサービス)に置けるなら、この複雑さ自体が要らなくなる。載せるかどうかは設計判断で、載せると決めた場合の道具がこれになる
  • Serviceとの組み合わせ: 実物では headless Service と組み合わせて db-0.db という安定した名前で個体を直接呼べる。仮想 IP で束ねるのとは逆で、個体を指したいときの形

対照と実例

区別のないレプリカStatefulSet
名前毎回変わる序数で固定
作る順並列でよい0 から順に1つずつ
消す順どれからでも大きい序数から
ボリューム共有か、持たない序数ごとに専用
途中で詰まると他は進む以降が全部止まる
向く相手ステートレスな Webデータベース、キュー、合意が要るもの

裏どり:

  • StatefulSet: 安定した名前、順序保証、volumeClaimTemplates による序数ごとのボリューム
  • podManagementPolicy: 既定は OrderedReadyParallel にすると順序保証を捨てて同時に立ち上げられる
  • PVC の寿命: StatefulSet を消しても PVC は残る。縮小でも残る。ただしこれは既定の話で、persistentVolumeClaimRetentionPolicywhenDeletedwhenScaled を書けば消させられる。既定は両方とも Retain
  • headless Service: clusterIP: None にすると仮想 IP を持たず、db-0.db のように個体を直接引ける

簡略化したこと

  • 更新なし: 実物は序数の大きいほうから逆順に入れ替える。partition で段階的に進めることもできる
  • ネットワーク識別子なし: 実物は headless Service で安定した名前が引ける
  • ボリュームは1つ: 実物は volumeClaimTemplates で複数持てる
  • 順序を捨てる設定なし: Parallel は扱わない
  • 論理時刻: 実時間でなく周期で数える

参考資料