Chapter 04

Firebaseへデプロイしてみよう

oubakiou
oubakiou
2022.10.13に更新

Next.jsをCloud FunctionsにデプロイしてSSRしてみよう

ここまで開発環境(手元のMac)で作業を続けてきましたがこの辺りで一度、本番環境(Cloud Functions for Firebase)にデプロイをしてみましょう。今回はお手軽にCloud Functionsを使います。

  • コールドスタート時にレスポンスが遅くなる(対策によってある程度の緩和は可能)
  • 1関数あたり最大100MBのアップロード上限などいくつかの制限がある

などのデメリットはあるものの、サーバーもコンテナすらも管理する必要がなく要件がマッチする場合には非常に便利なサービスです。

まずはFirebaseのブラウザコンソールへアクセスし新しいFirebaseプロジェクトを作成します。

プロジェクト名を入力するとユニークなプロジェクトIDが自動で割り振られます。

プロジェクトでGAを有効化するかを確認されますが、ひとまずは無効で問題ありません。

プロジェクトの作成が終わったら、歯車のアイコンからプロジェクトの設定画面を開いてプロジェクトIDを確認します。また「デフォルトの GCP リソース ロケーション」も設定しておきましょう。asia-northeast1が東京、asia-northeast2が大阪です。

また2022/4現在、Cloud Functions for Firebaseの利用にはBlaze(従量課金)プランである事が必須になっているので、「アップグレード」のリンクを開き料金プランをSpark(無料プラン)からBlaze(従量制プラン)へ変更しましょう。

なおBlazeであっても無料枠内の利用に収まっていれば請求は発生しません。例えばfunctionsであれば月に200万回の呼び出しまでは無料です。ホビー用途であれば基本的には無料枠に収まるでしょう。料金の詳細についてはFirebase公式を確認してください。

請求先情報の選択(無ければ新規作成)を求められるので選択します。

なお歯車アイコンから「使用量と請求額」へ移動した「詳細と設定」タブから予算アラートの設定へ移動する事ができます。意図しない請求を防ぐため規定額を越えた時にアラートが飛ぶよう設定しておくとよいでしょう。

Blazeプランの設定が終わったら続けて下記のファイルを作成します。

.envrc
dotenv
.env
XDG_CONFIG_HOME=.config

デフォルトではFirebase CLIはログイン情報などをMacユーザーのホームディレクトリの.configへ保存しますが、これをXDG_CONFIG_HOMEという環境変数でプロジェクトディレクトリの.configへ個別に保存するよう変更しています。続けてCLIからhelloworld-appディレクトリへ移動し

direnv: error .envrc is blocked. Run `direnv allow` to approve its content.

というメッセージが表示されたら下記を実行して.envrcの読み込みを許可しましょう。

direnv allow

またプロジェクトディレクトリの.configや.envがバージョン管理されないよう.gitignoreには下記を追加しておきましょう。

.gitignore
# typescript
*.tsbuildinfo

+# Firebase
+.firebase/
+
+# XDG Base Directory
+.config
+
+# env
+.env
+

次に以下を実行します

npm install firebase-functions
npm install --save-dev firebase-tools
npx firebase login

エラーレポートの送信可否を問われるのでyesかnoか自由に返答しましょう。途中でブラウザが起動しアカウント選択を促されるので、Firebaseプロジェクトを作成するのに使用したGoogleアカウントを選択しましょう。ログインに成功するとブラウザとコンソールにはそれぞれ下記のように表示されます。

✔  Success! Logged in as {選択したアカウントの名前}

ログインに成功すると.configディレクトリへログイン情報が保存されますが、gitignoreによってそれらがバージョン管理対象から外れている事を確認しておきましょう。問題なければセットアップを続けます。

npx firebase init

利用する機能ではFunctionsHosting: Configure files...にチェックを入れてEnter

デフォルトプロジェクトの設定ではUse an existing projectを選択してEnter

先ほどブラウザ操作で作っておいたプロジェクトが表示されるので選択してEnter

