Next.js + NestJSのモノレポでGitHub Actionsのデプロイパイプラインを構築した

CI/CDシリーズ:

今開発に関わっているプロダクトのCI/CD環境についてメモがてらやったことを書いていく。今回はデプロイパイプラインの土台部分の話。

前提: 構成

フロントエンドはNext.jsでVercelにホスティング。バックエンドはNest.jsでAWS上にECS FargateとAurora Serverless v2で動かしている。ORMはPrismaでIaCはTerraform。モノレポ構成で、フロントエンド、バックエンド、Terraformのインフラコードがすべてひとつのリポジトリに入れている。開発(dev)/本番(prod)で別AWSアカウントを使っていて、Terraformのtfbackendファイルで切り替える。

また、大事な前提としてはこれは新規でリリースするプロダクトなのでデプロイパイプライン構築時点では本番はおろか開発環境も暫定的なもの、というかVercelのプレビュー環境くらいしかなかった。

Terraformでインフラを一元管理

AWSリソースはTerraformで一元管理している。

構成自体はよくあるオーソドックスな構成でパブリック2個、プライベート2個の4サブネットのVPC上にECS FargateとAurora Serverlessの環境を構築している。

Aurora Serverlessはv2でPostgreSQLの15系。Aurora Serverless v2にしたのはマネージドDBのメリットを享受しつつ、最小ACUを低く設定できるから。開発環境はアクセスが少ないので0.5 ACUで十分だし、サービス自体がニッチなものなので本番も少なくともリリース当初はキャパは小さくていい。一応負荷がかかれば自動でスケールするので最小構成で始めるには最適。ただ、スケールにはそれなりにタイムラグがあることは注意。

NAT Gatewayはコスト削減のためにAZ-aのパブリックサブネットに1つだけのシングル構成にした。これに関しては単一構成だと当然ながら障害時の影響が大きいとかクロスAZの通信コストがかかるなんていうのもあるが、このあたりは現時点での可用性とコストのトレードオフとして選んだ。が、本番環境でこれが適切かは議論の余地がある。

TerraformのstateはS3バケットで管理していて、dev/prodで別のそれぞれのAWSアカウント上にある別のものを使用。

deploy.yml: 単一ワークフローで環境を切り替える

デプロイのワークフローはdeploy.ymlの1本だけにした。dev向けとprod向けで別々のワークフローを作るパターンもあるが、今回はGitHub Environmentsを使って単一ワークフローで環境を切り替える方式を採用している。

environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}

これだけでdevブランチへのpushならdev環境、mainブランチならprod環境にデプロイされる。環境ごとの差分は GitHub Environments の variables / secrets と AWS 側の Secrets Manager で吸収する。ワークフローのロジック自体は共通なので、devとprodでの環境差分による事故が減る。環境ごとに別ワークフローを作ると、片方だけ修正してもう片方を忘れるとか、微妙にロジックが乖離するとかいう事故が起きるのでこの方式にした。

パイプラインのステップ

ステップは結構ある。

まずOIDC認証でaws-actions/configure-aws-credentials@v4を使ってGitHub ActionsからAWS IAMロールへの一時的な認証を行う。

次にECRにログインしてDockerイメージをビルド&プッシュ。タグはコミットSHAとlatestの2つを付ける。コミットSHAタグはトレーサビリティのため、latestは後述するDB操作ワークフローで「デプロイ済みのイメージをそのまま使う」ときに参照するため。

その後がタスク定義の構築。既存のECSタスク定義をダウンロードしてきてSecrets ManagerからDATABASE_URLやFirebase、Geminiといった各シークレットのARNを取得する。そしてjqでJSONを動的に構築してタスク定義に注入し、read-onlyで登録時に渡せないフィールドを削除してからregister-task-definitionで登録する。

jqでJSONを動的構築するあたりはお世辞にもきれいとは言えないが、シェルスクリプトでやる以上こうなると思ってるがわからん。

DBマイグレーションはデプロイ前に

