Skip to content

CSIとボリューム

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

StatefulSet のボリュームが Pod より長生きなのは、下に別の層があるからだ。使う側は要求だけを書き、実体は別の担当が用意する。そして用意してから見えるまでが3段に分かれている。作る、ノードに繋ぐ、Pod から見せる。この分かれ方を知らないと、ReadWriteOnce を1つの Pod からだと誤解する。繋ぐのはノード単位だ。

この章で作るもの

StatefulSet と PVC の章では、ボリュームは Pod より長生きだと書いた。消えない置き場があるおかげで、同じ序数の Pod が作り直されても続きから始められる。あの章では、その置き場は与えられたものとして扱った。

この章は、その置き場を用意している下の層になる。

まず、要求と実体が分かれている。使う側が書くのは「20GiB を1ノードから読み書きで欲しい」という要求だけで、それがどのディスクになるかは知らない。要求に合う実体を用意するのは別の担当になる。Service が宛先を間接参照にしたのと同じ形で、使う側と実体の間に1枚挟まっている。

そして、用意してから Pod に見えるまでが3段に分かれている。作る、ノードに繋ぐ、Pod から見えるようにする。この分かれ方が、この章のほとんどすべてになる。

  ① 作る(provision)          保存の担当が、要求に合う実体を用意する
       claim: 20GiB / RWO

       volume: pv-1

  ② 繋ぐ(attach)             ノード単位。ここが ReadWriteOnce の「Once」
       pv-1 ──→ node-a
                 ✕ node-b     すでに node-a に繋がっているので繋げない

  ③ 見せる(mount)            Pod 単位。ノードの上なら何個でも
       node-a ──→ web-1  ✓
              ──→ web-2  ✓     同じノードなので共有できる
              ──→ web-3  ✓


  よくある誤解と、その正体

    誤解: ReadWriteOnce だから、1つの Pod からしか使えない
    実際: 1つの「ノード」から。繋ぐのがノード単位だから

    誤解: ノードが死んだらすぐ別のノードで起動できる
    実際: ②が外れたことを確かめられないと繋げない。だから待つ
繋ぐのはノード単位、見せるのは Pod 単位。だから同じノードの Pod は共有できる

順に見ていく。

  1. 要求と実体を分ける: 使う側は場所を知らない。用意するのは別の担当
  2. 3段は単位が違う: 繋ぐのはノード、見せるのは Pod。ReadWriteOnce の意味はここから来る
  3. 配置が先か、確保が先か: 区画があると順序が逆転する

① 要求と実体

まず、置くものを分ける:

go

// AccessMode は同時にどう使えるか。
type AccessMode int

const (
	// ReadWriteOnce は1つの「ノード」から読み書きできる。
	// 1つの Pod から、ではない。同じノードに載った2つの Pod は共有できる。
	ReadWriteOnce AccessMode = iota
	// ReadOnlyMany は複数ノードから読める。
	ReadOnlyMany
	// ReadWriteMany は複数ノードから読み書きできる。
	ReadWriteMany
)

func (a AccessMode) String() string {
	return [...]string{"ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany"}[a]
}

// Binding は確保の時機。
type Binding int

const (
	// Immediate は要求が来た時点で実体を作る。
	Immediate Binding = iota
	// WaitForFirstConsumer は、使う Pod の置き場所が決まるまで作らない。
	WaitForFirstConsumer
)

// Reclaim は要求が消えたときに実体をどうするか。
type Reclaim int

const (
	// Delete は実体も消す。
	Delete Reclaim = iota
	// Retain は実体を残す。中身を捨てたくないときに使う。
	Retain
)

// Class は実体の作り方の型。運用側が用意する。
type Class struct {
	Name    string
	Binding Binding
	Reclaim Reclaim
}

// Claim は使う側が書く要求。どのディスクかは書かない。
type Claim struct {
	Name  string
	Class string
	Size  int
	Mode  AccessMode
	// Zone は要求した区画(空なら指定なし)。
	Zone string
}

// Volume は実体。区画を持つので、後から他の区画へは動かせない。
type Volume struct {
	Name    string
	Claim   string
	Size    int
	Mode    AccessMode
	Zone    string
	Reclaim Reclaim
	// AttachedTo は今どのノードに繋がっているか(空なら繋がっていない)。
	AttachedTo string
	// MountedBy は今どの Pod から見えているか。
	MountedBy []string
	Released  bool // 要求が消えた後も残っている
}

