Skip to content

DaemonSet

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

調整ループは数を宣言し、スケジューラが場所を選んだ。数が先で、場所は後。だが各ノードのログを集める仕事には、この順序が合わない。ノードが5台なら5個要るし、そのノードに置かなければ意味がない。だから場所を宣言し、数はそこから導かれる形にする。この逆転が、ノードの増減への自動追随、スケジューラの出番が無いこと、汚れの扱いが逆になることを連れてくる。

この章で作るもの

この編の最初に作った調整ループは「Pod は3個であれ」と数を宣言した。スケジューラは、その3個をどこに置くかを決めた。数が先にあって、場所は後から選ばれる。

負荷を捌く仕事なら、これで筋が通っている。3個で足りるなら、どこに置いても3個ぶんの仕事はできる。だから置き場所は、空きや偏りの都合で自由に選んでよい。

だが、そうでない仕事もある。各ノードのログを集める、各ノードの状態を監視する、各ノードのネットワークを設定する。これらは「3個」では足りない。ノードが5台あれば5個、10台あれば10個要る。しかも、どのノードに置くかを選ぶ余地がない。ログを集めたいノードに置かなければ意味がないからだ。

つまり、数と場所の関係が逆になっている。場所が先にあって、数は後から決まる。

  調整ループ + スケジューラ        DaemonSet

  宣言: replicas = 3              宣言: すべてのノード
        │                               │
        ▼                               ▼
  数が決まる → 場所を選ぶ          場所が決まる → 数が決まる

  ノードが増えても 3 個のまま        ノードが増えれば自動的に増える
  (数を書き換える必要がある)         (何も書き換えなくてよい)

  対象ノード {node-1, node-2}
  Pod のあるノード {node-1}
   → 差は {node-2}。ここに1つ作る(集合の差を埋める)
数を宣言するか、場所を宣言するか。どちらが先かで、増減への追随のしかたが変わる

順に見ていく。

  1. 数を宣言しない: 宣言するのは「どこに要るか」。数はそこから導かれる
  2. 集合の差を埋める: 数でなく、対象ノードの集合と Pod の載っている集合を比べる
  3. 汚れの扱いが逆: 他の Pod が避けるノードにも、置かれてほしい

① 場所を宣言する

まず、宣言の形を作る:

go

// Node は Pod を置く1台。ラベルと汚れを持つ。
type Node struct {
	Name   string
	labels map[string]string
	taints []string
	// Ready が偽なら、そのノードには置けない。
	Ready bool
}

// matches はノードが selector をすべて満たすかを返す。
func (n *Node) matches(selector map[string]string) bool {
	for k, v := range selector {
		if n.labels[k] != v {
			return false
		}
	}
	return true
}

// tolerated は汚れがすべて許容されているかを返す。
func (n *Node) tolerated(tolerations []string) bool {
	for _, t := range n.taints {
		ok := false
		for _, tol := range tolerations {
			if tol == t || tol == "*" {
				ok = true
				break
			}
		}
		if !ok {
			return false
		}
	}
	return true
}

// Spec は「どこに要るか」の宣言。数はどこにも書かない。
type Spec struct {
	Name string
	// Selector はどのノードに置くか。空ならすべてのノード。
	Selector map[string]string
	// Tolerations は許容する汚れ。"*" ならすべて。
	Tolerations []string
}

// Pod は1つのノードに置かれた実体。
type Pod struct {
	Name string
	Node string
}

Spec に数がどこにも無いことに注目してほしい。あるのは Selector(どのノードか)と Tolerations(どんな汚れを許容するか)だけになる。

Tolerations の扱いが、普通の Pod と逆向きになる。スケジューラの章では、汚れは「特別な事情のあるノードを、それを必要とする Pod のために空けておく」ための仕組みだった。GPU ノードに普通の Web サーバを置かないための壁だ。

だが監視や収集は、その壁を越えたい。GPU ノードのログも集めたいし、専用ノードの状態も監視したい。他の Pod が避けるノードにこそ、置かれてほしい。だから DaemonSet は広く許容する。同じ仕組みを、逆の目的で使っている。テストで、許容しなければ汚れたノードに置かれず、すべて許容すれば置かれることを固定した。

② 集合の差を埋める

調整の本体はこうなる:

go

// Action は1回の調整で打った手。
type Action struct {
	Kind string // "create" / "delete"
	Node string
}

// Set は「対象のノードすべてに1つずつ」を保つ。
type Set struct {
	spec  Spec
	nodes []*Node
	pods  map[string]*Pod // ノード名 → Pod

	Log []string
}

// New は宣言 spec の集合を作る。
func New(spec Spec) *Set {
	return &Set{spec: spec, pods: map[string]*Pod{}}
}