利用言語はTypeScriptを選択してEnter

ESLint利用はnでEnter

依存ライブラリのインストール可否はnでEnter

公開ディレクトリはpublicディレクトリのままEnter

全てのリクエストをindex.htmlで受ける(リライトする)かを聞かれるのでYでEnter

Githubからのデプロイ設定はNでEnter。

以上でinitによる初期設定は終了です。Cloud Functions用にfunctionsというディレクトリが、Hosting用にpublic/index.htmlというファイルが生成されますが今回は不要なので両方とも削除しておきます。

それでは次にCloud Functions用のエントリーポイントを準備しましょう。

firebaseFunctions.ts
import * as functions from 'firebase-functions'
import next from 'next'

const nextjsServer = next({
  dev: false,
  conf: {
    distDir: '.next',
    future: {},
    experimental: {},
  },
})
const nextjsHandle = nextjsServer.getRequestHandler()

// @see https://firebase.google.com/docs/hosting/full-config?hl=ja#rewrite-functions
const fn = functions.region('us-central1')

export const nextjsFunc = fn.https.onRequest(async (req, res) => {
  await nextjsServer.prepare()
  return nextjsHandle(req, res)
})

firebase.jsonを編集し、functionsのデプロイ時前処理としてtscコマンドでfirebaseFunctions.tsからfirebaseFunctions.jsを生成するよう、hostingにおいて全てのリクエストをindex.htmlではなくnextjsFuncで受けるよう設定します。なおエントリーポイントの設定自体はpackage.jsonのmainディレクティブにあります。

firebase.json
{
  "functions": {
+    "runtime": "nodejs16",  
+    "source": ".",  
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run build",
+      "npx tsc --skipLibCheck firebaseFunctions.ts"
    ]
  },
  "hosting": {
    "public": "public",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "rewrites": [
      {
        "source": "**",
-	"destination": "/index.html"
+        "function": "nextjsFunc"
      }
    ]
  }
}

合わせてpackage.jsonにmainディレクティブを追加しfirebaseFunctions.jsがエントリーポイントになるよう設定します。またfirebaseへのデプロイ用にnpmスクリプトとしてdeployコマンドを追加しておきましょう。

package.json
{
  "name": "helloworld-app",
  "version": "0.1.0",
  "private": true,
+  "main": "firebaseFunctions.js",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+    "deploy": "firebase deploy --only functions,hosting"
  },

ここまでの準備が終わったら一度デプロイします。

npm run deploy

デプロイが終わったらブラウザコンソールからfunctionsのホスト名を確認しましょう。

https://${regionName}-${projectId}.cloudfunctions.net/nextjsFunc

という形式になっているURLの最後に/api/status/getStatus?id=1を加えてブラウザでアクセスします。

https://us-central1-${あなたのFirebaseプロジェクトID}.cloudfunctions.net/nextjsFunc/api/status/getStatus?id=1

APIへのアクセスで開発環境と同様のjsonを表示できる事が確認できたら、nextjsFuncの利用可能メモリ量を256MBから512MBへ引き上げておきましょう。

「使用状況の詳細な統計情報」をクリックしてFirebase Functionsのコンソールから、実体であるGCPのCloud Functionsのコンソールへ移動したら「編集」をクリック

「ランタイム、ビルド、接続、セキュリティの設定」をクリックで開き「割り当てられるメモリ」を変更してデプロイします。

メモリ設定の変更が終わったらページコンポーネントでfetchするAPIのホスト名をlocalhostから差し替えてもう一度デプロイしましょう。

pages/statuses/[id].tsx
  const res = await fetch(
-    `http://localhost:3000/api/status/getStatus?id=${context.query.id}`
+    `https://us-central1-${あなたのFirebaseプロジェクトID}.cloudfunctions.net/nextjsFunc/api/status/getStatus?id=${context.query.id}`
  )
pages/index.tsx
  const res = await fetch(
-      `http://localhost:3000/api/status/listStatuses`
+      `https://us-central1-${あなたのFirebaseプロジェクトID}.cloudfunctions.net/nextjsFunc/api/status/listStatuses`
  )