Claim に書いてあるのは大きさと使い方だけで、どのディスクかは書かない。Volume が実体で、Class が作り方の型になる。運用側が Class を用意し、アプリ側が Claim を書く。この役割の分割は、後の Gateway API の章にも同じ形で出てくる。

VolumeZone を持っているのが後で効いてくる。ディスクは作った場所から動かせない。この一点が、③の話につながる。

② 3段は単位が違う

操作はこうなる:

go

// Attach は実体をノードに繋ぐ。ノード単位の操作になる。
//
// 待つ設定の要求なら、ここで初めて実体ができる。Pod の置き場所が決まって
// はじめて区画が決まるので、区画をまたいで繋がらない事故を避けられる。
func (d *Driver) Attach(claim, node, zone string) (bool, string) {
	c, ok := d.claims[claim]
	if !ok {
		return false, "そんな要求は無い"
	}
	v := d.VolumeFor(claim)
	if v == nil {
		cl := d.classes[c.Class]
		if cl.Binding != WaitForFirstConsumer {
			return false, "実体がまだ無い"
		}
		v = d.provision(c, cl, zone) // 置き場所が決まったので、ここで作る
	}
	if v.Zone != "" && zone != "" && v.Zone != zone {
		return false, "実体は区画 " + v.Zone + " にある。区画 " + zone + " のノードからは繋がらない"
	}
	if v.AttachedTo == node {
		return true, ""
	}
	if v.AttachedTo != "" {
		if v.Mode == ReadWriteOnce {
			return false, "すでに " + v.AttachedTo + " に繋がっている。" +
				ReadWriteOnce.String() + " は1つのノードからしか繋げない"
		}
	}
	v.AttachedTo = node
	d.logf(v.Name + " を " + node + " に繋いだ")
	return true, ""
}

// Mount は Pod から見えるようにする。Pod 単位の操作になる。
//
// 繋がっているノードの上の Pod なら、何個からでも見える。
// ReadWriteOnce が「1つの Pod から」ではないことが、ここに出る。
func (d *Driver) Mount(claim, node, pod string) (bool, string) {
	v := d.VolumeFor(claim)
	if v == nil {
		return false, "実体がまだ無い"
	}
	if v.AttachedTo != node {
		return false, "このノードに繋がっていない(今は " + orNone(v.AttachedTo) + ")"
	}
	for _, p := range v.MountedBy {
		if p == pod {
			return true, ""
		}
	}
	v.MountedBy = append(v.MountedBy, pod)
	sort.Strings(v.MountedBy)
	d.logf(v.Name + " が " + pod + " から見えるようになった")
	return true, ""
}

// Unmount は Pod から見えなくする。
func (d *Driver) Unmount(claim, pod string) {
	v := d.VolumeFor(claim)
	if v == nil {
		return
	}
	var rest []string
	for _, p := range v.MountedBy {
		if p != pod {
			rest = append(rest, p)
		}
	}
	v.MountedBy = rest
}

// Detach はノードから外す。まだ見えている Pod があれば外せない。
//
// 外れないことが、ノードが死んだときの引き継ぎの遅さの正体になる。
// ノードが応答しないと、外れたことを確かめられない。
func (d *Driver) Detach(claim string) (bool, string) {
	v := d.VolumeFor(claim)
	if v == nil {
		return false, "実体がまだ無い"
	}
	if len(v.MountedBy) > 0 {
		return false, "まだ " + itoa(len(v.MountedBy)) + " 個の Pod から見えている"
	}
	if v.AttachedTo == "" {
		return true, ""
	}
	d.logf(v.Name + " を " + v.AttachedTo + " から外した")
	v.AttachedTo = ""
	return true, ""
}

// ForceDetach はノードの応答を待たずに外す。ノードが死んだときの最後の手段。
func (d *Driver) ForceDetach(claim string) {
	v := d.VolumeFor(claim)
	if v == nil {
		return
	}
	v.MountedBy = nil
	v.AttachedTo = ""
	d.logf(v.Name + " をノードの応答を待たずに外した")
}

Attach はノードを受け取り、Mount は Pod を受け取る。単位が違う。

