Skip to content

ヘルスチェック(probe)

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

プロセスが起動したことと、リクエストを処理できることは別だ。起動直後は温まっておらず、動いた後に固まることもある。Kubernetes は外から定期的に叩いて確かめる。効いてくるのは検査そのものより、失敗したときの扱いの違いになる。readiness は転送先から外すだけ、liveness は再起動する。3種類の検査を実装すると、扱いを取り違えたときに遅いだけの Pod を殺し続けることが分かる。

この章で作るもの

前章では、Pod を止めるときにリクエストを落とさない方法を作った。この章はその裏返しで、Pod を使い始めるときの話になる。

プロセスが起動したことと、リクエストを処理できることは別だ。起動直後の数秒、アプリは接続プールを張り、キャッシュを温め、設定を読んでいる。プロセスは動いているが、まだ応えられない。ここに振ると落ちる。逆の失敗もある。しばらく順調に動いた後、デッドロックで固まる。内部の状態が壊れて、何を投げても返さなくなる。それでもプロセスは生きているので、外からは動いているように見える。

どちらも、外から定期的に叩いて確かめるしかない。Kubernetes はこれを probe と呼ぶ。仕組みは単純で、一定間隔で問い合わせて、応えるかどうかを見るだけだ。難しいのはそこではない。失敗したときに何をするかで、probe は別物になる。

  startup   起動が終わったか
     │      失敗 → まだ待つ(他の検査は動かさない)
     │      成功 → ここから下の2つが動き出す

  readiness 今リクエストを受けられるか
            失敗 → 転送先から外す。Pod は生きたまま
            成功 → 転送先に戻す(自分で戻れる)

  liveness  まだ生きているか
            失敗 → 再起動する(経過も検査もやり直し)
3種類の検査。仕組みは同じで、失敗したときの扱いだけが違う。この違いを取り違えると事故になる

順に見ていく。

  1. 検査は同じ、扱いが違う: readiness も liveness も同じ検査。落ちたときの処理だけが分かれる
  2. readiness は取り返しがつく: 外すだけなので、回復すれば自分で戻る
  3. liveness は取り返しがつかない: 再起動は最初からやり直し。遅いだけの相手に使うと殺し続ける

① readiness: 受けられるまで振らせない

まず、検査の仕組みそのものを作る。readiness も liveness も、この同じ型で表す:

go

// Probe は 1 種類の検査の設定。readiness も liveness も同じ型で表す。
// 違うのは設定ではなく、失敗したときに何をするかだけ。
type Probe struct {
	InitialDelay     int // 起動から最初の検査までの待ち
	Period           int // 検査の間隔。0 ならこの検査を使わない
	FailureThreshold int // 何回続けて失敗したら落ちたと決めるか
	SuccessThreshold int // 何回続けて成功したら戻ったと決めるか
}

// enabled はこの検査を使うかを返す。
func (p Probe) enabled() bool { return p.Period > 0 }

// due は起動から age の時点が検査のタイミングかを返す。
func (p Probe) due(age int) bool {
	if !p.enabled() || age < p.InitialDelay {
		return false
	}
	return (age-p.InitialDelay)%p.Period == 0
}

// gate は 1 種類の検査の判定状態。連続回数で判定を切り替える。
//
// 1 回の失敗で判定を変えないのが肝になる。ネットワークの瞬断でも検査は
// 失敗するので、1 回で決めると健全な Pod が外れたり再起動されたりする。
// 連続回数を要求することで、たまたまの失敗と本当の異常を分ける。
type gate struct {
	ok      int
	ng      int
	passing bool
}

// record は 1 回の検査結果を記録し、判定が変わったかを返す。
func (g *gate) record(pass bool, p Probe) bool {
	if pass {
		g.ok++
		g.ng = 0
		if !g.passing && g.ok >= max1(p.SuccessThreshold) {
			g.passing = true
			return true
		}
		return false
	}
	g.ng++
	g.ok = 0
	if g.passing && g.ng >= max1(p.FailureThreshold) {
		g.passing = false
		return true
	}
	return false
}

func max1(n int) int {
	if n < 1 {
		return 1
	}
	return n
}

