Skip to content

ローリング更新

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

動いているものを、止めずに新しい版へ入れ替える。何個多く作ってよいか(maxSurge)と、何個まで減ってよいか(maxUnavailable)、この2つの幅が入れ替えの速さと保たれる容量を決める。進行の条件は readiness に委ねてあるので、壊れた版をデプロイしても古い版が消えず途中で止まる。実装すると、幅の設定がそのまま被害の上限になることが数字で出る。

この章で作るもの

この編で作ってきた部品が、ここで1つに合わさる。調整ループが宣言された数を守り、スケジューラが置き場所を決め、終了処理が落とさずに止め、ヘルスチェックが受けられるかを判定する。ローリング更新は、その全部を使って「動いているものを、止めずに入れ替える」を行う。

素朴には、全部消してから全部作ればいい。だがその間サービスは止まる。逆に全部作ってから全部消すなら、一瞬とはいえ2倍の資源が要る。現実にはその間のどこかを選ぶ。何個多く作ってよいか、何個まで減ってよいか。この2つの幅が、入れ替えの速さと、その間に保たれる容量を決める。

そして、更新には失敗がつきものだ。新しい版が起動しない、起動しても応えない。このとき何が起きてほしいかを、あらかじめ決めておく必要がある。壊れた版が全 Pod に行き渡ってからサービス全体が落ちるのでは遅い。

   総数の上限 ─────────────  目標 + maxSurge(多く作ってよい数)

                  │  この帯の中で
   目標 ───────────┼─────────  Replicas
                  │  作っては消す

   ready の下限 ───────────  目標 − maxUnavailable(減ってよい数)

   maxSurge を上げる    → 速く終わる。資源は一時的に増える
   maxUnavailable を上げる → 速く終わる。容量が落ちる
   どちらも 0           → 1歩も進めない
2つの幅。上に maxSurge のぶん多く作れ、下に maxUnavailable のぶん減ってよい。この帯の中でしか動けない

順に見ていく。

  1. 2つの幅がすべてを決める: maxSurge は速さ、maxUnavailable は容量の下限
  2. 進行の条件は readiness: 新しい版が ready にならなければ古い版は消えない
  3. 幅が被害の上限になる: 壊れた版を出したとき、どこまで容量が落ちるかを maxUnavailable だけが決める

① 2つの幅

まず、動ける範囲を決める設定を作る:

go

// Config は入れ替えの幅を決める設定。
type Config struct {
	// Replicas は保ちたいレプリカ数。
	Replicas int
	// MaxSurge は目標数より何個多く作ってよいか。入れ替えの速さを決める。
	MaxSurge int
	// MaxUnavailable は目標数より何個少なくてよいか。容量の下限を決める。
	MaxUnavailable int
}

// minAvailable は入れ替え中に保つべき ready な数を返す。
func (c Config) minAvailable() int {
	n := c.Replicas - c.MaxUnavailable
	if n < 0 {
		return 0
	}
	return n
}

// maxTotal は入れ替え中に持ってよい Pod の総数を返す。
func (c Config) maxTotal() int { return c.Replicas + c.MaxSurge }

// Deadlocked は幅がどちらも 0 で、入れ替えが1歩も進めない設定かを返す。
// 多く作ることも、減らすこともできないので、何も起こらない。
func (c Config) Deadlocked() bool { return c.MaxSurge == 0 && c.MaxUnavailable == 0 }

minAvailable が ready を保つべき数の下限、maxTotal が持ってよい総数の上限になる。入れ替えはこの帯の中でしか動けない。

2つの幅は、それぞれ別のものを買う。maxSurge を上げると、古いのを消す前に新しいのを作れるので、速く終わる。代わりに一時的に資源が増える。maxUnavailable を上げると、先に消してよいので、これも速く終わる。代わりに、その間の容量が落ちる。どちらも速さを買うが、支払うものが違う。

Deadlocked は、どちらも 0 の設定を検出する。多く作ることも、減らすこともできないので、入れ替えは1歩も進まない。実物の Kubernetes もこの組み合わせを拒否する。当たり前に見えて、両方を安全側に倒したくなる場面はあるので、明示しておく価値がある。テストで、この設定では1つも入れ替わらないことを固定した。

② 1周期に打つ手は2つだけ

入れ替えの本体は、拍子抜けするほど短い:

go

