Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Kubernetes Cluster 上でステートフルな共有ファイルストレージを立てるリソース構成例をご紹介!

こんばんわ。 マネーフォワード クラウドBox というサービスを開発しています、Rails エンジニアの SAKAMOTO X です。

マネーフォワード クラウド Box (以下 MFCBox)はその名の通りストレージサービスです。 開発環境上や検証前の共用開発環境では、ファイルストレージとして S3 互換サービスの MinIO を使っています。

この記事では、弊社の検証前の共用開発環境 の Kubernetes Cluster 上で上記をどう構成しているかを説明していきます。

焦点

この記事でご説明する焦点は大きく分けて主に二つです。

  • ステートフルなアプリケーションを構築するための Kubernetes リソースやパラメータ等の選定
  • 共用開発上でよくありそうな金銭面や運用負荷などの制約とどう折り合いをつけていくか

対象読者

読むと効果的な方は以下のような方を想定しています。

  • Kubernetes 以外でのインフラ構築経験がある方(クラウド環境含め)
  • Kubernetes のインプットをなんとなくしたけど、これからどう実際にインフラ構築していったらいいか不安な方
  • Kubernetes を触ったことがあるけれど、正直他人の書いたコードを雰囲気でコピペしてたかもな方

到達地点

読み終わった頃には以下ができるようになることを目指します。

  • 業務で Kubernetes のリソースでステートフルなストレージの表現を検討する際は、自信を持ってリソース選択、設計ができる
  • 下記の曼荼羅を納得感を持って理解できているようになる

記事は少し長くなりますが、一つでも参考になる箇所があれば嬉しいです。

共用開発環境で MinIO を利用している理由

前提1: 弊社の共用開発環境の概要

弊社の共用開発環境では、共用開発環境用の EKS でマネージされている Kubernetes Cluster 上に開発者が自由に Kubernetes の Namespace を作ることができ、その中でMFCBoxなどの検証したいサービスを必要なリソースの manifests を組み合わせて起動させることができます。

前提2: 共用開発環境では AWS リソースは使わない

弊社の共用開発環境では、以下の理由で S3 等の各サービス個別に必要な AWS リソースは使わず、Kubernetesのリソースで表現するという方針になっています。 (EKS、 EBS は例外)

  • Namespace ごとにTerraformでAWSリソースを定義しなければいけなくなり、Namespace を追加するための作業が増える
  • AWSリソースごとの費用がかさむ
  • あくまで開発環境なのでAWSのサービスが保証するような非機能要件が求められない
    • (ローカル開発で使われるイメージで十分)

Localstack じゃダメなのか

AWS 互換サービスは Localstack もあるのですが、以下の理由で MinIO を使っています。 * 欲しい AWS互換サービスは S3 のものだけであったこと * Web UI が綺麗 * チームメンバーが使い慣れている

Kubernetes のリソースを用いたインフラ構成

MFCBox の image のコンテナで起動している Pod と MinIO の image のコンテナで起動している Pod を分けて通信します。 ステートフルな Pod の実現方法と、共用開発環境上のコストやDB運用負荷からくる制約とどう折り合いをつけるかというところに焦点を置いています。

今回の構成を実現するに当たって重要な Kubernetes リソースは主に以下の5つです。

  • PersistentVolume
  • PersistentVolumeClaim
  • StorageClass
  • Statefulset
  • Headless Service

永続領域の Dynamic Provisioning と Pod からの利用

ファイルストレージコンテナには MinIO の image を使いますが、ステートフルなコンテナにするためには永続化領域が必要です。

永続化領域を確保するには、ネットワーク越しに Node にアタッチするタイプのディスクの機能として Kubernetes の Cluster レベルのリソースである PerisistentVolume を使い、PersistentVolumeClaim という Namespace レベルの Storage API を使って Pod から永続化領域を要求します。

ただし、Provisioner が設定された StorageClass というリソースを使うことによって、PersistentVolumeClaim が発行されてからクラウド環境の API を呼び出して動的に PersistentVolume の Provisioning を行うことにします。

このような Dynamic Provisioning では事前に開発者が PersistentVolume を作成しておく必要がなく、要求するリソースに対して割り当てる Volume 容量の無駄も生じさせないというメリットがあります。

参考: https://kubernetes.io/docs/concepts/storage/dynamic-provisioning/

今回の例では Provisioner に kubernetes.io/aws-ebs を使い Amazon EBS の領域を PersistentVolume に Provisioning します。

