Kubernetes: Topology Aware な Volume Provisioningを試す

最近なんだか仕事で良く聞かれる様になってきたので
CSI Topologyを使ったTopology AwareなPersistentVolume環境を作って試してみたいと思います。

## 環境の準備

今回用意したのは、こんな環境です。

### Compute
- Kubernetes 1.20.5
- Debian 10.9
- containerd 1.4.4-1
- master 1, worker 2 （それぞれ m1, w1, w2 という名前）

### Storage
- ONTAP 9.8 (Simulator)
- NetApp Trident 21.01.1

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128512/f34a3908-1263-9d70-038f-fa7a807876b4.png)

```
admin@m1:~$ kubectl get node -o wide -L topology.kubernetes.io/region,topology.kubernetes.io/zone
NAME   STATUS   ROLES                  AGE   VERSION   INTERNAL-IP       EXTERNAL-IP   OS-IMAGE                       KERNEL-VERSION    CONTAINER-RUNTIME    REGION   ZONE
m1     Ready    control-plane,master   15h   v1.20.5   192.168.255.100   <none>        Debian GNU/Linux 10 (buster)   4.19.0-16-amd64   containerd://1.4.4   myhome   myhome-kitchen
w1     Ready    <none>                 15h   v1.20.5   192.168.254.100   <none>        Debian GNU/Linux 10 (buster)   4.19.0-16-amd64   containerd://1.4.4   myhome   myhome-room1
w2     Ready    <none>                 15h   v1.20.5   192.168.253.100   <none>        Debian GNU/Linux 10 (buster)   4.19.0-16-amd64   containerd://1.4.4   myhome   myhome-room2
```
オンプレミス（自宅）だったのでメモリーとの戦い[^1]でした……。

