有名テック企業の技術ブログを、ひとつのフィードで。
フィード
32件
【デジカルチーム ブログリレー 3日目】 クラウド型電子カルテデジカルチームの黒木です。 最近、私たちのチームで 「Pull Requestごとに、そのコードがそのまま動く環境が自動で立ち上がる」仕組み──いわゆる PR環境(ephemeral environment / preview environment)を構築しました。 なぜ作ったのか 設計の方針 実装のポイント 1:stateを「1 PR = 1 state」で完全分離する 2:DBはPRごと、クラスタは共有 3:ALB Listener Ruleの優先度をPR番号から決める 4:デプロイワークフロー(ラベル起点) 5:破棄ワークフロー(PR close + 毎朝の掃除) まとめ We are hiring! なぜ作ったのか 普段からプロダクト開発を続けるなかで、こんな不満がじわじわ溜まっていました。 レビューでコードは読めても、実際に触ってみないと分からない。特にフロントエンドの見た目や操作感 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</code