ここから、いちばん有名な誤解の正体が出てくる。ReadWriteOnce は「1つのノードから読み書きできる」という意味で、「1つの Pod から」ではない。制限がかかっているのは繋ぐ段のほうだからだ。同じノードに載った Pod なら、何個でも同じボリュームを見られる。テストで、3つの Pod が同じノードの上で共有できること、別のノードには繋げないことを固定した。

これは実務でよく問題になる。同じ ReadWriteOnce のボリュームを使う Pod を2つ動かしていて、たまたま同じノードに載っている間は動いていたのに、ローリング更新で片方が別のノードに移った瞬間に止まる。設定は何も変わっていないのに、配置が変わっただけで壊れる。

外す側にも非対称がある。見えている Pod がある限り、ノードからは外せない。テストで、Unmount してからでないと Detach が通らないことを固定した。

これがノード障害のときの遅さになる。ノードが応答しなくなったとき、そのノードの上で Pod が本当に止まったのかは、外から確かめようがない。まだ書き込んでいるかもしれない相手からディスクを取り上げると、二重に書かれて壊れる。だから待つ。実物で、ノードが落ちてから StatefulSet の Pod が別のノードで立ち上がるまで数分かかるのは、この確認ができないからだ。ForceDetach はその確認を諦める操作で、最後の手段として置いてある。

③ 配置が先か、確保が先か

素朴には、先にディスクを用意してから Pod を置けばよさそうに見える。

だがtopology spread の章で見たとおり、実際のクラスタには区画がある。ディスクは作った区画から動かせないので、先に用意すると、その区画のノードにしか Pod を置けなくなる。運が悪ければ、その区画に空きが無くて Pod が置けない。

go

// Driver はボリュームの確保から取り外しまでを担う。
//
// 3つの段に分かれた操作を持つのが、この面の形になる。実物の CSI も
// Controller 側(作る・繋ぐ)と Node 側(見えるようにする)で別の面になっている。
type Driver struct {
	classes map[string]Class
	claims  map[string]Claim
	vols    map[string]*Volume
	seq     int

	Log []string
}

// New は class を登録した Driver を作る。
func New(cs ...Class) *Driver {
	d := &Driver{classes: map[string]Class{}, claims: map[string]Claim{}, vols: map[string]*Volume{}}
	for _, c := range cs {
		d.classes[c.Name] = c
	}
	return d
}

