Pull Requestごとにバックエンドも含めたプレビュー環境を自動構築する仕組みを作った

CI/CDシリーズ:

前回、GitHub Actionsを使ったCI/CDパイプラインの仕組みを作ったという話を書いたが、今回はPull Request(PR)ごとのプレビュー環境を構築したのでそれについて。

課題

フロントエンドのプレビュー環境はVercelが勝手にやってくれるので何も困らない。PRを作ればプレビューURLが払い出されてそこで確認できる。問題はバックエンドだ。

今回のプロジェクトの場合、PRは機能単位で作成されるのでフロントエンドとバックエンドの両方に修正が入ることが普通にある。フロントだけプレビューできてもバックエンドがdev環境のままだと、API側の変更を含む修正の確認ができない。

しかも同時に複数のPRが開かれてそれぞれレビューされるのもよくある。生成AIを大活用するようにした結果10とか20のタスクを並列で実行させるので、それらがPRとしても同時に並ぶ。このPRたちをそれぞれの変更内容について挙動確認をしたいのである。つまりPRごとに独立したバックエンド環境が必要になる。

設計の検討

バックエンドの分離レベル

選択肢としては大きく3つ。

  1. PR毎に独立したFargateタスクを立てつつDBはdev環境のAuroraを共有する
  2. dev環境のFargateを一時的にPRのイメージに差し替える
  3. PR毎にFargateもAuroraも完全に独立させる

2は複数PRが並行する状況での独立した確認というのができないので即却下。3つ目は PR ごとに DB クラスタを分離できる反面、コストと運用の複雑さが大きい。プレビュー用途としては過剰だったので今回は採用しなかった。というわけで1の案で進めることにした。

DB分離はPostgreSQLスキーマで

さて、1の案はアプリが動くコンテナはPRごとに動いて、DBは共有するというものだ。共有するとは言ったものの、複数PRが並行して動く以上データの分離は考える必要がある。特にDBマイグレーションが入るPRなど。あるPRのマイグレーションが別PRの動作を壊す可能性がある。

ここで助かったのがPrismaとPostgreSQLの組み合わせだ。PostgreSQLにはスキーマという概念があって、PostgreSQL ではPrismaの接続URLでschema パラメータを指定できるので、アプリのコードを変えずに接続先スキーマを切り替えられる。実際にはPrismaのDATABASE_URLに?schema=pr_123のようにクエリパラメータを付けるだけだ。アプリケーションコードの変更なしに。PR毎にpr_{PR番号}というスキーマを作成して、そのスキーマに対してprisma migrate deployを実行すれば各PRが独立したDBスキーマ上で動作する。

クリーンアップ時はDROP SCHEMA pr_123 CASCADEで一発。dev環境の本体スキーマには一切影響しない。

ちなみにCI上ではprisma migrate deployを使う。prisma migrate devは公式ドキュメントにもあるように shadow database を使う開発向けコマンド本番環境では推奨されない。

フロントエンドとバックエンドの接続

Vercelのプレビュー環境からPR固有のバックエンドに接続するために、Vercelの環境変数としてNEXT_PUBLIC_BACKEND_API_URLにPR固有のバックエンドURLを設定する方式にした。バックエンドのURL体系はpr-{PR番号}.preview.example.com。DNSにワイルドカードレコードを事前に作っておくことでPR毎のDNSレコード作成は不要。ALBのホストベースルーティングでPR番号に基づいて適切なFargateサービスに振り分ける。ちなみに今回はDNSとしてCloudflareを使っている。

3つのワークフローによるライフサイクル管理

最終的に3つのワークフローで完全なライフサイクル管理を構築した。

PR作成 → preview-deploy.yml → 隔離環境起動
PR更新 → preview-deploy.yml → 既存環境を更新(concurrencyで排他制御)
PRクローズ → preview-cleanup.yml → 全リソース削除
毎日深夜 → preview-scheduled-cleanup.yml → ゴミリソースを検知・削除

作成・削除・孤立検知の三段構成。PRクローズ時のクリーンアップワークフローが何らかの理由で失敗した場合のセーフティネットとして定期実行を入れている。作るだけ作って削除を忘れるとAWSの課金がじわじわ溜まるのでこれは精神衛生上も大事。

