Skip to content

Podの終了(graceful shutdown)

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

Pod は必ず消える。スケールダウンでも更新でも止められる。このとき、トラフィックの切り離しとプロセスの停止は別々に走る。切り離しが伝わる前に停止が効くと、まだ振られてくるのに受けられない Pod ができ、リクエストが落ちる。preStop で伝播を待ち、猶予期間で処理中を守る。事故と対策の両方を実装すると、設定ひとつで落ちる件数が変わることが数字で出る。

この章で作るもの

ここまでの3章で、Pod は宣言どおりに作られ(調整ループ)、ノードに置かれ(スケジューラ)、負荷に応じて増減する(水平オートスケール)。残っているのは終わり方だ。

Pod は必ず消える。スケールダウンで減らされ、更新で入れ替えられ、ノードの整理で追い出される。障害でなく、日々の正常な運用として止められる。ところが、この止め方を間違えると、リクエストが落ちる。厄介なのは、落ち方が静かなことだ。デプロイのたびに数十件が失敗する程度なら、全体のエラー率にはほとんど出ない。気づかないまま何年も落とし続けることがある。

原因は、終了のときに2つのことが並行して起こることにある。1つはトラフィックの切り離し。ロードバランサの転送先一覧から、この Pod を外す。もう1つはプロセスの停止。SIGTERM を送ってアプリを終わらせる。この2つは別々の仕組みが担当していて、どちらが先に効くかは保証されていない。この章は、その事故と対策の両方を作る。

  削除を決める(t=0)
   ├──────────────▶ 転送先一覧から除去   ┐
   │                 伝播に時間がかかる   ├ この差が事故の窓
   └──────────────▶ preStop → SIGTERM   ┘

  preStop なし:              preStop あり:
  t=0 SIGTERM(受付停止)      t=0 preStop 開始(受付は続く)
  t=1 ..まだ振られてくる ×    t=4 切り離し完了
  t=2 ..まだ振られてくる ×    t=4 SIGTERM(もう振られてこない)
  t=4 切り離し完了            → 落ちない
終了のときに並行して走る2つの流れ。切り離しが伝わりきる前に停止が効くと、その差の分だけリクエストが落ちる

順に見ていく。

  1. 転送先一覧は現実に遅れる: 一覧は制御側の持ち物で、Pod が実際に死んだことを知らない
  2. preStop は外から来る分を守る: 停止を遅らせて、切り離しが伝わりきるのを待つ
  3. 猶予期間は中で走っている分を守る: 処理中のリクエストを終わらせてから強制終了する

① 転送先一覧は現実に遅れる

まず、事故が起きる場所を作る。トラフィックが振られる先の一覧だ:

go

// Sim は「トラフィックが流れているところに Pod の削除を差し込む」状況を
// 時刻を1つずつ進めながら再現する。時計は論理時刻なので再現性がある。
type Sim struct {
	cfg  Config
	now  int
	pods []*Pod
	rr   int // 転送先を選ぶ順番(round-robin)

	Served  int // 完了したリクエスト
	Dropped int // 落ちたリクエスト
	Log     []string
}

// New は replicas 個の Pod が稼働している状態から始める。
func New(cfg Config, replicas int) *Sim {
	s := &Sim{cfg: cfg}
	for i := 0; i < replicas; i++ {
		s.pods = append(s.pods, &Pod{
			Name: "pod-" + itoa(i+1), phase: Ready, accepting: true,
			sigtermAt: -1, killAt: -1, removeAt: -1,
		})
	}
	return s
}

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

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

// Endpoints は今トラフィックが振られる先(転送先一覧)を返す。
//
// ここが Pod の生死を見ていないことが大事だ。この一覧は制御側が管理する
// もので、Pod が実際に止まったかどうかを知らない。削除を決めても
// Propagation のぶんは残り続けるし、その間に Pod が死んでいても残り続ける。
// 一覧が現実に追いついていない、この隙間が事故の置き場所になる。
func (s *Sim) Endpoints() []string {
	var out []string
	for _, p := range s.pods {
		if p.removeAt < 0 || s.now < p.removeAt {
			out = append(out, p.Name)
		}
	}
	return out
}

