【デジカルチーム ブログリレー 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
ジョブの流れは次のとおりです。
- PRのheadコミットをチェックアウト
- OIDCでAWSへ認証(長期キーは持たない)
- バックエンドのコンテナイメージをビルドしてECRへpush
- フロントエンドをビルド
terraform init(PRごとのstateキーを指定)→terraform apply- フロントエンドの成果物をS3の
pr-{N}/プレフィックスへsync、CloudFrontをinvalidate - ECSサービスが安定するまで待機(
aws ecs wait services-stable) - ECS Exec経由でDBを初期化
- プレビュー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) }}
各ジョブは次を行います。
- stateファイルの存在確認(一度もデプロイされていないPRのcloseならスキップ)
terraform destroy(ここでDROP DATABASEも走る)- S3のフロントエンド成果物を削除
- stateファイル本体を削除
- 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