Kubernetes上でReadWriteManyなNFSサーバを作る

# 本記事の概要

Kubernetesクラスター上にNFSサーバを作ることで、Kubernetesクラスター内でReadWriteMany(RWX)なボリューム、つまり「複数のPodから同時に読み書きできるボリューム」を割と簡単に利用可能にする方法を紹介します。

# 課題

Kubernetes上で、複数のPodから同時アクセスするRWXなボリュームを用意するのは意外と面倒です。例えばオンプレミスのKubernetesだとNFSサーバ等を用意する必要があります。パブリッククラウドのマネージドKubernetesの場合、EKSだとAmazon EFS、AKSだとAzure Files等が使えますが、リソース作成やKubernetesで利用可能にするためのセットアップにだいぶ手間がかかります。

特に検証用や小規模な環境の場合は、「なるべく手間とお金をかけずにReadWriteManyなボリュームを使える状態にしたい！」と思う方も多いでしょう。本記事はそのような時に活用できそうな内容を書いてみました。

# 背景

実は過去に同じようなことを試して、以下の記事にまとめたことがあります。

https://qiita.com/showchan33/items/fa3dadc546d4ae5e8c09

この方法だと、NFSサーバのコンテナイメージのビルドやKubernetesリソースの自分で作成する手間が要りました。
しばらく時が経って改めて調べてみると、nfs-server-provisionerという「NFSサーバ用のHelmチャート」が公開されていることに気づきました。

https://github.com/kubernetes-sigs/nfs-ganesha-server-and-external-provisioner/tree/master/charts/nfs-server-provisioner

これを使うと、``helm install``相当のコマンドで、NFSサーバを作れることがわかったので、本記事にまとることにしました。

# nfs-server-provisionerのHelmチャートのインストール

以後は、nfs-server-provisionerのインストール手順を記載します。

## 前提条件

* Kuberenetesクラスターが構築済であること
* ``helm`` と ``helmfile`` が使えること

## 事前準備

Kubernetesの各ノードに nfs-common をインストールしておく必要があります。ノードのOSがUbuntu系の場合は、以下のコマンドでインストールします。

```
sudo apt install nfs-common -y
```

#### ノードに nfs-common をインストールしないと、どうなるか？

（読み飛ばしていただいてOK）

nfs-common をインストールし忘れていると、NFSをマウントするPodを起動する際に次のようなメッセージがEventに出力されてマウントに失敗します。
```
  Warning  FailedMount       21s (x7 over 53s)  kubelet            MountVolume.SetUp failed for volume "pvc-756add44-f386-4714-956e-1a15028636ed" : mount failed: exit status 32
Mounting command: mount
Mounting arguments: -t nfs -o retrans=2,timeo=30,vers=3 10.107.158.61:/export/pvc-756add44-f386-4714-956e-1a15028636ed /var/lib/kubelet/pods/f479f261-76ca-410a-b33f-093e865f3216/volumes/kubernetes.io~nfs/pvc-756add44-f386-4714-956e-1a15028636ed
Output: mount: /var/lib/kubelet/pods/f479f261-76ca-410a-b33f-093e865f3216/volumes/kubernetes.io~nfs/pvc-756add44-f386-4714-956e-1a15028636ed: bad option; for several filesystems (e.g. nfs, cifs) you might need a /sbin/mount.<type> helper program.
```

※ 本当はノードで個別設定をするのは避けたいのですが、現状回避策が見当たりませんでした。もし見つかった場合は追記or修正予定です。

##### nfs-common のインストール可否に関する補足（24/11/13追記）

（読み飛ばしていただいてOK）