preview-deploy.yml

以下のステップで完全な隔離環境を構築する。

Step 0: リソース命名の決定

PR番号からすべてのリソース名を自動生成する。こんな感じで。

リソース 命名パターン 例(PR #123)
ECSタスク定義 {アプリ名}-dev-preview-pr-{番号} ...-pr-123
ECSサービス preview-pr-{番号} preview-pr-123
ALBターゲットグループ preview-pr-{番号} preview-pr-123
ECRイメージタグ pr-{番号} pr-123
DBスキーマ pr_{番号} pr_123
APIホスト pr-{番号}.{ドメイン} pr-123.preview.example.com

PR番号を起点に全リソースの命名が一意に決まるので、クリーンアップ時にどのリソースを消すべきかが明確になる。

Step 1: Vercel環境変数の事前設定

ここは少しハマった。

当初の設計ではバックエンドのヘルスチェックが通ってからVercelの環境変数を設定する想定だった。だがVercelはGitHubにpushした時点で自動的にプレビュービルドを開始する。Next.jsのNEXT_PUBLIC_*環境変数はビルド時にインライン化されるため、ビルド開始前に設定しないとデフォルト値(localhost)が使われてしまうのだ。

つまり順序が逆で、まずVercelの環境変数を設定してからでないとフロントエンドが正しいバックエンドURLを持てない。というわけでStep 1でまずVercelの環境変数を設定するようにした。バックエンドURL用の環境変数をブランチ単位で設定する。

ただしこの時点ではバックエンドはまだ起動していない。Vercelの初回ビルドはバックエンドのURLは正しいが、バックエンド自体がまだ存在しないので疎通はしない。これはStep 6のリデプロイで解消する。

Step 2: Dockerイメージビルド → ECR Push

docker/build-push-action@v6を使用し、GitHub Actions Cache(cache-from: type=gha)で高速化。タグはpr-{PR番号}

Step 3: タスク定義の構築

devの既存タスク定義をベースに、プレビュー用のタスク定義を動的に生成する。DATABASE_URLにスキーマパラメータ(?schema=pr_{番号})を付加して同一Aurora内でDB隔離を実現。

Step 3.5: DBオペレーション(ECS Run Task)

サービス起動前にmigrate → patch → seed-master → seed-devを順次実行する。前回の記事で紹介したentrypoint.shDB_COMMAND環境変数を使って、本番デプロイと全く同じ方法でDB操作を行う。前回書いたensure-schema.jsがここで活きる。PR固有のスキーマがまだ存在しない場合に自動で作成してくれる。

Step 4: ALBルールの作成

PR番号ベースのホストヘッダルールをALBリスナーに追加する。ターゲットグループをip型で作成し、ヘルスチェック(/health、30秒間隔)を設定。

ALBリスナールールの優先度は地味に悩んだポイント。当初は1000 + PR番号で考えていたが、PR番号が大きくなるとALBの優先度上限(50000)を超える可能性がある。うちのPR番号は既に800番台なので、将来的に上限に達するのは現実的な懸念。

最終的にはPR番号の剰余演算(PR番号 % 49000 + 1)で決定する形にした。これなら大きなPR番号でも範囲内に収まる。衝突の可能性はゼロではないが、同時に49000本のPRが開かれることはまあないだろう。

ただし、ここを厳密にするには実装として以下のようなことをしたほうがいい。

  • 作成前に既存 priority を列挙して空きを探す
  • あるいは固定範囲内の allocator を持つ
  • あるいは PR 番号に依存しない別の一意戦略を使う

Step 5: ECSサービスの起動・ヘルスチェック

サービスが安定するまで待機し、ヘルスチェックが通ることを確認する。

Step 6: Vercelリデプロイ

バックエンドの準備完了後、Vercel Deployments APIで最新のプレビューデプロイメントをリデプロイする。Step 1で環境変数を設定済みなので、このリデプロイで正しいバックエンドURLを使ってフロントエンドが再ビルドされる。バックエンドも起動済みなので、今度はフロントからバックまで疎通した状態になる。

Step 7: PRコメント

プレビュー環境のフロントエンドURL・バックエンドAPI URLをPRコメントとして投稿する。レビュアーはこのURLをクリックするだけで確認できる。これが地味に便利で、PRを開いてURLをクリックするだけでフロントからバックまで動く環境にアクセスできる。

concurrencyグループによる排他制御

同じPRに対して複数のpushが立て続けに発生すると、プレビュー環境の構築が同時に走ってリソースが競合する。ALBターゲットグループの名前が衝突したりECSサービスの作成が二重に走ったりして面倒なことになる。

これを防ぐためにpreview-{PR番号}というconcurrencyグループを設定してPR単位の排他制御をしている。先行するワークフローはキャンセルされて、最新のpushに対するワークフローだけが実行される。どうせ最新のコミットでプレビューできればいいのだから、途中のコミットのプレビュー環境は不要。

クリーンアップ (preview-cleanup.yml)

PRクローズ時にトリガーされる。マージでもクローズでもどちらでも発火する。以下の順序で削除する。

  1. ECSサービスのスケールダウン(desired-count=0)→ 30秒待機 → サービス削除
  2. 全リビジョンのタスク定義を登録解除
  3. ALBリスナールール削除 → ターゲットグループ削除
  4. PostgreSQLスキーマ DROP SCHEMA pr_{番号} CASCADE
  5. ECRイメージ削除(タグ pr-{番号}
  6. Vercel環境変数削除

全コマンドに|| trueを付けているのは、これによって、既に消えているリソースがあっても cleanup 全体が止まらないようにしている。途中で1つ失敗しても残りのクリーンアップが続行される。リソースの削除順序は依存関係を考慮していて、サービス→ルール→ターゲットグループの順でないとAWSが怒る。

ゴミリソース検知 (preview-scheduled-cleanup.yml)

毎日深夜UTC(0 0 * * *)に実行。GitHub APIでオープンPR一覧を取得し、対応するPRが存在しないプレビューサービスを検知・削除する。

PRクローズ時のcleanupが何らかの理由で失敗した場合のセーフティネット。GitHub Actionsは100%成功するわけではないので、この保険は必要だと思っている。ECSサービスは動いているだけで課金されるし。これはECSに限らないが。

Terraform側の変更 (infra/preview.tf)

プレビュー環境固有のリソースはTerraformで管理している。infra/preview.tfとして以下を追加した。

プレビュー用CloudWatch Logグループ、プレビュー用ACM証明書(ワイルドカード *.preview.example.com)、ECSタスクロールのIAMポリシー拡張。DNSでワイルドカードレコードも事前に設定しておくことでPR毎のDNSレコード作成が不要になっている。

プレビュー用のFargateサービス自体はGitHub Actionsから動的に作成するのでTerraform管理外だが、その土台(ロググループ、証明書、IAM、DNS)はTerraformで管理する。動的に作られるリソースと静的に管理するリソースの線引きが大事。

その他

その他いくつかを振り返りがてら。

devのAWSアカウント上にプレビュー環境を構築したこと。 ステージングにするか別アカウントにするか少し悩んだが、今回はコスト管理の一元化とIAM設定の簡素化のためにdev上に乗せた。実際これで困ったことはない。

DBをスキーマ分離にしたこと。 PR単位でAuroraクラスタを起動するのはコスト過大だし起動時間も気になる。スキーマ分離は軽量で十分な隔離を実現できた。

ワークフローのライフサイクル管理。 作るだけ作って削除のことを考えないのは危険、というかコスト的にNG。クリーンアップを明示的に2段階(PRクローズ時+定期実行)で組んだのは正解だと思う。

一方で想定外だったのは、Vercel環境変数の事前設定の必要性。初歩的な話なんだろうけどNext.jsのクライアントサイドは環境変数をビルド時に読み込む。当たり前といえば当たり前なんだがハマってしまった。設計段階で見通せなかったのは甘かったが、こういうのが実装フェーズでちょこちょこ出てくるので潰していくしかない。

というわけで俺の最強のデプロイパイプライン作りの旅が始まった。

©Keisuke Nishitani, 2023   プライバシーポリシー