Pod 間通信の準備

Statefulset

ステートフルなアプリケーションを管理するための Workload API には、一般的に使われている Statefulset を弊チームで使用することにしました。

Statefulset は以下の点で良い性質を持っているからです。

  • 作成される Pod 名のサフィックス は数字のインデックスが付与されたものになるということ
  • Pod のスケールアウト時にはサフィックスの数字が小さいものから一つずつ順に数字を上げて増えていき、スケールインの時には逆に数字が大きい順に削除されていくこと

これらの性質によって、PersistentVolume を PersistentVolumeClaim と紐づけて Pod にマウントすると、 Pod ごとに PersistentVolume が割り当てられ、各 Pod の再作成時には喪失前と同じディスクを参照することができます。

つまり、次に紹介する Headless service と組み合わせれば Pod 名で Pod のIP アドレスを引けるようになるということも合わせると、サフィックスが 0 の Pod をマスターとしたリードレプリカ構成を実現しやすい仕組み になっています。

参考: https://kubernetes.io/ja/docs/tasks/run-application/run-replicated-stateful-application/

( ただし実際は Pod を 1 つしか起動しないのであれば Statefulset の恩恵はあまりなく、Deployment でも大丈夫です。今回の例でも 1つしか起動していませんが、今後は複数起動する可能性もあるので Statefulset を使っています。)

Headless Service

StatefulSetは現在、Podのネットワークアイデンティティーに責務をもつために Headless Service を要求する、という制限事項 があります。

したがって弊チームでも Pod の DNS を実現する Service API としてはPod の IP アドレスを直接返す Headless Service を作成します。

この制限の背景は、上述のように Statefulset は Pod によって割り当てられる PersistentVolume が異なり、他の Pod から Statefulset のどの Pod に接続するかで扱うデータが異なるので、リクエスト先の Pod の宛先ホスト名を明示して使用したい場合があります。

そうなると、各 Pod に対してネットワークアイデンティティを保たれていることが Podごとに固有のFQDNを払い出す Statefulset と相性が良いから、だと私は考察しています。

Component ごとの manifest

上記の構成を 表現したものが以下の manifests です。

StorageClass の manifest

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-ext4-ebs-sc
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  fsType: ext4

PersistentVolumeClaim の manifest

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    create-snapshots: "true"
  annotations:
    argocd.argoproj.io/sync-options: Prune=false
  name: mfc-box-filestorage-pvc
spec:
  storageClassName: gp3-ext4-ebs-sc
  accessModes:
  - ReadWriteOnce # アクセスを許可するノードを1つに限定する。EKS では ReadWriteMany をサポートしていないという背景もあっての選択。
  resources:
    requests:
      storage: 100Gi

Statefulset では spec.volumeClaimTempletes で PersistentVolumeClaim に相当する項目を設定しても良い。

Headless Service の manifest

apiVersion: v1
kind: Service
metadata:
  name: mfc-box-filestorage-service
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: mfc-box-filestorage
  ports:
  - name: minio
    protocol: TCP
    port: 9000
    targetPort: minio-port

Statefulset の manifest

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mfc-box-filestorage
spec:
  serviceName: mfc-box-filestorage-service # この Statefulset 特有のフィールドで Pod 側から Headless service を直接指定することで Pod 名で IP アドレスを引けるようになる。
  selector:
    matchLabels:
      app.kubernetes.io/name: mfc-box-filestorage
  replicas: 1
  updateStrategy: # Statefulset 特有のフィールド。RollingUpdate がデフォルトなので無くてもOk
    type: RollingUpdate
  template:
    metadata:
      labels:
        app.kubernetes.io/name: mfc-box-filestorage
    spec:
      containers:
      - name: set-buckets
        image: amazon/aws-cli:2.4.15
        # run startup script
        command: ["sh"]
        args: ["/root/.aws/set-buckets.sh"]
        env:
        - name: AWS_DEFAULT_REGION
          value: ap-northeast-1
        - name: AWS_ACCESS_KEY_ID
          value: hoge
        - name: AWS_SECRET_ACCESS_KEY
          value: fuga
        volumeMounts:
        - name: set-buckets
          mountPath: /root/.aws
      - name: mfc-box-filestorage
        image: minio/minio:RELEASE.2020-09-21T22-31-59Z
        imagePullPolicy: Always
        args:
        - server
        - /data
        env:
        - name: MINIO_ACCESS_KEY
          value: minioadmin
        - name: MINIO_SECRET_KEY
          value: minioadmin
        ports:
        - containerPort: 9000
          name: minio-port
        resources:
          requests:
            memory: 128Mi
            cpu: 250m
          limits:
            memory: 256Mi
            cpu: 500m
        volumeMounts:
        - name: mfc-box-filestorage-pv
          mountPath: /data
      volumes:
      - name: mfc-box-filestorage-pv
        persistentVolumeClaim:
          claimName: mfc-box-filestorage-pvc
      - name: set-buckets
        configMap:
          name: minio-config
