PRごとに検証環境が立ち上がる仕組みをTerraform × GitHub Actionsで作った話 - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

PRごとに検証環境が立ち上がる仕組みをTerraform × GitHub Actionsで作った話

【デジカルチーム ブログリレー 3日目】

クラウド型電子カルテデジカルチームの黒木です。

最近、私たちのチームで 「Pull Requestごとに、そのコードがそのまま動く環境が自動で立ち上がる」仕組み──いわゆる PR環境(ephemeral environment / preview environment)を構築しました。

なぜ作ったのか

普段からプロダクト開発を続けるなかで、こんな不満がじわじわ溜まっていました。

  • レビューでコードは読めても、実際に触ってみないと分からない。特にフロントエンドの見た目や操作感
    • Storybookはあるものの、モックの限界がある
  • かといってレビュアー全員が git fetch してブランチを切り替えてローカル起動……は何かと面倒
  • 共有のステージング環境は限られた数しかなく、調整や、たまに順番待ちも発生する

「このPR、ちょっと触ってみたいんだけど」が気軽にできず、これを解決したいというのが出発点です。

完成後はPRに preview ラベルを付けると、数分後にBotがこんなコメントを返してきます。

## 🚀 PR Dev Environment

https://PR固有のID.preview.example.dev にデプロイしました (commit `a1b2c3d`)。

- DB は毎回リセットされます。手動で投入したデータは消えます
- 再デプロイしたい場合は `preview` ラベルを一度外してから再付与してください
- ラベルを外しても環境は残ります。最終デプロイから 3 日経過するか、PR がクローズされたタイミングで自動削除されます

URLを開けば、そのPRのコードがそのまま動く環境にアクセスできます。レビュアーも、非エンジニアのステークホルダーも、URLを踏むだけで動作検証が可能です。

設計の方針

PR環境を作るうえで最初に決めたのは「何を環境横断で共有し、何をPRごとに作成するか」でした。

PRごとに VPC や RDS クラスタまで丸ごと作っていては、コストも起動時間もかかりすぎます。一方で、アプリのコンテナや DB のスキーマは PR ごとに完全に独立させたいので、次のように役割を割り当てました。

グループ 扱い 具体例
共有インフラ 全PRで使い回す VPC、ALB、Aurora(RDS)クラスタ、ECSクラスタ、ECR、CloudFront、ワイルドカードDNS
per-PR リソース PRごとに生成・破棄 ECSサービス群、ALB Target Group / Listener Rule、DBスキーマ、他SQSキュー等

ルーティングは、共有ALBにPRごとのListener Ruleを足していく方式です。PR固有のID.preview.example.dev というホストヘッダーで振り分け、そのPR専用の Target Group(= ECSサービス)へ転送します。DNSは *.preview.example.dev のワイルドカードレコードを共有インフラ側に1つ用意しておけば、PRごとにレコードを作る必要はありません。

実装のポイント

1:stateを「1 PR = 1 state」で完全分離する

PR環境の実体は、1つの Terraform スタックです。ポイントは state を PR ごとに完全に分けていることです。

# backend.tf (key は CI 側から動的に渡す)
terraform {
  backend "s3" {
    bucket = "example-terraform-state"
    # key は terraform init -backend-config で指定する
  }
}

CI側では、PR番号から state のキーを組み立てて init します。

terraform init -backend-config="key=states/${PR_NUMBER}.tfstate"
terraform apply -auto-approve \
  -var="pr_number=${PR_NUMBER}" \
  -var="image_tag=${IMAGE_TAG}"

これにより、

  • PRごとのリソースが互いに干渉しない(state が別なので、他PRの apply / destroy の影響を受けない)
  • 並列に何個でも PR環境を立てられる
  • 破棄はそのPRの state に対して terraform destroy するだけ

という性質が手に入ります。共有インフラの情報(VPC ID、ALBの Listener ARN、Aurora のエンドポイントや ARN など)は、別管理している共有スタックの state を terraform_remote_state で参照して取得します。

