kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

TerraformによるStripeのマスタデータ管理

こんにちは、あらいです。

本記事はChatwork Product Day 2023応援記事です。

lp.chatwork.com

Chatworkは7/3に価格改定を実施しました*1。 実はこれに先だって料金系システムにStripe が導入されています。

導入の過程でマスタデータの管理にTerraformを使ってみたところ体験が良かったです。 本記事で知見を共有したいと思います。

Stripeの導入

stripe.com

Stripeは多彩な機能を持つ決済SaaSですが、今回はStripeのSubscription機能から利用を始めることになりました。 Subscription機能を利用するにあたっては、Product, Priceをあらかじめ登録しておく必要があります。 これはChatworkで言うと、

  • Product = ビジネスプラン、エンタープライズプランなど
  • Price = 各プランの月額料金、年額料金

にマッピングされる概念です。 両方とも重要なマスタデータなので厳密に管理したいです。

データ管理にあたり

Stripeは1つのアカウントに本番環境とテスト環境があり、それぞれ別個に各種データが登録できます。 また開発生産性を考えると、各開発者に1つずつ開発用のStripeアカウントがあった方が望ましいです。

と考えると、複数の環境にもれなく確実に反映する仕組みがほしいですね。

実際に検討した手法を紹介します。

「本番にコピー」ボタン

Stripeの管理コンソールには「本番にコピー」というボタンがあり、 テスト環境のデータを同アカウントの本番環境にコピーできます。

「本番環境にコピー」ボタン

  • 🙆‍♂️ 手軽に使える
  • 🙅‍♂️ 初回のコピー(本番への新規作成)はできるが、変更の差分反映ができない
  • 🙅‍♂️ 手動操作が前提になっている。環境数に比例して手間が増える
  • 🙅‍♂️ 本番環境にコピーするとProductのidが変わってしまう

Stripe APIでの機能自作

StripeはAPIが豊富で充実しています。 Product, Priceの内容をjsonで取得できるため、 これを保存しておけば機械的に反映する機能を自作可能です。

docs.stripe.com

  • 🙆‍♂️ 機械的に反映でき、手間が少ない
  • 🙆‍♂️ 最初のリソース作成は難しくはない
  • 🙅‍♂️ 差分反映しようと思うと難しい
    • 変更が可能なmutableなリソース(Productなど)と、一度作ると不可能なimmutableなリソース(Priceなど)が混在している
  • 🙆‍♂️ ProductのIDを指定可能

Terraform

TerraformでStripeリソースを扱うProviderは公式には提供されていませんが、 幸いコミュニティのものがいくつかあります。

Terraform Registry

  • 🙆‍♂️ 機械的に反映できる
  • 🙆‍♂️ 差分反映ができる
  • 🙅‍♂️ Stripeの公式サポートがない
  • 🙅‍♂️ Terraformの概念に慣れてないメンバーがいる

選択

最終的にはTerraformを採用しました。

  • 機械的に差分反映できるのは魅力的
  • 社内でTerraformの採用例が多数あるので、教育コストも回収できる

が決め手でした。

Providerには、ドキュメントが充実していたこちらを利用させてもらうことにします。

Terraformを使う上での工夫

ディレクトリ構成

Terraformでは moduleenvironment でディレクトリを分ける構成が多いです。 これは本番環境とテスト環境でインフラ構成やEC2インスタンスの強さを変えるためのプラクティスです。

しかし今回Terraformで管理したいものはマスタデータであり、環境ごとの差異はありません。

今回はルート直下に main.tf を置き、module のディレクトリだけを分けることにしました。