以下記事を参考にして[csi-driver-nfs](https://github.com/kubernetes-csi/csi-driver-nfs)のHelmチャートをインストールし、作成されたStorageClass経由でPVC作成およびPodからマウントすると、各ノードに nfs-common をインストールしなくて済みそうです。

https://qiita.com/showchan33/items/649a1cc3d75f337e1de4

しかしながら今度は、「CSI Driver が nfs-server-provisioner への書き込み権限を持っていなくてPVCを作れない」事態が発生してしまうため、現時点(24/11/13)では実現不可能ということもわかりました。
nfs-server-provisionerは NFS Ganesha を使ってNFSサーバを動かしているのですが、サーバへのアクセス権限が[こちら](https://github.com/kubernetes-sigs/nfs-ganesha-server-and-external-provisioner/blob/nfs-server-provisioner-1.8.0/pkg/server/server.go#L51)のように``MDONLY_RO``になっているために、コンテナの外部からは書き込みが不可のようです。

## Helmfileの用意

事前準備ができたら、以下のHelmfile形式のファイルを用意します。

```yaml:nfs-server-provisioner.yaml
repositories:

- name: nfs-ganesha-server-and-external-provisioner
  url: https://kubernetes-sigs.github.io/nfs-ganesha-server-and-external-provisioner

releases:

- name: nfs-server-provisioner
  namespace: nfs-server-provisioner
  createNamespace: true
  chart: nfs-ganesha-server-and-external-provisioner/nfs-server-provisioner
  version: 1.8.0
  values:
  - persistence: # 必須ではないが、設定を強く推奨（後述）
      enabled: true
      storageClass: "openebs-hostpath" # あくまで一例
```

最後の ``values[0].persistence`` は、NFSサーバのコンフィグやマウント後の作成ファイルの永続化に関する設定をします。

### ``enabled: true``

``enabled`` はデフォルトが ``false`` です。デフォルトから変えないと、NFSマウントで作成したファイルが ``emptyDir`` のボリュームに作られてしまい、永続化が保証されないので注意が必要です。（この辺の話は本記事の最後の方にも詳しく書いています）

### ``storageClass: "openebs-hostpath"``

本例では上のように書くことで、永続化ボリュームとして ``openebs-hostpath`` のStorageClassリソースを使う設定にしていますが、ここは環境に応じて適宜変更ください。StorageClassではなくて、直接PVCを永続化先として指定する場合は以下のように書きます。

```yaml
  values:
  - persistence:
      enabled: true
      existingClaim: "export-pvc" # あくまで一例
```

## Helmチャートのインストール

helmfileコマンドで、nfs-server-provisionerのHelmチャートをインストールします。

```
helmfile apply -f nfs-server-provisioner.yaml
```

リソースが作成されたのを確認します。

```
$ kubectl get all -n nfs-server-provisioner
NAME                           READY   STATUS    RESTARTS   AGE
pod/nfs-server-provisioner-0   1/1     Running   0          12s

NAME                             TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                                                                                                     AGE
service/nfs-server-provisioner   ClusterIP   10.106.177.244   <none>        2049/TCP,2049/UDP,32803/TCP,32803/UDP,20048/TCP,20048/UDP,875/TCP,875/UDP,111/TCP,111/UDP,662/TCP,662/UDP   12s

NAME                                      READY   AGE
statefulset.apps/nfs-server-provisioner   1/1     12s
```

NFSを利用可能なStorageClassも以下のように作られます。

```
$ kubectl get storageclass -l app=nfs-server-provisioner
NAME   PROVISIONER                            RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
nfs    cluster.local/nfs-server-provisioner   Delete          Immediate           true                   33s
```

これで、Kubernetesクラスター内でNFSのボリュームが使える状態になりました。

# NFSのボリュームをマウントしてみる

実際にNFSボリュームが作れるのかを試してみます。

## PVCの作成

まずはボリューム作成のためのPVCを以下のマニフェストで作ってみます。
```yaml:nfs-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-pvc
  namespace: default
spec:
  storageClassName: nfs # ここがポイント
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 1Gi
```
実行コマンドは以下
```
kubectl apply -f nfs-pvc.yaml
```

PVCが作られて、ダイナミックプロビジョニングでPVも作られていることを確認できます。

```
$ kubectl get pvc -n default
NAME      STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
nfs-pvc   Bound    pvc-c0b29986-7679-4f90-b49d-d1f7a2da99ab   1Gi        RWX            nfs            <unset>                 7s
```

## Podにマウントしてみる

続いて、上記のボリュームをマウントするDeploymentリソースをレプリカ数3で作ります。以下がマニフェストです。

```yaml:mnt-nfs-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mnt-nfs
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mnt-nfs
  template:
    metadata:
      labels:
        app: mnt-nfs
    spec:
      containers:
      - name: mnt-nfs
        image: busybox
        command: ["tail", "-f", "/dev/null"]
        volumeMounts:
        - name: nfs-volume
          mountPath: /data
      volumes:
      - name: nfs-volume
        persistentVolumeClaim:
          claimName: nfs-pvc
```
実行コマンドは以下
```
kubectl apply -f mnt-nfs-deploy.yaml
```

各PodでNFSボリュームがマウントされて、以下のように起動するのを確認します。
```
$ kubectl get pod -n default
NAME                       READY   STATUS    RESTARTS   AGE
mnt-nfs-65f866fc8b-8kfg2   1/1     Running   0          18s
mnt-nfs-65f866fc8b-cs99m   1/1     Running   0          18s
mnt-nfs-65f866fc8b-msm6h   1/1     Running   0          18s
```

確かに、``/data``ディレクトリがNFSでマウントされていることがわかります。
```
$ kubectl exec -n default -it deploy/mnt-nfs -- mount | grep /data
10.107.158.61:/export/pvc-c0b29986-7679-4f90-b49d-d1f7a2da99ab on /data type nfs (rw,relatime,vers=3,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=30,retrans=2,sec=sys,mountaddr=10.107.158.61,mountvers=3,mountport=20048,mountproto=udp,local_lock=none,addr=10.107.158.61)
```

## NFSボリュームのマウント元の確認

上記の``/data``ディレクトリの実体は、最初に作った ``nfs-server-provisioner-0`` というPod（以下）の ``/export`` ディレクトリ配下に存在します。

```
$ kubectl get pod -n nfs-server-provisioner
NAME                       READY   STATUS    RESTARTS   AGE
nfs-server-provisioner-0   1/1     Running   0          120m
```

それを確認すべく、試しにマウント先で適当なファイルを作ってみます。
```
kubectl exec -n default -it deploy/mnt-nfs -- \
sh -c 'echo hello >> /data/tmp.txt'
```

これと同じファイルを、``nfs-server-provisioner-0`` の以下のディレクトリで確認できます。

```
$ kubectl exec -n nfs-server-provisioner -it nfs-server-provisioner-0 -- \
ls -l /export/pvc-c0b29986-7679-4f90-b49d-d1f7a2da99ab
（以下が実行結果）
total 4
-rw-r--r-- 1 root root 6 Oct 27 06:08 tmp.txt
```

さらに言うと、``/export`` ディレクトリも特定のボリュームをマウントして作られているので、本当の実体はさらに別のところにあります。この部分の設定は、最初に nfs-server-provisioner のHelmチャートを作った時の value の中で、``persistence`` 配下の設定によって決まります。

何だか文章だけではわけがわからなくなってきたので、今の状況を図にしてみました。

![nfs-server-provisioner.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/370519/2ee24a4b-5a00-0ba0-63ad-5ff438daf203.png)

この図を見ると、マウントポイントとマウント元が多重になっているのがわかります。
また障害耐性やパフォーマンスの観点では、一番右の「nfs-server-provisionerの設定に依存」の部分が、実際のデータが格納されている箇所でかなり重要であることがわかります。

繰り返しになりますが、nfs-server-provisionerのHelmチャートをデプロイする際に全てデフォルトのvalueを使うと、``/export`` ディレクトリのボリュームがKubernetesのemptyDirと呼ばれる場所に作られます。この場合、もし``nfs-server-provisioner-0``のPodが何らかの原因で消えてしまうと、NFSのマウントポイントで作成したデータも全て消えてしまいます。
したがってnfs-server-provisionerのHelmチャートをデプロイする際は、何かしらの永続的なストレージリソースを ``persistence`` 配下のvalueで指定しておくことを強くお勧めします。

# 上手くいかなかった点

nfs-server-provisionerのStatefulSetのレプリカ数がデフォルト1なので、冗長性を上げるために2にしてみましたが、NFSをマウントするPodがRunningにならずに上手く動作しませんでした。

ドキュメントの [Persistence](https://github.com/kubernetes-sigs/nfs-ganesha-server-and-external-provisioner/tree/master/charts/nfs-server-provisioner#persistence) の部分に次のように書かれているので、動かなかったのも一応納得です。

> If this chart is deployed with more than 1 replica, storageClass.defaultClass=true and persistence.storageClass, then the 2nd+ replica will end up using the 1st replica to provision storage - which is likely never a desired outcome.