DBマイグレーションはデプロイ前にECS Run Taskで実行する。アプリ起動時にマイグレーションを走らせるのではなく、独立したタスクとして先に実行する方式。

これはマイグレーション失敗時にデプロイを中断できるのが一番のメリット。アプリ起動時にマイグレーションを走らせる方式だと、マイグレーションが失敗してもアプリ自体は(古いスキーマで)起動してしまう可能性がある。それは困る。まあこれ自体は防ぎようがあるので今回の本質はアプリ起動処理と分離することで、失敗時にロールアウト前に止められるという点。

マイグレーション→データパッチ→マスタデータシード→開発用データシード(devのみ)と順番に実行して、全部通ったら最後にupdate-service --force-new-deploymentwait services-stableで可能な限りダウンタイムを避ける形でデプロイ。

entrypoint.sh

entrypoint.shというものを用意してDockerコンテナの起動モードをDB_COMMAND環境変数で切り替える仕組みにしている。

DB_COMMAND=migrate       → スキーマ確認 + failed migration回復 + prisma migrate deploy
DB_COMMAND=seed-master   → node dist/prisma/seed-master.js
DB_COMMAND=seed-dev      → node dist/prisma/seed-dev.js
DB_COMMAND=patch         → node dist/prisma/patch-runner.js
(未設定)                 → node dist/main(通常のアプリ起動)

ECS Run TaskからcontainerOverridesDB_COMMANDを注入することで、同一イメージでアプリ起動・DB操作・バッチ処理に対応する。イメージを用途ごとに分けて管理する必要がないのでシンプルだし、少なくともアプリ本体と DB 操作用タスクで実行環境差分を減らせる。

新しいバッチ処理が必要になったらここにパターンを追加するだけで対応できる。ワークフロー側はDB_COMMANDの値を変えるだけ。実際、後からText to speechで生成する音声ファイルのキャッシュ生成のパターンも追加した。

Dockerマルチステージビルド

Dockerfileは3ステージ構成にしている。

builderステージで全依存インストール→Prisma generate→NestJSビルド。depsステージで本番依存のみインストール(--production)。runnerステージでbuilderのdist/とdepsのnode_modules/とPrismaクライアントとRDS CA証明書を配置する。

ベースイメージはECRにミラーリングしたnode:22-alpineを使っている。自前で持ってるのはDocker Hub rate limit対策。対策というかPRによるプレビュー環境のデプロイが大量に走ることでリミットに引っかかってしまうので仕方なくといったところ。

コンテナはもちろん非rootユーザーで実行し、Aurora接続用のRDS CAバンドルもコンテナ内に同梱している。

あと地味だが重要なヘルパースクリプトとしてensure-schema.js(DBスキーマの存在確認)とresolve-failed-migrations.js(失敗マイグレーションの回復)がある。ensure-schema.jsはプレビュー環境でPR固有のスキーマが存在するか確認してなければ作成するもの。resolve-failed-migrations.jsは前回のマイグレーションが中途半端に失敗している場合に回復するもの。これらはプレビュー環境で特に活躍するが今回はその話には触れない。

テストワークフロー (test.yml)

test.ymlはPR作成・更新時に自動実行される。concurrencyグループでPR単位の重複実行を防止している。

PostgreSQLのサービスコンテナを起動してyarn install --frozen-lockfileで依存関係インストール、prisma migrate deployでスキーマを適用、yarn buildしてからDI失敗を検知するための起動チェックをしたのちyarn testを実行する。

「DI失敗を検知するための起動チェック」というのはNestJSの依存注入が正常に解決されるか確認するステップで、ダミーのFirebase認証情報を使って実際にアプリケーションを起動してみる。TypeScriptの型チェックだけでは見つからないDIの設定ミスを検出することが目的。yarn buildではNODE_OPTIONS: --max-old-space-size=4096でメモリ上限を拡張している。GitHub Actionsのランナーはデフォルトだとメモリが足りなくてビルドが落ちることがあったので。

最後に

という感じでインフラを構成したが、今回の環境で語ることのメインはプレビュー環境周り。というわけで近い内にそっちの話もしたい。

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