Skip to content

kubeletとCRI

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

スケジューラは配置を決めたが、決めただけでは何も動かない。実際にプロセスを起こすのが各ノードの kubelet になる。これも調整ループだが、現状の取得先が置き場でなくランタイムなのが違う。しかも変化の通知に頼らず、一覧を毎回取り直す。そしてファイルから読む Pod があり、これが鶏と卵を解く。置き場そのものを、置き場を経由せずに起こす。

この章で作るもの

スケジューラは、Pod をどのノードに置くかを決めた。だが決めたのは配置だけで、そこには何のプロセスも無い。決まった配置を実際に起こすのが、各ノードで動いている kubelet になる。この編でずっと扱ってきた話の、いちばん下の端になる。

kubelet も調整ループになっている。あるべき姿と現状を比べて、差を埋める。形は同じだ。

違うところが1つある。現状の取得先が置き場ではない。ランタイムに聞く。

これは大きな違いになる。ここまでのコントローラは、置き場に書かれた「あるべき姿」と、置き場に書かれた「今の姿」を比べていた。どちらも同じ場所にある文字列だった。kubelet が比べるのは、置き場に書かれた宣言と、このマシンで実際に動いているプロセスになる。宣言と現実が突き合わされるのは、この層が初めてになる。

そしてもう1つ、この層にしかないものがある。ファイルから読む Pod だ。置き場を経由せず、ノード上のディレクトリに置いたファイルから直接起動する。これが鶏と卵を解く。

  ① まだ置き場が無い

  ノード ─────────────────────────────
   kubelet ──読む──→ /etc/kubernetes/manifests/
                       kube-apiserver.yaml

       └─CRI─→ ランタイム → kube-apiserver のプロセス

  ② 置き場が立った

  ノード ─────────────────────────────
   kubelet ──読む──→ ファイル(そのまま)

       └──watch──→ 置き場 ← ①で起こした本人

       └─CRI─→ ランタイム → apiserver / web / worker


  調整の中身は、置き場を見に行くコントローラと同じ形

    宣言(ファイル + 置き場)          実際(ランタイムの一覧)
       web/app                           web/app     Running
       worker/app          ←差を埋める→   (無い)
       (無い)                            stray/x     Running

    → worker/app を作る、stray/x を消す

  違うのは右側の出どころ。ここだけが本物のプロセスを見ている
置き場そのものを、置き場を経由せずに起こす。だから最初の1手が打てる

順に見ていく。

  1. 現状はランタイムに聞く: 宣言と現実が突き合わされるのは、この層だけ
  2. 一覧を毎回取り直す: 変化の通知に頼らない。最下層でも level-triggered
  3. ファイルの宣言が最初の1手を打つ: 置き場そのものを、置き場なしで起こせる

① ランタイムとの境界

まず、CRI の向こう側を作る:

go

// State はランタイムから見たコンテナの状態。
type State int

const (
	Creating State = iota // 起動中
	Running               // 稼働中
	Exited                // 終了した
)

func (s State) String() string {
	return [...]string{"Creating", "Running", "Exited"}[s]
}

// ContainerSpec は1つのコンテナの宣言。挙動は台本で与える。
type ContainerSpec struct {
	Name string
	// StartupTicks は起動にかかる時間。
	StartupTicks int
	// FailAfter は稼働に入ってから落ちるまでの時間(0 なら落ちない)。
	FailAfter int
}

// ContainerStatus はランタイムが返す一覧の1件。
type ContainerStatus struct {
	ID    string
	Pod   string
	Name  string
	State State
}

type instance struct {
	id    string
	pod   string
	spec  ContainerSpec
	state State
	left  int // 起動までの残り、または落ちるまでの残り
}

// Runtime は CRI の向こう側。kubelet はここに直接触らず、この面越しにだけ扱う。
//
// 境界を1枚置いたことが、実装を差し替えられることの正体になる。kubelet が
// 知っているのは「作れ」「消せ」「一覧をくれ」の3つだけで、その先が何であるかは
// 知らない。
type Runtime struct {
	seq   int
	items []*instance

	// Relists は一覧を取り直した回数。イベントに頼っていないことがここに出る。
	Relists int
	// Creates と Removes は呼び出しの回数。
	Creates int
	Removes int
}

// NewRuntime は空のランタイムを作る。
func NewRuntime() *Runtime { return &Runtime{} }

// Create はコンテナを作り、識別子を返す。
func (r *Runtime) Create(pod string, spec ContainerSpec) string {
	r.seq++
	r.Creates++
	id := "c" + itoa(r.seq)
	st := Creating
	left := spec.StartupTicks
	if left <= 0 {
		st, left = Running, spec.FailAfter
	}
	r.items = append(r.items, &instance{id: id, pod: pod, spec: spec, state: st, left: left})
	return id
}

