GitHub ActionsでPRごとにNext.jsのアプリケーションをCloud Runへデプロイする方法

こんにちは。新規IAMプロダクトでフロントエンドアーキテクトを担当している@shisama_です。

この記事では、開発スピードを上げるためにGitHub Actionsを使ってプルリクエストごとにレビュー用の検証環境をGoogle Cloud Run(以下、Cloud Run)に構築する仕組みについて紹介します。

今回紹介する内容のサンプルのリポジトリはこちらになります。

github.com

この仕組みは業務委託でお手伝いいただいていた@chimame_rtさんが考案し設計してくれました 👏

はじめに

現在サイボウズでは新規のIAMプロダクトの開発に取り組んでいます。 2023年内にプロトタイプの開発を終えて製品開発に着手したいという感じでやっております。 詳しくは @ymmt2005が書いた記事を読んで頂けると幸いです。

blog.cybozu.io

フロントエンドチームが抱えていた課題

まず、今回紹介する仕組みがなぜ必要になったのかを説明します。

IAMプロダクトのフロントエンドチームは次のロールで構成されており、日本とアメリカにメンバーがいます。

  • デザイナー(日本、PM兼務)
  • フロントエンドエンジニア(日本/アメリカ)
  • フルスタックエンジニア(日本)

デザイナーがページや機能ごとに要件とデザインを考えて、 バックログに書き起こしたバックログをエンジニアが実装するという流れで開発しています。

プルリクエストのレビューはエンジニアがコードをレビューし、デザイナーがUIや動作をレビューしています。 各エンジニアのプルリクエストがマージされるとアプリケーションが検証用の環境へデプロイされるようになっています。

しかし、このやり方だとデザイナーが自分のローカルマシンでアプリケーションを起動して動作確認するか、エンジニアと一緒に動作確認するかしないといけませんでした。

チームメンバーが増えてきて、アメリカとの時差の関係もあり、デザイナーとエンジニアが一緒に作業する時間調整が難しくなってきました。

そこでプルリクエストごとにレビュー環境をクラウド上にサクッと立ち上げてデザイナーが動作確認やUIの確認ができるようにしたいという要望が出てきました。

プルリクエストごとに Cloud Run に検証環境を構築する

デザイナーと他のエンジニアと話して次のような要件を満たすようにレビュー用の環境を整えることにしました。

  • プルリクエストごとに個別の環境を用意する
  • 認証によるアクセス制限
  • 任意のタイミングでデプロイできるようにする

複数人で開発しているため、1つの環境を共有してしまうとデータの更新や他の人の修正の影響により動作確認がうまくできない可能性があります。 そのため、プルリクエストごとにレビュー用の環境を用意することにしました。

プルリクエストごとに任意のタイミングでアプリケーションをCloud Runへデプロイする仕組みを作りました。
このプロジェクトでは GCP を利用していたので、Cloud Run を選択しました。
デプロイの仕組みの概要は次の図のとおりです。

デプロイまでのフロー

ざっくりと次のフローになっています。

Step 1. プルリクエストにラベルを適用してGitHub Actionsを起動(上図の2)
Step 2. Next.jsのビルド
Step 3. Google Cloudの認証 Step 4. Dockerイメージのビルドとプッシュ(上図の3、4)
Step 5. Next.jsのアプリケーションをCloud Runへデプロイ(上図の5)
Step 6. デプロイに成功したサービスのURLをプルリクエストにコメントする

上記のStep.2から順番に説明していきます。

GitHub Actionsの全体像は以下のリンク先をご確認ください。

github.com

Step 1. プルリクエストにラベルを適用して GitHub Actions を起動

デプロイは手動でやるのではなく、GitHub Actionsで自動化しています。

プルリクエストに deploy というラベルを適用すれば、GitHub Actionsがレビュー環境のサービスを立ち上げてURLをコメントしてくれるようになっています。

プルリクエストを作ったタイミングやコミットをpushしたタイミングではない理由は、開発環境の改善やリファクタリングのためのプルリクエストの場合はデザイナーによるUIの確認は不要だからです。

また、作業途中の内容をpushしても検証環境にデプロイする意味はないため、任意のタイミングでデプロイできるようにしています。

GitHub Actionsのworkflowsのyamlファイルには以下のように記述することでラベルを付与したときに起動できます。

name: deploy verification environment

on:
  pull_request:
    types: labeled

また、ラベルの名前がdeployのときだけに限定するには次のように設定します。

jobs:
  deploy-verification-environment:
    if: ${{ github.event.label.name == 'deploy' }}
# 以下省略

