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つ作る(集合の差を埋める)順に見ていく。
- 数を宣言しない: 宣言するのは「どこに要るか」。数はそこから導かれる
- 集合の差を埋める: 数でなく、対象ノードの集合と Pod の載っている集合を比べる
- 汚れの扱いが逆: 他の Pod が避けるノードにも、置かれてほしい
① 場所を宣言する
まず、宣言の形を作る:
// 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 は広く許容する。同じ仕組みを、逆の目的で使っている。テストで、許容しなければ汚れたノードに置かれず、すべて許容すれば置かれることを固定した。
② 集合の差を埋める
調整の本体はこうなる:
// 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 を外すと対象から外れ、戻すと置き直される。「汚れは避ける」に切り替えると、汚れ付きノードが対象から外れる。
宣言しているのは「どこに要るか」だけで、数はどこにも書いていない。ノードを足して「調整する」を押すと、 そのノードにだけ 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 が同居する場合は扱わない
参考資料
- DaemonSet — 対象ノードの決め方と、スケジューラとの関係
- Taints and Tolerations — DaemonSet に自動で付く許容
- 実装: orchestration/daemonset