// Remove はコンテナを消す。
func (r *Runtime) Remove(id string) {
	var rest []*instance
	for _, it := range r.items {
		if it.id != id {
			rest = append(rest, it)
			continue
		}
		r.Removes++
	}
	r.items = rest
}

// List は今あるコンテナの一覧を返す。Pod 名、コンテナ名の順で決定的にする。
func (r *Runtime) List() []ContainerStatus {
	r.Relists++
	out := make([]ContainerStatus, 0, len(r.items))
	for _, it := range r.items {
		out = append(out, ContainerStatus{ID: it.id, Pod: it.pod, Name: it.spec.Name, State: it.state})
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Pod != out[j].Pod {
			return out[i].Pod < out[j].Pod
		}
		return out[i].Name < out[j].Name
	})
	return out
}

// Step は実際の世界を1つ進める。起動が完了し、台本どおりにプロセスが落ちる。
// kubelet はこれを呼ばない。世界が勝手に進むことを表している。
func (r *Runtime) Step() {
	for _, it := range r.items {
		switch it.state {
		case Creating:
			it.left--
			if it.left <= 0 {
				it.state = Running
				it.left = it.spec.FailAfter
			}
		case Running:
			if it.spec.FailAfter <= 0 {
				continue
			}
			it.left--
			if it.left <= 0 {
				it.state = Exited
			}
		}
	}
}

Runtime に対して kubelet ができることは3つしかない。作る、消す、一覧をもらう。その先が何であるかは知らない。

この狭さが、境界を1枚置いたことの正体になる。実物では、この面が gRPC で切られていて、向こう側が containerd でも CRI-O でも同じように扱える。kubelet が知っているのがこの3つだけなので、差し替えても kubelet を書き直さずに済む。

Step を kubelet が呼んでいないことも、この分け方の一部になる。プロセスは kubelet の許可を待たずに落ちる。世界は勝手に進み、kubelet は起きたことを後から知る。

② 一覧を毎回取り直す

調整はこうなる:

go

// Sync は1周ぶんの調整を行う。
//
// 一覧を取り直すところから始まるのが肝になる。前回から何が変わったかを
// 覚えておいて差分で進めるのではなく、毎回まるごと見る。取りこぼしても
// 次の周で必ず気づく。
func (k *Kubelet) Sync() {
	actual := k.rt.List()

	// 今あるものを、Pod とコンテナの組で引けるようにする。
	live := map[string]ContainerStatus{}
	for _, s := range actual {
		live[s.Pod+"/"+s.Name] = s
	}

	wanted := map[string]bool{}
	for _, p := range k.Desired() {
		for _, c := range p.Containers {
			key := p.Name + "/" + c.Name
			wanted[key] = true

			cur, ok := live[key]
			switch {
			case !ok:
				k.start(p, c, key)
			case cur.State == Exited:
				k.rt.Remove(cur.ID)
				if !p.Restart {
					k.logf(key + " は終了した。作り直さない宣言なのでそのまま")
					continue
				}
				k.restarts[key]++
				k.waitUntil[key] = k.now + backoffFor(k.restarts[key])
				k.logf(key + " が落ちた。" + itoa(backoffFor(k.restarts[key])) +
					" 待って作り直す(" + itoa(k.restarts[key]) + " 回目)")
			}
		}
	}

	// 宣言に無いものは消す。集合の差を埋める形は[DaemonSet](daemonset)と同じ。
	for _, s := range actual {
		if !wanted[s.Pod+"/"+s.Name] {
			k.rt.Remove(s.ID)
			k.logf(s.Pod + "/" + s.Name + " は宣言に無い。消す")
		}
	}
}

// start は待ち時間を見たうえでコンテナを作る。
func (k *Kubelet) start(p PodSpec, c ContainerSpec, key string) {
	if until, ok := k.waitUntil[key]; ok && k.now < until {
		return // まだ待ち時間の内側
	}
	k.rt.Create(p.Name, c)
	if k.restarts[key] == 0 {
		k.logf(key + " を作った(" + p.Source.String() + " の宣言)")
	} else {
		k.logf(key + " を作り直した")
	}
}

// backoffFor は作り直しの待ち時間を倍に伸ばしていく(上限つき)。
func backoffFor(restarts int) int {
	d := 1
	for i := 1; i < restarts; i++ {
		d *= 2
		if d >= 8 {
			return 8
		}
	}
	return d
}

// Tick は世界を1つ進めてから調整する。
//
// 順序が大事で、先に世界が動く。kubelet は起きたことを後から知る。
func (k *Kubelet) Tick() {
	k.rt.Step()
	k.Sync()
	k.now++
}

