概要
- Nuxt アプリケーションを Firebase Hosting にホスティングして SPA していたシンプルな構成を、SSR に切り替えたお話です。
- Nuxt で SSR し、Firebase で認証&データ管理し、Firebase Hositng x Cloud Run でサーバーレスに配信し、CircleCI で自動ビルドするアーキテクチャについての知見を共有します!
システムアーキテクチャ
- 青がユーザーのアクセスフロー
- 赤がデプロイフロー
実装手順
- 【①】アプリケーションを SSR で動作するようにする
- 【②】docker build&run で動作するようにする
- 【③】Cloud Run でアプリケーションが動くようにする
- 【④】Firebase Hosting でアクセスを受けて、Cloud Run にリダイレクトできるようにする
- 【⑤】ブランチへの push で CircleCI 経由で自動デプロイされるようにする
【①】アプリケーションを SSR で動作するようにする
【①-1】クライアント依存コードを撲滅する
-
window.*
やdocument.*
などは、クライアント側でしか動作しないようにする
【①-2】SSR 時と CSR 時の挙動を揃える
- 「初回ロード時」と「a タグ遷移時」は SSR としてレンダリングされ、サーバー側とクライアント側で、それぞれのライフサイクルが実行される => SSR 時でもクライント側で実行されるライフサイクルがあることに注意
- nuxt-link などの「画面遷移時」は、クライアント側でしか実行されない => SSR 時のクライアント側で実行されるライフサイクルと差異があることに注意
- 全てのパターンにおいて共通した初期化処理を実行したい場合、vue-router のガード処理(beforeEach, afterEach)しかないことにも留意
- 参考:【完全版】Nuxt.jsにおけるライフサイクルまとめ
- 参考:Nuxt ライフサイクル
【①-3】runtimeConfig を使用する
- nuxt は
$ nuxt build
時に、環境変数を定数で固定化してしまうので、「ビルドしたイメージに、環境変数一つ渡すだけで環境を切り替えられるようにしたい」場合、runtimeConfig を使用しておくと良い - runtimeConfig は、dotenv に変わる仕組みとして
nuxt v2.13.x
から導入された機能 - 参考:Moving from @nuxtjs/dotenv to runtime config
【①-4】SSR 時と CSR 時で、認証情報を共有する
- 結論から言うと、「SSR 時は
firebase-admin
で認証し、CSR 時はfirebase-js-sdk
で認証する」とよい - CSR 時
- firebase-js-sdk で認証する
-
.onAuthStateChanged()
すると、ブラウザの IndexedDB に認証情報が保存される - 認証ユーザーから
.getIdToken()
で ID トークンと、.refreshToken
でリフレッシュトークンを取得する- ID トークン自体の有効期限は 1h に設定されているが、リフレッシュトークンによって毎度更新される
- firebase-js-sdk の場合、
.onAuthStateChanged()
の実行時点で自動更新される(参考:Firebase what should I do after idToken expires) - firebase-admin-sdk の
.createCustomToken()
を使用すれば有効期限を最大 3600 秒後まで設定できるが、カスタムトークンの認証メソッドも firebase-admin-sdk 側にしかないため、firebase-js-sdk で認証できない (参考:カスタム トークンを作成する)
- Firebase Hosting を経由する場合、キー名が
__session
以外の Cookie はサーバー側のアプリケーションに到達できないため、ID トークンとリフレッシュトークンは、オブジェクトとしてcookie.__session
に保存する(参考:Cookie の使用)
- SSR 時
- リクエストヘッダーから
__session
を取得し、ID トークンとリフレッシュトークンを取得する - 本当はサーバー側でも firebase-js-sdk で認証したいが、firebase-js-sdk にはトークンで認証するメソッドが存在しない
- そのため、firebase-admin の
.verifyIdToken()
で認証を行う -
.verifyIdToken()
は、firebase-js-sdk の.onAuthStateChanged()
とは違い、トークンの自動更新を行わないため、もしトークンの有効期限が切れていたら、リフレッシュトークンを使用し、明示的にトークンを更新する必要がある - firebase-admin-sdk 使用の際は、サービスアカウントの秘密鍵を生成してプロジェクトから参照できるようにする必要がある(参考: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等の違いと使い分け) - マルチステージビルドを利用し、仕上がりのイメージサイズをできるだけ小さくする
# ビルド用
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
$ 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
# デプロイ対象のプロジェクトに切り替える
$ 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 にビルドされたイメージを取得して、ローカルで動かして確認する場合はこちら
# 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 を使用した動的コンテンツの配信とマイクロサービスのホスティング)
- Firebase Hosting に配置した静的コンテンツは、リダイレクト先よりも優先されるため、画像などの静的コンテンツは Firebase Hosting に置いておくと良い(参考:ウェブ フレームワークを使用する)
- 当初、Firebase Functions で動作させる方法を試したが、現時点(2021/12/27)では Firebase Hosting から Firebase Functions へリダイレクトさせる際は、
us-central1
しかサポートされていないため、Cloud Run を利用することにした(参考:Cloud Functions を使用した動的コンテンツの配信とマイクロサービスのホスティング)
firebase.json
{
"hosting": {
"public": "static",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"run": {
"serviceId": "app-name",
"region": "asia-northeast1"
}
}
]
}
}
- あとは Firebase Hosting にデプロイすれば、アクセスは Cloud Run にリダイレクトされるようになる
$ firebase deploy --project project-name --only hosting -p static
【⑤】ブランチへの push で CircleCI 経由で自動デプロイされるようにする
- ここまでの一連のビルドステップを、circleci で実行できるようにする
-
gcloud
はセットアップがめんどくさいので、orbs を利用すると楽 -
firebase-tools
は入れるのは楽だが、実行時には CI からの実行用のトークンが必要なことに注意(参考:CI システムで CLI を使用する)
.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
まとめ
- 盛り盛りな内容となってしまいましたが、わりとスマートなアーキテクチャを構築できたかなと思います。
- フロントエンジニアだけで開発〜運用まで完結できる、いい時代になりましたね!
- サーバーレスにシステムを構築すると、事業の爆速立ち上げが可能になるし、なにより運用にかかる人件費が大幅に削減できるメリットが大きいと感じます。
- 筆者は株式会社バトラで代表兼エンジニアをしております。ぜひお気軽にご連絡ください!