EBS CSI Driver を使ったリモート検証環境上の MySQL 作成時間の高速化


こんにちは
最近 POL 社内では、前回の Advent Calendar の担当であるゲバさんをゴールキーパーにして遊ぶのが流行っております。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/562414/ae77863e-d621-b4f3-78b5-83e16e5d9841.png)

そんなゲバさんからバトンを受け取りPOL アドベントカレンダー 5 日目担当となります、株式会社 POL にてエンジニアをしている山田高寛です。


株式会社 POL では理系学生に特化した採用サービス LabBase を運営しており、このサービスは Amazon EKS 上で稼働しております。
また、各エンジニアのリモート検証環境も EKS 上で提供しており、開発中の機能ごと (Git ブランチごと) に専有の環境を EKS 上に割り当てることができます。
このあたりの詳細については弊社のテックブログにて記載しましたので、ぜひご一読ください。

https://note.com/tyrwzl/n/n14703cfca236

リモート検証環境では、本番環境 like な環境を提供しており、フロントエンド、バックエンドからデータベースまで環境ごとに作成されます。
以前まではデータベースとして MySQL コンテナを利用しており、環境が作成されるたびに S3 に保存されているダミーデータが含まれたダンプファイルを取得して ```/docker-entrypoint-initdb.d``` に格納することでデータ領域の初期化を実施していました。
しかし、このデータベースの初期化プロセスはではメモリ消費量がスパイクし、同じホスト (EC2 インスタンス) に同居している他のコンテナの挙動が不安定になる、といった問題点を抱えていました。
例えば、適切な Resource Quota を設定したり、Taint/Toleration や Node Affinity を駆使することで MySQL コンテナに対して専有のホスト (EC2 インスタンス) を割り当てればこの問題は解決できるかもしれませんが、開発中の機能ごとに環境数が増えていくため、この対策方法ではインフラコストが増加してしまいます。

そこで、今回 EKS クラスタのバージョンアップに伴いベータ利用が可能になった (Kubernetes v1.17 から) Volume Snapshots 機能を用いて、EBS スナップショットから MySQL のデータ領域を復元してみたので、その知見を共有します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/562414/b014546c-276e-c914-28bc-01edfcc560f8.png)


# Volume Snapshots

Volume Snapshots 機能は Kubernetes クラスタ上のボリュームに対してスナップショットを取得したり、スナップショットから Kubernetes クラスタ上にボリュームを作成することができます。
この Volume Snapshots 機能を利用するには CSI ドライバーのサポートが必要ですが、EKS の場合は EBS CSI ドライバーの利用が可能です。

登場する API リソース は VolumeSnapshotContent と VolumeSnapshot です。
これらは PersistentVolume と PersistentVolumeClaim の関係と似ています。

PersistentVolumeClaim と PersistentVolume が 1 対 1 の関係であるように、VolumeSnapshot と VolumeSnapshotContent も 1 対 1 の関係をとります。
また、PersistentVolume は 特定の Namespace 配下に属し、PersistentVolumeClaim が Cluster リソースであるように、VolumeSnapshotContent は 特定の Namespace 配下に属し、VolumeSnapshot は Cluster リソースとなります。

|                       | 役割                        | Cluster リソース (Namespaces に属さない) |
| --------------------- | --------------------------- | ---------------------------------------- |
| PersistentVolume      | ボリューム (EBS ボリューム) | yes                                      |
| PersistentVolumeClaim | PersistentVolume を要求する | no                                       |

|                       | 役割                                    | Cluster リソース (Namespaces に属さない) |
| --------------------- | --------------------------------------- | ---------------------------------------- |
| VolumeSnapshotContent | スナップショット (EBS スナップショット) | yes                                      |
| VolumeSnapshot        | VolumeSnapshotContent を要求する        | no                                       |

また、VolumeSnapshotContent/VolumeSnapshot と PersistentVolume/PersistentVolumeClaim 間の関係、つまりスナップショットからボリュームを復元する方法については、PersistentVolumeClaim の作成時に VolumeSnapshot を指定することができます。
従って VolumeSnapshot を作成した後に、作成した VolumeSnapshot を指定した PersistentVolumeClaim を作成すれば、所望の VolumeSnapshot をもとにした PersistentVolume が Dynamic Provisioning によって作成され、スナップショットからボリュームが復元されます。

# EBS CSI ドライバーのインストール方法

Volume Snapshots 機能を利用するために EBS CSI ドライバーを Kubernetes クラスターにインストールします。
EBS CSI ドライバーのインストール方法については公式のドキュメントや EBS CSI driver の GitHub の README に記載がありますが、Volume Snapshot を利用する場合は 下記 GitHub Issue に記載があるように、現時点ではドキュメントの手順では利用できなかったため自分が実施したインストール方法を紹介します。

