こんばんわ。 マネーフォワード クラウド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_KEY
や MINIO_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 のステートレスなアプリケーション間で共有する
というようなリソース構成例をご紹介しました。何かしらご参考になれば嬉しいです。
マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。
【会社情報】 ■Wantedly ■株式会社マネーフォワード ■福岡開発拠点 ■関西開発拠点(大阪/京都)
【SNS】 ■マネーフォワード公式note ■Twitter - 【公式】マネーフォワード ■Twitter - Money Forward Developers ■connpass - マネーフォワード ■YouTube - Money Forward Developers