init container と sidecar
実装:
orchestration/initcontainer// 実行:go test ./orchestration/initcontainer/
ここまで Pod は1つの塊として扱ってきた。だが中には複数のコンテナが入っていて、その間にも順序がある。宣言しなければ並行に立ち上がるので、速いほうが先になる。プロキシより本体の起動が速ければ、出口のないまま本体が動く時間ができる。終了のときは向きが逆で、プロキシが先に消えれば処理は外に出られない。Pod の内側にも、外側で見たのと同じ事故がある。
この章で作るもの
この編はずっと Pod を1つの箱として扱ってきた。作る、消す、置き場所を決める、数を合わせる。だが箱の中には、たいてい複数のコンテナが入っている。設定を取ってくる処理、通信を代理するプロキシ、ログを送る常駐、そして本体。
素朴には、必要なコンテナを並べて書けばよさそうに見える。だが並べて書くと、それらは並行に立ち上がる。並行ということは、どれが先に準備できるか決まっていないということだ。決まっていない以上、起動の速いものが先になる。
ここで困るのは、本体が最も速いことが多い点にある。設定の取得は外部に問い合わせるし、プロキシは経路情報を読み込む。本体はプロセスを起こすだけで済む。結果として、本体だけが先に立ち上がり、依存しているものが揃っていないまま動く時間ができる。
そして終了のときは、向きが逆になる。プロキシが先に消えると、本体がまだ抱えている処理の出口が塞がる。Pod の終了で見た「転送先一覧から外れる前に止まる」事故と、まったく同じ形が Pod の内側でも起きる。
この章で作るのは、この差を数えるものになる。同じ3つのコンテナを、順序を宣言せずに並べた場合と、宣言した場合とで動かして、出口のないまま本体が動いていた時間を比べる。
順序を宣言しない 順序を宣言する
(3つとも同じ枠に並べる) (proxy を sidecar 枠へ)
init が完了 init が完了
proxy と web が同時に起動を始める proxy が起動を始める
web が稼働開始 ✕ 出口がまだ無い proxy が稼働開始
(proxy はまだ起動中) ✕ web が起動を始める
proxy が稼働開始 web が稼働開始
── 停止要求 ── ── 停止要求 ──
proxy と web が同時に止まり始める web が止まり始める
proxy が消える ✕ 出口が先に消えた web が消える
web はまだ処理を抱えている ✕ proxy が止まり始める
web が消える proxy が消える
出口なしの時間: 4 出口なしの時間: 0
Ready までの時間: 6 Ready までの時間: 7順に見ていく。
- 並べると並行になる: 順序は宣言しない限り存在しない
- sidecar は非対称: 起動は本体より先、停止は本体より後
- 穴は両端に空く: 起動側だけ直しても、終了側で同じことが起きる
① 順番に、1つずつ
まず、コンテナを置く枠を3つに分ける:
// Kind はコンテナが起動順序のどこに置かれるかを表す。
type Kind int
const (
// Init は宣言順に1つずつ動き、完了してから次へ進む。
// すべての Init が終わるまで App は1つも始まらない。
Init Kind = iota
// Sidecar も Init 枠に並ぶが、完了を待たずに次へ進む。
// 動き続けたまま App の起動を許し、停止は App の後になる。
Sidecar
// App は本体。Init 枠がすべて片付いてから、まとめて並行に起動する。
App
)
// Spec は 1 つのコンテナの宣言。起動と停止にかかる時間を台本として与えるので、
// 実時間にも乱数にも依存せず、同じ入力なら必ず同じ結果になる。
type Spec struct {
Name string
Kind Kind
// Boot は準備が整うまでにかかる tick。
Boot int
// Fails は最初の何回を失敗させるかの台本。Init の失敗で Pod 全体が
// 止まることを再現するために使う。
Fails int
// Drain は停止要求を受けてから止まるまでにかかる tick。
Drain int
// Proxy は、このコンテナが通信の出入口を担うかを表す。
// 本体が動いているのに出入口が居ない時間を数えるために使う。
Proxy bool
}Init は宣言順に1つずつ動き、完了してから次へ進む。1つでも失敗すれば、そこで止まったまま後続は始まらない。並行ではないので、遅い。だが順序は確実になる。
Sidecar も同じ枠に並ぶが、完了を待たない。稼働に入ったら次へ進ませ、自分は動き続ける。プロキシやログ送信のように「終わらないが、先に立っていてほしい」ものがここに入る。
App は本体で、枠をすべて通り切ってから、まとめて並行に起動する。
進め方はこうなる:
// startStep は起動処理を1 tick 進める。
//
// gate は1つずつしか進めない。1つが片付いた時刻に次を開始し、開始した時刻には
// 進捗させない。この「開始と進捗を同じ時刻に重ねない」規則で、宣言順が
// そのまま時間順になる。
func (p *Pod) startStep() {
for p.idx < len(p.gate) {
c := p.gate[p.idx]
if c.phase == Waiting {
p.begin(c)
return
}
p.progress(c)
if c.phase == Done || (c.spec.Kind == Sidecar && c.phase == Running) {
p.idx++
continue
}
return
}
// gate を通り切った。本体はここから並行に立ち上がる。
// 並行ということは、どれが先に Running になるかは Boot の短さで決まる。
for _, c := range p.main {
if c.phase == Waiting {
p.begin(c)
continue
}
p.progress(c)
}
}p.idx が「枠のどこまで通ったか」を持っていて、これが進まない限り本体は1つも始まらない。ループの中で begin した時刻には進捗させず、次の時刻から数える。この規則があるので、宣言順がそのまま時間順になる。テストで、Init が起動中の間ずっと本体が Waiting のままであること、前の Init が完了した時刻に次の Init が始まることを固定した。
Sidecar だけ Running でも次へ進むのが、ここでの分かれ目になる。Init は Done になるまで通さないが、Sidecar は稼働に入った時点で通す。同じ枠に居ながら、通過の条件だけが違う。
② 起動は先、停止は後
Sidecar を Sidecar たらしめているのは、実は停止のほうになる:
// terminateStep は停止処理を1 tick 進める。
//
// 本体が先、Sidecar は後。この順序が Sidecar を Sidecar たらしめている。
// 本体と同じ枠に置いたコンテナは本体と同時に止まり始めるので、停止が速ければ
// 本体より先に消える。
func (p *Pod) terminateStep() {
// 起動の途中で消された場合、まだ順番待ちや起動中だった Init は打ち切られる。
for _, c := range p.gate {
if c.spec.Kind == Init {
p.drain(c)
}
}
for _, c := range p.main {
p.drain(c)
}
if !p.mainDone() {
return
}
for _, c := range p.gate {
if c.spec.Kind != Sidecar {
continue
}
p.drain(c)
}
}
// drain は 1 つのコンテナを停止方向へ1 tick 進める。
func (p *Pod) drain(c *Container) {
switch c.phase {
case Running:
c.phase = Draining
c.left = c.spec.Drain
p.logf(c.spec.Name + " の停止を開始(残り処理 " + itoa(c.left) + " tick)")
if c.left <= 0 {
c.phase = Done
p.logf(c.spec.Name + " が停止")
}
case Draining:
c.left--
if c.left <= 0 {
c.phase = Done
p.logf(c.spec.Name + " が停止")
}
case Waiting, Booting, Failed:
// 起動しきる前に止められた分は、そのまま終わる。
c.phase = Done
}
}
// mainDone は本体がすべて終了したかを返す。
func (p *Pod) mainDone() bool {
for _, c := range p.main {
if c.phase != Done {
return false
}
}
return true
}本体を先に止め、本体がすべて終わってから Sidecar を止める。この非対称が肝になる。起動では本体より先、停止では本体より後。つまり Sidecar の生存区間は、本体の生存区間を完全に覆う。覆っているから、本体が動いている間はいつでも出口がある。
本体と同じ枠に置いたコンテナには、この保護が無い。停止要求は全員に同時に届くので、あとは止まるのが速いほうが先に消える。プロキシは抱えている処理が少ないぶん速く消えるので、たいてい本体より先に消える。
Init が打ち切られる経路も、ここに入れてある。起動の途中で Pod が消されることは普通にあるし、そのとき順番待ちだったコンテナは一度も動かないまま終わる。テストで、Init の起動中に Terminate しても全コンテナが終了状態に収束することを固定した。
③ 穴を数える
差を言葉で説明しても伝わらないので、数える:
// exposed は本体が動いている(処理を抱えている)のに出入口が居ない状態かを返す。
//
// 両側で Draining を数えているのが大事なところになる。本体は停止処理の最中も
// まだ処理を抱えているし、出入口は停止処理の最中もまだ通してくれる。
// 数えたいのは、本体がまだ生きているのに出入口が消えた時間だけだ。
func (p *Pod) exposed() bool {
live, gateway := false, false
for _, c := range p.all {
alive := c.phase == Running || c.phase == Draining
if c.spec.Proxy {
gateway = gateway || alive
continue
}
if c.spec.Kind == App && alive {
live = true
}
}
return live && !gateway
}本体が生きているのに出入口が居ない時刻を、1 tick ずつ数え上げる。
両側で Draining を生きている側に入れているのが、この判定でいちばん間違えやすいところになる。本体は停止処理の最中もまだ処理を抱えているので、生きているに数える。出入口も停止処理の最中はまだ通してくれるので、居るに数える。数えたいのは、本体がまだ生きているのに出入口が完全に消えた時間だけだ。片方だけ Draining を除くと、停止の開始した時刻を誤って穴に数えてしまう。
テストで、順序を宣言した側は起動から停止まで通して 0、並べただけの側は起動側 2 と停止側 2 で合計 4 になることを固定した。同時に、宣言した側のほうが Ready になるまで長くかかることも固定してある。安全と速さが引き換えになっていることを、テストの形で残しておきたかった。
失敗したときの振る舞いも入れてある:
// begin はコンテナの起動を始める。この時刻には進捗させない。
func (p *Pod) begin(c *Container) {
c.phase = Booting
c.left = c.spec.Boot
p.logf(c.spec.Name + " の起動を開始")
if c.left <= 0 {
p.settle(c)
}
}
// progress は起動中・再試行待ちのコンテナを1 tick 進める。
func (p *Pod) progress(c *Container) {
if c.phase == Failed {
c.backoff--
if c.backoff <= 0 {
c.phase = Booting
c.left = c.spec.Boot
p.logf(c.spec.Name + " を再試行する")
}
return
}
if c.phase != Booting {
return
}
c.left--
if c.left > 0 {
return
}
p.settle(c)
}
// settle は起動の結末をつける。台本で失敗が残っていれば失敗させ、
// 待ち時間を伸ばして再試行を待つ。実物の CrashLoopBackOff と同じ形になる。
func (p *Pod) settle(c *Container) {
if c.attempts < c.spec.Fails {
c.attempts++
c.phase = Failed
c.backoff = backoffFor(c.attempts)
p.logf(c.spec.Name + " が失敗(" + itoa(c.attempts) + " 回目)。" +
itoa(c.backoff) + " tick 待って再試行する。この間、後続は1つも始まらない")
return
}
if c.spec.Kind == Init {
c.phase = Done
p.logf(c.spec.Name + " が完了")
return
}
c.phase = Running
p.logf(c.spec.Name + " が稼働開始")
}
// backoffFor は失敗回数に応じて待ち時間を倍にしていく(上限つき)。
func backoffFor(attempts int) int {
d := 1
for i := 1; i < attempts; i++ {
d *= 2
if d >= 8 {
return 8
}
}
return d
}Init が失敗すると、待ってから再試行する。待ち時間は 1, 2, 4, 8 と倍に伸び、上限で止まる。この間、後続のコンテナは1つも始まらない。実物で Pod が Init:0/2 のまま何分も止まって見えるのは、この状態になっている。
動かす
下のデモは、同じ3つのコンテナを2通りの書き方で並べて動かす。左が順序を宣言した書き方、右が並べただけの書き方。時刻を進めると、右だけ本体が先に立ち上がって赤くなる。停止させると、今度は右のプロキシが先に消えてまた赤くなる。「Init を失敗させる」を入れると、後続が1つも始まらないまま待ち時間が伸びていく。
同じ3つのコンテナを2通りの書き方で並行に動かしている。違いは proxy をどの枠に置くかだけ。 帯の色は各時刻の状態で、薄い灰が順番待ち、黄が起動中と停止処理中、緑が稼働中、赤が失敗。 いちばん下の行が、本体が生きているのに出口が居ない時刻を表す。左は起動が1 tick 遅い代わりに この行が空のままで、右は起動が速い代わりに両端が埋まる。 「init を1回失敗させる」を入れると、後続が1つも始まらないまま待ち時間が伸びる様子が見える。
設計の観点
- 順序は宣言しない限り無い: 並べた順は起動順ではない。速いものが先になるだけで、たまたま動いていることがある
- 依存の向きに枠を合わせる: 終わるものは
Init、終わらないものはSidecar。この区別だけで両端の穴が閉じる - 生存区間で考える:
Sidecarは本体の生存区間を覆う。覆っていれば、本体が動いている間はいつでも依存先がある - 速さと確実さの取引: 1つずつ通せば起動は遅くなる。遅くなったぶんが、穴の無さになっている
- 失敗は伝播させる:
Initが失敗したら後続を始めない。中途半端に立ち上がった Pod より、立ち上がらない Pod のほうが扱いやすい - Pod の終了と同じ構造: 外側では転送先一覧と停止の競合、内側ではコンテナ同士の競合。どちらも「切り離しと停止の順序」の問題になっている
対照と実例
| 並べただけ | 順序を宣言する | |
|---|---|---|
| 起動の順序 | 起動の速いものが先 | 宣言順 |
| 起動にかかる時間 | 短い(並行) | 長い(直列) |
| 起動直後の穴 | 本体が先に立つと空く | 空かない |
| 停止の順序 | 止まるのが速いものが先 | 本体が先、Sidecar が後 |
| 停止時の穴 | 出入口が先に消えると空く | 空かない |
| 依存先の失敗 | 本体は起動してしまう | 本体は起動しない |
裏どり:
- init container: 宣言順に1つずつ実行され、すべて成功するまでアプリコンテナは開始されない。失敗すると
restartPolicyに従って再試行する - sidecar container:
initContainersにrestartPolicy: Alwaysを付けたものが sidecar として扱われる。準備が整った時点で次へ進み、アプリの終了後に停止する - CrashLoopBackOff: 再起動の待ち時間は 10 秒から倍に伸び、5 分で頭打ちになる。これは既定で、いまはノードごとに頭打ちを 1 秒から 300 秒の間で変えられる。頭打ちを 10 秒より短くすると、最初の待ち時間もそこまで下がる。既定そのものを 1 秒と 60 秒へ落とす設定も別に用意されている
- service mesh: プロキシを注入する構成では、この起動順序が実際の問題として現れる
簡略化したこと
- プロセスなし: コンテナの中で何が動くかは扱わない。準備にかかる時間を台本で与えるだけ
- リクエストなし: 出口の有無を tick 数で数えるだけで、実際の通信は流さない
- プローブなし: 実物は readiness probe で準備完了を判定する。ここでは時間で決める
- 共有なし: 実物のコンテナは Pod 内でネットワークとボリュームを共有する
- リソースなし:
ResourceQuotaの計算で init と本体の扱いが違う点は扱わない
参考資料
- Init Containers — 宣言順の実行と失敗時の再試行
- Sidecar Containers — 起動順序と停止順序の保証
- 実装: orchestration/initcontainer