// Step は入れ替えを1周期進める。起動を進めてから、作る手と消す手を1つずつ打つ。
//
// 判断はどちらも「今 ready な数」に基づく。新しい版が ready にならない限り
// 古い版は消えないので、壊れた版をデプロイしても置き換えは途中で止まる。
func (r *Rollout) Step() {
	for _, p := range r.pods {
		p.age++
	}

	var acts string

	// ① 目標の版が足りず、総数に余裕があれば作る(maxSurge の枠内)。
	if r.countVersion(r.target.Version) < r.cfg.Replicas && len(r.pods) < r.cfg.maxTotal() {
		p := r.create(r.target)
		acts = "create " + p.Name + "(v" + itoa(p.Version) + ")"
	}

	// ② 消しても下限を割らないなら、古い版を1つ消す。
	// ここが安全弁になる。余裕がなければ古い版は残り、容量が保たれる。
	if old := r.oldest(); old != nil && r.canRemove(old) {
		r.remove(old)
		if acts != "" {
			acts += " / "
		}
		acts += "delete " + old.Name + "(v" + itoa(old.Version) + ")"
	}

	if acts == "" {
		acts = "動けない(新しい版が ready になるのを待つ)"
	}
	r.logf(acts)
	r.snapshot(acts)
	r.step++
}

Step がやるのは、作る手と消す手を1つずつ打つことだけだ。目標の版が足りず、総数に余裕があれば作る。消しても下限を割らないなら、古い版を1つ消す。これを繰り返す。

ここで注目してほしいのは、どちらの判断も「今の状態」だけを見ていることだ。前の周期で何をしたかを覚えていないし、これから何手打つかの計画も持たない。毎回、現状を数え直して、打てる手を打つ。調整ループとまったく同じ形になっている。実際、実物の Kubernetes でも Deployment コントローラはこの調整ループとして書かれていて、入れ替えは専用の手続きではない。

Deploy も同じ発想だ。目標の版を書き換えるだけで、何も作らず何も消さない。以降の Step が差を見て埋めていく。手順でなく状態を宣言する、というこの編の一貫した形が、ここでも効いている。ロールバックが特別な操作でないのも、これが理由になる。壊れた版で止まった後、元の版をもう一度宣言すれば、同じ仕組みが逆向きに走って戻る。テストで、止まった状態から Deploy し直すだけで元に戻ることを固定した。

③ readiness が全滅を防ぐ

消す手の条件が、この章でいちばん大事な部分だ:

go

// canRemove は p を消しても ready な数が下限を割らないかを返す。
//
// p がそもそも ready でなければ、消しても ready な数は変わらない。だから
// いつでも消せる。当たり前に見えて、これが無いと壊れた版から戻れなくなる。
// ready にならない Pod が枠を埋めたまま、下限に張り付いて動けなくなるからだ。
func (r *Rollout) canRemove(p *Pod) bool {
	if !p.Ready() {
		return true
	}
	return r.Available() > r.cfg.minAvailable()
}

// oldest は目標でない版の Pod を1つ返す。ready でないものを先に選ぶ。
// 消しても損をしない相手から片づけるほうが、入れ替えが詰まりにくい。
func (r *Rollout) oldest() *Pod {
	var fallback *Pod
	for _, p := range r.pods {
		if p.Version == r.target.Version {
			continue
		}
		if !p.Ready() {
			return p
		}
		if fallback == nil {
			fallback = p
		}
	}
	return fallback
}

canRemove は、消しても ready な数が下限を割らないかを見る。新しい版がまだ起動中なら ready な数は増えていないので、古い版は消せない。起動が終わって ready になれば、余裕ができて消せる。つまり入れ替えの進行は、新しい版が実際に受けられるようになったことを条件にしている。

この条件が、そのまま安全弁になる。壊れた版をデプロイしたとする。新しい Pod は作られるが、いつまでも ready にならない。ready な数は古い版のぶんだけのままなので、余裕が生まれず、古い版は消えない。置き換えは途中で止まる。全滅しない。テストで、maxUnavailable を 0 にしておけば、壊れた版をデプロイしても容量が目標のまま保たれ、古い版が全部残ることを固定した。

そして、どこまで落ちてから止まるかを決めるのは maxUnavailable だけになる。0 なら1つも減らずに止まる。2 なら 2 つ減ってから止まる。この値は「入れ替え中に許容できる容量の落ち」であると同時に、「壊れた版を出したときの被害の上限」でもある。テストで、同じ壊れた版でも値が違えば止まる深さが変わることを固定した。

canRemove の前半、ready でない Pod はいつでも消せるという判定も要る。消しても ready な数は変わらないからだ。当たり前に見えるが、これが無いと壊れた版から戻れなくなる。ready にならない Pod が枠を埋めたまま、下限に張り付いて動けなくなる。これは実装していて実際に踏んだ間違いで、ロールバックのテストが捉えた。

