Skip to content

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
同じ3つのコンテナ。並べただけの左は両端で穴が空き、順序を宣言した右は空かない

順に見ていく。

  1. 並べると並行になる: 順序は宣言しない限り存在しない
  2. sidecar は非対称: 起動は本体より先、停止は本体より後
  3. 穴は両端に空く: 起動側だけ直しても、終了側で同じことが起きる

① 順番に、1つずつ

まず、コンテナを置く枠を3つに分ける:

go

// 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 は本体で、枠をすべて通り切ってから、まとめて並行に起動する。

進め方はこうなる:

go

// 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 でも次へ進むのが、ここでの分かれ目になる。InitDone になるまで通さないが、Sidecar は稼働に入った時点で通す。同じ枠に居ながら、通過の条件だけが違う。

② 起動は先、停止は後

SidecarSidecar たらしめているのは、実は停止のほうになる:

go

// 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 しても全コンテナが終了状態に収束することを固定した。

③ 穴を数える

差を言葉で説明しても伝わらないので、数える:

go

// 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 になるまで長くかかることも固定してある。安全と速さが引き換えになっていることを、テストの形で残しておきたかった。

失敗したときの振る舞いも入れてある:

go

// 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つも始まらないまま待ち時間が伸びていく。

デモinit container と sidecar出口なし 宣言 0 / 並べただけ 2
順序を宣言するproxy を sidecar 枠に置く
config-fetchinit終了
proxysidecar稼働中
webapp稼働中
出口なし0 tick
t=7 ・ 本体が稼働中
t=2 config-fetch が完了
t=2 proxy の起動を開始
t=5 proxy が稼働開始
t=5 web の起動を開始
t=6 web が稼働開始
並べただけproxy も本体と同じ枠に置く
config-fetchinit終了
proxyapp稼働中
webapp稼働中
出口なし2 tick
t=7 ・ 本体が稼働中
t=2 config-fetch が完了
t=2 proxy の起動を開始
t=2 web の起動を開始
t=3 web が稼働開始
t=5 proxy が稼働開始
並べただけの Pod は、起動の途中で本体が出口なしのまま 2 tick 動いていた。停止させると、今度は出口のほうが先に消えて同じ穴が空く

同じ3つのコンテナを2通りの書き方で並行に動かしている。違いは proxy をどの枠に置くかだけ。 帯の色は各時刻の状態で、薄い灰が順番待ち、黄が起動中と停止処理中、緑が稼働中、赤が失敗。 いちばん下の行が、本体が生きているのに出口が居ない時刻を表す。左は起動が1 tick 遅い代わりに この行が空のままで、右は起動が速い代わりに両端が埋まる。 「init を1回失敗させる」を入れると、後続が1つも始まらないまま待ち時間が伸びる様子が見える。

設計の観点

  • 順序は宣言しない限り無い: 並べた順は起動順ではない。速いものが先になるだけで、たまたま動いていることがある
  • 依存の向きに枠を合わせる: 終わるものは Init、終わらないものは Sidecar。この区別だけで両端の穴が閉じる
  • 生存区間で考える: Sidecar は本体の生存区間を覆う。覆っていれば、本体が動いている間はいつでも依存先がある
  • 速さと確実さの取引: 1つずつ通せば起動は遅くなる。遅くなったぶんが、穴の無さになっている
  • 失敗は伝播させる: Init が失敗したら後続を始めない。中途半端に立ち上がった Pod より、立ち上がらない Pod のほうが扱いやすい
  • Pod の終了と同じ構造: 外側では転送先一覧と停止の競合、内側ではコンテナ同士の競合。どちらも「切り離しと停止の順序」の問題になっている

対照と実例

並べただけ順序を宣言する
起動の順序起動の速いものが先宣言順
起動にかかる時間短い(並行)長い(直列)
起動直後の穴本体が先に立つと空く空かない
停止の順序止まるのが速いものが先本体が先、Sidecar が後
停止時の穴出入口が先に消えると空く空かない
依存先の失敗本体は起動してしまう本体は起動しない

裏どり:

  • init container: 宣言順に1つずつ実行され、すべて成功するまでアプリコンテナは開始されない。失敗すると restartPolicy に従って再試行する
  • sidecar container: initContainersrestartPolicy: Always を付けたものが sidecar として扱われる。準備が整った時点で次へ進み、アプリの終了後に停止する
  • CrashLoopBackOff: 再起動の待ち時間は 10 秒から倍に伸び、5 分で頭打ちになる。これは既定で、いまはノードごとに頭打ちを 1 秒から 300 秒の間で変えられる。頭打ちを 10 秒より短くすると、最初の待ち時間もそこまで下がる。既定そのものを 1 秒と 60 秒へ落とす設定も別に用意されている
  • service mesh: プロキシを注入する構成では、この起動順序が実際の問題として現れる

簡略化したこと

  • プロセスなし: コンテナの中で何が動くかは扱わない。準備にかかる時間を台本で与えるだけ
  • リクエストなし: 出口の有無を tick 数で数えるだけで、実際の通信は流さない
  • プローブなし: 実物は readiness probe で準備完了を判定する。ここでは時間で決める
  • 共有なし: 実物のコンテナは Pod 内でネットワークとボリュームを共有する
  • リソースなし: ResourceQuota の計算で init と本体の扱いが違う点は扱わない

参考資料