Nuxt アプリを Firebase Hosting + Cloud Run で SSR した話

## 概要
- Nuxt アプリケーションを　Firebase Hosting にホスティングして SPA していたシンプルな構成を、SSR に切り替えたお話です。
- Nuxt で SSR し、Firebase で認証&データ管理し、Firebase Hositng x Cloud Run でサーバーレスに配信し、CircleCI で自動ビルドするアーキテクチャについての知見を共有します！

## システムアーキテクチャ
<img width="100%" alt="system_architecture" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65535/c00570f5-e6bb-d4e0-0d2a-891c2074a0f4.png">

- 青がユーザーのアクセスフロー
- 赤がデプロイフロー

## 実装手順
- 【①】アプリケーションを SSR で動作するようにする
- 【②】docker build&run で動作するようにする
- 【③】Cloud Run でアプリケーションが動くようにする
- 【④】Firebase Hosting でアクセスを受けて、Cloud Run にリダイレクトできるようにする
- 【⑤】ブランチへの push で CircleCI 経由で自動デプロイされるようにする

## 【①】アプリケーションを SSR で動作するようにする
#### 【①-1】クライアント依存コードを撲滅する
- `window.*` や `document.*` などは、クライアント側でしか動作しないようにする

#### 【①-2】SSR 時と CSR 時の挙動を揃える
<img width="500px" alt="nuxt_lifecycle" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65535/1021b0c9-2c72-16c8-0749-552a958feaa0.png">