動かす

下のデモは、2つの幅を変えながら入れ替えを最後まで走らせる。maxSurge を上げると速く終わり、maxUnavailable を上げると容量が落ちる。新しい版を「壊れている」に切り替えると、置き換えが途中で止まり、そのとき容量がどこまで落ちるかが maxUnavailable で変わる。どちらの幅も 0 にすると1歩も進まない。

デモローリング更新完了 12 周期 / 最小容量 4
maxSurge(何個多く作ってよいか)012
maxUnavailable(何個まで減ってよいか)012
新しい版 v2正常壊れている(readyにならない)

目標 4 レプリカ / 起動に 2 周期 / 保つべき ready 数 4 / 持ってよい総数 5

下限 4
0
1
2
3
4
5
6
7
8
9
10
11
v1 稼働中v2 稼働中起動待ち(まだ受けられない)
12 周期で全部 v2 になった。その間 ready な数は最小 4 まで(下限 4)
打った手
step 0: create pod-5(v2)
step 1: 動けない(新しい版が ready になるのを待つ)
step 2: delete pod-1(v1)
step 3: create pod-6(v2)
step 4: 動けない(新しい版が ready になるのを待つ)
step 5: delete pod-2(v1)
step 6: create pod-7(v2)
step 7: 動けない(新しい版が ready になるのを待つ)
step 8: delete pod-3(v1)
step 9: create pod-8(v2)
…ほか 2 行

maxSurge を上げると多く作れるので速く終わり、maxUnavailable を上げると先に消せるので容量が落ちる。 速さと容量は交換になっている。新しい版を「壊れている」に切り替えると、v2 が ready にならないので v1 が消えず、 置き換えは途中で止まる。このとき容量がどこまで落ちるかは maxUnavailable だけが決めている。 進めてよいかの判断を readiness に委ねていることが、そのまま全滅を防ぐ仕掛けになっている。

設計の観点

  • 2つの幅は別のものを買う: maxSurge は資源で速さを買い、maxUnavailable は容量で速さを買う。どちらを払えるかは、余っている資源と、落とせる容量で決まる
  • maxUnavailable は被害の上限: 入れ替え中の設定であると同時に、壊れた版を出したときにどこまで壊れるかの上限でもある。この二重の意味で読むと、値の決め方が変わる
  • readiness の正しさに乗っている: 起動しただけで ready を返す実装だと、この安全弁は効かない。壊れた版が ready を名乗り、次々と置き換わって全滅する。ヘルスチェックが実際の処理能力を見ていることが前提になる
  • 止まったまま放置しない: 実物には progressDeadline があり、一定時間進まないと失敗として記録する。止まったこと自体に気づける仕掛けが要る
  • 消す側は終了処理を通る: ここでは即座に消しているが、実際には終了処理が挟まる。preStop が長ければ、入れ替え全体もそのぶん遅くなる
  • 段階的な公開は別の道具: 新しい版に少しだけトラフィックを流して様子を見る(カナリア)には、置き換えでなく振り分けの制御が要る。ローリング更新だけでは足りない

対照と実例

maxSurge を上げるmaxUnavailable を上げる
速さ速くなる速くなる
支払うもの一時的な資源(台数・費用)入れ替え中の容量
壊れた版のとき被害は変わらない被害が深くなる
向く場面資源に余裕がある容量に余裕がある
0 にすると先に消すしかない先に作るしかない

裏どり:

  • Deployment のローリング更新: maxSurge / maxUnavailable の既定は 25% ずつ。両方 0 は拒否される
  • available の定義: minReadySeconds を過ぎて ready であり続けた Pod だけが available として数えられる
  • progressDeadlineSeconds: 既定 600 秒。進まないまま超えると Progressing=False になり、止まったことが記録される
  • ロールバック: 実物は版ごとの ReplicaSet が残るので、前の版の宣言に戻すだけで戻れる。本章の Deploy し直しにあたる

簡略化したこと

  • ReplicaSet を作らない: 実物は版ごとに ReplicaSet を作り、その replicas を上下させて入れ替える。ここは Pod を直接扱う
  • 版は2つまで: 入れ替え中にさらに別の版をデプロイする場合の扱いは簡略化している
  • 終了処理なし: 消す側の preStop や猶予期間は扱わない。ここでは即座に消える
  • progressDeadline なし: 止まったことを記録して失敗と扱う仕組みは入れていない
  • 論理時刻: 実時間でなく周期で数える

参考資料