😽

GitHub ActionsでNext.jsのSG時にCloud Storageからデータを取得しFirebase Hostingへデプロイ

2021/11/06に公開

はじめに

おはようございます、加藤です。Next.jsにはStatic Generation(以降、SG)という概念があります。これは「事前にWebサイトを静的なHTMLで生成する」という意味です。これを実現するために、Next.jsには getStaticProps() という関数があります。今回はこの関数ないでGoogle CloudのSDKを使って、Cloud Storageからデータを取得してみます。
また、GitHub ActionsからGoogle CloudとFirebase Hostingへのアクセスには、最近公式にサポートされたGitHub ActionsのOpenID Connect機能を使用しています。これによりサービスアカウントキーの発行が不要となるので、キーのローテーションが不要となります。

構成図

前提

  • Google Cloudのプロジェクトとそれに関連付けられたFirebaseアカウントが既に作成されていること
  • Node.jsおよびnpmがインストールされていること
  • GitHubアカウントを所有しており、GitHub Acitonsが使用可能であること

リポジトリの作成

create-next-appを使用してNext.jsの初期生成を行います。

export GITHUB_REPO_NAME=YOUR_REPO_NAME # GitHubのリポジトリ名を指定してください
npx create-next-app --typescript ${GITHUB_REPO_NAME}
cd ${GITHUB_REPO}
rm -rf pages/api # SGするために不要なファイルを削除

今回はSGするのでpackage.jsonのスクリプトのbuild部分を下記のように変更します。

  "scripts": {
    "dev": "next dev",
    "build": "next build && next export",
    "start": "next start",
  },

必要なライブラリとツールをインストールします。

npm i @google-cloud/storage
npm i -D firebase-tools

ここまでの変更をコミットします。

git add .
git commit "initial commit"

GitHubリポジトリを作成します。ここではghコマンドを使用していますが、もちろんブラウザで作成してもよいです。プライベートリポジトリで作成しました。

gh repo create
# ? Repository name $GITHUB_REPO_NAME
# ? Repository description
# ? Visibility Private
# ? This will add an "origin" git remote to your local repository. Continue? Yes
# ✓ Created repository $GITHUB_REPO_OWNER/$GITHUB_REPO_NAME on GitHub
# ✓ Added remote git@github.com:$GITHUB_REPO_OWNER/$GITHUB_REPO_NAME.git

Google Cloud リソースの作成

Google CloudにCloud StorageとService Accountを作成します。

export BUCKET_NAME=YOUR_BUCKET_NAME # バケット名を指定してください
gsutil mb -c regional -l asia-northeast1 gs://${BUCKET_NAME}

export SERVICE_ACCOUNT_ID=YOUR_SERVICE_ACCOUNT_ID # サービス アカウント名を指定してください
gcloud iam service-accounts create ${SERVICE_ACCOUNT_ID} --display-name "Service Account for GitHub Actions"

export PROJECT_ID=YOUR_PROJECT_ID # プロジェクトIDを指定してください
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
    --member="serviceAccount:${SERVICE_ACCOUNT_ID}@${PROJECT_ID}.iam.gserviceaccount.com" \
    --role="roles/storage.objectViewer"
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
    --member="serviceAccount:${SERVICE_ACCOUNT_ID}@${PROJECT_ID}.iam.gserviceaccount.com" \
    --role="roles/firebasehosting.admin"

Firebase Hostingへのデプロイを設定

GitHub Actions上でのFirebase Hostingへのデプロイを設定します。

npx -p firebase-tools firebase init

? Which Firebase features do you want to set up for this directory? Press Space to select features, then Enter to confirm your choices. Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys

? Please select an option: Use an existing project

? Select a default Firebase project for this directory: $PROJECT_ID ($PROJECT_ID)
i  Using project $PROJECT_ID ($PROJECT_ID)

? What do you want to use as your public directory? out

? Configure as a single-page app (rewrite all urls to /index.html)? No

? Set up automatic builds and deploys with GitHub? No

.firebasercfirebase.jsonが生成されるので、Gitリポジトリへコミットします。

git add .
git commit -m 'setup firebase hosting'

GitHub ActionsでのOpenID Connectの設定

GitHub Actions上でのOpenID Connectを使い、Google CloudのWorkload Identity連携を使ってGoogle CloudとFirebaseへアクセスする為の設定をおこないます。

参考: キーなしの API 認証 - サービス アカウント キーを必要としない Workload Identity 連携によるクラウド セキュリティの向上 | Google Cloud Blog

パラメーターを環境変数に設定します。