deployラベルを付け直すとその時点で最新のコミットのコードをビルドしてデプロイするようにしています。 また、同じプルリクエスト内でラベルの付け直しをすることで複数回アプリケーションのデプロイをできます。 そのため、プルリクエスト内で修正して再レビューをするときもdeployラベルを付けることで修正版のアプリケーションの動作確認を行うことができます。

Step 2. Next.js のビルド

私たちはフロントエンドのフレームワークにNext.jsを利用しています。

Next.jsをビルドしますが、実行効率を上げるためにnode_modulesはあらかじめキャッシュされているものを使います。

キャッシュはactions/cacheを使ってキャッシュします。package-lock.jsonのハッシュ値を含んだ文字列をキャッシュのキーにしているため、package-lock.jsonに変更があったときはキャッシュがヒットせずnpm ciを実行してダウンロードしたnode_modules以下のファイルをキャッシュします。 package-lock.jsonに変更がない場合はキャッシュがヒットするので、npm ciは実行せずにキャッシュを利用します。

GitHub Actionsでは以下のように定義しています。

- uses: actions/cache@v3
  id: node_modules_cache_id
  env:
    cache-name: cache-node-modules
  with:
    path: '**/node_modules'
    key: ${{ runner.os }}-setup-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
  run: npm ci
  shell: bash

次に、キャッシュされているnode_modules内のnpmパッケージを利用してNext.jsのアプリケーションをビルドします。

Next.jsを含む現在のバンドラーの仕組みだとコンテナ内でビルドせずにGitHub Actions側(ホスト側)でビルドした結果をコンテナに取り込むことが大切です。 なぜなら、コンテナ内にビルドのキャッシュを含めてビルドしないと差分キャッシュが効かないため、パフォーマンスが悪くなります。ただ、ビルドキャッシュをコンテナにコピーするのは必要ないものを取り込むことになるのでホスト側にキャッシュは保持しておきます。

CI上でのNext.jsのビルドのキャッシュについてはこちらのページをご参照ください。

nextjs.org

Step 3. Google Cloud の認証

Google Container Registry(以下、GCR)やCloud Runを利用するには、Google Cloudへの認証が必要です。

GitHub Actionsでは以下のように定義しています。

- id: "auth"
  uses: "google-github-actions/auth@v0"
  with:
    credentials_json: "${{ secrets.GCP_SERVICE_ACCOUNT_KEY }}"
- name: Configure docker to use the gcloud cli
  run: gcloud auth configure-docker --quiet

google-github-actions/authというGitHub Actionsを利用しています。

https://github.com/google-github-actions/auth

gcloud auth configure-dockergcloud でDockerを扱うための認証を行っています。

secrets.GCP_SERVICE_ACCOUNT_KEY はGitHubのSecretsに格納されているGoogle Cloudのサービスアカウントのキーを参照しています。 サービスアカウントは書き込み権限とCloud Runを操作する権限を持っています。

サービスアカウントの権限

詳しくは以下のドキュメントをお読みください。

https://cloud.google.com/sdk/gcloud/reference/auth/configure-docker

Step 4. Docker イメージのビルドとプッシュ

次に、Next.jsのにビルド結果のファイルをデプロイするためのDockerイメージをビルドしていきます。
ビルドしたDockerイメージはGCRへプッシュします。

GitHub Actionsでは以下のように定義しています。

- name: Build a docker image
  run: docker build --cache-from ${{ env.IMAGE }},${{ env.CACHE_IMAGE }} -t ${{ env.IMAGE }} -f ./Dockerfile .
- name: Tag a container image
  run: docker tag $(docker images --filter 'label=build-stage=installer' -q | head -n 1) ${{ env.CACHE_IMAGE }}
- name: Push the docker image
  run: |
    docker push ${{ env.IMAGE }} &
    docker push ${{ env.CACHE_IMAGE }} &
    wait

ビルド時に利用するイメージキャッシュ( installer ステージ)も併せてプッシュしています。

このイメージはすべてのプルリクエストで cache というタグの同じイメージを共有しています。 プロトタイプの段階では、このイメージが頻繁に変わることないと見込んで、同じタグ名を使っています。 現在はまだプロトタイプということもあり、Renovateなどで自動でアップデートする設定なども入れておらず、手動で行っています。

ただ、プロトタイプより後のフェーズになると、セキュリティパッチは可能な限り素早く取り込みたいので、ライブラリのアップデート頻度も変わる可能性があります。 そうなると、このキャッシュの方法も変える必要があるかもしれません。

また、現在はGCRを利用していますが、Artifact Registryへの移行も検討しています。

cloud.google.com