// Running は今稼働しているコンテナの「Pod/名前」を返す。
func (k *Kubelet) Running() []string {
	var out []string
	for _, s := range k.rt.List() {
		if s.State == Running {
			out = append(out, s.Pod+"/"+s.Name)
		}
	}
	return out
}

Sync の最初の行が k.rt.List() になっているのが肝で、ここで毎回まるごと取り直している。前回から何が変わったかを覚えておいて差分で進める、という書き方をしていない。

実物のランタイムは変化を通知する仕組みを持っているし、kubelet もそれを補助的には使う。だが判断の土台は一覧のほうになる。通知は取りこぼすことがあるが、一覧は取りこぼしようがないからだ。API サーバと informer の章で見たのと同じ理屈が、いちばん下の層でも繰り返されている。

テストで、kubelet の知らないところでランタイムにコンテナが現れても、次の周で消えることを固定した。差分で進めていたら、この闖入者には永遠に気づけない。毎周 5 回の調整で 5 回一覧を取っていることも固定した。

宣言に無いものを消す部分は、DaemonSet の章と同じ集合の差になっている。数を数えるのではなく、あるべき集合と今ある集合を突き合わせる。

落ちたときの扱いもここにある。作り直す宣言なら作り直し、待ち時間が 1, 2, 4, 8 と倍に伸びる。実物で CrashLoopBackOff が見えるとき、この待ち時間を数えているのは各ノードの kubelet になる。調整ループを回している中央のコントローラではない。

③ ファイルの宣言が最初の1手を打つ

ここまでの話には、まだ穴がある。置き場から宣言が届く、と言ってきたが、その置き場は誰が起こすのか。

実物のクラスタでは、API サーバ自身が Pod として動いていることが多い。だとすると、API サーバを起こすには API サーバから宣言を受け取る必要があり、そのためには API サーバが動いていなければならない。

go

// Kubelet は1台のノードで、宣言と現実の差を埋め続ける。
type Kubelet struct {
	rt *Runtime

	filePods []PodSpec // ファイルから読んだもの。置き場と無関係に動く
	apiPods  []PodSpec // 置き場から届いたもの。最後に届いた内容を持ち続ける
	linked   bool      // 置き場に届くか

	now       int
	restarts  map[string]int
	waitUntil map[string]int // 再作成を待つ時刻

	Log []string
}

// New はランタイムに繋がった kubelet を作る。置き場とは最初は繋がっていない。
func New(rt *Runtime) *Kubelet {
	return &Kubelet{rt: rt, restarts: map[string]int{}, waitUntil: map[string]int{}}
}

// SetFilePods はノード上のファイルを置き換える。置き場とは無関係に効く。
func (k *Kubelet) SetFilePods(ps []PodSpec) {
	k.filePods = normalize(ps, FromFile)
	k.logf("ファイルの宣言を読み直した(" + itoa(len(ps)) + " 件)")
}

// Link は置き場との接続を切り替える。
func (k *Kubelet) Link(up bool) {
	if k.linked == up {
		return
	}
	k.linked = up
	if up {
		k.logf("置き場に届くようになった")
	} else {
		k.logf("置き場に届かなくなった。ファイルの宣言だけで動き続ける")
	}
}

// Linked は置き場に届くかを返す。
func (k *Kubelet) Linked() bool { return k.linked }

// Deliver は置き場から届いた宣言を受け取る。届かない状態なら何も起きない。
//
// 届かない間、最後に受け取った宣言が残り続けるのが大事なところになる。
// kubelet は置き場を見失っても止まらない。知っている宣言を守り続ける。
func (k *Kubelet) Deliver(ps []PodSpec) bool {
	if !k.linked {
		return false
	}
	k.apiPods = normalize(ps, FromAPIServer)
	return true
}