2:DBはPRごと、クラスタは共有

RDSクラスタを PR ごとに立てると、起動に時間がかかるうえコストもかさむので、CREATE DATABASE pr_123 のようにそのPR専用のDBを作成し、そこにマイグレーションとシードデータを流すだけにします。

これを Terraform の terraform_data リソース + local-exec プロビジョナで実現しています。apply 時に CREATE DATABASE、destroy 時に DROP DATABASE します。SQLの発行には RDS Data API(aws rds-data execute-statement)を使いました。

resource "terraform_data" "db_bootstrap" {
  triggers_replace = {
    pr_database_name = local.pr_database_name  # 例: "pr_123"
  }

  # apply 時:存在しなければ CREATE DATABASE
  provisioner "local-exec" {
    interpreter = ["/bin/bash", "-c"]
    command     = <<-EOT
      EXISTS=$(aws rds-data execute-statement ... \
        --sql "SELECT 1 FROM pg_database WHERE datname = '${self.output.pr_database_name}'" \
        --output json | jq '.records | length')
      if [ "$EXISTS" = "0" ]; then
        aws rds-data execute-statement ... \
          --sql "CREATE DATABASE ${self.output.pr_database_name}"
      fi
    EOT
  }

  # destroy 時:接続を切ってから DROP DATABASE
  provisioner "local-exec" {
    when    = destroy
    command = <<-EOT
      aws rds-data execute-statement ... \
        --sql "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
               WHERE datname = '${self.output.pr_database_name}' AND pid <> pg_backend_pid()"
      aws rds-data execute-statement ... \
        --sql "DROP DATABASE IF EXISTS ${self.output.pr_database_name}"
    EOT
  }
}

※ ECSサービスがDBに繋ぎっぱなしだと DROP が失敗するので、DROP DATABASE の前に pg_terminate_backend で明示的に切断しています

3:ALB Listener Ruleの優先度をPR番号から決める

共有ALBに複数PRの Listener Rule がぶら下がるので、ルールの優先度(priority)が PR 間で衝突しないようにする必要があります。

これはシンプルに priority = PR番号 としました。PR番号が50000に到達すると問題になりますが、現状まだ大丈夫です。

4:デプロイワークフロー(ラベル起点)

デプロイの起点は、PRへの preview ラベル付与です。opened / synchronize で毎回立てるのではなく、レビュアーや作者が明示的にラベルを付ける運用にしています。これでコストを必要なときだけに抑えられます。

on:
  pull_request:
    types: [labeled]

jobs:
  deploy:
    if: github.event.label.name == 'preview'
    concurrency:
      # DB操作の途中でキャンセルされて環境が壊れるのを防ぐため、
      # cancel-in-progress: false(キャンセルせず順番待ちにする)
      group: pr-dev-env-deploy-${{ github.event.pull_request.number }}
      cancel-in-progress: false