Endpoints が Pod の生死をまったく見ていないことに注目してほしい。返す条件は「まだ除去の時刻に達していないか」だけで、その Pod が受け付けを止めていようが停止していようが、一覧には残り続ける。これは手抜きでなく、現実の写し取りだ。転送先一覧は制御側が管理していて、Pod のプロセスが今どうなっているかを知らない。知るには通知が要り、通知には時間がかかる。

route を見ると、一覧に残っている Pod に振ったのに、その Pod が受け付けを止めていれば、そのリクエストは落ちる。一覧が現実に追いついていない間、この隙間は開きっぱなしになる。実物の Kubernetes でも、Pod の削除が Endpoints に反映されるまでには、API サーバ経由での伝播と、各ノードの kube-proxy によるルールの書き換えが挟まる。ミリ秒では終わらない。なぜ遅れるのかは、Serviceとkube-proxyで踏み込む。

② preStop: 停止を遅らせて伝播を待つ

事故の構造が分かれば、対策は素直だ。削除の開始を見る:

go

// Terminate は Pod の削除を始める。ここで2つのことが同時に動き出す。
// 転送先一覧からの除去(Propagation 後に反映)と、停止の予定(PreStop 後に
// SIGTERM、その Grace 後に SIGKILL)だ。どちらが先に効くかで結果が変わる。
func (s *Sim) Terminate(name string) {
	for _, p := range s.pods {
		if p.Name != name || p.phase != Ready {
			continue
		}
		p.phase = Terminating
		p.removeAt = s.now + s.cfg.Propagation
		p.sigtermAt = s.now + s.cfg.PreStop
		p.killAt = p.sigtermAt + s.cfg.Grace
		s.logf(p.Name + " の削除を開始(転送先から外れるのは t=" + itoa(p.removeAt) +
			"、SIGTERM は t=" + itoa(p.sigtermAt) + ")")
		return
	}
}

Terminate は3つの時刻を予定するだけで、何も実行しない。除去が反映される時刻、SIGTERM を送る時刻、SIGKILL を送る時刻。この3つの大小だけで、落ちるか落ちないかが決まる。

伝播の遅れ(Propagation)は、こちらからは短くできない。制御が伝わるのにかかる時間で、外部要因だ。動かせるのは PreStop、つまり SIGTERM を送るまで待つ時間のほうになる。これを伝播の遅れ以上に取れば、切り離しが伝わりきってから停止が始まる。振られてこなくなってから受け付けを止めるので、隙間が閉じる。テストで、同じ伝播の遅れに対して preStop を 0・半分・十分と変えると、落ちる件数が段階的に減って 0 になることを固定した。

実物では、これは preStop フックに待つだけの処理を置くという形になる。アプリが何かをするわけではない。ただ、アプリが何もしていない時間を作る。奇妙に見えるが、これが標準的な対処になっている。伝播が終わったことを Pod 側から知る手段がないので、十分な時間を待つ以外にない。

③ 猶予期間: 処理中を終わらせる

preStop は、これから来るリクエストを守る。だが SIGTERM の時点ですでに処理中のリクエストは、また別に守る必要がある:

go

// Tick は時刻を1つ進める。まず今の時刻に予定されている終了処理を反映し、
// その状態で rps 件のリクエストを振り、処理中のリクエストを進める。
// 順序が結果を決める。予定を先に反映するので、SIGTERM が効いた直後の
// 時刻に振られてきたリクエストは、ちゃんと落ちる。
func (s *Sim) Tick(rps int) {
	s.lifecycleStep()
	s.route(rps)
	s.advance()
	s.now++
}