gate が判定の本体で、連続した回数で判定を切り替える。ここで 1 回の失敗で判定を変えないのが肝になる。ネットワークの瞬断でも検査は失敗するので、1 回で決めると健全な Pod が転送先から外れたり、再起動されたりする。連続回数を要求することで、たまたまの失敗と本当の異常を分ける。テストで、間に成功が挟まれば数え直され、続いたときだけ判定が変わることを固定した。

readiness が落ちた Pod は転送先から外れる。振られてこなくなるので、応えられない状態で叩かれることがない。そして、回復すれば自分で戻る。外れている間も Pod は生きていて、検査は回り続けている。

効き方が分かりやすいのは、温まった Pod と起動中の Pod が並んでいるときだ。スケールアウトのときも、更新のときも、必ずこの状況ができる。readiness がなければ、順番が回ってきたリクエストは起動中の Pod に届いて落ちる。隣に処理できる Pod がいるのに落ちる。readiness を入れれば、起動が終わるまで転送先に入らないので、温まった側が全部受ける。テストで、同じ状況が 3 件の失敗と 0 件に分かれることを固定した。

② liveness: 生きていないなら作り直す

readiness では直せない状態がある。デッドロックで固まった Pod は、待っても戻らない。転送先から外しても、そのまま死んだように居座り続ける。こういう相手には再起動しかない:

go

// Config は 3 種類の検査の設定。
type Config struct {
	// Readiness は「今リクエストを受けられるか」を見る。失敗しても再起動しない。
	Readiness Probe
	// Liveness は「まだ生きているか」を見る。失敗すると再起動する。
	Liveness Probe
	// Startup は「起動が終わったか」を見る。これが通るまで他の2つは動かない。
	Startup Probe
}

// Sim はトラフィックを流しながら検査を回す。時計は論理時刻で決定的。
type Sim struct {
	cfg  Config
	now  int
	pods []*Pod
	rr   int

	Served  int
	Dropped int
	Log     []string
}

// New は台本 bs のぶんだけ Pod を作る。
func New(cfg Config, bs ...Behavior) *Sim {
	s := &Sim{cfg: cfg}
	for i, b := range bs {
		s.pods = append(s.pods, &Pod{
			Name: "pod-" + itoa(i+1), b: b,
			live: gate{passing: true}, // 生きている前提から始める
		})
	}
	return s
}

// Now は現在の論理時刻を返す。
func (s *Sim) Now() int { return s.now }

// Pods は Pod 一覧を返す。
func (s *Sim) Pods() []*Pod { return s.pods }

// Restarts は全 Pod の再起動回数の合計を返す。
func (s *Sim) Restarts() int {
	n := 0
	for _, p := range s.pods {
		n += p.Restarts
	}
	return n
}

// Endpoints はトラフィックが振られる先を返す。readiness を通った Pod だけ。
//
// readiness を使わない設定なら、ここは全 Pod を返す。起動中でも振られる
// ということで、それが事故になる。
func (s *Sim) Endpoints() []string {
	var out []string
	for _, p := range s.pods {
		if !s.cfg.Readiness.enabled() || p.ready.passing {
			out = append(out, p.Name)
		}
	}
	return out
}

// Tick は時刻を1つ進める。検査を回してから、リクエストを振る。
func (s *Sim) Tick(rps int) {
	s.check()
	s.route(rps)
	for _, p := range s.pods {
		p.age++
	}
	s.now++
}

// check は今のタイミングに当たる検査を回し、結果を反映する。
func (s *Sim) check() {
	for _, p := range s.pods {
		healthy := p.b.healthy(p.age)

		// 起動用の検査。これが通るまで、他の2つは一切動かない。
		// 起動が遅いことと、動いてから壊れたことを区別するための仕掛け。
		if s.cfg.Startup.enabled() && !p.startup.passing {
			if !s.cfg.Startup.due(p.age) || !p.startup.record(healthy, s.cfg.Startup) {
				continue // まだ起動が終わっていない。他の検査は一切動かさない
			}
			s.logf(p.Name + " の起動検査が通った。ここから readiness と liveness が動き出す")
		}

		if s.cfg.Readiness.due(p.age) && p.ready.record(healthy, s.cfg.Readiness) {
			if p.ready.passing {
				s.logf(p.Name + " が readiness を通った。転送先に入る")
			} else {
				s.logf(p.Name + " が readiness に落ちた。転送先から外す(再起動はしない)")
			}
		}

		if s.cfg.Liveness.due(p.age) && p.live.record(healthy, s.cfg.Liveness) && !p.live.passing {
			// liveness の失敗は再起動。readiness と違い、取り返しがつかない。
			s.logf(p.Name + " が liveness に落ちた。再起動する(" + itoa(p.Restarts+1) + " 回目)")
			p.restart()
		}
	}
}

