etcdの履歴と容量
実装:
orchestration/etcdops// 実行:go test ./orchestration/etcdops/
informer が古すぎて追いつけない、という話をした。それを作っているのが、下で定期的に走る圧縮になる。書くたびに版が増え、古い版がしばらく残る。残っているから写しは差分で追いつけるが、残るから容量が増える。同じ性質の表と裏だ。しかも捨ててもファイルは小さくならず、使い切ると書き込みが止まって自分では抜けられない。
この章で作るもの
API サーバと informer の章で、写しが古すぎると差分では追いつけず、全件を取り直すことになると書いた。あのときは「履歴は無限には持てないので、いつか古い分を捨てる」とだけ書いて、誰がいつ捨てるのかは扱わなかった。
捨てているのは、いちばん下にある置き場になる。この章はそこを見る。
置き場はキーの履歴を持っている。書くたびに版が1つ増え、古い版もしばらく残る。残っているから、写しは「版5まで見た。それ以降をくれ」と言える。履歴があることが、watch が成立する条件そのものになっている。
だが履歴は増え続ける。しかも上書きも削除も、履歴としては書き込みなので量が増える。「消したのに軽くならない」のはそのためだ。放っておけば容量を使い切るので、どこかで捨てる。捨てると、そこより古い版を見ていた写しは追いつけなくなる。
同じ性質の表と裏になっている。追えることと、増えること。
書くたびに版が増え、古い版も残る
版 1 2 3 4 5 6
a="1" a="2" a="3" a="4"
b="x" b 削除
論理的には a と b の2キー。物理的には6件ぶん
① 圧縮(compact) 履歴を捨てる。ファイルは小さくならない
┌──────────────────────────────┐
│ 使う │ 余り │ 余り │ 使う │ ← 中で余るだけ
└──────────────────────────────┘
ファイルの大きさは変わらない
② デフラグ(defrag) 余りを実際に返す。この間そのメンバーは応答しない
┌────────────┐
│ 使う │ 使う │
└────────────┘
容量を使い切ったとき
書き込み ✕ 設定の変更も書き込みなので ✕
読み出し ○
抜けるには順番が決まっている
① 捨てる → ② 返す → ③ 解除する
①だけで解除しても、次の書き込みでまた止まる順に見ていく。
- 追えることと増えることは同じ性質: 履歴があるから watch が成立し、履歴があるから容量が増える
- 捨てるのと返すのは別: 圧縮は履歴を捨てるだけで、ファイルは小さくならない
- 使い切ると自分では抜けられない: 書けないので設定も変えられない。順番の決まった3手だけが道になる
① 追えることと増えること
まず、履歴を持つ置き場を作る:
// ErrNoSpace は容量を使い切って書けないこと、ErrCompacted は古すぎて追えないこと。
type Err string
func (e Err) Error() string { return string(e) }
const (
ErrNoSpace = Err("容量を使い切っている。書き込みは受け付けない")
ErrCompacted = Err("その版はもう捨てられている")
)
// Put は値を書き、新しい版を返す。
//
// 上書きでも古い版が残るのが肝になる。だから物理的な量は、書いた回数ぶん増える。
// 論理的な量はキーの数ぶんしか増えない。この差が、そのまま履歴の重さになる。
func (s *Store) Put(key, value string) (int, error) {
if s.alarm {
return 0, ErrNoSpace
}
s.rev++
if len(s.history[key]) == 0 {
s.logical++
}
s.history[key] = append(s.history[key], version{rev: s.rev, value: value})
s.physical++
s.checkQuota()
return s.rev, nil
}
// Delete は削除の版を積む。削除も履歴なので、量は増える。
func (s *Store) Delete(key string) (int, error) {
if s.alarm {
return 0, ErrNoSpace
}
if len(s.history[key]) == 0 {
return s.rev, nil
}
s.rev++
if !s.history[key][len(s.history[key])-1].del {
s.logical--
}
s.history[key] = append(s.history[key], version{rev: s.rev, del: true})
s.physical++
s.checkQuota()
return s.rev, nil
}
func (s *Store) checkQuota() {
if s.quota > 0 && s.physical > s.quota && !s.alarm {
s.alarm = true
s.logf("容量の上限を超えた。書き込みを止める(読み出しは通る)")
}
}Put が古い版を消していないのが、この章のすべての出発点になる。上書きしても前の版は残る。だから Physical は書いた回数ぶん増え、Logical はキーの数ぶんしか増えない。この差が、そのまま履歴の重さになる。
削除も同じ形で扱う。削除は「消えたという版」を積むことなので、量は増える。テストで、削除したあとに物理的な量が増えること、それでも消える前の版は読めることを固定した。掃除のつもりで大量に消すと、かえって容量を使う。
読む側はこうなる:
// Get は今の値を返す。書き込みが止まっていても読める。
func (s *Store) Get(key string) (string, bool) {
vs := s.history[key]
if len(vs) == 0 {
return "", false
}
last := vs[len(vs)-1]
if last.del {
return "", false
}
return last.value, true
}
// GetAt は指定した版の時点の値を返す。捨てた版より古ければ読めない。
func (s *Store) GetAt(key string, rev int) (string, bool, error) {
if rev < s.compactedAt {
return "", false, ErrCompacted
}
var cur version
found := false
for _, v := range s.history[key] {
if v.rev > rev {
break
}
cur, found = v, true
}
if !found || cur.del {
return "", false, nil
}
return cur.value, true, nil
}
// Since は from より後の変更を順に返す。捨てた版より古ければ追えない。
//
// [informer](apiserver) が写しを保つときに呼ぶのがこれになる。ここが失敗したら、
// 差分では追いつけないので全件を取り直すしかない。
func (s *Store) Since(from int) ([]Event, error) {
if from < s.compactedAt {
return nil, ErrCompacted
}
var out []Event
keys := make([]string, 0, len(s.history))
for k := range s.history {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range s.history[k] {
if v.rev > from {
out = append(out, Event{Rev: v.rev, Key: k, Value: v.value, Del: v.del})
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Rev < out[j].Rev })
return out, nil
}Since が、informer が写しを保つときに呼ぶものになる。API サーバ の章で Since が偽を返す場合があると書いたが、その偽を返しているのがここになる。
② 捨てるのと返すのは別
// Compact は rev より古い版を捨てる。
//
// 捨てるのは履歴だけで、ファイルは小さくならない。空いた場所は中で余るだけになる。
// これが「圧縮したのに容量が減らない」の正体で、返すには Defrag が要る。
func (s *Store) Compact(rev int) int {
if rev <= s.compactedAt {
return 0
}
dropped := 0
for k, vs := range s.history {
var keep []version
for i, v := range vs {
// その版の時点の値を読めるように、境界の1つ手前は残す。
isLast := i == len(vs)-1
if v.rev < rev && !isLast && vs[i+1].rev <= rev {
dropped++
continue
}
keep = append(keep, v)
}
s.history[k] = keep
}
s.compactedAt = rev
s.logf("版 " + itoa(rev) + " より古い履歴を " + itoa(dropped) + " 件捨てた(ファイルの大きさは変わらない)")
return dropped
}
// Defrag は余っている場所を実際に返す。
//
// 実物ではこの間そのメンバーが応答しない。1台ずつ順に行うことになる。
func (s *Store) Defrag() int {
before := s.physical
live := 0
for _, vs := range s.history {
live += len(vs)
}
s.physical = live
freed := before - s.physical
s.logf("余っていた " + itoa(freed) + " を返した(この間そのメンバーは応答しない)")
return freed
}
// Disarm は書き込みの停止を解除する。
//
// 空きが戻っていなければ、解除しても次の書き込みでまた止まる。
// だから順番が決まっている。捨てる、返す、そして解除する。
func (s *Store) Disarm() bool {
if !s.alarm {
return true
}
if s.quota > 0 && s.physical > s.quota {
s.logf("解除したが、空きが戻っていないので次の書き込みでまた止まる")
s.alarm = false
return false
}
s.alarm = false
s.logf("書き込みを再開できる状態になった")
return true
}Compact は履歴を捨てるが、physical を触っていない。捨てた場所は中で余るだけで、ファイルの大きさは変わらない。テストで、10 件書いてから捨ててもファイルの量が変わらないこと、Defrag を呼んで初めて減ることを固定した。
これは実物でもそのとおりで、「圧縮したのに db_total_size_in_bytes が減らない」という状態は正常になる。減るのは db_total_size_in_use_bytes のほうだけだ。返すには defrag が要る。
そして Defrag の間、そのメンバーは応答しない。3台構成なら1台ずつ順に行うことになる。全台を同時にやると、その間クラスタ全体が止まる。
捨てるときの境界の扱いにも、細かいが大事な点がある。Compact(rev) は「rev 時点の値が読める」ようにするので、rev より古い版でも、rev 時点の値を表しているものは残す。ここを間違えると、圧縮した瞬間に現在の値が消える。テストで、圧縮したあとも今の値が読めることを固定した。
③ 使い切ると自分では抜けられない
容量を使い切ると、書き込みが止まる。読み出しは通る。
止まると、状況が厄介になる。設定を変えるのも書き込みなので、そのままでは自分で抜け出せない。しかも API サーバ の下にあるので、クラスタ全体で何も作れず、何も更新できなくなる。調整ループは現状を読めるが、差を埋める書き込みができない。
抜けるには順番が決まっている。捨てる、返す、解除する。テストで、捨てただけで解除しても抜けられないこと、返してから解除して初めて書けるようになることを固定した。
順番が決まっているのは、解除の判断が空き容量を見ているからだ。捨てただけではファイルが小さくなっていないので、解除しても次の書き込みでまた止まる。この「解除したのにまた止まる」を1周してから気づくと、復旧が長引く。
写しの扱いも、ここに関わる:
// Snapshot は今の状態の写しを取る。履歴は運ばず、今の値だけを運ぶ。
type Snapshot struct {
Rev int
Data map[string]string
}
// Take は写しを取る。書き込みが止まっていても取れる。
func (s *Store) Take() Snapshot {
snap := Snapshot{Rev: s.rev, Data: map[string]string{}}
for k := range s.history {
if v, ok := s.Get(k); ok {
snap.Data[k] = v
}
}
return snap
}
// Restore は写しから新しい置き場を作る。
//
// 履歴は運ばれないので、復元した直後は誰も過去を追えない。写しを持っていた
// informer は、全件を取り直すことになる。
func Restore(snap Snapshot, quota int) *Store {
s := New(quota)
s.rev = snap.Rev
s.compactedAt = snap.Rev
keys := make([]string, 0, len(snap.Data))
for k := range snap.Data {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
s.history[k] = []version{{rev: snap.Rev, value: snap.Data[k]}}
s.logical++
s.physical++
}
s.logf("写しから復元した(版 " + itoa(snap.Rev) + " から。それより前の履歴は無い)")
return s
}写しは今の値だけを運び、履歴は運ばない。だから復元した直後は、誰も過去を追えない。テストで、復元先で Since が失敗することを固定した。写しを持っていた informer は全件を取り直すことになるので、復元の直後にはクラスタ全体で読み直しが起きる。
止まっている状態でも写しは取れる。復旧に手を付ける前に、まず写しを取っておける。
動かす
下のデモは、上限の小さい置き場に書き込んでいく。上限を超えると赤くなって書けなくなる。そこから捨てる、返す、解除するの3つを好きな順で押せる。順番を間違えると抜けられないことが見える。
上限の小さい置き場に書き込んでいる。上書きも削除も履歴なので、物理の帯だけが伸びる。 上限を超えると書き込みが止まる。①だけ押して③を押すと、解除はできても次の書き込みでまた止まる。 ①②③の順に押して初めて抜けられる。そして①を押した時点で、古い写しは差分では追いつけなくなる。
設計の観点
- 履歴は機能であり負債: 追えることの対価が増えること。どちらか一方だけは選べない
- 論理と物理を分けて見る: 使っている量と確保している量は違う。片方だけ見ていると判断を誤る
- 削除は軽くしない: 掃除のつもりの大量削除が、容量を使い切る引き金になることがある
- 止まった状態から抜ける道を、止まる前に確かめる: 書けない状態では手順を試せない
- informerの取り直しを想定に入れる: 圧縮も復元も、写しの取り直しを引き起こす
- 1台ずつ触る: 応答しなくなる操作は、同時に全台へ当てない
対照と実例
| 履歴を持たない置き場 | 履歴を持つ置き場(etcd) | |
|---|---|---|
| 差分での追いつき | できない | できる |
| 過去の時点の読み出し | できない | 捨てるまではできる |
| 容量 | データの量で決まる | 書いた回数で増える |
| 掃除 | 消せば減る | 消すと増える |
| 必要な運用 | ほぼ無い | 圧縮・デフラグ・容量の監視 |
裏どり:
- revision: 書き込みのたびにクラスタ全体で単調に増える。キーごとの版とは別
- compaction: 自動でも手動でも行える。
--auto-compaction-retentionで保持を指定する。捨てた版より古い watch はErrCompactedになる - defrag: 圧縮で空いた場所を返す。
db_total_size_in_bytesが減るのはこちらで、実行中そのメンバーは応答しない --quota-backend-bytes: 既定は 2GiB。ただし運用の文書は数値を書かず「控えめな既定」とだけ言っていて、数値は設定の一覧のほうにある。超えるとNOSPACEの警報が立ち、読み取りと削除だけを受け付ける状態になる。書き込みだけが止まるので、消して減らす道は残っている- 復旧の3手: 圧縮、デフラグ、
etcdctl alarm disarm。順番を守らないと解除しても再発する - スナップショット:
etcdctl snapshot save/restore。復元すると新しいクラスタとして立ち上がる - Raft の上に載っている: 複数台への複製と合意は別の層。この章は1台の中の話
簡略化したこと
- 合意なし: 複数台への複製は Raft の章に任せる。ここは1台の中だけ
- バイト数でない: 容量は件数で数える。実物はバイト数で判定する
- 自動の圧縮なし: 保持期間による自動圧縮は扱わない。手で呼ぶだけ
- リースなし: 期限つきのキーと、その期限切れによる削除は扱わない
- トランザクションなし: 条件つきの書き込みや、複数キーの一括更新は扱わない
- デフラグの停止時間なし: 応答しなくなることは文章で触れるだけで、時間としては数えない
参考資料
- etcd maintenance — 圧縮、デフラグ、容量の警報と復旧の手順
- Operating etcd for Kubernetes — スナップショットと復元
- 実装: orchestration/etcdops