apiVersion: v1
kind: ConfigMap
metadata:
  name: minio-config
data:
  set-buckets.sh: |-
    #!/bin/bash
    # 同じ Pod に含まれる container 同士はネットワークが隔離されておらず、IP アドレスを共有しているので、mfc-box-filestorage コンテナには localhost 宛で通信が可能。
    set -e -x -u
    MINIO_ENDPOINT="http://localhost:9000"
    MFC_BOX_MINIO_ORIGINAL_BUCKET_NAME=mfc-box-original-data

    echo "Waiting for S3"
    # 使用しているブログで、 bash を不正なスクリプトとして判定されてしまうので `bash` としていますが、本来`(backtick)は不要です
    timeout 2m `bash` -c "until aws s3 ls --endpoint-url=$MINIO_ENDPOINT ; do sleep 5; done"
    echo "Minio is there"
    EXISTING_BUCKETS=`aws s3 ls --endpoint-url=$MINIO_ENDPOINT`

    if echo $EXISTING_BUCKETS | grep -q $MFC_BOX_MINIO_ORIGINAL_BUCKET_NAME; then
      echo "Bucket ${MFC_BOX_MINIO_ORIGINAL_BUCKET_NAME} already exists."
    else
      echo "Creating ${MFC_BOX_MINIO_ORIGINAL_BUCKET_NAME}"
      aws s3 mb s3://$MFC_BOX_MINIO_ORIGINAL_BUCKET_NAME --endpoint-url=$MINIO_ENDPOINT
      echo "Done"
    fi

    aws s3 ls --endpoint-url=$MINIO_ENDPOINT
    echo "wait forever to keep state clean.."

    sleep infinity

MinIO の image で起動した filestorage 用のコンテナとは別に bucket 作成用のサブコンテナをたてるサイドカーパターンにしました。

この bucket 作成用のコンテナが起動したら MinIO の bucket を作成する shell スクリプトを実行します。

これらの pod の起動順を initContainers などを使って調整することもできるのですが、bucket 作成用のスクリプト上で MinIO のコンテナが起動し、aws s3 ls コマンドに応答するまで待つことで起動順を気にしなくてよい関係になっています。

この記事の MinIO image は Apache license 2.0 のやや昔のものです。最新の MinIOの場合、AGPL になっていたり、MINIO_ACCESS_KEYMINIO_SECRET_KEY の部分等、異なる部分があると思うのでご注意ください。

Kubernetes は v1.21.3 です。API document: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/

ステートフルなリソースはどこに置くべきか

上述のコードで示したファイルストレージ用のリソースは、結論から言うと アプリケーションコードのリソースとは別の Namespace に配置しています。

具体的には例えば以下のディレクトリツリーの shared-mfc-box-filestorage の配置です。

(manifets 管理ツールには Kustomize(v3.6.1)を利用しています。)

.
├── namespaces
│   ├── mfc-box-a
│   │   ├── kustomization.yaml # services/mfc-box の内容を取り込む
│   │   └── namespace.yaml
│   ├── mfc-box-b
│   │   ├── kustomization.yaml # services/mfc-box の内容を取り込む
│   │   └── namespace.yaml
│   ├── shared-mfc-box-filestorage
│   │   ├── kustomization.yaml  # services/mfc-box-filestorage の内容を取り込む
│   │   └── namespace.yaml
│   └── shared-db #  services/mfc-box-db や他のサービスの DB の内容を取り込む
│       ├── kustomization.yaml
│       └── namespace.yaml
└── services
    ├── mfc-box
    │   └── 略
    ├── mfc-box-filestorage
    │   ├── base
    │   │   ├── kustomization.yaml
    │   │   ├── persistent-volume-claim.yaml
    │   │   ├── storage-class.yaml
    │   │   ├── service.yaml
    │   │   └── statefulset.yaml
    │   └── kustomization.yaml
    └── mfc-box-db
        └── 略

