株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

GitHub ActionsでNextJSアプリのビルドとCloud Runへのデプロイを組む

株式会社ヘンリーでSREなどをやってる戸田(id:eller)です。最近の仕事のテーマはリスクコミュニケーションとサイト信頼性です。

弊社のビルドとデプロイは長らくCircle CIを使ってきました。一方でGitHub Actionsも強力なRunnerを使うハードルが下がったり、Circle CIのcontextsよりも使いやすいvariablesやsecretsの管理ができるようになってきたりしています。特にNodeJS開発界隈はGitHub ActionsがメジャーなCI/CD環境になってきている感触もあります。

今回は既存デプロイパイプライン整理のため、NextJSプロジェクトのデプロイパイプラインをGitHub Actionsで組み直しました。要点をご紹介いたしますので、どなたかの参考になれば幸いです。

要件

  • ビルドとデプロイを分離すること。コンテナイメージとアセットをビルドのタイミングで作成しておき、デプロイでは gcloud run deploygcloud 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-artifactactions/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で書くこともできるでしょう。複雑化しやすいワークフローを制御するテクニックとして覚えておいて損はないと思います。