Skip to content

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)     余りを実際に返す。この間そのメンバーは応答しない

     ┌────────────┐
     │ 使う │ 使う │
     └────────────┘


  容量を使い切ったとき

     書き込み ✕      設定の変更も書き込みなので ✕
     読み出し ○

     抜けるには順番が決まっている
       ① 捨てる → ② 返す → ③ 解除する
     ①だけで解除しても、次の書き込みでまた止まる
捨てても中で余るだけ。返すには別の操作が要る。そして使い切ると自分では抜けられない

順に見ていく。

  1. 追えることと増えることは同じ性質: 履歴があるから watch が成立し、履歴があるから容量が増える
  2. 捨てるのと返すのは別: 圧縮は履歴を捨てるだけで、ファイルは小さくならない
  3. 使い切ると自分では抜けられない: 書けないので設定も変えられない。順番の決まった3手だけが道になる

① 追えることと増えること

まず、履歴を持つ置き場を作る:

go

// 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 はキーの数ぶんしか増えない。この差が、そのまま履歴の重さになる。

削除も同じ形で扱う。削除は「消えたという版」を積むことなので、量は増える。テストで、削除したあとに物理的な量が増えること、それでも消える前の版は読めることを固定した。掃除のつもりで大量に消すと、かえって容量を使う。

読む側はこうなる:

go

// 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 が偽を返す場合があると書いたが、その偽を返しているのがここになる。

② 捨てるのと返すのは別

go

// 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周してから気づくと、復旧が長引く。

写しの扱いも、ここに関わる:

go

// 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つを好きな順で押せる。順番を間違えると抜けられないことが見える。

デモetcd の履歴と容量物理 9 / 上限 8 ・ 論理 3
物理(ファイル)9
論理(使っている)3
縦線が上限 8
書き込み止まっている
読み出しいつでもできる
差分での追いつき版 1 から追える
書き込みが止まっている。設定を変えるのも書き込みなので、捨てる・返す・解除するの3手でしか抜けられない
版 7: key-1 を書いた
版 8: key-2 を書いた
版 9: key-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台の中だけ
  • バイト数でない: 容量は件数で数える。実物はバイト数で判定する
  • 自動の圧縮なし: 保持期間による自動圧縮は扱わない。手で呼ぶだけ
  • リースなし: 期限つきのキーと、その期限切れによる削除は扱わない
  • トランザクションなし: 条件つきの書き込みや、複数キーの一括更新は扱わない
  • デフラグの停止時間なし: 応答しなくなることは文章で触れるだけで、時間としては数えない

参考資料