- https://docs.aws.amazon.com/eks/latest/userguide/ebs-csi.html
- https://github.com/kubernetes-sigs/aws-ebs-csi-driver/issues/537

## IAM リソースの作成

EBS CSI ドライバーを利用する場合はクラスターノードに EBS 関連の IAM ポリシーを付与する必要があります。
具体的にはドキュメントに記載のある通り、下記のような手順で IAM ポリシーを作成した後に、クラスターノードの IAM ロールに作成した IAM ポリシーをアタッチします。

```
$ curl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-ebs-csi-driver/v0.7.1/docs/example-iam-policy.json
$ aws iam create-policy --policy-name Amazon_EBS_CSI_Driver \
    --policy-document file://example-iam-policy.json
```

## EBS CSI ドライバー関連リソースのインストール

まず、下記のように Helm を使って EBS CSI ドライバーに必要なリソースをインストールします。

```
$ helm upgrade --install aws-ebs-csi-driver \
    --namespace kube-system \
    --set enableVolumeScheduling=true \
    --set enableVolumeResizing=true \
    --set enableVolumeSnapshot=true \
    https://github.com/kubernetes-sigs/aws-ebs-csi-driver/releases/download/v0.7.1/helm-chart.tgz
```

その後、下記のような POD が作成されます。

```
$ kubectl get pod
...
ebs-csi-controller-df4678449-bmf89   6/6     Running   0          5m14s
ebs-csi-controller-df4678449-hcdgf   6/6     Running   0          5m14s
ebs-csi-node-8z8vg                   3/3     Running   0          5m15s
ebs-csi-node-hxrrh                   3/3     Running   0          5m15s
ebs-csi-node-l6p2q                   3/3     Running   0          5m15s
ebs-snapshot-controller-0            1/1     Running   0          5m14s
...
```

追加で、Volume Snapshots 機能に必要な CRD を下記コマンドでインストールします。

```
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/master/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/master/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/master/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml
```

最後に下記マニフェストを適用して StorageClass VolumeSnapshotClass を作成しておきます。

```
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: ebs-sc
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer

---

apiVersion: snapshot.storage.k8s.io/v1beta1
kind: VolumeSnapshotClass
metadata:
  name: csi-aws-vsc
driver: ebs.csi.aws.com
deletionPolicy: Delete
```

これで Volume Snapshots 機能を利用する準備が完了しました。

# EBS スナップショットからのボリューム復元

今回のゴールとしては、AWS の世界の言葉を使うと、MySQL のデータ領域が含まれた EBS スナップショットから EBS ボリュームを復元し、EBS ボリュームを MySQL に提供することです。
Kubernetes の世界の言葉で置き換えると、MySQL のデータ領域が含まれた VolumeSnapshotContent から PersistentVolume を復元し、復元した MySQL コンテナに提供します。
したがって作成するべきリソースは

- MySQL のデータ領域が含まれた EBS スナップショット
- EBS スナップショットと紐付いた VolumeSnapshotContent
- VolumeSnapshotContent を消費する VolumeSnapshot
- VolumeSnapshot を dataSource フィールドに指定した PersistentVolumeClaim

となります。

はじめに、復元対象となる MySQL のデータ領域が含まれた EBS スナップショットを作成します。
そのためにまずは MySQL コンテナを作成します。

```
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: statefulset-mysql
spec:
  serviceName: mysql-headless
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/component: statefulset-mysql
  volumeClaimTemplates:
    - metadata:
        name: root
      spec:
        accessModes:
          - ReadWriteOnce
        storageClassName: ebs-sc
        resources:
          requests:
            storage: 8Gi
  template:
    metadata:
      labels:
        app.kubernetes.io/component: statefulset-mysql
    spec:
      initContainers:
      containers:
      - name: mysql
        image: mysql:5.7
        args:
          - "--ignore-db-dir=lost+found"
        env:
        - name: MYSQL_ROOT_PASSWORD
          value: password
        ports:
          - containerPort: 3306
            name: mysql
        volumeMounts:
          - name: root
            mountPath: /var/lib/mysql

---

apiVersion: v1
kind: Service
metadata:
  name: mysql-headless
  labels:
    app.kubernetes.io/component: mysql-headless
spec:
  ports:
  - port: 3306
    name: mysql
  selector:
    app.kubernetes.io/component: statefulset-mysql
  clusterIP: None
```

上記マニフェストを適用すると、下記のように PersistentVolumeClaim/PersistentVolume が作成されます。