func normalize(ps []PodSpec, src Source) []PodSpec {
	out := append([]PodSpec(nil), ps...)
	for i := range out {
		out[i].Source = src
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

// Desired はこのノードで動いているべき Pod を返す。2つの出所を合わせたもの。
func (k *Kubelet) Desired() []PodSpec {
	out := append([]PodSpec(nil), k.filePods...)
	out = append(out, k.apiPods...)
	sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

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

// Restarts はコンテナが作り直された回数を返す。
func (k *Kubelet) Restarts(pod, name string) int { return k.restarts[pod+"/"+name] }

答えが filePods になる。kubelet は置き場のほかに、ノード上のディレクトリも見ている。そこに置かれたファイルは、置き場を一度も経由せずに Pod として起動される。

だから順序はこうなる。kubelet が起きる。ファイルを読んで、API サーバのコンテナを作る。API サーバが立つ。立ったので kubelet が置き場を見に行けるようになる。そこから他の Pod が届き始める。テストで、置き場に一度も繋がっていない状態でファイルの宣言だけが起動すること、置き場が立ってから他の Pod が続くことを固定した。

この構造には副産物がある。ファイルの宣言は置き場と無関係なので、置き場を見失っても消えない。それどころか、置き場から届いた宣言も消えない。最後に届いた内容を、kubelet はそのまま守り続ける。テストで、接続を切っても両方が動き続けること、切れている間に落ちたコンテナも作り直されることを固定した。

ノードは自律している、という言い方をよくするが、その中身はこれになる。指示が届かなくなっても、最後に知っている指示のとおりに動き続ける。切れている間の変更は届かないので、届くようになったところで追いつく。これも level-triggered だから成り立つ。

動かす

下のデモは、置き場が存在しないところから始める。ファイルの宣言だけで API サーバが立ち上がり、立ってから他の Pod が届く。接続を切ると、届いていた宣言はそのまま守られる。コンテナを外から落とすと、kubelet が一覧を取り直して気づき、作り直す。

デモkubelet と CRI一覧の取り直し 6 回 / t=6
宣言(あるべき姿)
kube-apiserver/apiserverfile
web/appapiserver
worker/appapiserver
実際(ランタイムの一覧)
kube-apiserver/apiserverrunning
web/apprunning
worker/apprunning
宣言と実際が一致している。置き場からの宣言も、ファイルの宣言も、両方が満たされている
t=0 kube-apiserver/apiserver を作った(file の宣言)
t=3 置き場が立った。ここから宣言が届く
t=3 web/app を作った(apiserver の宣言)
t=3 worker/app を作った(apiserver の宣言)

置き場が存在しないところから始まっている。ファイルの宣言だけで kube-apiserver が立ち上がり、 立ってから web と worker が届く。「置き場: 届かない」に切り替えても、最後に知っている宣言のまま 動き続ける。外からプロセスを落としたり、知らないコンテナを増やしたりすると、次の周で一覧を 取り直したときに気づいて直す。変化の通知を待っていないので、誰にも知らされなくても必ず気づく。

設計の観点

  • 宣言と現実の突き合わせは1箇所で: 中間の層はすべて置き場の中で完結する。実物と比べるのは最下層だけ
  • 通知でなく一覧を土台にする: 通知は取りこぼすが、一覧は取りこぼさない。重いぶんを払う価値がある
  • 境界は狭くする: 作る、消す、一覧をもらう。これだけなら実装を差し替えられる
  • 最初の1手の経路を別に用意する: 自分自身を起こせない仕組みは、外から起こす道が要る
  • 切れても動き続ける: 指示が届かないことと、指示が変わったことは違う。区別できないと切断のたびに全部止まる
  • 調整ループが最下層まで同じ形: 中央のコントローラも、ノードの kubelet も、やっていることは差を埋めることだけ

対照と実例

中央のコントローラkubelet
あるべき姿の出どころ置き場置き場 + ノード上のファイル
現状の出どころ置き場(写し)ランタイム(実物)
差の埋め方置き場に書くプロセスを起こす・止める
置き場を見失うと何もできない最後の宣言のまま動き続ける
取りこぼしへの備え一覧を取り直す一覧を取り直す

裏どり:

  • CRI: kubelet とランタイムの間の gRPC の面。RuntimeServiceImageService に分かれる。containerd や CRI-O が実装する
  • 3層になっている: kubelet → CRI → 高位ランタイム(containerd)→ 低位ランタイム(runc)。低位は OCI の仕様に従う
  • PLEG: Pod Lifecycle Event Generator。ランタイムの一覧を定期的に取り直して変化を検出する。この取り直しが重いことは既知の課題で、evented PLEG という改良が進んでいる
  • static pod: --pod-manifest-path が指すディレクトリのファイルから起動する Pod。kubeadm で作ったクラスタでは、API サーバ、etcd、コントローラマネージャ、スケジューラがこれで動く
  • static pod のミラー: 置き場が立った後、kubelet は static pod の写しを置き場に作る。見えるようにするためで、その写しを消しても本体は止まらない
  • node status: kubelet は定期的に自分の状態を置き場へ書き戻す。止まるとノードが NotReady になり、そこから Pod の退去が始まる

簡略化したこと

  • gRPC なし: CRI は関数呼び出しにしている。実物はプロセス間の通信になる
  • イメージなし: 取得や層の展開は扱わない。作れと言えば作れる
  • ミラーなし: static pod の写しを置き場に作る部分は扱わない
  • 状態の書き戻しなし: ノードの状態を置き場へ返す部分は扱わない。ノードが NotReady になる話には繋がらない
  • cgroup なし: 資源の割り当てや制限はコンテナの章に任せる
  • ヘルスチェック なし: 健康の判定は別の章で扱った。ここでは落ちたかどうかだけを見る
  • 1台だけ: ノード1台の中の話に閉じている

参考資料