npm run deploy

今度はAPIではなくページコンポーネントの表示を確認します。

https://us-central1-${あなたのFirebaseプロジェクトID}.cloudfunctions.net/nextjsFunc/statuses/1

functionsのインスタンスが無い状態での初回アクセスはコールドスタートと呼ばれ、functionsインスタンスの初期セットアップ(依存関係の解決なども含まれる)から始まるため通常のアクセス時と比べ処理時間がとても長くなります。これが問題になる用途ではキャッシュと組み合わせたり、インスタンスを常駐させるといった対策が必要になります。

環境変数を使ってアプリケーションの外から設定を注入してみよう

Next.jsでは.envファイルによる環境変数という仕組みを使うことで、アプリケーションの外から設定を注入する事ができます。またFirebase Functionにおいても同様に環境変数がサポートされています。先ほどのセクションでハードコーディングしていたAPIのリクエスト先について、これらを利用して設定してみましょう。

pages/statuses/[id].tsx
const res = await fetch(
  `${process.env.API_ROOT}/api/status/getStatus?id=${context.query.id}`
)
pages/index.tsx
const res = await fetch(`${process.env.API_ROOT}/api/status/listStatuses`)

Node.jsで動くプログラム内からはprocess.envで設定した環境変数にアクセスする事ができます。

続けて開発環境用に.envファイルへAPI_ROOTという環境変数を追加します。

.env
XDG_CONFIG_HOME=.config
+API_ROOT=https://us-central1-{あなたのFirebaseプロジェクトID}.cloudfunctions.net/nextjsFunc

変更と再デプロイが終わったら以前にメモリ設定を変更した画面で、nextjsFuncに設定されている環境変数を確認してみましょう。

これでどちらの環境でも.envのAPI_ROOTを利用できるようなりました。チーム開発であれば『開発環境では手元の.envを利用し、デプロイ時には本番環境用の.envを新たに生成して使う』といった運用も考えられますが個人開発であればより簡易な運用でも良いでしょう。

今回は.envとは別に.env.{あなたのFirebaseプロジェクトID}を作る事で環境毎の設定を切り替えます。

.env
XDG_CONFIG_HOME=.config
API_ROOT=http://localhost:3000/

.envを開発環境用に

.env.{あなたのFirebaseプロジェクトID}
API_ROOT=https://us-central1-{あなたのFirebaseプロジェクトID}.cloudfunctions.net/nextjsFunc

.env.{あなたのFirebaseプロジェクトID}を本番環境用に使い

.gitignore
-.env
+.env*

.gitignoreでは.envで始まる全てのファイルがバージョン管理対象外になるよう設定しましょう。

デプロイ時にこのように表示されていれば成功です。.env.env.{あなたのFirebaseプロジェクトID}の両方が読み込まれていますがプロジェクトIDが入ったファイルの内容が優先されます。

Firebase Hostingの設定をしてみよう

本書ではHTMLやスタイルといったファイルはfunctionsが動的に配信し、例えばロゴ画像のような静的なファイルの配信はfunctionsの前段にあるFirebase Hostingが担う構成になっています。先ずはFirebase Hostingのブラウザコンソールを確認してみましょう。

初期状態ではFirebaseプロジェクトIDを元に生成されたドメインが自動で割り振られていますが、Google Domainなどで取得した独自ドメインを設定する事もできます。

リリース履歴として一覧表示されているように、Firebase Hostingではデプロイが発生するたびに古いバージョンのコンテンツをバックアップとして保存するようになっています。デプロイによって何らかの問題が発生した時などに、このバックアップからロールバック(巻き戻し)を行う事もできますが、ストレージ容量の節約をしたい場合は「ストレージのリリースに関する設定」から保存する世代数を有限へ変更しておきましょう。

それでは自分のプロジェクトのFirebase Hostingへ割り振られているドメインへ、実際にアクセスしてみましょう。

問題なく表示される事を確認したら、最後にstale-while-revalidateによるFirebase Hostingでのキャッシュ設定を行いましょう。