ジョブの流れは次のとおりです。

  1. PRのheadコミットをチェックアウト
  2. OIDCでAWSへ認証(長期キーは持たない)
  3. バックエンドのコンテナイメージをビルドしてECRへpush
  4. フロントエンドをビルド
  5. terraform init(PRごとのstateキーを指定)→ terraform apply
  6. フロントエンドの成果物をS3の pr-{N}/ プレフィックスへsync、CloudFrontをinvalidate
  7. ECSサービスが安定するまで待機(aws ecs wait services-stable
  8. ECS Exec経由でDBを初期化
  9. プレビューURLをPRにコメント(既存コメントがあれば更新)

マイグレーションとシードデータ投入は、起動済みの api コンテナの中で実行しています。aws ecs execute-command でコンテナに入り込み、初期化 → マイグレーション → シード投入の順で叩きます。

ここで注意点があります。aws ecs execute-command は、リモートで実行したコマンドの終了コードを呼び出し元に伝播してくれません。セッションさえ確立できれば、リモート側が失敗していてもCLI自体は exit 0 を返してしまいます。これだとコマンドが失敗しても検知できません。

そこで、リモート側でコマンドの終了コードをマーカー文字列として標準出力に吐かせ、それをログから grep してジョブの成否を判定するようにしました。

LOG=$(mktemp)
script -q -c 'aws ecs execute-command \
  --cluster "$API_CLUSTER" --task "$TASK_ARN" --container api --interactive \
  --command "sh -c '\''migrate; echo REMOTE_EXIT=\$?'\''"' "$LOG"

# => REMOTE_EXIT=0 が出ていなければ失敗とみなしてジョブを落とす
grep -q "REMOTE_EXIT=0" "$LOG" || {
  echo "::error::migrate failed"
  exit 1
}

5:破棄ワークフロー(PR close + 毎朝の掃除)

立てたものは、確実に片付けないとゴミが溜まります。破棄ワークフローは3つの起点を持たせました。

on:
  pull_request:
    types: [closed]      # PRがクローズされたら破棄
  schedule:
    - cron: '0 20 * * *' # 毎朝5時(JST)に定時清掃
  workflow_dispatch:     # 手動実行

このワークフローは2段構成です。

1. discoverジョブ ― 破棄対象を見つける

  • pull_request.closed 起点なら、対象はそのPR
  • スケジュール / 手動起点なら、S3上のstateファイルの LastModified が3日以上前のものを探す
THRESHOLD=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
KEYS=$(aws s3api list-objects-v2 --bucket "$STATE_BUCKET" --prefix 'states/' \
  --query "Contents[?LastModified < \`${THRESHOLD}\` && ends_with(Key, '.tfstate')].Key" \
  --output json)
# => states/123.tfstate から 123 を抜き出し、PR番号の配列にする
PR_NUMBERS=$(echo "$KEYS" | jq '[.[] | capture("states/(?<n>[0-9]+)\\.tfstate") | .n | tonumber]')

state ファイルの更新時刻を「最終デプロイ時刻」の代わりに使って、「3日間誰もデプロイし直していない=放置されている環境」を検出しています。

2. destroyジョブ ― マトリックスで並列破棄

discover が返した PR番号の配列を matrix に展開し、PRごとに並列で破棄します。

strategy:
  fail-fast: false
  matrix:
    pr_number: ${{ fromJson(needs.discover.outputs.pr_numbers) }}

各ジョブは次を行います。

  1. stateファイルの存在確認(一度もデプロイされていないPRのcloseならスキップ)
  2. terraform destroy(ここで DROP DATABASE も走る)
  3. S3のフロントエンド成果物を削除
  4. stateファイル本体を削除
  5. PRにクリーンアップ完了をコメント + preview ラベルを除去
# destroy 時には image_tag は使われないのでダミーを渡す
terraform destroy -auto-approve \
  -var="pr_number=${PR_NUMBER}" \
  -var="image_tag=destroy-placeholder"

fail-fast: false にしているので、ある1つのPRの破棄が失敗しても他のPRの破棄は止まりません。

まとめ

PR環境を作ることで、

  • 誰でも気軽に変更を触れるようになった:非エンジニアを含むステークホルダーも、実物を見てフィードバックできるようになった
  • 複数PRを同時に検証できるようになった:順番待ちや調整が不要になった
  • 環境の後始末が自動になった:共有環境のように元に戻さずとも勝手に消えるようになった

のような効果を得ることができ、出発点にあった不満が解消されとても快適になりました。

同じような課題を抱えているチームの参考になれば幸いです!

We are hiring!

エムスリーでは、一緒に医療の未来を変えるエンジニアを募集しています。「開発体験そのものを良くしていく」のが好きな方も大歓迎です。少しでも興味を持っていただけたら、カジュアル面談や採用へのご応募をお待ちしています!

  • エンジニア採用ページはこちら jobs.m3.com