```
$ kubectl describe pvc
Name:          root-statefulset-mysql-0
Namespace:     default
StorageClass:  ebs-sc
Status:        Bound
Volume:        pvc-95a088d1-07fa-47b3-9d52-5d64cfae31b3
Labels:        app.kubernetes.io/component=statefulset-mysql
Annotations:   pv.kubernetes.io/bind-completed: yes
               pv.kubernetes.io/bound-by-controller: yes
               volume.beta.kubernetes.io/storage-provisioner: ebs.csi.aws.com
               volume.kubernetes.io/selected-node: ip-10-0-44-42.ap-northeast-1.compute.internal
Finalizers:    [kubernetes.io/pvc-protection]
Capacity:      8Gi
Access Modes:  RWO
VolumeMode:    Filesystem
Mounted By:    statefulset-mysql-0
Events:
  Type     Reason                 Age                    From                                                                                     Message
  ----     ------                 ----                   ----                                                                                     -------
  Warning  ProvisioningFailed     7m37s (x6 over 8m42s)  persistentvolume-controller                                                              storageclass.storage.k8s.io "ebs-sc" not found
  Normal   ExternalProvisioning   7m33s (x2 over 7m33s)  persistentvolume-controller                                                              waiting for a volume to be created, either by external provisioner "ebs.csi.aws.com" or manually created by system administrator
  Normal   Provisioning           7m33s                  ebs.csi.aws.com_ebs-csi-controller-df4678449-bmf89_3c5764b4-4ae9-4df6-b7fe-d08b7480f981  External provisioner is provisioning volume for claim "default/root-statefulset-mysql-0"
  Normal   ProvisioningSucceeded  7m30s                  ebs.csi.aws.com_ebs-csi-controller-df4678449-bmf89_3c5764b4-4ae9-4df6-b7fe-d08b7480f981  Successfully provisioned volume pvc-95a088d1-07fa-47b3-9d52-5d64cfae31b3

$ kubectl describe pv
Name:              pvc-95a088d1-07fa-47b3-9d52-5d64cfae31b3
Labels:            <none>
Annotations:       pv.kubernetes.io/provisioned-by: ebs.csi.aws.com
Finalizers:        [kubernetes.io/pv-protection external-attacher/ebs-csi-aws-com]
StorageClass:      ebs-sc
Status:            Bound
Claim:             default/root-statefulset-mysql-0
Reclaim Policy:    Delete
Access Modes:      RWO
VolumeMode:        Filesystem
Capacity:          8Gi
Node Affinity:
  Required Terms:
    Term 0:        topology.ebs.csi.aws.com/zone in [ap-northeast-1c]
Message:
Source:
    Type:              CSI (a Container Storage Interface (CSI) volume source)
    Driver:            ebs.csi.aws.com
    VolumeHandle:      vol-0379c8f2d0477f643
    ReadOnly:          false
    VolumeAttributes:      storage.kubernetes.io/csiProvisionerIdentity=1607136209524-8081-ebs.csi.aws.com
Events:                <none>
```

PersistentVolume の出力のうち、VolumeHandle フィールドに作成された EBS ボリューム ID が記載されます。
この EBS ボリュームに対する EBS スナップショットを AWS マネジメントコンソールなり、AWS CLI なりで作成して上げればよいです。
今回は EBS スナップショットを作成する前に、データベースが復元されたことを確認するために、事前に MySQL 上でカスタムデータベースを作成しておきます。

```
mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.00 sec)

mysql> CREATE DATABASE niziu;
Query OK, 1 row affected (0.00 sec)

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| niziu              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)
```

その後 EBS スナップショットを作成します。
AWS CLI では下記のように実行します。

```
$ aws ec2 create-snapshot  --volume-id vol-0379c8f2d0477f643
...
    "SnapshotId": "snap-0c7d1565ef573dad7",
...
```

なお、実際には EBS スナップショットを作成する場合はデータ整合性を保つために必要に応じて事前に静止点を確保してください。
次に下記のような EBS スナップショットと紐付いた VolumeSnapshotContent と VolumeSnapshot を作成します。

```
apiVersion: snapshot.storage.k8s.io/v1beta1
kind: VolumeSnapshotContent
metadata:
  name: mysql-snapshotcontent
spec:
  deletionPolicy: Retain
  volumeSnapshotRef:
    kind: VolumeSnapshot
    name: mysql-snapshot
    namespace: default
  source:
    snapshotHandle: snap-0c7d1565ef573dad7
  driver: ebs.csi.aws.com
  volumeSnapshotClassName: csi-aws-vsc

---

apiVersion: snapshot.storage.k8s.io/v1beta1
kind: VolumeSnapshot
metadata:
  name: mysql-snapshot
spec:
  volumeSnapshotClassName: csi-aws-vsc
  source:
    volumeSnapshotContentName: mysql-snapshotcontent
```

注意点としては下記の 3 点です。