// route はリクエストを転送先へ順に振る。中身が応答できなければ落ちる。
func (s *Sim) route(rps int) {
	eps := s.Endpoints()
	if len(eps) == 0 {
		s.Dropped += rps
		if rps > 0 {
			s.logf("転送先が空。" + itoa(rps) + " 件が行き場を失う")
		}
		return
	}
	for i := 0; i < rps; i++ {
		name := eps[s.rr%len(eps)]
		s.rr++
		p := s.find(name)
		if p == nil || !p.b.healthy(p.age) {
			// 転送先には入っているのに、中身が応えられない。
			s.Dropped++
			s.logf(name + " は応答できない状態なのに振られた。1 件失う")
			continue
		}
		s.Served++
	}
}

// Safe は 1 件も落とさなかったかを返す。
func (s *Sim) Safe() bool { return s.Dropped == 0 }

func (s *Sim) find(name string) *Pod {
	for _, p := range s.pods {
		if p.Name == name {
			return p
		}
	}
	return nil
}

func (s *Sim) logf(msg string) { s.Log = append(s.Log, "t="+itoa(s.now)+" "+msg) }

check の後半に、その扱いの違いが出ている。readiness が落ちたときは転送先から外すだけで、Pod には何もしない。liveness が落ちたときは restart を呼ぶ。経過も、3つの検査の状態も、すべて初期化される。起動からやり直しだ。

ここが取り違えの起きる場所になる。再起動は取り返しがつかない。「応えない」という同じ症状に対して、readiness は待ち、liveness は殺す。相手が自分で戻れるなら、待てば直る。戻れないなら、待っても無駄で殺すしかない。区別すべきなのは症状でなく、回復できるかどうかだ。

そして実際には、区別がつかないことが多い。負荷が高くて応答が遅れているだけの Pod は、放っておけば戻る。だが検査から見れば固まった Pod と同じに見える。ここで liveness を厳しくしていると、戻る前に殺す。再起動した Pod は起動からやり直すので、しばらく処理できない。その間、残りの Pod に負荷が集まり、そちらも遅くなり、また殺される。負荷が高いときにだけ全滅する仕掛けができあがる。テストで、一時的に詰まっただけの Pod を、厳しい設定は殺し続け、緩い設定は自力で回復させることを固定した。

③ startup: 起動の遅さと故障を区別する

もう1つ、liveness が誤って殺す相手がいる。起動が遅いだけの Pod だ。

liveness は起動からの経過で動き始める。JVM の暖機や大きなモデルの読み込みで起動に何分もかかるアプリだと、その間ずっと応えない。liveness から見れば故障と区別がつかないので、起動を終える前に殺す。再起動してまた起動を始め、また殺される。永久に立ち上がらない。テストで、起動が liveness の猶予より遅いと、一度も起動を終えられずに再起動を繰り返すことを固定した。

素朴な対処は liveness の猶予を伸ばすことだが、これは筋が悪い。起動に 5 分かかるからと猶予を 5 分にすると、動き始めた後に固まったときも 5 分気づかない。起動の遅さに合わせると、故障の検知が鈍る。

分けたいのは、起動が終わっていないことと、動いてから壊れたことだ。だから検査をもう1つ足す。check の冒頭にある起動用の検査(startup)がそれで、これが通るまで他の2つは一切動かない。起動には十分な回数を許し、通った後の liveness は短く保てる。起動の遅さと故障の検知を、別々に決められるようになる。テストで、同じ遅い起動が、起動用の検査を足すだけで殺されなくなることを固定した。

動かす

