Chapter 04

Firebaseへデプロイしてみよう

oubakiou
oubakiou
2021.07.25に更新

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

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

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

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

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

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

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

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

確認したプロジェクトIDは.firebasercdefaultエイリアスとして設定します。

.firebaserc
{
  "projects": {
    "default": "<project-name-here>"
  }
}

この設定はfirebase CLIから利用されます。なおprojectsには例えばdevelop用のプロジェクトIDとproduction用のプロジェクトIDのように複数のプロジェクトを紐付けるような使い方もできます。

また2021/6現在、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へ個別に保存するよう変更しています。また

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

というメッセージが表示されたら下記を実行しましょう。

direnv allow

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

.gitignore
# vercel
.vercel

+# XDG Base Directory
+.config

次に以下を実行します

npx firebase login

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

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

Firebase CLIのログインに成功したらプロジェクトの一覧を確認してみましょう。

npx firebase projects:list

先ほど作成したプロジェクトは表示されているでしょうか?

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

firebaseFunctions.ts
import * as functions from 'firebase-functions'
import next from 'next'
import { join } from 'path'
import * as config from './src/next.config'

const nextjsDistDir = join('src', config.distDir)

const nextjsServer = next({
  dev: false,
  conf: {
    distDir: nextjsDistDir,
    future: {},
    experimental: {
      turboMode: false,
      reactRoot: false,
    },
  },
})
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のデフォルトリソースロケーションではasia-northeast1等を設定していたのに、ここではus-central1を指定している事を不思議に思うかもしれませんが、これは2021/6現在Firebase Hostingは、us-central1でのみCloud Functionsをサポートしているためです。

firebase.jsonのfunctions.predeployで、デプロイ時前処理としてこのfirebaseFunctions.tsからfirebaseFunctions.jsを生成するよう設定します。なおエントリーポイントの設定自体はpackage.jsonのmainディレクティブにあります。

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

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

npm run deploy

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

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

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

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

Cloud Functionsで開発環境と同様のjsonを表示できる事が確認できたら、ページコンポーネントでfetchするAPIのホスト名をlocalhostから差し替えてもう一度デプロイしましょう。

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

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

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

環境変数を使って開発環境と本番環境とで異なる設定を適用してみよう

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

本来Next.jsでは環境に応じて異なる環境変数を自動で読み込む仕組みもありますが、Cloud Functions for Firebaseでは通常の環境変数をユーザーが設定して利用する事はできないため、開発環境では通常通りprocess.env.API_ROOTから、本番環境ではfunctions.config().api.rootから、それぞれ参照するように変更します。

src/pages/statuses/[id].tsx
+import * as functions from 'firebase-functions'

-  const res = await fetch(
-    `https://us-central1-${あなたのプロジェクトID}.cloudfunctions.net/nextjsFunc/api/status?id=${context.query.id}`
-  )
+  const apiRoot =
+    process.env.NODE_ENV === 'development'
+      ? process.env.API_ROOT
+      : functions.config().api.root
+  const url = `${apiRoot}/api/status?id=${context.query.id}`
+  const res = await fetch(url)
src/pages/index.tsx
+import * as functions from 'firebase-functions'

-  const res = await fetch(
-      `https://us-central1-${あなたのプロジェクトID}.cloudfunctions.net/nextjsFunc/api/status`
-  )
+  const apiRoot =
+    process.env.NODE_ENV === 'development'
+      ? process.env.API_ROOT
+      : functions.config().api.root
+  const url = `${apiRoot}/api/status`
+  const res = await fetch(url)

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

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

.envを変更した後は、ディレクトリ移動または下記でdirenvによる環境変数の再読み込みをしておきましょう

direnv reload

次は本番環境用ですが、Cloud Functions for FirebaseではFirebase CLIを利用して設定する事ができます。下記のように実行しましょう。

npx firebase functions:config:set api.root=https://us-central1-${あなたのプロジェクトID}.cloudfunctions.net/nextjsFunc

なお設定された環境変数は下記で確認する事ができます。

npx firebase functions:config:get

Next.jsでクライアントサイドに対しても環境変数を提供したい場合は、環境変数名にNEXT_PUBLIC_というプレフィックスを付ける必要があります。なお本番環境(functions)においてはまた別途手段が必要となります。

Firebase Hostingの設定をしてみよう

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

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

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

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

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

このセクションで紹介しているstale-while-revalidateディレクティブに関する挙動についてはFirebase Hostingの公式ドキュメントでの言及がないため、将来的に予告なく挙動が変更される可能性が考えられます。なおFirebaseと同じくGoogleが提供しているGCPのCDNサービスであるCloud CDNではドキュメントに記載されています。

src/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ヘッダへ変更される可能性があります)によってキャッシュにヒットしたかどうかを教えてくれます。