[^1]: [KSM](https://www.kernel.org/doc/html/latest/admin-guide/mm/ksm.html)のおかげでなんとかなりました。ASLRを切って試してみたのですが結構回収してくれますね。

## Tridentの設定

CSIの Provisionerである Tridentのバックエンド設定は[公式ドキュメント]を参考に、こんな感じにしてみました。

[公式ドキュメント]: https://netapp-trident.readthedocs.io/en/stable-v21.01/kubernetes/operations/tasks/volumes/topology.html

ポイントになるのは .supportedTopologies の項目で、
ここに「バックエンドが提供するボリュームを、利用可能なRegion/Zone」を記入します。

```json:myhome-room1
{
    "version": 1,
    "storageDriverName": "ontap-nas",
    "managementLIF": "192.168.254.210",
    "dataLIF": "192.168.254.216",
    "username": "vsadmin",
    "password": "UltraSecret",
    "supportedTopologies": [
            {"topology.kubernetes.io/region": "myhome", "topology.kubernetes.io/zone": "myhome-room1"}
    ]
}
```

```json:myhome-room2
{
    "version": 1,
    "storageDriverName": "ontap-nas",
    "managementLIF": "192.168.253.210",
    "dataLIF": "192.168.253.216",
    "username": "vsadmin",
    "password": "SuperSecret",
    "supportedTopologies": [
            {"topology.kubernetes.io/region": "myhome", "topology.kubernetes.io/zone": "myhome-room2"}
    ]
}
```

バックエンドを作成します

```console
admin@m1:~$ tridentctl create backend -n trident -f ./backend-ontap-nas-topology-room1.json
+--------------------------+----------------+--------------------------------------+--------+---------+
|           NAME           | STORAGE DRIVER |                 UUID                 | STATE  | VOLUMES |
+--------------------------+----------------+--------------------------------------+--------+---------+
| ontapnas_192.168.254.216 | ontap-nas      | f117f775-eaa1-4a93-ad14-5a47a0b8756e | online |       0 |
+--------------------------+----------------+--------------------------------------+--------+---------+

admin@m1:~$ tridentctl create backend -n trident -f ./backend-ontap-nas-topology-room2.json
+--------------------------+----------------+--------------------------------------+--------+---------+
|           NAME           | STORAGE DRIVER |                 UUID                 | STATE  | VOLUMES |
+--------------------------+----------------+--------------------------------------+--------+---------+
| ontapnas_192.168.253.216 | ontap-nas      | 5b922cf6-c807-412f-8273-9906a89dda7a | online |       0 |
+--------------------------+----------------+--------------------------------------+--------+---------+
```

## 基本動作の確認: 即時バインド

バックエンドはTopology Awareになりましたが、気にせず普通に使ってみます。
StorageClassとしては 以下を使います。

```yaml:storageclass-ontap-csi-nas.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nas
provisioner: csi.trident.netapp.io
parameters:
  backendType: "ontap-nas"
```

このStorageClassを指定してPVCを単独で作ります。

```yaml
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: nas-vol1
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
        storage: 100Mi
  storageClassName: nas
```

当たり前ですが要求(PVC)に対応(PV作成)されて、PVCとPVがすぐに関連付け=Bindされます。
この StorageClassではTopology Awareな動きをしていないように見えますが……。

```console
admin@m1:~$ kubectl create -f ./pvc1-nas.yaml
persistentvolumeclaim/nas-vol1 created

admin@m1:~$ kubectl get pvc
NAME       STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
nas-vol1   Bound    pvc-78b792fe-0825-41c6-ac32-321e7c551bbc   100Mi      RWX            nas            5s
```

作成されたPVには topology を keyにした Node Affinityが付加されています。

```console
Name:              pvc-78b792fe-0825-41c6-ac32-321e7c551bbc
StorageClass:      nas
Status:            Bound
Claim:             default/nas-vol1
Node Affinity:
  Required Terms:
    Term 0:        topology.kubernetes.io/zone in [myhome-room1]
                   topology.kubernetes.io/region in [myhome]
```

このため、このPersistentVolumeを利用する Podの配置は myhome-room1 zoneの中に限定されます。
nodeSelectorでPodの配置と矛盾した状況になると Scheduling出来ずに Podは Pendingのままとなりますので注意です。

Zoneにルーズな StorageClassとあわせて構成する場合、
バックエンドとしては supportedTopologiesの有り・無しで分けておく必要があるかもしれません。

## WaitForFirstConsumerによるバインド

Podの位置が決まるまで ボリュームをBINDしないようにvolumeBindingModeを
WaitForFirstConsumerに変更します

```yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nas-wait
provisioner: csi.trident.netapp.io
volumeBindingMode: WaitForFirstConsumer
parameters:
  backendType: "ontap-nas"
```

このStorageClassにしてからPVCを作成すると PVがすぐにBindされず Pendingの状態で待ちになります。

```console
admin@m1:~$ kubectl get pvc
NAME            STATUS    VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
nas-wait-vol1   Pending                                                                        nas-wait       9s
```

PVCの状態を確認すると「利用者が現れるまで（使う場所が決まるまで）待ってる」的な理由で止まっているのがわかります。

```
Name:          nas-wait-vol1
StorageClass:  nas-wait
Status:        Pending
Events:
  Type    Reason                Age               From                         Message
  ----    ------                ----              ----                         -------
  Normal  WaitForFirstConsumer  4s (x3 over 20s)  persistentvolume-controller  waiting for first consumer to be created before binding
```

この状態でPodが作成されると、ボリュームがプロビジョニングされます。

```
Events:
  Type    Reason                 Age                   From                                                                                     Message
  ----    ------                 ----                  ----                                                                                     -------
  Normal  WaitForFirstConsumer   22s (x11 over 2m38s)  persistentvolume-controller                                                              waiting for first consumer to be created before binding
  Normal  Provisioning           10s                   csi.trident.netapp.io_trident-csi-759f4b869c-84nv9_70970a49-4b34-4f7a-bbfd-197c862e90f9  External provisioner is provisioning volume for claim "default/nas-wait-vol1"
  Normal  ExternalProvisioning   7s (x2 over 10s)      persistentvolume-controller                                                              waiting for a volume to be created, either by external provisioner "csi.trident.netapp.io" or manually created by system administrator
  Normal  ProvisioningSuccess    7s                    csi.trident.netapp.io                                                                    provisioned a volume
  Normal  ProvisioningSucceeded  7s                    csi.trident.netapp.io_trident-csi-759f4b869c-84nv9_70970a49-4b34-4f7a-bbfd-197c862e90f9  Successfully provisioned volume pvc-3da6cea8-de34-4b85-a7ea-e2ffd94d321e
```

このとき PVC には selected-node という annotationが付いてました。

```
Annotations:   pv.kubernetes.io/bind-completed: yes
               pv.kubernetes.io/bound-by-controller: yes
               volume.beta.kubernetes.io/storage-provisioner: csi.trident.netapp.io
               volume.kubernetes.io/selected-node: w1
```

なお、PVの Source情報から、w1の属する myhome-room1 の backendから
ボリュームが払い出されているのがわかります。

```
Source:
    VolumeAttributes:      backendUUID=f117f775-eaa1-4a93-ad14-5a47a0b8756e
```

## 特定Zoneだけのプロビジョニング

特定のZoneのみBind可能なStorageClassを作ってみます

先程の WaitForFirstConsumer に加えて、
.allowedTopologiesに Topology の Labelを記述します。

```
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nas-zoned
provisioner: csi.trident.netapp.io
volumeBindingMode: WaitForFirstConsumer
parameters:
  backendType: "ontap-nas"
allowedTopologies:
- matchLabelExpressions:
  - key: topology.kubernetes.io/zone
    values:
    - myhome-room2
```

この StorageClassを指定すると zone=myhome-room2 の Backend だけ利用されます。
PVCを作ったときの動きも確認しましたが、普通にZoneが限定されるだけなので、記事からは省略します。

## Podと PersistentVolumeで 配置するゾーンが矛盾した場合

TopologyAwareなBackendを使った StorageClassから作った PVには、
前述の通りNodeAffinityが追加されます。

これを利用する Podは myhome-room2 の ノードにしか配置できなくなるわけですが、
もし矛盾したnodeSelectorなどが指定されているとどうなるのか？と思ってやってみました。

nodeSelctorに myhome-room1 の ノードを指定しています。
Podが room1内、PersistentVolumeが room2という状況です。

```
Name:         pv-zoned-pod
Namespace:    default
Status:       Pending
Containers:
  pv-container:
Conditions:
  Type           Status
  PodScheduled   False
Volumes:
  pv-nas-storage:
    Type:       PersistentVolumeClaim (a reference to a PersistentVolumeClaim in the same namespace)
    ClaimName:  nas-zoned-vol1
Node-Selectors:  kubernetes.io/hostname=w1
Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  2m46s  default-scheduler  0/3 nodes are available: 1 node(s) didn't find available persistent volumes to bind, 1 node(s) didn't match Pod's node affinity, 1 node(s) had taint {node-role.kubernetes.io/master: }, that the pod didn't tolerate.
```

利用出来るノードが見つからないという以下の様な Warningになっています。

「3ノードあるが、0/3でノードが使えない。1ノード(room1)は PersistentVolumeが見つからない、もう1つのノード(room2)は Podの Affinityに反する。最後の1ノード(kitchen)は Taintされていて、Podは Tolerateされていない。」

ここで Podの nodeSelectorを取り除いた場合、PersistentVolume側の都合にあわせて Podがスケジューリングされます。

## PodのZoneに合わせたプロビジョニング

さて、やっと本命です。StatefulSetを Zoneを跨って展開してみます。

このためには StorageClassが複数のZoneに対応したバックエンドを対象とする必要があります。
先程のallowedTopologiesに、もう一つのゾーン情報を追加します。

```yaml:storageclass-nas-topology-aware.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nas-topology-aware
provisioner: csi.trident.netapp.io
volumeBindingMode: WaitForFirstConsumer
parameters:
  backendType: "ontap-nas"
allowedTopologies:
- matchLabelExpressions:
  - key: topology.kubernetes.io/zone
    values:
    - myhome-room1
    - myhome-room2
```

StatefulSet は以下を使ってみました。
ここは特に工夫はありません。単にStorageClassとして Topology Awareな物を指定しているだけです。

```yaml:statefulset-ss-topology-aware.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: ss-topology-aware
spec:
  selector:
    matchLabels:
      app: ss-topology-aware
  serviceName: ss-topology-aware
  replicas: 4
  template:
    metadata:
      labels:
        app: ss-topology-aware
    spec:
      containers:
      - name: ss-topology-aware
        image: alpine:latest
        volumeMounts:
        - name: myvol
          mountPath: /mnt/myvol
        command: ["/bin/sh"]
        args: ["-c","while true; do date >>/mnt/myvol/time.log;sleep 1; done"]
  volumeClaimTemplates:
  - metadata:
      name: myvol
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: nas-topology-aware
      resources:
        requests:
          storage: 100Mi
```

結果はこんな感じ。

```
admin@m1:~$ kubectl get pod -o wide
NAME                  READY   STATUS    RESTARTS   AGE    IP              NODE   NOMINATED NODE   READINESS GATES
ss-topology-aware-0   1/1     Running   0          101s   192.168.62.71   w1     <none>           <none>
ss-topology-aware-1   1/1     Running   0          88s    192.168.62.72   w1     <none>           <none>
ss-topology-aware-2   1/1     Running   0          66s    192.168.27.69   w2     <none>           <none>
ss-topology-aware-3   1/1     Running   0          51s    192.168.62.73   w1     <none>           <none>

admin@m1:~$ kubectl get pod -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\t"}{.spec.volumes[?(@.name=="myvol")].persistentVolumeClaim.claimName}{"\n"}'
ss-topology-aware-0     w1      myvol-ss-topology-aware-0
ss-topology-aware-1     w1      myvol-ss-topology-aware-1
ss-topology-aware-2     w2      myvol-ss-topology-aware-2
ss-topology-aware-3     w1      myvol-ss-topology-aware-3

admin@m1:~$ kubectl get pv -o jsonpath='{ range .items[*]}{.spec.claimRef.name}{"\t"}{.metadata.name}{"\t"}{.spec.nodeAffinity.required.nodeSelectorTerms[*].matchExpressions[1].key}{"\t"}{.spec.nodeAffinity.required.nodeSelectorTerms[*].matchExpressions[1].values[*]}{"\n"}' | sort
myvol-ss-topology-aware-0       pvc-7cecc303-0b74-49d6-b1b2-e290ae25c70d        topology.kubernetes.io/zone     myhome-room1
myvol-ss-topology-aware-1       pvc-c3de2d8a-d393-4809-a453-5ebae6db4a62        topology.kubernetes.io/zone     myhome-room1
myvol-ss-topology-aware-2       pvc-c304f449-2ec6-4bdf-97a4-831c3ea14157        topology.kubernetes.io/zone     myhome-room2
myvol-ss-topology-aware-3       pvc-95f29ec4-51fe-4f16-8bab-53832fc9b587        topology.kubernetes.io/zone     myhome-room1
```

ポッドの場所に合わせてゾーンが選択されています。

でも、3:1って なんかすっきりしないバランスですね。

## +Topology Spread Constraints

綺麗にゾーンで別れないので [Topology Spread Constraint]を設定してみます。

[Topology Spread Constraint]: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/

.spec.template.spec.topologySpreadConstraintsが新しい要素です。
maxSkewが1になっているので、各ゾーン間のPod数の差がこれ以上広がらない様に
Podを配置してくれます。

```yaml
apiVersion: apps/v1
kind:  StatefulSet
metadata:
  name: ss-zone-constrained
spec:
  selector:
    matchLabels:
      app: ss-zone-constrained
  serviceName: ss-zone-constrained
  replicas: 6
  template:
    metadata:
      labels:
        app: ss-zone-constrained
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: ss-zone-constrained
      containers:
      - name: ss-zone-constrained
        image: alpine:latest
        volumeMounts:
        - name: myvol
          mountPath: /mnt/myvol
        command: ["/bin/sh"]
        args: ["-c","while true; do date >>/mnt/myvol/time.log;sleep 1; done"]
  volumeClaimTemplates:
  - metadata:
      name: myvol
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: nas-topology-aware
      resources:
        requests:
          storage: 100Mi
```

上の Manifestを投入すると Podが 2つ DeployされたあとPendingになって止まります（罠）

理由は上の構成図を見るとわかると思います[^2]が、
同じRegion内にKitchenという もう一つの Zoneがあります。

Kitchenには Taintされたマスターノードしかいないので、
Podが配置出来ず、差が1までしか許容されていないので 制約で動けなくなるという事です。

[^2]: 最初、気が付かずに30分ぐらい悩みました(笑)

この場合、nodeSelector や Affinityなどで、予め そのZoneのノードが一台も対象に入らない様にしてやることで回避可能です。

適当にノードにラベルをつけて nodeSelector[^3]を追加して実行します。

```yaml
spec:
  template:
    spec:
      nodeSelector:
        boss: me
```

結果はこんな感じです。綺麗にゾーン毎に 3つずつPodが分散しています。

```console
admin@m1:~$ kubectl get pod -o wide
NAME                    READY   STATUS    RESTARTS   AGE    IP              NODE   NOMINATED NODE   READINESS GATES
ss-zone-constrained-0   1/1     Running   0          107s   192.168.62.83   w1     <none>           <none>
ss-zone-constrained-1   1/1     Running   0          85s    192.168.27.76   w2     <none>           <none>
ss-zone-constrained-2   1/1     Running   0          71s    192.168.62.84   w1     <none>           <none>
ss-zone-constrained-3   1/1     Running   0          62s    192.168.27.77   w2     <none>           <none>
ss-zone-constrained-4   1/1     Running   0          52s    192.168.62.85   w1     <none>           <none>
ss-zone-constrained-5   1/1     Running   0          41s    192.168.27.78   w2     <none>           <none>

admin@m1:~$ kubectl get pod -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\t"}{.spec.volumes[?(@.name=="myvol")].persistentVolumeClaim.claimName}{"\n"}'
ss-zone-constrained-0   w1      myvol-ss-zone-constrained-0
ss-zone-constrained-1   w2      myvol-ss-zone-constrained-1
ss-zone-constrained-2   w1      myvol-ss-zone-constrained-2
ss-zone-constrained-3   w2      myvol-ss-zone-constrained-3
ss-zone-constrained-4   w1      myvol-ss-zone-constrained-4
ss-zone-constrained-5   w2      myvol-ss-zone-constrained-5

admin@m1:~$ kubectl get pv -o jsonpath='{ range .items[*]}{.spec.claimRef.name}{"\t"}{.metadata.name}{"\t"}{.spec.nodeAffinity.required.nodeSelectorTerms[*].matchExpressions[?(@.key=="topology.kubernetes.io/zone")].key}{"\t"}{.spec.nodeAffinity.required.nodeSelectorTerms[*].matchExpressions[?(@.key=="topology.kubernetes.io/zone")].values[*]}{"\n"}' | sort

myvol-ss-zone-constrained-0     pvc-636c0031-ee71-4417-a798-9233ba452f1c        topology.kubernetes.io/zone     myhome-room1
myvol-ss-zone-constrained-1     pvc-ff2780ad-8bf5-49aa-a79a-90b0598ed454        topology.kubernetes.io/zone     myhome-room2
myvol-ss-zone-constrained-2     pvc-e47ec00a-71d6-4d1a-9a2c-2cab8f55163b        topology.kubernetes.io/zone     myhome-room1
myvol-ss-zone-constrained-3     pvc-362c2ce0-1a2a-4413-be88-4b90279e07bb        topology.kubernetes.io/zone     myhome-room2
myvol-ss-zone-constrained-4     pvc-3e76485e-b29b-451e-b51d-69db3ae8a7f5        topology.kubernetes.io/zone     myhome-room1
myvol-ss-zone-constrained-5     pvc-7e9af95e-fd89-4048-9416-af4e12ae23df        topology.kubernetes.io/zone     myhome-room2

admin@m1:~$ tridentctl -n trident get backend
+--------------------------+----------------+--------------------------------------+--------+---------+
|           NAME           | STORAGE DRIVER |                 UUID                 | STATE  | VOLUMES |
+--------------------------+----------------+--------------------------------------+--------+---------+
| ontapnas_192.168.254.216 | ontap-nas      | f117f775-eaa1-4a93-ad14-5a47a0b8756e | online |       3 |
| ontapnas_192.168.253.216 | ontap-nas      | 5b922cf6-c807-412f-8273-9906a89dda7a | online |       3 |
+--------------------------+----------------+--------------------------------------+--------+---------+
```

## まとめ

というわけで、Topology Awareな バックエンド登録の仕方と基本的な動きについて検証してみました。

- Tridentの構成で supportedToporogiesをバックエンドに追加
- StorageClassに対して Binding Modeを WaitForFirstConsumerに
- 調整は allowedTopologies などを適宜

という感じでしょうか。

こういう構成の導入が、企業で検討されるようになってきたんだなぁなんて、しみじみと感じている今日この頃です。
ポエムな感じですが 終わります。