ビルド時の引数について

  • -t ${{ env.IMAGE }}
    • ビルドしたイメージにタグ付けをします
    • asia.gcr.io/${{ secrets.GCP_PROJECT_ID }}/frontend-pr-verification-environment:${{ github.head_ref }} で構成される文字列です
    • ${{ github.head_ref }} はプルリクエストのブランチ名に置き換わります
    • ${{ secrets.GCP_PROJECT_ID }} はGCPのプロジェクトIDに置き換わります
  • --cache-from ${{ env.IMAGE }},${{ env.CACHE_IMAGE }}
    • 前回のビルドイメージおよびnpmキャッシュイメージをビルド時に利用する、という指定です

Dockerfile

キャッシュをヒットさせやすくするためにマルチステージビルドを利用しているため、Dockerfile の中身に続いて仕組みを解説していきます。 該当のDockerfileは以下のリンク先をご確認ください。

github.com

installerステージ

installer はnpmパッケージをインストールし、node_modules に配置するためのステージです。この後のステージや、次回以降のビルドのキャッシュとして利用します。

runnerステージ

runner は名前の通りNext.jsの実行を含むステージです。 すでにインストールされたnpmパッケージを installer ステージからコピーして利用します。

COPY --from=installer --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=installer --chown=nextjs:nodejs /app/package.json ./package.json

同様に、Next.jsのビルド結果やNext.jsの設定をホスト側からコピーします。

COPY --chown=nextjs:nodejs ./.next ./.next
COPY --chown=nextjs:nodejs ./next.config.js ./

次のステップでGCRへPushしたイメージを使ってCloud Runへデプロイします。

Step 5. Next.js のアプリケーションを Cloud Run へデプロイ

Cloud Runへデプロイします。

- name: Deploy to Cloud Run
  id: deploy
  uses: google-github-actions/deploy-cloudrun@v0
  with:
    service: frontend-pr-${{ github.event.pull_request.number }}
    image: ${{ env.IMAGE }}
    region: ${{ env.GCP_REGION }}
    flags: "--allow-unauthenticated --memory=512Mi --port=3000"

デプロイにはdeploy-runというGitHub Actionsを利用します。

github.com

いくつかのパラメータを設定しなければいけません。それぞれ次の値を設定します。

  • service: サービス名を設定します。frontend-pr-<プルリクエストの番号>を指定します。
  • image: イメージの場所を指定します。PushしたGCRのURLを指定します。
  • region: デプロイするリージョンを指定します。
  • flags: gloudコマンドのフラグを設定します。詳細は後述。

flagsには--allow-unauthenticated --memory=512Mi --port=3000を設定します。

  • --allow-unauthenticated: サービスを公開しないとアクセスできないため、公開しています。(参考)
  • --memory=512Mi: メモリを確保しています。
  • --port=3000: ポート番号3000で起動するようにしています。

ここまででプルリクエストのブランチからCloud RunへNext.jsのアプリケーションのデプロイが完了します。 この時点でサービスへのURLが発行されるので、アクセスは可能になります。

Step 6. デプロイに成功したサービスの URL をプルリクエストにコメント

すでにCloud Runへのデプロイは完了し、デプロイしたアプリケーションへはアクセス可能になっています。
しかし、そのアクセス先のURLはGCPのコンソールを見に行かないといけません。
それだと手間ですし、非エンジニアには難しいかもしれません。
そこで、デプロイが完了したら自動でURLが共有されるようにしています。
URLの共有は該当のプルリクエストのコメントで行われます。

workflowの該当箇所は次のリンク先になります。

- name: Comment
  uses: peter-evans/create-or-update-comment@v1.4.5
  id: report-comment
  with:
    issue-number: ${{ github.event.pull_request.number }}
    body: "${{ steps.deploy.outputs.url }}"

プルリクエストへのコメント投稿にはpeter-evans/create-or-update-commentを利用しています。

https://github.com/peter-evans/create-or-update-comment

このGitHub Actionsにはパラメータを設定しなければいけません。それぞれの設定値は次のとおりです。

  • issue-number: プルリクエストの番号を指定します
  • body: デプロイしたアプリケーションのURL

これにより、次のようにGitHub ActionsのbotがURLをコメントしてくれます。

プルリクエストにURLを共有するBotのコメント

これでデザイナーもバックログのIssueに紐づくプルリクエストからすぐに検証環境へアクセスすることが可能になります。

プルリクエストを閉じたら Cloud Run のサービスを削除する

Cloud Run上に立ち上げたサービスはそのままにしていくと溜まっていきます。 そこで、そのプルリクエストのために構築した検証環境のCloud Runのサービスを削除するGitHub Actionsのワークフローも作成しました。

GitHub Actionsのworkflowの全体像は以下のリンク先をご確認ください。