// Volumes は実体を名前順で返す。
func (d *Driver) Volumes() []*Volume {
	var out []*Volume
	for _, v := range d.vols {
		out = append(out, v)
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

// VolumeFor は要求に結びついた実体を返す(無ければ nil)。
func (d *Driver) VolumeFor(claim string) *Volume {
	for _, v := range d.vols {
		if v.Claim == claim && !v.Released {
			return v
		}
	}
	return nil
}

// Request は要求を受け取る。
//
// ここで実体ができるとは限らないのが大事なところになる。時機が
// WaitForFirstConsumer なら、使う Pod の置き場所が決まるまで何も作らない。
func (d *Driver) Request(c Claim) (bool, string) {
	cl, ok := d.classes[c.Class]
	if !ok {
		return false, "そんな class は無い"
	}
	d.claims[c.Name] = c
	if cl.Binding == WaitForFirstConsumer {
		d.logf(c.Name + " は使う Pod の置き場所が決まるまで待つ")
		return true, ""
	}
	d.provision(c, cl, c.Zone)
	return true, ""
}

// provision は実体を作る。区画はここで決まり、後から変えられない。
func (d *Driver) provision(c Claim, cl Class, zone string) *Volume {
	d.seq++
	v := &Volume{
		Name: "pv-" + itoa(d.seq), Claim: c.Name, Size: c.Size,
		Mode: c.Mode, Zone: zone, Reclaim: cl.Reclaim,
	}
	d.vols[v.Name] = v
	if zone == "" {
		d.logf(v.Name + " を作った(" + itoa(c.Size) + "GiB、区画の指定なし)")
	} else {
		d.logf(v.Name + " を作った(" + itoa(c.Size) + "GiB、区画 " + zone + ")")
	}
	return v
}

Binding がその選択になる。WaitForFirstConsumer にしておくと、要求を受け取っても何も作らない。使う Pod の置き場所が決まって、Attach が呼ばれた時点で初めて作る。そのとき渡されたノードの区画に合わせて作るので、区画がずれない。

テストで、待つ設定なら要求だけでは実体ができないこと、Attach の時点で Pod の区画に合わせて作られることを固定した。先に作る設定では、別の区画のノードから繋がらないことも固定した。

順序を入れ替えるだけで、繋がらない組み合わせが消える。だが代わりに、実体の確保が Pod の起動を待つことになる。ここでも速さと確実さが交換になっている。

動かす

下のデモは、1つの要求を3段で進める。ReadWriteOnce のまま、同じノードの Pod を増やすと共有でき、別のノードに繋ごうとすると止まる。区画を持つ設定に切り替えると、先に作るか待つかで結果が変わる。

デモCSI とボリュームpv-1 ・ 未接続 ・ 0 個から見えている
node-a
繋がっていない
見えている Pod なし
node-b
繋がっていない
見えている Pod なし
実体はあるが、まだどのノードにも繋がっていない
pv-1 を作った(区画の指定なし)

「繋ぐ」はノード単位、「Pod に見せる」は Pod 単位。同じノードで「Pod に見せる」を何回か押すと、 ReadWriteOnce のまま複数の Pod が共有できる。別のノードで「繋ぐ」を押すと止まる。 見えている Pod がある間は外せず、応答が無いノードには最後の手段しか残らない。 区画ありにすると、先に作る設定では実体が区画に固定され、待つ設定では Pod の区画に合わせて作られる。

設計の観点

  • 間接参照は使う側の自由になる: 要求だけ書けば、どのディスクかを知らずに済む。実装を差し替えられる
  • 単位を混同しない: 繋ぐのはノード、見せるのは Pod。同じ「同時に使えるか」でも段によって答えが違う
  • 確かめられないことは待つ: 止まったかどうかが分からない相手からは、取り上げない。二重書き込みのほうが高くつく
  • 場所が決まると動かせないものは、最後に決める: ディスクの区画がその例。決める順序が、置ける場所を狭める
  • StatefulSetの遅さの理由がここにある: 順に立ち上げる設計だけでなく、ボリュームの引き継ぎも待たせている
  • 消えない選択を用意する: 要求を消しても実体を残せる。取り返しのつかない操作には、取り返せる道を並べておく

対照と実例

直接ディスクを指定する要求と実体を分ける
使う側が知ることどのディスクか大きさと使い方だけ
実装の差し替え書き換えが要るClass を変えるだけ
区画の扱い自分で考える待つ設定に任せられる
消したとき自分で片付けるReclaim が決める
ReadWriteOnceReadOnlyManyReadWriteMany
繋げるノード1台複数複数
同じノードの Pod何個でも何個でも何個でも
実装ブロックストレージ同左(読み取り)ファイルストレージが要る

裏どり:

  • CSI: Container Storage Interface。Controller 側(作る・繋ぐ)と Node 側(見せる)で別のサービスになっている。この分け方が3段の正体
  • ReadWriteOncePod: 本当に1つの Pod からだけにしたいときのために、後から追加された。ReadWriteOnce がノード単位であることの裏返し
  • attach detach controller: 繋ぐ・外すは中央のコントローラが行う。ノード側の kubelet が行うのは見せる・隠すだけ
  • ノード障害時の待ち時間: ノードが NotReady になってから強制的に外すまで、既定でしばらく待つ。二重書き込みを避けるため
  • volumeBindingMode: ImmediateWaitForFirstConsumer の2つで、既定は Immediate。ただし区画のある環境では Immediate だと置けない場所に領域を作ってしまうので、WaitForFirstConsumer が実質の推奨になる
  • reclaimPolicy: DeleteRetain。動的に作ったものは Delete が既定で、消し忘れると課金が続く

簡略化したこと

  • 実際の保存なし: 中身の読み書きは扱わない。繋がっているかどうかだけ
  • サイズの検査なし: 要求より小さい実体が割り当たる状況は扱わない
  • 静的な実体なし: あらかじめ用意された実体に要求を結びつける経路は扱わない
  • スナップショットと拡張なし: 実物の CSI は複製や容量の拡張も担う
  • 待ち時間なし: ノード障害から強制的に外すまでの時間は数えない。操作として呼ぶだけ
  • kubeletとの分担なし: 見せる操作を誰が行うかは扱わない

参考資料