// route は rps 件のリクエストを転送先一覧へ順に振る。
// 転送先に残っている Pod が受け付けを止めていれば、そのリクエストは落ちる。
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.accepting {
			// 転送先には残っているのに、受け付けを止めている。これが事故。
			s.Dropped++
			s.logf(name + " は受け付けを止めているのに振られた。1 件失う")
			continue
		}
		p.inflight = append(p.inflight, s.cfg.Work)
	}
}

// advance は処理中のリクエストを1 tick 進め、終わったものを数える。
func (s *Sim) advance() {
	for _, p := range s.pods {
		var rest []int
		for _, left := range p.inflight {
			if left-1 <= 0 {
				s.Served++
				continue
			}
			rest = append(rest, left-1)
		}
		p.inflight = rest
	}
}

// lifecycleStep は終了処理の予定を時刻に照らして進める。
func (s *Sim) lifecycleStep() {
	for _, p := range s.pods {
		if p.phase != Terminating {
			continue
		}
		if p.removeAt >= 0 && s.now == p.removeAt {
			s.logf(p.Name + " が転送先一覧から外れた")
		}
		if p.sigtermAt >= 0 && s.now == p.sigtermAt {
			// SIGTERM。ここから新規は受けない。処理中のぶんは続ける。
			p.accepting = false
			s.logf(p.Name + " に SIGTERM。新規の受け付けを止める(処理中 " + itoa(len(p.inflight)) + " 件)")
		}
		if !p.accepting && len(p.inflight) == 0 {
			// 処理中が捌けた。猶予を待たずに自分から終わる。
			p.phase = Stopped
			s.logf(p.Name + " は処理中を捌き終えて停止")
			continue
		}
		if p.killAt >= 0 && s.now >= p.killAt {
			// 猶予切れ。処理中のリクエストは道連れになる。
			if n := len(p.inflight); n > 0 {
				s.Dropped += n
				s.logf(p.Name + " が猶予切れで SIGKILL。処理中 " + itoa(n) + " 件を道連れにする")
			}
			p.inflight = nil
			p.phase = Stopped
		}
	}
}

lifecycleStep に、その扱いが出ている。SIGTERM で止まるのは新規の受け付けだけで、処理中のリクエストは続く。捌け終われば、猶予を待たずに自分から停止する。だが捌け終わらないまま killAt に達すれば、SIGKILL で処理中のぶんは道連れになる。テストで、猶予が処理時間より短いと道連れが出て、十分なら出ないことを固定した。

つまり preStop と猶予期間は、守る対象が違う。preStop は外から来る分を、猶予期間は中で走っている分を守る。どちらが欠けても落ちるし、片方だけ延ばしても解決しない。よくある失敗は、落ちるからと猶予期間だけを長くすることで、これは処理中のリクエストにしか効かないので、伝播の隙間で落ちる分はそのまま残る。

Tick の中の順序も、実は結果を決めている。予定された終了処理を先に反映してから、リクエストを振る。逆にすると、SIGTERM が効いたのと同じ周期に振られてきたリクエストが、まだ受け付けられていることになってしまう。これは実装を書いていて最初に踏んだ間違いで、テストが捉えた。落ちるはずのケースで 0 件になり、対策の効果を証明できなくなっていた。

動かす

下のデモは、トラフィックが流れているところに Pod の削除を差し込む。preStop を 0 にすると、転送先に残ったまま受け付けを止めた周期(黄)が生まれ、そこでリクエストが落ちる(赤)。伝播を覆う長さに変えると窓が閉じて 0 件になる。そのうえで猶予を短くすると、今度は処理中のぶんが落ちる。

デモPodの終了(graceful shutdown)成功 48 / 失敗 8
preStop(SIGTERM の前に待つ時間)0(待たない)2(伝播より短い)4(伝播を覆う)
猶予(SIGKILL までの時間)1(処理時間より短い)10(十分)

転送先から外れるまでの遅れ 4 / 1リクエストの処理時間 3 / 毎周期 4 件が届く / レプリカ 2