下のデモは、温まった pod-1 と、起動が遅く途中で一時的に詰まる pod-2 を並べる。最初は検査を何も使っていないので、起動中に振られて落ち、詰まりで再起動もされる。readiness を入れると落ちなくなり、起動用の検査を入れると起動中に殺されなくなる。liveness を厳しくすると、自力で戻れる詰まりまで殺すようになる。

デモヘルスチェック(probe)失敗 26 / 再起動 5
readiness(受けられるかを見る)使わない使う
liveness(生きているかを見る)厳しい(1回で再起動)緩い(5回続いたら)
起動用の検査(startup)使わない使う

pod-1 は最初から応答できる / pod-2 は起動に 8 周期かかり、t=14 から 3 周期だけ詰まる / 毎周期 2 件が届く

pod-10回
pod-25回
t=0t=25
転送先にいて応答できる転送先から外れている応答できないのに振られた再起動された
26 件が落ち、5 回の再起動が起きた
起きたこと
t=4 pod-2 が liveness に落ちた。再起動する(1 回目)
t=9 pod-2 が liveness に落ちた。再起動する(2 回目)
t=14 pod-2 が liveness に落ちた。再起動する(3 回目)
t=19 pod-2 が liveness に落ちた。再起動する(4 回目)
t=24 pod-2 が liveness に落ちた。再起動する(5 回目)

readiness を使わないと、まだ起動が終わっていない pod-2 にも順番で振られて落ちる。使えば、起動が終わるまで 転送先に入らないので、隣の pod-1 が全部受ける。liveness を厳しくすると、起動を終える前の pod-2 を殺し続け、 いつまでも立ち上がらない。起動用の検査を足すと、それが通るまで liveness が動かないので守られる。 3つとも正しく設定したときだけ、失敗も再起動も 0 になる。

設計の観点

  • 症状でなく回復可能性で分ける: 「応えない」は同じでも、自分で戻れるなら readiness、戻れないなら liveness。相手の性質を見て選ぶ
  • liveness は無くてもよい: 固まる不具合が実際にないなら、liveness を付けないほうが安全なことがある。誤って殺す損失のほうが大きい場面は多い
  • 連続回数は必須: 1 回の失敗で動く設定は、瞬断のたびに健全な Pod を巻き込む。検査そのものも失敗しうる、という前提で組む
  • 間隔は検知の速さと負荷の釣り合い: 短くすれば速く気づくが、その分だけ叩く回数が増える。検査自体が重いと、検査で潰れる
  • 依存先を検査に含めない: データベースへの疎通を readiness に入れると、データベースが不調なとき全 Pod が同時に転送先から外れ、転送先が空になる。自分が処理できるかだけを見る
  • 調整ループとの関係: liveness の再起動は kubelet がノード内で行い、Pod は作り直されない。Pod ごと作り直すのは調整ループの仕事で、層が違う

対照と実例

readinesslivenessstartup
問い今受けられるかまだ生きているか起動が終わったか
失敗すると転送先から外す再起動する待ち続ける
回復自分で戻れるやり直しになる通れば以後は不要
向く相手温まり待ち、一時的な過負荷固まって戻らない不具合起動が遅いアプリ
厳しすぎると転送先が空になる再起動を繰り返す起動を諦める

裏どり:

  • Liveness, Readiness and Startup Probes: 3種類の役割の違いと、失敗したときの扱い
  • Probe の設定値: initialDelaySeconds / periodSeconds / failureThreshold / successThreshold の意味と既定値
  • startup probe の導入経緯: 起動が遅いアプリのために liveness の猶予を伸ばすと検知が鈍る、という問題への対処として追加された
  • liveness の誤用: 負荷が高いときに liveness が連鎖的な再起動を起こす失敗例が、運用の記事で繰り返し取り上げられている

簡略化したこと

  • 検査の中身なし: HTTP GET / TCP / exec の区別は扱わない。応えるかどうかの真偽だけ
  • タイムアウトなし: 実物は検査自体にタイムアウトがあり、その満了も失敗として数える
  • 1 コンテナ: 実物は Pod 内の全コンテナが ready でないと Pod は ready にならない
  • 再起動の間隔なし: 実物は再起動を繰り返すと間隔が指数的に伸びる(CrashLoopBackOff)
  • 論理時刻: 実時間でなく tick で数える。秒への対応づけは設定しだい

参考資料