- VolumeSnapshotContent リソースの ```spec.volumeSnapshotRef.name``` と VolumeSnapshot リソースの ```metadata.name``` が一致しなければならない
- VolumeSnapshotContent リソースの ```spec.source.snapshotHandle``` には EBS スナップショットの ID を指定する (上記 AWS CLI の例では、create-snapshot コマンドの実行結果である SnapshotId の値を指定します。)
- それぞれのリソースにて、volumeSnapshotClassName に「EBS CSI ドライバー関連リソースのインストール」項で作成した VolumeSnapshotClass の ```metadata.name``` を指定する

最後に PersistentVolumeClaim と MySQL を作成してデータベースを復元します。

```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-snapshot-restored-claim
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: ebs-sc
  resources:
    requests:
      storage: 8Gi
  dataSource:
    name: mysql-snapshot
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io

---

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: statefulset-restored-mysql
spec:
  serviceName: mysql-headless
  selector:
    matchLabels:
      app.kubernetes.io/component: statefulset-mysql
  template:
    metadata:
      labels:
        app.kubernetes.io/component: statefulset-mysql
    spec:
      containers:
      - image: mysql:5.7
        name: mysql
        env:
          # Use secret in real usage
        - name: MYSQL_ROOT_PASSWORD
          value: password
        ports:
        - containerPort: 3306
          name: mysql
        volumeMounts:
        - name: persistent-storage
          mountPath: /var/lib/mysql
      volumes:
      - name: persistent-storage
        persistentVolumeClaim:
          claimName: mysql-snapshot-restored-claim
```


こちらの注意点としては、PersistentVolumeClaim リソースの ```spec.dataSource.name``` と ```VolumeSnapshot``` リソースの metadata.name が一致させる必要があります。
さて、復元した MySQL に対して SQL クエリを実行してみると、

```
mysql> SHOW DATABASES;
+---------------------+
| Database            |
+---------------------+
| information_schema  |
| #mysql50#lost+found |
| mysql               |
| niziu               |
| performance_schema  |
| sys                 |
+---------------------+
6 rows in set (0.00 sec)

```
となり、これで無事データベースが復元されたことが確認できました。

# 結果

最後に本来の目標であるリソース消費量を比較します。

今までの ```/docker-entrypoint-initdb.d``` にダンプデータを格納してデータベース初期化を実施していたときは下記のように最大で 1.9 GB のメモリを消費していました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/562414/ed90b002-2ae8-41fa-bc7d-9b608ce40946.png)

対して、今回のように VolumeSnapshots を利用した場合は およそ 600 MB となり、メモリ使用量のスパイクを抑えられることが確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/562414/0fad184a-3265-31ec-0814-6a2ebd613ce6.png)


また、副次的な効果として、リモート検証環境の立ち上がり時間も平均して 4 分間かかっていたものが、2 分間となりました。
微々たる差ではありますが、起動時間を半減することができました。

# 困ったこと
「Volume Snapshots」の項でも述べたように、VolumeSnapshotContent は Cluster リソース、Namespaces に属しません。
なので、単純に該当する Namespace を削除するだけでは VolumeSnapshotContent 残り続けます。
一方で、VolumeSnapshotContent と紐づく VolumeSnapshot は削除されます。
この状態で、再度同じ VolumeSnapshot を作成しても VolumeSnapshotContent が他の VolumeSnapshot と紐付いているため利用できないとエラーが出てしまいます。

リモート検証環境の場合、ときたま環境を作り直したい場合があり、その際 Namespace を削除してから再デプロイを実施するのですが、上記のような挙動のため単純に Namespace を削除するだけでは VolumeSnapshot から MySQL を復元することができません。
暫定対策として、VolumeSnapshot と VolumeSnapshotContent の ```metadata.name``` にランダムな suffix を付与することで対応しています。


# 今後について　

EBS をお使いになられたことがある方人は EBS スナップショットから復元した EBS ボリュームに対するパフォーマンスについて気になられているかもしれません。
EBS スナップショットはデータが S3 に保存されているため、EBS スナップショットから EBS ボリュームを復元する場合は S3 からスナップショットデータを取得する必要がありますが、すべてのデータがダウンロードされるまで時間がかかり、パフォーマンスに影響が生じる場合があります。

今回のリモート検証環境では MySQL データベースのサイズが大きなものではなかったためパフォーマンスに影響はなかったのですが、今後データベースのサイズが大きくなってきた場合は対策を実施する必要があります。

対策の一つとしては、最近リリースされた Fast Snapshot Restore 機能が利用可能です。
しかしながら、Fast Snapshot Restore 機能は追加のコストがかかるため、実際には MySQL 専用のノードを用意した場合とのコストとどちらがコストを抑えられるのかを比較して上げる必要があると思っております。

もうひとつは、EBS スナップショットから復元時に Init Container を利用して dd, fio コマンドによって EBS ボリュームの事前ウォーミングをしてあげることで対策可能であると考えられます。

上記方法についてはまだ試していないので、今後必要に応じて検証してみたいと思います (もし検証済みの方がいれば教えて下さい！)。