pages/statuses/[id].tsx
  if (!isStatus(statusData)) {
    return { notFound: true }
  }
+  
+  const m = 60
+  const h = 60 * m
+  const d = 24 * h
+  context.res.setHeader(
+    'Cache-Control',
+    `public, s-maxage=${10 * m}, stale-while-revalidate=${30 * d}`
+  )

  return { props: { status: statusData } }

getServerSidePropsのcontextからレスポンスヘッダを設定します。またここで設定しているものはキャッシュコントロールヘッダと呼ばれているものです。

コラム:もっとくわしく。SWR(stale-while-revalidate)とは?

SWRは端的に言えばキャッシュ戦略に関する標準化された手法の一つです。ブラウザの世界ではChromeなどが実装していますが、CDNの世界でもいくつかのサービスで利用可能です。なおNext.jsの開発元であるVercel社が開発しているvercel/swrというSWR方式を採用した同名ライブラリもありますが直接の関係はありません。

s-maxage

s-maxageディレクティブは、CDNやプロキシのような共有キャッシュ(今回の例で言えばFirebase Hosting)においてキャッシュが新鮮であると無条件で信用する秒数の設定です。

仮にs-maxageを600秒に設定した場合は、CDN上に一度作られたキャッシュは600秒間は無条件で信用され使用されます。その間はCDNからバックエンド(今回の例で言えばfunctions)にアクセスしないため高速に配信できバックエンドの負荷も減る反面、バックエンドのオリジナルコンテンツに変更や削除があったとしても過去にキャッシュされている古い内容がそのまま返るという事でもあります。そして600秒を経過するとキャッシュは古くなった(stale)と見なされ破棄され、CDNはバックエンドから再び最新のコンテンツを取得しキャッシュする事になります。

  1. キャッシュ作成から600秒までは無条件にキャッシュから高速に返す
  2. 600秒以降アクセスがあった場合は既にキャッシュが破棄されているので、バックエンドから同期的に最新のコンテンツを取得し(キャッシュから返すよりは)低速に返す。またキャッシュを再検証(再生成)する

stale-while-revalidateが併用されている場合はこの600秒経過後の動作が変わります。

stale-while-revalidate

仮にs-maxageが600秒かつ、stale-while-revalidateが1000秒に設定されている場合

  1. キャッシュ作成から600秒までは無条件にキャッシュから高速に返す
  2. 600秒から1600秒の間にアクセスがあった場合はとりあえず古くなっているキャッシュを高速に返すが、非同期にバックエンドから最新のコンテンツを取得しキャッシュを再検証(再生成)する
  3. 1600秒以降に初めてアクセスがあった場合は既にキャッシュが破棄されているので、バックエンドから同期的に最新のコンテンツを取得し(キャッシュから返すよりは)低速に返す。またキャッシュを再検証(再生成)する

という挙動になります。バックエンドへのアクセス回数や負荷という点ではstale-while-revalidate自体にメリットはありませんが、クライアントに対して常に高速にコンテンツを返せるという点がメリットです。

revalidateのトリガーとなるアクセスで1度は古いコンテンツがクライアントに渡ってしまう可能性がある、というデメリットを許容できるかどうか、あるいは設定秒数の長さなどはそのページの特性や扱っているビジネスによっても変わってくるでしょう。

npm run deploy

再デプロイが終わったらFirebase HostingのURLにアクセスし、デベロッパーツールなどでレスポンスヘッダを確認してみましょう。

Response Headers
- cache-control: private, no-cache, no-store, max-age=0, must-revalidate
- x-cache: MISS
+ cache-control: public, s-maxage=600, stale-while-revalidate=2592000
+ x-cache: HIT

cache-controlに設定した内容は反映されているでしょうか。なおFirebase Hostingの場合はレスポンスヘッダ内のx-cacheヘッダ(独自仕様のため将来的にCache-Statusヘッダへ変更される可能性があります)によってキャッシュにヒットしたかどうかを教えてくれます。