00
1×2
2×2
3×2
4×2
52
62
74
84
94
104
114
124
134
144
154
処理できた転送先に残っているが受け付けていないリクエストが落ちた
8 件が落ちた: 転送先に残ったまま受け付けを止めた周期が 4 回ある
起きたこと
t=1 pod-1 の削除を開始(転送先から外れるのは t=5、SIGTERM は t=1)
t=1 pod-1 に SIGTERM。新規の受け付けを止める(処理中 2 件)
t=1 pod-1 は受け付けを止めているのに振られた。1 件失う
t=1 pod-1 は受け付けを止めているのに振られた。1 件失う
t=2 pod-1 は受け付けを止めているのに振られた。1 件失う
t=2 pod-1 は受け付けを止めているのに振られた。1 件失う
t=3 pod-1 は処理中を捌き終えて停止
t=3 pod-1 は受け付けを止めているのに振られた。1 件失う
t=3 pod-1 は受け付けを止めているのに振られた。1 件失う
t=4 pod-1 は受け付けを止めているのに振られた。1 件失う
t=4 pod-1 は受け付けを止めているのに振られた。1 件失う

削除を決めると、転送先一覧からの除去と SIGTERM が同時に動き出す。除去が伝わるには時間がかかるので、 preStop を 0 にすると、まだ振られてくるのに受け付けない Pod ができる。preStop を伝播以上に取れば、 その窓が閉じて 1 件も落ちない。猶予を短くすると、今度は処理中のリクエストが SIGKILL に巻き込まれる。 preStop は外から来る分を、猶予は中で走っている分を守っていて、どちらが欠けても落ちる。

設計の観点

  • 並行する2つの流れとして見る: 終了を1本の手順として考えると、この事故は理解できない。切り離しと停止が競走していて、どちらが勝つか分からない、と捉えるのが正しい
  • 待つことが対策になる: preStop に置くのは処理でなく、ただの待ち時間。伝播の完了を Pod 側から知れない以上、十分な時間を待つ以外に手がない
  • 守る対象が違う2つの設定: preStop は外から来る分、猶予期間は中で走っている分。症状が同じでも原因が違うので、片方を延ばしても直らない
  • レプリカ1個だと必ず落ちる: 転送先が空になる瞬間ができる。冗長化は障害対策だけでなく、正常な更新のためにも要る
  • 静かに落ちる: デプロイのたびに少量が落ちる形なので、全体のエラー率には埋もれる。デプロイ時刻とエラーを結びつけて見ないと気づけない
  • 調整ループとの関係: ローリング更新は「古い Pod を消して新しい Pod を作る」の繰り返しで、その消す側がこの章の処理になる。更新の回数だけこの窓が開く

対照と実例

preStop猶予期間(terminationGracePeriod)
守るものこれから振られてくる分すでに処理中の分
効く相手転送先一覧の遅れ処理の長さ
決め方伝播の遅れ以上最長の処理時間以上
短いと受けられないのに振られる処理中が強制終了される
実物フックに待ち時間を置くPod の設定値(既定 30 秒)

裏どり:

  • Pod の終了フロー: 削除から、Endpoints の更新、preStop、SIGTERM、猶予、SIGKILL までの順序
  • terminationGracePeriodSeconds: 既定 30 秒。preStop の時間もこの猶予の中に含まれる点に注意が要る
  • Endpoints の伝播: EndpointSlice の更新と kube-proxy のルール書き換えが非同期であること
  • Container Lifecycle Hooks: preStop に待ち時間を置く手法が、実務上の標準的な対処として広く使われていること

簡略化したこと

  • probe なし: readiness probe による転送先の出し入れは扱わない。ここでは削除だけを起点にする(次章ヘルスチェック)
  • preStop が猶予に含まれない: 実物の猶予期間は preStop の時間も含む。両方を足した値を見積もる必要がある
  • 1 コンテナ: サイドカーの終了順序は扱わない。実物では、本体より先にサイドカーが落ちる問題が起きる
  • 接続の再利用なし: keep-alive で保持された接続が終了後も使われる問題は扱わない
  • 論理時刻: 実時間でなく tick で数える。秒への対応づけは設定しだい

参考資料