この構成にした背景は、またしても共用開発環境特有の制約が関係しています。 また、ファイルストレージはどのようにして使われるかから考えると導き出しやすいと思います。

ファイルストレージ用の Namespace はアプリケーション用とは分ける

弊社の共用開発環境上での ストレージのインフラ構成を踏まえながら、ツリーのような配置の理由ご説明します。

前提

この共用開発環境上の Kubernetes Cluster では 各サービスの DB に関するリソースは、アプリケーションのリソースがある Namespace とは別れていて、共通の Namespace に置かれています。

アプリケーションのコンテナと違い、DBのコンテナは image が共通でもデータに違いがあるため、DBのコンテナが乱立した状態だと何か問題が生じたときに対応負荷が上がるからです。


MinIO のようにステートフルなファイルストレージは、同じくステートフルな DB とできるだけ寿命を一致させたいため、アプリケーションコードのリソースとは別の Namespace に配置します。

(例では shared-mfc-box-filestorage という Namespace にしています。)

つまり共用開発環境では、検証したい Namespace のアプリケーションから共通のファイルストレージを読み書きします

具体的には、ある人が立ち上げた MFCBox の Namespace(mfc-box-a) のリソースと、他の人が立ち上げた MFCBox(mfc-box-b) の Namespace のリソースから共通の MinIO の bucket のリソースを読み書きすることになります。

一方で、アンチパターンとして、ある人が立ち上げた MFCBox の Namespace に MinIO の bucket 用のリソースも入れてしまうとどうなるかというと、bucket の内容が 共通のDB と内容とずれてしまいますし、検証が終わって Namespace ごと削除した場合には 共通 DB に不要な内容が残ってしまいます。

Pod 間通信方法

MFCBox のアプリケーションコードの image で起動している同一クラスター内の Pod から、 上記でご紹介したファイルストレージの Pod に接続することを考えます。

Kubernetes の Service の仕組みによって Pod は

<Service名>.<Namespace名>.svc.cluster.local:

というドメインで名前解決されます。

つまり Service が listen する Port まで含めると http://mfc-box-filestorage-service.shared-mfc-box-filestorage.svc.cluster.local:9000 です。


もしくは、Service の中でも今回の構成では Headless Service を使い、かつそれを Statefulset から serviceName で指定しているので以下のように Pod 名でも Pod の IP アドレスを引くことができます。

<Pod名>.<Service名>.<Namespace名>.svc.cluster.local:<Port番号>

したがって、

http://mfc-box-filesotrage-0.mfc-box-filestorage-service.shared-mfc-box-filestorage.svc.cluster.local:9000

という URI でも接続できます。


9000 は Headless service が listen している port 番号です。 上記のエンドポイントに向かってリクエストをすると mfc-box-filestorage コンテナの 9000 port へトラフィックを転送してくれます。


最後に以上の説明を踏まえて、

MFCBox のアプリケーションコードの Pod がある Namespace を mfc-box とすると、その中からファイルストレージ用の Pod に接続するインフラ構成図を表現すると、冒頭に示した図になります。

(DBのリソースは簡単化のため省略しています)

まとめ

私たちが使える共用開発環境では以下のような条件(前提)や制約がありました。

  • 共用開発環境で Kubernetes を使用できるということ
  • 各自が自由に Namespace を作ってサービスを立ち上げられる共用開発環境であること
  • 金銭面やコード上の運用負荷の制約からAWS リソースを使えないということ
  • DB の問題発生時の運用負荷の関係から共通の DB 用 Namespace があり各アプリケーションの Namespace とは分かれていること

このような状況下において、

ステートフルなファイルストレージ用のリソースは共有 Namespace に配置し、各々が検証したい image で起動した Namespace のステートレスなアプリケーション間で共有する

というようなリソース構成例をご紹介しました。何かしらご参考になれば嬉しいです。


参考: Kubernetes 完全ガイド 第2版


マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。

【会社情報】 ■Wantedly株式会社マネーフォワード福岡開発拠点関西開発拠点(大阪/京都)

【SNS】 ■マネーフォワード公式noteTwitter - 【公式】マネーフォワードTwitter - Money Forward Developersconnpass - マネーフォワードYouTube - Money Forward Developers