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 は作られない
→ 順序を守るとは、詰まったら止まるということ順に見ていく。
- 名前は序数から決まる: 作り直しても同じ序数には同じ名前が戻る
- 順に作り、逆順に消す: 一度に1つずつ。手前が ready になるまで次へ進まない
- ボリュームは Pod より長生きする: Pod が消えても残り、同じ序数に再接続される
① 序数が3つのものを束ねる
まず、序数で結びつく2つを作る:
// 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
}Pod も PVC(PersistentVolumeClaim。Pod が使う永続ボリュームへの要求)も Ordinal を持っていて、名前がそこから決まる。序数 1 の Pod は必ず db-1 で、繋がるボリュームは必ず data-db-1 になる。この対応が固定されていることが、この仕組みの土台になっている。
なぜ名前が大事かというと、分散システムのメンバーは互いを名前で知っているからだ。設定ファイルに「db-0, db-1, db-2 でクラスタを組む」と書いてある。Pod が作り直されるたびに名前が変わったら、その設定を全員に配り直すことになる。序数で決まる名前なら、作り直しても同じ名前が戻るので、誰も設定を変えなくてよい。テストで、消して作り直しても同じ序数には同じ名前が戻ることを固定した。
PVC に Ordinal を持たせているのも同じ理由で、そちらは名前でなくデータのためだ。
② 順に作り、逆順に消す
増減は1つずつ、順序を守って進む:
// 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 がまた同じボリュームに繋がる。
周期を進めると 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: 既定は
OrderedReady。Parallelにすると順序保証を捨てて同時に立ち上げられる - PVC の寿命: StatefulSet を消しても PVC は残る。縮小でも残る。ただしこれは既定の話で、
persistentVolumeClaimRetentionPolicyにwhenDeletedとwhenScaledを書けば消させられる。既定は両方ともRetain - headless Service:
clusterIP: Noneにすると仮想 IP を持たず、db-0.dbのように個体を直接引ける
簡略化したこと
- 更新なし: 実物は序数の大きいほうから逆順に入れ替える。
partitionで段階的に進めることもできる - ネットワーク識別子なし: 実物は headless Service で安定した名前が引ける
- ボリュームは1つ: 実物は
volumeClaimTemplatesで複数持てる - 順序を捨てる設定なし:
Parallelは扱わない - 論理時刻: 実時間でなく周期で数える
参考資料
- StatefulSets — 安定した名前、順序保証、ボリュームの対応
- Persistent Volumes — PVC の寿命と回収の方針
- 実装: orchestration/statefulset