export GITHUB_REPO_OWNER=YOUR_REPO_OWNER # GitHubのアカウント名またはOrganization名を指定してください
export GITHUB_REPO_NAME=YOUR_REPO_NAME # GitHubのリポジトリ名を指定してください
export POOL_NAME="actions-pool"
export PROVIDER_NAME="actions-provider"
export SERVICE_ACCOUNT="${SERVICE_ACCOUNT_ID}@${PROJECT_ID}.iam.gserviceaccount.com"

Google CloudのプロジェクトでIAM Service Account Credentials APIが有功化されていない場合は下記のコマンドを実行します。

gcloud services enable iamcredentials.googleapis.com \
  --project "${PROJECT_ID}"

GitHub ActionsからOpenID Connectするための設定を行います。

gcloud iam workload-identity-pools create "${POOL_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="GitHub Actions pool"

export WORKLOAD_IDENTITY_POOL_ID=$(\
gcloud iam workload-identity-pools describe "${POOL_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --format="value(name)"\
  )

gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool=${POOL_NAME} \
  --display-name="GitHub Actions provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --issuer-uri="https://token.actions.githubusercontent.com"

gcloud iam service-accounts add-iam-policy-binding "${SERVICE_ACCOUNT}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}"

最後に表示された値は後ほどGitHub Actionsの設定で使用されます。

フロントエンドの構築

今回はCloud Storageに果物に関する情報が格納されており、それを元に表を作成したいというシチュエーションで構築します。

pages/index.tsxを下記の用に書き換えます。

import { GetStaticProps } from "next";
import type { NextPage } from "next";
import styles from "../styles/Home.module.css";
import { Storage } from "@google-cloud/storage";

interface Fruit {
  name: string;
  color: string;
}

interface Props {
  fruits: Fruit[];
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const storage = new Storage();
  const file = storage.bucket(process.env.BUCKET_NAME!).file("fruits.json");
  const { fruits } = JSON.parse((await file.download()).toString()) as Props;

  return {
    props: {
      fruits,
    },
  };
};

const Home: NextPage<Props> = ({ fruits }) => {
  return (
    <div className={styles.container}>
      <table>
        <thead>
          <tr>
            <th>名称</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {fruits.map(({ name, color }, index) => (
            <tr key={index}>
              <th>{name}</th>
              <th>{color}</th>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default Home;

下記の用にfruits.jsonを作成します。

{
  "fruits": [
    {
      "name": "apple",
      "color": "red"
    },
    {
      "name": "banana",
      "color": "yellow"
    },
    {
      "name": "orange",
      "color": "orange"
    }
  ]
}

ファイルをバケットにアップロードします。

gsutil cp fruits.json gs://${BUCKET_NAME}/
rm fruits.json

Firebase Hostingへデプロイする前にローカルでも動作を確認するために、今回はローカル環境からCloud Storageへアクセスを試みます。

Service Account Keyを発行します。

echo 'service-account.json' >> .gitignore
gcloud iam service-accounts keys create ./service-account.json --iam-account=${SERVICE_ACCOUNT}

Google Cloud SDKにService Account Keyのパスを伝える為に環境変数を設定します。

export GOOGLE_APPLICATION_CREDENTIALS=./service-account.json

direnvを使っている場合は下記のように.envrcを作成し、direnv allowを実行して環境変数に読み込んでおきます。

export BUCKET_NAME=YOUR_BUCKET_NAME
export GOOGLE_APPLICATION_CREDENTIALS=./service-account.json

npm run devで、テーブルが表示されることが確認できたら、あわせてnpm run buildでビルド&エクスポートが出来ることを確認しておきます。

最後に変更をコミットします。

git add .
git commit -m 'feat: fruits table'

GitHub Actionsの設定

GitHub Actionsの定義ファイルを作成します。定義ファイルの変数になっているWORKLOAD_IDENTITY_PROVIDER, SERVICE_ACCOUNT, BUCKET_NAMEの3カ所は下記のコマンドで値を確認して設定してください。

Googleによって公開されているこちらのActionsを使っています。

google-github-actions/auth: GitHub Action for authenticating to Google Cloud with GitHub Actions OIDC tokens and Workload Identity Federation.

echo "WORKLOAD_IDENTITY_PROVIDER 例:projects/123456789123/locations/global/workloadIdentityPools/${POOL_NAME}/providers/${PROVIDER_NAME}"
echo "${WORKLOAD_IDENTITY_POOL_ID}/providers/${PROVIDER_NAME}"

echo "SERVICE_ACCOUNT 例:${SERVICE_ACCOUNT_ID}@${PROJECT_ID}.iam.gserviceaccount.com"
echo ${SERVICE_ACCOUNT}

echo "BUCKET_NAME 例:xxxxxxxxxxxxxxx"
echo ${BUCKET_NAME}

.github/workflows/firebase-hosting-merge.ymlに以下のように設定します。

name: Deploy to Firebase Hosting on merge
"on":
  push:
    branches:
      - main

permissions:
  contents: "read"
  id-token: "write"

jobs:
  build_and_deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - id: "auth"
        name: "Authenticate to Google Cloud"
        uses: "google-github-actions/auth@v0.3.1"
        with:
          token_format: "access_token"
          create_credentials_file: true
          activate_credentials_file: true
          workload_identity_provider: "${WORKLOAD_IDENTITY_PROVIDER}"
          service_account: "${SERVICE_ACCOUNT}"
      - run: npm ci && npm run build
        env:
          BUCKET_NAME: "${YOUR_BUCKET_NAME}"
      - run: npm install -g firebase-tools
      - run: firebase deploy --only hosting
        env:
          FIREBASE_TOKEN: ${{ steps.auth.outputs.access_token }}

.github/workflows/firebase-hosting-pull-request.ymlに以下のように設定します。

name: Deploy to Firebase Hosting on PR
"on": pull_request

permissions:
  contents: "read"
  id-token: "write"

jobs:
  build_and_preview:
    if: "${{ github.event.pull_request.head.repo.full_name == github.repository }}"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - id: "auth"
        name: "Authenticate to Google Cloud"
        uses: "google-github-actions/auth@v0.3.1"
        with:
          token_format: "access_token"
          create_credentials_file: true
          activate_credentials_file: true
          workload_identity_provider: "${WORKLOAD_IDENTITY_PROVIDER}"
          service_account: "${SERVICE_ACCOUNT}"
      - run: npm ci && npm run build
        env:
          BUCKET_NAME: "${YOUR_BUCKET_NAME}"
      - run: npm install -g firebase-tools
      - run: firebase hosting:channel:deploy ${{github.head_ref}}
        env:
          FIREBASE_TOKEN: ${{ steps.auth.outputs.access_token }}

GitHub Actions上でどのように処理が行われているか解説します。
リポジトリをチェックアウトした後に、google-github-actions/authを使い、一時的なクレデンシャルファイルの生成とアクセストークンの取得を行います。この時、生成されたクレデンシャルファイルのパスが環境変数GOOGLE_APPLICATION_CREDENTIALSに登録されます。この環境変数は、google-github-actions/authが後続の処理からも読み取れるように設定しています。Google CloudのSDKはこの環境変数が設定されている場合、自動的に認証情報を受け取り使用します。

続いてNext.jsによってSGが行われます、この時にバケット名を環境変数で与えてあげる必要があります。

最後にFirebase CLIをインストールしデプロイを行います。マージの場合は通常のデプロイ、プルリクエストの場合はブランチ名をチャンネル名として使用しプレビュー環境にデプロイを行います。この時、

参考: サービス アカウントとして認証する  |  Google Cloud

最後の最後に変更をコミット&プッシュします。

git add .
git commit -m 'feat: add CD'
git push -u origin main

Firebase Hostingの管理画面から、URLを確認してテーブルが表示されることを確認します。
さらに、適当にpages/index.tsxを編集して、プルリクエストを作成し、Firebase Hostingの管理画面からプレビューURLを確認して変更が表示されることを確認して完了です!

あとがき

GitHub ActionsをがOpenID Connectに対応してくれたおかげで、ローテーションが推奨されるサービスアカウントキーを使わずにデプロイが行えるようになりました。デプロイはもちろん、本記事のようにデータソースとしてGoogle CloudのリソースをGitHub Acitonsから扱いやすくなったことはとてもうれしいです。
しかし、SGとはいえフロントエンドからデータソースへ直接アクセスする本記事の構成のままプロジェクト規模を広げたり、何度もデータの形式を追加・変更するとカオスなことになることが予測されるのは乱用は避けましょう。そういった場合はAPI化して仕様に基づいて、フロントエンドとバックエンド(データ)を連携させると良いと思います。
今回はGoogle Cloudですが、Amplify ConsoleとS3を使えばAWSでも同等のことが出来るそうですね。また、Cloud Storageでは無くCloud SQLやFirestoreからデータを取得することも出来るはずな ので、この構成は色々と応用が利きそうです。
以上でした。

Discussion