- 「初回ロード時」と「a タグ遷移時」は SSR としてレンダリングされ、サーバー側とクライアント側で、それぞれのライフサイクルが実行される => **SSR 時でもクライント側で実行されるライフサイクルがあることに注意**
- nuxt-link などの「画面遷移時」は、クライアント側でしか実行されない => **SSR 時のクライアント側で実行されるライフサイクルと差異があることに注意**
- 全てのパターンにおいて共通した初期化処理を実行したい場合、vue-router のガード処理(beforeEach, afterEach)しかないことにも留意
- [参考：【完全版】Nuxt.jsにおけるライフサイクルまとめ](https://qiita.com/too/items/e8ffcf7de7d48dcb9a9b)
- [参考：Nuxt ライフサイクル](https://nuxtjs.org/ja/docs/concepts/nuxt-lifecycle/)

#### 【①-3】runtimeConfig を使用する
- nuxt は `$ nuxt build` 時に、環境変数を定数で固定化してしまうので、「ビルドしたイメージに、環境変数一つ渡すだけで環境を切り替えられるようにしたい」場合、runtimeConfig を使用しておくと良い
- runtimeConfig は、dotenv に変わる仕組みとして `nuxt v2.13.x` から導入された機能
- [参考：Moving from @nuxtjs/dotenv to runtime config](https://nuxtjs.org/tutorials/moving-from-nuxtjs-dotenv-to-runtime-config/)

#### 【①-4】SSR 時と CSR 時で、認証情報を共有する
<img width="100%" alt="auth" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65535/6d496972-4655-dbc4-c801-f842987dc59f.png">


- 結論から言うと、「SSR 時は `firebase-admin` で認証し、CSR 時は `firebase-js-sdk` で認証する」とよい
- CSR 時
  - [firebase-js-sdk](https://firebase.google.com/docs/reference/js/auth.auth?hl=ja) で認証する
  - `.onAuthStateChanged()` すると、ブラウザの IndexedDB に認証情報が保存される
  - 認証ユーザーから `.getIdToken()` で ID トークンと、`.refreshToken` でリフレッシュトークンを取得する
      - ID トークン自体の有効期限は 1h に設定されているが、リフレッシュトークンによって毎度更新される
      - firebase-js-sdk の場合、`.onAuthStateChanged()` の実行時点で自動更新される([参考:Firebase what should I do after idToken expires](https://stackoverflow.com/questions/54058211/firebase-what-should-i-do-after-idtoken-expires))
      - firebase-admin-sdk の `.createCustomToken()`を使用すれば有効期限を最大 3600 秒後まで設定できるが、カスタムトークンの認証メソッドも firebase-admin-sdk 側にしかないため、firebase-js-sdk で認証できない ([参考：カスタム トークンを作成する](https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja))
  - Firebase Hosting を経由する場合、キー名が `__session` 以外の Cookie はサーバー側のアプリケーションに到達できないため、ID トークンとリフレッシュトークンは、オブジェクトとして `cookie.__session` に保存する([参考：Cookie の使用](https://firebase.google.com/docs/hosting/manage-cache?hl=ja#using_cookies))
- SSR 時
  - リクエストヘッダーから `__session` を取得し、ID トークンとリフレッシュトークンを取得する
  - 本当はサーバー側でも firebase-js-sdk で認証したいが、firebase-js-sdk にはトークンで認証するメソッドが存在しない
  - そのため、[firebase-admin](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja) の `.verifyIdToken()` で認証を行う
  - `.verifyIdToken()` は、firebase-js-sdk の `.onAuthStateChanged()` とは違い、トークンの自動更新を行わないため、もしトークンの有効期限が切れていたら、リフレッシュトークンを使用し、明示的にトークンを更新する必要がある
  - firebase-admin-sdk 使用の際は、サービスアカウントの秘密鍵を生成してプロジェクトから参照できるようにする必要がある([参考：SDK の初期化](https://firebase.google.cn/docs/admin/setup?hl=ja#initialize-sdk))

```middleware/currentUser.js
export default async ({ store, app, req }) => {
  let uid = null
  if (process.server) {
    // リクエストヘッダー内の Cookie から、認証情報を取り出す
    const [token, refreshToken] = app.$appFirebase.authentication.getTokens(req.headers.cookie)
    if (token && refreshToken) {
      // firebase-admin-sdk 経由で認証する
      uid = await app.$appFirebase.authentication.validOnServer(token, refreshToken)
    }
  } else {
    // firebase-js-sdk 経由で認証する
    uid = await app.$appFirebase.authentication.validOnClient()
  }
  if (uid) {
    // ログインユーザーを store に保存するなどする
  }
}
```

```plugins/firebase.js
import firebase from 'firebase/compat/app'
import FirebaseAuthentication from '@/plugins/firebase/authentication'
export default async ({ app }, inject) => {
  // firebase-js-sdk の初期化
  if (!firebase.apps.length) {
    firebase.initializeApp(
      {
        apiKey: app.$config.FIREBASE_API_KEY,
        authDomain: app.$config.FIREBASE_AUTH_DOMAIN,
        databaseURL: app.$config.FIREBASE_DATABASE_URL,
        projectId: app.$config.FIREBASE_PROJECT_ID,
        storageBucket: app.$config.FIREBASE_STORAGE_BUCKET,
        messagingSenderId: app.$config.FIREBASE_MESSAGING_SENDER_ID,
        appId: app.$config.FIREBASE_APP_ID,
        measurementId: app.$config.FIREBASE_MEASUREMENT_ID
      }
    )
  }

  // firebase-admin の初期化
  let firebaseAdmin = null
  if (process.server) {
    // firebase-admin-sdk は node 上でしか動作しないため、メソッド内で require する
    firebaseAdmin = require('firebase-admin')
    const serviceAccount = require('@/path/to/serviceAccount.json')
    if (!firebaseAdmin.apps.length) {
      firebaseAdmin.initializeApp({
        credential: firebaseAdmin.credential.cert(serviceAccount)
      })
    }
  }

  // firebase: クライアント側で使用する
  // firebaseAdmin: サーバー側で使用する
  const firebaseContext = {
    firebase: firebase,
    firebaseAdmin: firebaseAdmin
  }

  // authentication の初期化
  const authentication = new FirebaseAuthentication(firebaseContext, app)

  inject('appFirebase', {
    authentication: authentication,
  })
})
```

```plugins/firebase/authentication.js
import axios from 'axios'
import Cookies from 'js-cookie'
import { COOKIE_KEYS } from '@/constants/COOKIE_KEYS'

export default class FirebaseAuthentication {
  constructor(firebaseContext, appContext) {
    this.firebase = firebaseContext.firebase
    this.firebaseAdmin = firebaseContext.firebaseAdmin
    this.config = appContext.$config
  }

  // クライアント側で行う、ログインユーザー確認
  validOnClient() {
    return new Promise((resolve, reject) => {
      if (!process.server) {
        this.firebase.auth().onAuthStateChanged(async (user) => {
          if (user && user.uid) {
            // ID トークンの取得
            const token = await user.getIdToken().then(idToken => {
              return idToken
            })
            // リフレッシュトークンの取得
            const refreshToken = user.refreshToken
            // cookie に保存 -> SSR 時の認証に使用する
            this.setTokens(token, refreshToken)
            resolve(user.uid)
          } else {
            resolve(null)
          }
        })
      } else {
        resolve(null)
      }
    })
  }

  // サーバー側で行う、ログインユーザー確認
  async validOnServer(token, refreshToken) {
    if (process.server) {
      let uid = null
      await this.firebaseAdmin.auth().verifyIdToken(token).then(decodedToken => {
        uid = decodedToken.uid
      }).catch(async e1 => {
        // verifyIdToken が失敗した場合、refreshToken で ID トークンの有効期限を更新し、再度 verifyIdToken を実行する
        let newToken = null
        // refreshToken で ID トークンの有効期限を更新する
        await axios.post(`https://securetoken.googleapis.com/v1/token?key=${this.config.FIREBASE_API_KEY}`, {
          grant_type: 'refresh_token',
          refresh_token: refreshToken
        }).then(res => {
          newToken = res.data.access_token
        }).catch(e2 => {
          // ID トークンの更新が失敗した場合は、未ログインとして扱う
          uid = null
        })
        if (newToken) {
          // 有効期限を延長した ID トークンで、再度認証する
          await this.firebaseAdmin.auth().verifyIdToken(newToken).then(decodedNewToken => {
            uid = decodedNewToken.uid
          }).catch(e3 => {
            // 有効期限を延長した ID トークンでの認証に失敗することはないと思うが、万が一失敗した場合は、未ログインとして扱う
            uid = null
          })
        }
      })
      return uid
    } else {
      return null
    }
  }

  // 生の cookie データから、認証情報を取得する
  getTokens(cookie) {
    if (cookie) {
      const tokenString = cookie.split(';').find(c => c.trim().startsWith(`${COOKIE_KEYS.AUTH_TOKEN}=`))
      if (tokenString) {
        const token = tokenString.split('=')[1]
        if (token) {
          const parsedToken = JSON.parse(decodeURIComponent(token))
          if (parsedToken.token && parsedToken.refreshToken) {
            return [parsedToken.token, parsedToken.refreshToken]
          }
        }
      }
    }
    return [null, null]
  }

  // 認証情報を cookie に保存する
  setTokens(token, refreshToken) {
    // Firebase Hosting を経由する場合、__session のキー名しかアプリに到達できない
    // そのため、一つのキーに対して、複数のデータをセットする
    const tokens = {
      token: token,
      refreshToken: refreshToken
    }
    const tokensString = JSON.stringify(tokens)
    Cookies.set(COOKIE_KEYS.AUTH_TOKEN, tokensString, { expires: XXXX, path: '/' })
  }
} 
```

#### 【①-5】SSR モードでアプリケーションを動作させる
- `nuxt.config.js` で `ssr: true` にし、アプリケーションが正常動作したら OK

## 【②】docker build&run で動作するようにする
- イメージは `-alphain`　など、小さいものを使用する([参考：Dockerイメージ alpine,slim,stretch,buster,jessie等の違いと使い分け](https://www.ted027.com/post/docker-debian-difference/))
- マルチステージビルドを利用し、仕上がりのイメージサイズをできるだけ小さくする

```Dockerfile
# ビルド用
FROM node:16.13-alpine as builder

ARG _APP_ENV
ENV APP_ENV=$_APP_ENV
ENV HOST=0.0.0.0
ENV PORT=8080

WORKDIR /app

ADD package.json ./
ADD yarn.lock ./
RUN yarn install --frozen-lockfile --non-interactive --production
ADD . ./
RUN NODE_OPTIONS="--max-old-space-size=2048" yarn build

# 実行用
FROM node:16.13-alpine

ARG _APP_ENV
ENV APP_ENV=$_APP_ENV
ENV HOST=0.0.0.0
ENV PORT=8080

WORKDIR /app

COPY --from=builder /app /app

EXPOSE 8080
CMD [ "yarn", "start" ]
```

- 作成できたイメージを指定し、動作確認できたら OK

```bash
$ docker build --build-arg _APP_ENV=dev -t app-name .
$ docker run -p 3000:8080 --name container-name app-name
=> localhost:3000 で動作確認
```

## 【③】Cloud Run でアプリケーションが動くようにする
- Cloud Run で、作成したイメージを元に、アプリケーションが起動するようにする

```cloudbuild.yaml
steps:
  # [docker] コンテナイメージをビルドする
  - name: gcr.io/cloud-builders/docker
    args:
      - build
      - "--no-cache"
      - "--build-arg"
      - "_APP_ENV=$_APP_ENV"
      - "-t"
      - "asia.gcr.io/project-name/app-name:$SHORT_SHA"
      - "-t"
      - "asia.gcr.io/project-name/app-name:latest"
      - "-f"
      - "Dockerfile"
      - .
  # [Container Registry] コンテナイメージをデプロイする
  - name: gcr.io/cloud-builders/docker
    args:
      - push
      - "asia.gcr.io/project-name/app-name"
  # [Cloud Run] Container Registry のコンテナイメージを指定して、作成/更新する
  - name: gcr.io/cloud-builders/gcloud
    args:
      - run
      - deploy
      - app-name
      - "--image=asia.gcr.io/project-name/app-name:$SHORT_SHA"
      - "--platform=managed"
      - "--port=8080"
      - "--memory=2Gi"
      - "--cpu=1"
      - "--concurrency=80"
      - "--max-instances=100"
      - "--region=asia-northeast1"
      - "--project=project-name"
      - "--labels=short_sha=$SHORT_SHA"
      - "--allow-unauthenticated"
      - "--no-traffic"
      - "--quiet"
  # [Cloud Run] 全てのトラフィックが最新のビルドに流れるようにする
  - name: gcr.io/cloud-builders/gcloud
    args:
      - run
      - services
      - update-traffic
      - app-name
      - "--platform=managed"
      - "--region=asia-northeast1"
      - "--project=project-name"
      - "--to-latest"
options:
  env:
    # SHORT_SHA は外部から渡されたものを優先する
    - "SHORT_SHA=$SHORT_SHA"
  # CircleCI 経由で実行すると、Google Cloud Storage への書き込みが失敗するため、ログ出力先は Google Cloud Build だけにする
  logging: CLOUD_LOGGING_ONLY
timeout: 3600s
```

- 作成した cloudbuild.yaml を元に、gcloud コマンドでビルドを実行する
- 実行が成功し、Cloud Run 上でアプリケーションが動作したら OK

```bash
# デプロイ対象のプロジェクトに切り替える
$ gcloud config set project project-name
# プロジェクトで Cloud Build と Cloud Run を有効化する
$ gcloud services enable run.googleapis.com
$ gcloud services enable cloudbuild.googleapis.com
# git のコミットハッシュを取得する
$ _SHORT_SHA=$(git rev-parse --short HEAD)
# cloudbuild.yaml を元に、ビルドを実行する
$ gcloud builds submit --config=cloudbuild.yaml --substitutions=_APP_ENV=project-name,SHORT_SHA=$_SHORT_SHA
=> コンソールに出力される URL にアクセスして動作確認
```

- 念の為、Conatainer Registry にビルドされたイメージを取得して、ローカルで動かして確認する場合はこちら

```bash
# Container Registry からイメージを取ってこれるようにする
$ gcloud auth configure-docker
# Container Registry からイメージ取得&実行
$ docker run -p 3000:8080 --name container-name asia.gcr.io/project-name/app-name:latest
=> localhost:3000 で動作確認
```

## 【④】Firebase Hosting でアクセスを受けて、Cloud Run にリダイレクトできるようにする
- Firebase Hosting だと CDN に乗るので、その恩恵を受けるべく、Firebase Hosting から Cloud Run にリダイレクトできるようにする
- 方法は公式ドキュメントに詳しい([参考：Cloud Run を使用した動的コンテンツの配信とマイクロサービスのホスティング](https://firebase.google.cn/docs/hosting/cloud-run?hl=ja))
- Firebase Hosting に配置した静的コンテンツは、リダイレクト先よりも優先されるため、画像などの静的コンテンツは Firebase Hosting に置いておくと良い([参考：ウェブ フレームワークを使用する](https://firebase.google.cn/docs/hosting/functions?hl=ja#use_a_web_framework))
- 当初、Firebase Functions で動作させる方法を試したが、現時点(2021/12/27)では Firebase Hosting から Firebase Functions へリダイレクトさせる際は、`us-central1` しかサポートされていないため、Cloud Run を利用することにした([参考：Cloud Functions を使用した動的コンテンツの配信とマイクロサービスのホスティング](https://firebase.google.cn/docs/hosting/functions?hl=ja))

```firebase.json
{
  "hosting": {
    "public": "static",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "run": {
          "serviceId": "app-name",
          "region": "asia-northeast1"
        }
      }
    ]
  }
}
```

- あとは Firebase Hosting にデプロイすれば、アクセスは Cloud Run にリダイレクトされるようになる

```bash
$ firebase deploy --project project-name --only hosting -p static
```

## 【⑤】ブランチへの push で CircleCI 経由で自動デプロイされるようにする
- ここまでの一連のビルドステップを、circleci で実行できるようにする
- `gcloud` はセットアップがめんどくさいので、orbs を利用すると楽
- `firebase-tools` は入れるのは楽だが、実行時には CI からの実行用のトークンが必要なことに注意([参考：CI システムで CLI を使用する](https://firebase.google.cn/docs/cli?hl=ja))

```.circleci.config.yml
version: 2.1

orbs:
  gcp-cli: circleci/gcp-cli@2.4.0

executors:
  default:
    docker:
      - image: cimg/node:16.13.0

jobs:
  deploy_gcloud:
    parameters:
      env_name:
        description: 環境名(dev/prd)
        type: string
    executor:
      name: gcp-cli/default
    steps:
      - checkout
      # gcloud コマンドのセットアップ
      - gcp-cli/install
      - gcp-cli/initialize:
          gcloud-service-key: GCLOUD_SERVICE_KEY
          google-compute-region: GOOGLE_COMPUTE_REGION
          google-compute-zone: GOOGLE_COMPUTE_ZONE
          google-project-id: GOOGLE_PROJECT_ID
      # gcloud で docker が扱えるように認証
      - run: gcloud auth configure-docker --quiet
      # $CIRCLE_SHA1 には github の最新のコミットハッシュが入っている -> $(git rev-parse --short HEAD) と同じ結果が得られるように、$CIRCLE_SHA1 を先頭 7 文字を取得し、SHORT_SHA のキー名で環境変数にセットする
      - run: echo 'export SHORT_SHA=$(echo $CIRCLE_SHA1 | cut -c -7)' >> $BASH_ENV
      # cloudbuild.yaml に従い、イメージのビルド -> Container Registry にデプロイ -> Cloud Run をセットアップまでを行う
      - run:
          command: gcloud builds submit --config=cloudbuild.yaml --substitutions=_APP_ENV=<< parameters.env_name >>,SHORT_SHA=$SHORT_SHA
          no_output_timeout: 3600s
  deploy_firebase:
    executor:
      name: default
    steps:
      - checkout
      - run: sudo yarn global add firebase-tools
      - run: yarn install
      # Firebase Hosting にデプロイ
      - run: firebase deploy --token $FIREBASE_CI_TOKEN --project project-name --only hosting -p static

workflows:
  version: 2
  deploy_by_merge:
    jobs:
      # develop ブランチへの merge -> dev 環境へデプロイ
      - deploy_gcloud:
          name: deploy_gcloud-dev
          env_name: dev
          filters:
            branches:
              only:
                - develop
      - deploy_firebase:
          name: deploy_firebase-dev
          env_name: dev
          requires:
            - deploy_gcloud-dev
          filters:
            branches:
              only:
                - develop
      # master ブランチへの merge -> prd 環境へデプロイ
      - deploy_gcloud:
          name: deploy_gcloud-prd
          env_name: prd
          filters:
            branches:
              only:
                - master
      - deploy_firebase:
          name: deploy_firebase-prd
          env_name: prd
          requires:
            - deploy_gcloud-prd
          filters:
            branches:
              only:
                - master
```

## まとめ
- 盛り盛りな内容となってしまいましたが、わりとスマートなアーキテクチャを構築できたかなと思います。
- フロントエンジニアだけで開発〜運用まで完結できる、いい時代になりましたね！
- サーバーレスにシステムを構築すると、事業の爆速立ち上げが可能になるし、なにより運用にかかる人件費が大幅に削減できるメリットが大きいと感じます。
- 筆者は[株式会社バトラ](https://www.batra.jp/)で代表兼エンジニアをしております。ぜひお気軽にご連絡ください！