|-- main.tf   # backendやproviderの設定
|-- plan.tf   # プランの定義
|-- modules/
|   `-- price_set/  # Product, Priceをまとめたmodule
|       `-- main.tf
`-- tax.tf    # 税の定義

この構成はシンプルで分かりやすく、副次的効果としてdependabotの導入もしやすかったです。

version: 2

updates:
  - package-ecosystem: terraform
    directory: /
    schedule:
      interval: monthly

ブランチとリリース

main.tfをenvironmentで分けないとすると、各環境へのデプロイはどうするの?という問題が生じます。

今回は環境をTerraformのworkspaceで分け、AtlantisとGitLab Flowを使ったワークフローを設計しました。

AtlantisはTerraformをGitHubのPRを通して操作する仕組みです。アクセス権をAtlantisに閉じ込め、PRで監査可能・共有可能な形で操作ができるので嬉しいことばかりです。

GitLab Flowはmainから環境別のブランチを派生させて管理する手法です。 これを組み合わせると以下のようになります。

   main stg prod
    |    |    |
    |    |    |
   /|    |    |
  * |    |    |     main からfeatureブランチを作成し、任意の変更をする
  | |    |    |
  * |    |    |
 PR\|    |    |     main への PR で `atlantis apply` すると
    *    |    |     test環境にデプロイされて、自動マージされる
    |\   |    |
    | \  |    |
    |  \ |    |
    | PR\|    |     main -> stg への PR を作る
    |    *    |     `atlantis apply` でstg環境にデプロイされて自動マージ
    |    |\   |
    |    | \  |
    |    |  \ |
    |    | PR\|     stg -> prod への PR を作る
    |    |    *     `atlantis apply` でprod環境にデプロイされて自動マージ
    |    |    |

atlantisの設定は次のとおりです。

version: 3
automerge: true

projects:
  # name: Stripeアカウント名/モード
  - name: chatwork-local/test-mode
    dir: .
    branch: /main/
    workspace: default
    autoplan:
      when_modified: ["*.tf", "modules/*/*.tf"]
      enabled: true
    workflow: myworkflow
  - name: chatwork-prod/test-mode
    dir: .
    branch: /stg/
    workspace: staging
    autoplan:
      when_modified: ["*.tf", "modules/*/*.tf"]
      enabled: true
    workflow: myworkflow
  - name: chatwork-prod/live-mode
    dir: .
    branch: /prod/
    workspace: production
    autoplan:
      when_modified: ["*.tf", "modules/*/*.tf"]
      enabled: true
    workflow: myworkflow

workflows:
  myworkflow:
    plan:
      steps:
        - init
        - plan:
            extra_args: ["-parallelism=1"]
    apply:
      steps:
        - apply:
            extra_args: ["-parallelism=1"]

(APIを連続で叩きすぎてRate Limitに引っかかるのを回避するため、parallelism=1をつけています)

あとは、workspace名に応じてStripeのAPIキーを切り替えることで、適用先の環境を分けられます。

# main.tfから抜粋
locals {
  # terraform workspaceとデプロイ先環境の対応
  workspace_to_environment = {
    default    = "test"
    staging    = "stg"
    production = "prod"
  }
  env = local.workspace_to_environment[terraform.workspace]
}

data "aws_ssm_parameter" "stripe_api_key" {
  name = "/terraform/${local.env}/stripe_api_key"
  #name = "/terraform/test/stripe_api_key"
  #name = "/terraform/stg/stripe_api_key"
  #name = "/terraform/prod/stripe_api_key"
  with_decryption = true
}

provider "stripe" {
  api_key = data.aws_ssm_parameter.stripe_api_key.value
}

ローカル対応

次に開発者のローカル環境のことを考えます。

本番ではAWS SSMにStripeのAPIキーを入れましたが、ローカルではそこまでしたくありません。APIキーは環境変数、stateはファイルで置いておくことにします。

main.tfをいい感じに差し替える方法・・・が分からなかったので、安直にmain.tf自体を一時的に上書きする方式にしました。

/**
 * ローカル開発用の main.tf ファイル
 *
 * サーバ向けの main.tf ファイルに上書きして使ってください。
 * (誤ってコミットしないよう注意)
 *
 * 以下の方法でStripe APIキーを設定できます:
 *
 * (1) terraform.tfvars ファイルに指定する(make localでできるのでオススメ)
 * (2) 環境変数 `TF_VAR_stripe_api_key` に設定する
 */
variable "stripe_api_key" {
  type      = string
  sensitive = true
  nullable  = false
}

terraform {
  required_providers {
    stripe = {
      source  = "lukasaron/stripe"
      version = ##STRIPE_PROVIDER_VERSION##
    }
  }
}

provider "stripe" {
  api_key = var.stripe_api_key
}

Makefileに差し替えるコマンドを書いておき make local でその構成に切り替える仕組みです。

実行するとこのようになります。

$ make local
Generating terraform.tfvars for local.
API Key > 🔐
Override main.tf OK?
(Enter to continue) > 
VERSION=$(sed -ne 's/^ *version *= *\(".*"\) *$/\1/p' main.tf); \
        sed -e "s/##STRIPE_PROVIDER_VERSION##/$VERSION/" main.tf.local > main.tf

Makefile全体はこのようになっています。

#!/usr/bin/make -f

# parallelismがデフォルトの10だとAPI Rate Limitになることがある。
PARALLELISM := 5

fmt:
    terraform fmt -recursive
.PHONY: fmt

plan:
    terraform init
    terraform plan -parallelism $(PARALLELISM)
.PHONY: plan

apply:
    terraform apply -parallelism $(PARALLELISM)
.PHONY: apply

refresh:
    terraform refresh -parallelism $(PARALLELISM)
.PHONY: refresh

local: terraform.tfvars
    @echo "Override main.tf OK?"
    @read -p "(Enter to continue) > "
    VERSION=$$(sed -ne 's/^ *version *= *\(".*"\) *$$/\1/p' main.tf); \
        sed -e "s/##STRIPE_PROVIDER_VERSION##/$$VERSION/" main.tf.local > main.tf
.PHONY: local

restore:
    git restore main.tf
.PHONY: restore

terraform.tfvars:
    @echo "Generating terraform.tfvars for local."
    @stty -echo
    @read -p "API Key > " INPUT && echo 'stripe_api_key = "'$$INPUT'"' > $@
    @stty echo && echo

Provider作者氏とのやりとり

今回Providerにはlukasaron/stripeを使わせていただきました。

実はこのProviderでは、「ProductのID指定ができない」という難点がありました。

Chatworkのプラン選択、決済画面をそのまま使いつつ裏でStripeにデータを送る今回のPJでは、Chatworkにおけるプラン(ビジネス、エンタープライズ)とStripeのオブジェクト(ProductおよびPrice)を正確に紐づける必要があります。

Stripeのオブジェクトを特定するのは複数の方法が提供されていますが、最も確実なのはIDを使う方法です。 IDは自動的にランダム文字列が割り振られ、ユニークであることが保証されますが、 本番環境/テスト環境など、違う環境にあるオブジェクトは基本的に異なるものであって、違うIDが割り振られます。 ということは、ID指定したい場合、環境に応じてプロダクトごとのIDをアプリケーションに注入しなければなりません。これは面倒です。

Stripe APIはおそらくこの面倒さを考慮して設計されており、Productに限っては作成時に任意のIDを指定できるのです。

Stripe API reference – Create a product – curl

これを使えばIDを固定してアプリケーション内にハードコードできる! やった!

と思ったのも束の間、上記ProviderではID指定がサポートされていないのでした。

悩ましかったのですが、考えた結果:

  • TerraformとこのProviderは採用する
  • 自前でユニークなmetadataをProductに付与しておき、アプリではmetadataで検索→Product IDを特定→そのIDを使う、というステップを踏む
    • APIリクエスト回数が増えるが、結果をキャッシュしておけば致命的にはならない
  • 合わせて、Provider作者のLukas Aronさんに「ID指定機能ない?」と聞いてみる

ということにしました。

github.com

そして数週間後、

v1.6.5でできるようになったから確認してみて

なんと機能追加してくださったのです。この時ほどGitHub Sponsorsしたいことはなかった。

というわけで前述のmetadataのステップは切り戻して、アプリをシンプルな作りにできました。

この他にもバグレポしたらすぐ対応してもらったりなど、大変お世話になりました。 この場を借りてお礼を申し上げます。

ありがとう!!! Thank you very very much Lukas!!!

まとめ

というわけでStripeは導入でき、Terraformの活用でデータの管理もいい感じにできている。という話でした。

リリース後にPJメンバーにTerraform導入についての感想を聞いてみました。

  • 😀 たくさんある設定に対して、生成も破壊もまとめて操作できるのが助かる
  • 😀 本番環境への設定がとても楽で安心できた
  • 😕 色々な概念・知識が入り混じって理解が難しかった
  • 😥 便利すぎて間違えて本番環境ぶっ壊さないか怖い
  • 😣 壊れたらと考えると怖い

という意見をもらいました。 ブランチモデルは若干凝りすぎた面があるのと、やはりTerraform自体には一定の難易度があることがよく分かりました。

とはいえ、全体として受けられた恩恵はかなり大きかったので、機会があればぜひ試してみてほしいです。 願わくばStripe社がTerraformを公式サポートしてくれないかなー😁

この記事はZoom65 Essential Edition R2 (GMK JIS + Gateron OilKing) で書きました。