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 切り離し完了 → 落ちない順に見ていく。
- 転送先一覧は現実に遅れる: 一覧は制御側の持ち物で、Pod が実際に死んだことを知らない
- preStop は外から来る分を守る: 停止を遅らせて、切り離しが伝わりきるのを待つ
- 猶予期間は中で走っている分を守る: 処理中のリクエストを終わらせてから強制終了する
① 転送先一覧は現実に遅れる
まず、事故が起きる場所を作る。トラフィックが振られる先の一覧だ:
// 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: 停止を遅らせて伝播を待つ
事故の構造が分かれば、対策は素直だ。削除の開始を見る:
// 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 の時点ですでに処理中のリクエストは、また別に守る必要がある:
// 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 件になる。そのうえで猶予を短くすると、今度は処理中のぶんが落ちる。
転送先から外れるまでの遅れ 4 / 1リクエストの処理時間 3 / 毎周期 4 件が届く / レプリカ 2
削除を決めると、転送先一覧からの除去と 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 で数える。秒への対応づけは設定しだい
参考資料
- Pod Lifecycle: Termination of Pods — 終了の順序
- Container Lifecycle Hooks — preStop の位置づけ
- EndpointSlices — 転送先一覧の伝播
- 実装: orchestration/lifecycle