// AddNode はノードを1台足す。次の調整で自動的に Pod が置かれる。
func (s *Set) AddNode(name string, ready bool, labels map[string]string, taints ...string) *Node {
	if labels == nil {
		labels = map[string]string{}
	}
	n := &Node{Name: name, labels: labels, taints: taints, Ready: ready}
	s.nodes = append(s.nodes, n)
	return n
}

// RemoveNode はノードを取り除く。載っていた Pod も一緒に消える。
func (s *Set) RemoveNode(name string) {
	for i, n := range s.nodes {
		if n.Name != name {
			continue
		}
		s.nodes = append(s.nodes[:i], s.nodes[i+1:]...)
		delete(s.pods, name)
		s.logf(name + " が取り除かれた(載っていた Pod も消える)")
		return
	}
}

// SetReady はノードの状態を変える。
func (s *Set) SetReady(name string, ready bool) {
	for _, n := range s.nodes {
		if n.Name == name {
			n.Ready = ready
			return
		}
	}
}

// Nodes はノードを名前順に返す。
func (s *Set) Nodes() []*Node {
	out := append([]*Node(nil), s.nodes...)
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

// Pods は Pod をノード名順に返す。
func (s *Set) Pods() []*Pod {
	names := make([]string, 0, len(s.pods))
	for n := range s.pods {
		names = append(names, n)
	}
	sort.Strings(names)
	out := make([]*Pod, len(names))
	for i, n := range names {
		out[i] = s.pods[n]
	}
	return out
}

// PodOn はノード name に Pod が載っているかを返す。
func (s *Set) PodOn(name string) bool { _, ok := s.pods[name]; return ok }

// Targets は置くべきノードを名前順に返す。ここが「どこに要るか」の答えで、
// この個数がそのまま必要な Pod の数になる。数は宣言せず、ここから導かれる。
func (s *Set) Targets() []string {
	var out []string
	for _, n := range s.Nodes() {
		if n.Ready && n.matches(s.spec.Selector) && n.tolerated(s.spec.Tolerations) {
			out = append(out, n.Name)
		}
	}
	return out
}

// Desired は必要な Pod の数を返す。宣言された数ではなく、対象ノードの数。
func (s *Set) Desired() int { return len(s.Targets()) }

// Reconcile は対象のノードすべてに1つずつ載っている状態へ寄せる。
//
// 調整ループと同じ形だが、数え方が違う。あちらは宣言された数と現状を比べた。
// こちらは対象のノードの集合と、Pod が載っているノードの集合を比べる。
// 数でなく集合の差を埋めるので、ノードが増えれば自動的に増える。
func (s *Set) Reconcile() []Action {
	var actions []Action

	want := map[string]bool{}
	for _, n := range s.Targets() {
		want[n] = true
	}

	// 置くべきなのに載っていないノードへ置く。
	for _, n := range s.Targets() {
		if !s.PodOn(n) {
			s.pods[n] = &Pod{Name: s.spec.Name + "-" + n, Node: n}
			actions = append(actions, Action{Kind: "create", Node: n})
			s.logf(n + " に " + s.pods[n].Name + " を作成")
		}
	}

	// 置くべきでないのに載っているノードから消す。
	for _, n := range sortedKeys(s.pods) {
		if !want[n] {
			s.logf(n + " から " + s.pods[n].Name + " を削除(対象から外れた)")
			delete(s.pods, n)
			actions = append(actions, Action{Kind: "delete", Node: n})
		}
	}
	return actions
}

// Converged は対象すべてに1つずつ載っているかを返す。
func (s *Set) Converged() bool {
	if len(s.pods) != s.Desired() {
		return false
	}
	for _, n := range s.Targets() {
		if !s.PodOn(n) {
			return false
		}
	}
	return true
}

Targets が「置くべきノード」を返し、その個数がそのまま必要な Pod の数になる。Desired は宣言された数ではなく、対象ノードの数を数えているだけだ。

Reconcile調整ループと同じ形をしているが、比べているものが違う。あちらは「宣言された数」と「今ある数」を比べた。こちらは「対象ノードの集合」と「Pod が載っているノードの集合」を比べる。数でなく集合の差を埋める。

この違いが、ノードの増減への自動追随を生む。ノードが1台増えると、Targets が1つ増え、差が生まれ、次の調整でそこに Pod が作られる。何も書き換えていない。逆にノードが減れば、対象から外れた分の Pod が消える。テストで、ノードを足すと足したノードにだけ作られ、外すと消えることを固定した。

ラベルの変更にも同じように追随する。ノードに role=edge を付ければ対象に入り、外せば出る。ノード側のラベルを変えるだけで、どこに置かれるかが変わる。テストで、ラベルの付け外しで Pod が置かれたり消えたりすることを固定した。

Ready でないノードが対象から外れるのも、同じ仕組みで説明できる。Targets の条件に入っているので、外れれば集合から消え、載っていた Pod も消える。戻れば、また置かれる。テストで固定した。

③ スケジューラの出番がない

この章に、スケジューラが出てこないことにも意味がある。

普通の Pod は、どのノードに置くかをスケジューラが決めた。空きを見て、汚れを見て、偏りを見て、点数をつけて選ぶ。だが DaemonSet には選ぶ余地がない。対象のノードに置くのであって、他を選ぶことはできない。「node-2 が混んでいるから node-3 に置く」という判断は、意味を成さない。node-2 のログを集めたいのだから。

実物の Kubernetes でも、DaemonSet の Pod は当初スケジューラを通らず、コントローラが直接ノードを指定していた。今はスケジューラを通るが、置き場所はノードを名指しする形で決まっていて、選ぶ処理は走らない。

代わりに、別の問題が出る。対象のノードに空きが無かったらどうするか。普通の Pod なら別のノードへ回せばよいが、こちらは回せない。実物では DaemonSet の Pod に高い優先度をつけて、他の Pod を追い出せるようにする。置かなければならないものと、置ければよいものの区別が、優先度として表れている。

動かす

下のデモは、ノードを増減させながら調整する。数はどこにも宣言していないのに、ノードを足せばそこに Pod が作られる。ready を外すと対象から外れ、戻すと置き直される。「汚れは避ける」に切り替えると、汚れ付きノードが対象から外れる。

デモDaemonSet対象 4 / 載っている 4
どこに要るか(数は宣言しない)すべてのノードrole=edge のみ汚れも許容汚れは避ける
node-1ready
(ラベルも汚れも無し)
log-agent-node-1
node-2ready
(ラベルも汚れも無し)
log-agent-node-2
node-3ready
role=edge
log-agent-node-3
infra-4ready
dedicated=infra
log-agent-infra-4
対象 4 台すべてに1つずつ載っている。数はどこにも宣言していない
起きたこと
(まだ何も起きていない)

宣言しているのは「どこに要るか」だけで、数はどこにも書いていない。ノードを足して「調整する」を押すと、 そのノードにだけ Pod が作られる。外せば載っていた Pod も消える。ready を外すと対象から外れて Pod が消え、 戻すとまた置かれる。「role=edge のみ」に切り替えると対象が絞られ、「汚れは避ける」に切り替えると 汚れ付きノードが対象から外れる。監視や収集は、他の Pod が避けるノードにも置かれてほしいので、 普通は広く許容する。

設計の観点

  • 何を宣言するかで追随のしかたが決まる: 数を宣言すれば数を守り、場所を宣言すれば場所を守る。ノードの増減に追随させたいなら、場所を宣言する
  • 集合の差は数の差より強い: 数だけを合わせると、どこに載っているかは保証されない。集合を合わせれば、載っている場所まで保証される
  • 同じ仕組みが逆の目的に使える: 汚れは普通の Pod を遠ざけるためのものだが、DaemonSet はそれを越えるために許容する。壁を作る側と越える側の両方が要る
  • 選べないものには優先度が要る: 置き場所を選べないので、空きが無ければ他の Pod を追い出すしかない。「置かなければならない」を表現する手段が別に必要になる
  • Cluster Autoscalerとの相性: ノードを増やすと DaemonSet の Pod も増える。ノード1台あたりの固定費として、この分を見込んでおく必要がある
  • Operatorから見れば同じ形: 宣言と現状の差を埋めるという骨格は変わらない。変わるのは、何を差として数えるかだけになる

対照と実例

調整ループ(ReplicaSet)DaemonSet
宣言するもの場所
数の決まり方人が書く対象ノードの数から導かれる
ノードが増えると変わらない自動的に増える
置き場所スケジューラが選ぶ選ぶ余地がない
汚れ避ける越える(広く許容する)
向く相手負荷を捌く仕事各ノードで動く必要がある仕事

裏どり:

  • DaemonSet: 対象のノードすべてに Pod を1つずつ置く。ノードの追加に自動で追随する
  • nodeSelector と tolerations: どのノードを対象にするかを絞り、汚れを越えるかを決める
  • スケジューラとの関係: 以前はコントローラが直接ノードを指定していた。現在はスケジューラを通るが、置き場所の選択は行われない
  • 優先度: システムの DaemonSet には高い優先度が付き、空きが無ければ他の Pod を追い出せる

簡略化したこと

  • 更新なし: 実物は DaemonSet の更新もローリングで行える
  • 資源の確認なし: 実物は置く前に空き容量を見る。足りなければ Pending になる
  • 優先度なし: 他の Pod を追い出す仕組みは扱わない
  • taint の effect なし: NoSchedule / NoExecute の区別は扱わない
  • 1つの集合のみ: 複数の DaemonSet が同居する場合は扱わない

参考資料