株式会社ヘンリーでSREなどをやってる戸田(id:eller)です。最近の仕事のテーマはリスクコミュニケーションとサイト信頼性です。
弊社のビルドとデプロイは長らくCircle CIを使ってきました。一方でGitHub Actionsも強力なRunnerを使うハードルが下がったり、Circle CIのcontextsよりも使いやすいvariablesやsecretsの管理ができるようになってきたりしています。特にNodeJS開発界隈はGitHub ActionsがメジャーなCI/CD環境になってきている感触もあります。
今回は既存デプロイパイプライン整理のため、NextJSプロジェクトのデプロイパイプラインをGitHub Actionsで組み直しました。要点をご紹介いたしますので、どなたかの参考になれば幸いです。
要件
- ビルドとデプロイを分離すること。コンテナイメージとアセットをビルドのタイミングで作成しておき、デプロイでは
gcloud run deploy
やgcloud storage cp
を実行するだけにして、変更のリードタイムを短縮する一環とします。 - 動作確認環境や本番環境など、複数の環境へのデプロイを統一的に扱えること。
- ランニングコストがCircle CIと大きく乖離しないこと。将来的にCircleCIの利用を止められればユーザごと料金を大きく削れるため、多少のランニングコスト増は許容範囲とします。
課題
ビルドとデプロイを分離するために、以下のようなワークフローが必要になります。弊社の組み方ですと対象環境ごとにコンテナイメージを作る必要があったため、ビルドマトリックスを利用しています。
graph LR テスト subgraph ビルド development staging production end subgraph デプロイ d-development[development] d-staging[staging] d-production[production] end development --> d-development staging --> d-staging production --> d-production
また弊社はアセットをCloud Storageにアップロードして、Cloud Load Balancingを経由してエンドユーザーに配布しています。Cloud Storageへのアップロードが即エンドユーザーに対する公開となるため、コンテナイメージはビルドフェーズにpushし、アセットはデプロイフェーズにアップロードさせたいところです。しかしどちらも next build
コマンドによって作成するものであるため、アセットのアップロードをビルドフェーズではなくデプロイフェーズまで遅延させる必要がありました。
graph LR subgraph ビルド direction TB build[job] --コンテナイメージ--> ar[(Artifact Registry)] end ビルド --アセットの受け渡し--> デプロイ subgraph デプロイ direction TB deploy[job] --アセット--> gcs[(Cloud Storage)] end
最後に、Pull Request(PR)作成時のワークフローではテストやビルドは行いたいもののコンテナイメージのアップロードはしたくない、またデプロイフェーズは実行する必要がないという特徴があります。こうした制御によってランニングコストを下げるとともに、エンジニアの開発体験を改善することができます。
ビルドマトリックス間の依存関係を表現する
今回の要件はほぼGitHub Actionsの基本機能で実現可能ですが、ビルドフェーズとデプロイフェーズの双方でビルドマトリックスを使っているところだけ注意が必要です。ジョブ同士の依存には jobs.*.needs
を使いますが、ここでは matrix
を参照できないからです。
jobs: build: strategy: matrix: env: - development - staging - production # ... deploy: strategy: matrix: env: - development - staging - production needs: - build[matrix.env] # このようには書けない
この問題に対応するため、今回は cloudposse/github-action-matrix-outputs-write
を採用しました。ビルドフェーズに含まれる各々のジョブからひとつずつアーティファクトをアップロードし、これを統合するジョブをデプロイフェーズの前に挟むことで、ビルド用ジョブとデプロイ用ジョブの間に依存関係を持たせつつ、不必要なデプロイ用ジョブを実行しない仕組みを実現しています:
jobs: build: # ... - uses: cloudposse/github-action-matrix-outputs-write@928e2a2d3d6ae4eb94010827489805c17c81181f # v0.4.2 if: steps.trigger-release.outputs.result == 'true' # リリースが必要な場合。後述 with: matrix-step-name: ${{ github.job }} matrix-key: ${{ matrix.env }} outputs: |- include: true # デプロイが必要な環境をリストアップしてmatrix用のJSONを出力する prepare: runs-on: ubuntu-latest needs: build steps: - uses: cloudposse/github-action-matrix-outputs-read@ea1c28d66c34b8400391ed74d510f66abc392d5e # v0.1.1 id: read with: matrix-step-name: build - uses: actions/github-script@v7 id: set-result with: script: | const input = JSON.parse(${{ toJSON(steps.read.outputs.result) }}); return input.include ? Object.keys(input.include) : []; outputs: result: "${{ steps.set-result.outputs.result }}" deploy: needs: prepare runs-on: ubuntu-latest if: join(fromJSON(needs.prepare.outputs.result), '') != '' strategy: matrix: env: ${{ fromJson(needs.prepare.outputs.result) }} environment: ${{ matrix.env }}
ジョブの依存関係は以下のようになります。
graph LR テスト subgraph ビルド development staging production end subgraph デプロイ d-development[development] d-staging[staging] d-production[production] end development & staging & production --> prepare --> d-development & d-staging & d-production
ビルドマトリックス間のファイル受け渡し
依存関係が表現できれば、ファイルの受け渡しは難しくありません。actions/upload-artifact
と actions/download-artifact
を利用して、 ${{ matrix.env }}
をnameに含むアーティファクトをアップロード&ダウンロードするようにします。アーティファクトは一定時間で削除されますが、デプロイが充分に頻繁であれば問題にならないでしょう。
jobs: build: steps: # ... - uses: actions/upload-artifact@v4 with: name: next-static-${{ matrix.env }} path: .next/static # ... deploy: steps: # ... - uses: actions/download-artifact@v4 with: name: next-static-${{ matrix.env }} path: .next/static merge-multiple: true
コンテナイメージのpushやデプロイフェーズの必要性を判断する
弊社はGitflowを使って開発をしています。コンテナイメージのpushやデプロイフェーズの必要性を整理すると、以下のようになります:
- developブランチに変更をpushしたら、開発環境にデプロイ
- releaseブランチからmasterブランチに向けたPRを更新したら、動作確認環境にデプロイ
- masterブランチに変更をpushしたら、本番環境にデプロイ
これ以外のケース、例えばtopicブランチへの変更のpushやdevelopブランチに向けたPRの更新では、ビルドやテストは必要でもコンテナイメージのpushやデプロイの実行は不要です。この判断をGitHub Actions Workflowのフォーマットで表現することは可能ですが、単体テストを書きたいと考えたためJavaScriptファイルに切り出してactions/github-script
で実行することとしました:
- run: | echo "head=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT id: extract-branch - uses: actions/github-script@v7 id: trigger-release with: result-encoding: string script: | const triggerRelease = require(".github/workflows/trigger-release"); const {HEAD_BRANCH, BASE_BRANCH, APP_ENV} = process.env; return triggerRelease(HEAD_BRANCH, BASE_BRANCH, APP_ENV); env: HEAD_BRANCH: ${{ steps.extract-branch.outputs.head }} BASE_BRANCH: ${{ github.base_ref }} APP_ENV: ${{ matrix.env }}
// .github/workflows/trigger-release.js /** * @param {string} head PRのHEAD、あるいはPUSHされたブランチの名前 * @param {string|undefined} base PRのBASEブランチの名前、あるいはundefined * @param {string} env development, staging, productionのいずれか */ module.exports = (head, base, env) => { const isPush = base === undefined || base.length === 0; if (env === "production") { return isPush && head === "master"; } else if (env === "staging") { return !isPush && head.startsWith("release/"); } else { return isPush && head === "develop"; } };
まとめ
弊社のユースケースでは next build
はコンテナイメージとアセットの双方を作成するのに必要なコマンドでありビルドフェーズに実行したいものでしたが、アセットのアップロードタイミングはデプロイ時にまで遅延させる必要がありました。またビルドマトリックスを利用するために、ビルドジョブとデプロイジョブの依存関係管理が複雑化していました。アーティファクトを利用することでこの2つの問題が解消できました。
またコンテナイメージのpushやデプロイフェーズの必要性を判断する条件は複雑化しがちですが、JavaScriptに切り出すことでVitestなどによる単体テストを書けるようになります。必要ならTypeScriptで書くこともできるでしょう。複雑化しやすいワークフローを制御するテクニックとして覚えておいて損はないと思います。