github.com

全体の流れは以下のとおりです。

  1. プルリクエストをマージまたはクローズしたらGitHub Actionsを起動
  2. Google Cloudの認証
  3. デプロイした検証環境のサービス名を取得
  4. 検証環境のサービスを削除
  5. GCRからイメージを削除

Step 1. プルリクエストをマージまたはクローズしたら GitHub Actions を起動

閉じたプルリクエストに関してはレビューが完了しているとみなして、サービスを削除しても問題ありません。 そのため、プルリクエストが閉じられたときにGitHub Actionsが実行されるように設定しました。

on:
  pull_request:
    types:
      - closed

Step 2. Google Cloud の認証

まずはGoogle Cloudの認証ですが、この処理についてはデプロイ時の「Step 4. Google Cloud の認証」と同じため解説は割愛します。

Step 3. 検証環境がデプロイされているか確認

次にそのプルリクエストがデプロイされているかどうかを検証します。 検証のためにサービスが存在するかを名前でフィルタリングします。

GitHub Actionsの定義は以下のとおりです。

- name: check if Cloud Run is deployed
  id: check
  run: echo "service=$(gcloud run services list --filter metadata.name=frontend-pr-${{ github.event.pull_request.number }} --format 'value(metadata.name)')" >> $GITHUB_OUTPUT

以下のコマンドでfrontend-pr-<プルリクエストの番号>のサービス名を取得しています。

gcloud run services list --filter metadata.name=frontend-pr-${{ github.event.pull_request.number }}

次のように取得したサービス名がfrontend-pr-からはじまるか検証します。もし上記のgcloudコマンドでサービス名が取得できていなかった場合は後続の処理は行われません。

if: ${{ startsWith(steps.check.outputs.service, 'frontend-pr-') }}

Step 4. 検証環境のサービスを削除

次にCloud Runのサービスを削除します。

workflowの該当箇所は次のリンク先になります。

- name: destroy Cloud Run service
  if: ${{ startsWith(steps.check.outputs.service, 'frontend-pr-') }}
  run: gcloud run services delete frontend-pr-${{ github.event.pull_request.number }} --region ${{ env.GCP_REGION }} --quiet

gcloud run services deleteはCloud Run上のサービスを削除するコマンドです。サービス名とリージョンを指定しています。 詳しくは公式のドキュメントをお読みください。

https://cloud.google.com/sdk/gcloud/reference/run/services/delete

Step 5. GCR からイメージを削除

最後にGCRから検証環境用にビルドしてPushしたDockerイメージを削除します。

workflowの該当箇所は次のリンク先になります。

- name: delete Google Cloud Registry
  if: ${{ startsWith(steps.check.outputs.service, 'frontend-pr-') }}
  run: |
    for d in $(gcloud container images list-tags ${{ env.IMAGE }} --filter='-tags:*' --format="get(digest)"); do
      gcloud container images delete ${{ env.IMAGE }}@$d --quiet;
    done
    gcloud container images delete ${{ env.IMAGE }}:${{ github.head_ref }} --quiet

for d in ... doのブロックで、タグ付けされていないイメージの削除を行っています。

これは同じプルリクエストで複数回デプロイされたときに残っているタグが付いていない古いイメージを削除するためです。PushするDockerイメージはブランチ名でタグ付けされるようにしていますが、同じプルリクエスト内に複数回このワークフローを実行すると、最新のイメージにタグが付与され、同じ名前でタグ付けされていた古いイメージからはタグが外されます。

そのため、古いイメージはタグがないまま残ってしまうので、それらを削除するためのコマンドです。

gcloud container images delete ${{ env.IMAGE }}:${{ github.head_ref }} --quietはプルリクエストに紐づく検証環境のサービスのために作成したイメージを削除しています。 github.head_refにはブランチ名が入ります。

これでCloud Runからサービスを削除し、GCRからもイメージを削除できました。

まとめ

GitHub Actionsを使ってプルリクエストごとに検証環境をCloud Runにデプロイする方法について説明しました。

この仕組みを用意したことで、非エンジニアでも簡単にプルリクエストの変更箇所の動作確認をできるようになりました。

これによりレビューを効率的に行えるようになり開発スピードが加速したと感じています。

とても便利なので、ぜひご活用ください。

今回は紹介しませんでしたがアクセス制限をかけたい場合は、GCP側またはNext.jsのmiddlewareなどを利用して認証を行うようにしてください。

最後に

サイボウズではチームワークあふれる社会を創るための仲間を募集しています。 今回紹介したような開発体験の向上を好きなメンバーやフロントエンドエンジニアを募集しています。 よろしくお願いします!

cybozu.co.jp