Nuxt 3 を Google App Engine 本番環境 で SSR + キャッシュ運用してみる
はじめに
去る10月12日、Nuxt.jsのバージョン3のPublic Beta版が公開されました。
さっそくダウンロードして一通り触ってみていますが、個人的にはかなり好感触です。
これまでVue 3で開発スタートする際の有力な選択肢だったViteと比較しても、SSR、ルーティング(vue-router)、Metaタグ(vue-meta)、ストア、TypeScript、JSX/TSX記法などを追加設定なしで使えるのが非常に大きく、
「必須ではないがほとんど必須に近い、または自分で設定するのが地味に面倒な機能」が最初からセットアップされた状態から開発をスタートできるため、Vue 3で開発するハードルが格段に下がりました。
ベータ版なので当然ながら不安定な部分や未提供の機能などがあるものの、現状でも既に利用する価値のあるフレームワークになっています。
そんなNuxt 3、当然ながらまだプロダクション環境での利用は推奨されていませんが、せっかく提供開始されたからには何か作ってみたいし、もし作れたらそれをデプロイまでしたいはずです。
ということでこの記事では、あえて今すぐプロダクション環境で実際に動かしてみる方法を説明したいと思います。
どんなアプリケーションを作るのか
やってみるとは言っても、例えばSPAサイトを静的ビルドとしてホスティングするだけであればNetlifyなどにそのままデプロイするだけで特に困るポイントはないので、もう少し複雑な要件を設定します。
- 動的ルーティングができる
- OGP対応のためSSRまたはSSGが必須(SPAは不可)
- 初回アクセス以外ではキャッシュにアクセスさせる
要するにNext.js + Vercel + ISRみたいなことをします。
ところで、NuxtでOGP対応と言えばまず真っ先に浮かぶのは静的サイト生成(SSG)だと思いますが、Nuxt 3は現在 nuxt generate
に対応していません。
そのため、現状でOGP対応を行うのであれば選択肢はSSRに絞られます。VercelとかLambdaとかいろいろ選択肢はあると思いますが、今回はGoogle App Engine(以下GAE)を選ぶことにしました。
理由としては、Cloud Functionsを使うような「サーバーレス環境でのSSR」は設定にコツや制限があることが多く、不安定かつドキュメント不足なNuxt3 Betaで実現するのが、このあたりに詳しくない自分にはハードルが高かったからです(というか試している最中で諦めました)。
その点GAEは最初からサーバーサイドで動かすことを前提にしたアプリケーションなので、少ない設定でSSRを素直に設定しやすいと考えたためです。個人的にも以前にも一度(Nuxt 2で)試したことがあり、難易度は高くない印象を持っています。無料枠もあります。
また、Cloud CDNと組み合わせることで、stale-while-revalidate
やキャッシュの手動破棄ができ、Next + ISRに近い環境を容易に作れそうなのも魅力的でした。
Nuxt 3でSSRアプリケーションを作る
※この章はNuxt 3の主要機能の一部を駆け足で触るだけの内容なので、興味のない方・GAEへのデプロイ方法だけ知りたい方は飛ばしてください。
まずは公式ドキュメントに沿ってNuxt 3の新規アプリケーションを作成します。
npx nuxi init nuxt3-app
code -r nuxt3-app
yarn install
yarn dev
Nuxt 2の時のようなフレームワーク選択などもなく、一瞬です。起動も爆速。
script setup
デフォルトで用意されている App.vue
を削除して、pages
ディレクトリを作成。このあたりはNuxt 2と同じですね。
せっかくなのでscript setup syntaxを使ってみます。
<script lang="ts" setup>
const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>
<template>
<div>
<button @click="count++">Click Me!</button>
<p>
Single: {{ count }}<br />
Double: {{ doubled }}
</p>
</div>
</template>
驚くほどシンプルに書けるようになりました。ref や computed などが自動でインポート・型補完されるのも凄いです。
TSX、vue-router、SCSS
Vue 3ではJSX/TSXが正式にサポートされたので、こちらの書き方も試してみます。
ちなみにVue × TSX で書けると何が嬉しいかについては、Nuxt 2の頃に書いたことがあるので、気になる方はそちらをご覧ください(PR)。
驚くべきことに、composition-api や vite と違って追加のプラグインすら不要で、tsconfig.json
の compilerOptions
に jsx: preserve
を指定するだけでTSXが使えます。
また、SCSSについても設定は必要なく、 yarn add sass
だけで使えるようになります。
<script lang="tsx">
export default defineComponent({
setup() {
const { params } = useRoute();
return () => (
<div class="container">
<div>{params.id}</div>
</div>
);
},
});
</script>
<style lang="scss">
.container {
width: 100%;
}
</style>
こちらも特に困ることはなく、簡単にパスを表示させることができました。
ちなみに動的ルーティング。ファイル名が _id.vue
から [id].vue
に変わりました。Next.jsと揃いましたね。
ただし、TSXで書いていると(特にSSR時に)エラーが出ることがそこそこ多かったので、安定版がリリースされるまでの間は、Vue Templateで書いた方が良いかもしれません。
SSR対応
デフォルトではSSRではなくSPAになっているので、nuxt.config.tsでSSRオプションをtrueにします。
import { defineNuxtConfig } from "nuxt3";
export default defineNuxtConfig({
rootDir: "./project",
ssr: true,
});
サーバーサイドでのデータ取得についてはNuxt 3 - Data Fetching のページを参照して実装できます。
上記の通りTSXだといろいろエラーが出たので一旦script setupで書き直します。
<script lang="ts" setup>
import dayjs from "dayjs";
const { params } = useRoute();
const clientDate = ref();
onMounted(() => {
clientDate.value = dayjs().toString();
});
const { data: serverDate } = await useAsyncData("date", async () => {
const date = dayjs().toString();
await new Promise((resolve) => setTimeout(resolve, 1000));
return date;
});
</script>
<template>
<div class="box">
<div>Path: {{ params.id }}</div>
<div>Server Date: {{ serverDate }}</div>
<div>Client Date: {{ clientDate }}</div>
</div>
</template>
server dateがサーバーサイドで取得された時刻、client dateがクライアントサイドで取得された時刻。asyncDataで1秒待たせます。
このように約1秒のズレが発生していることから、サーバーとクライアントで別々に時間を取得していることが確認できました。
OGP設定
OGP表示が確認できるように設定します。この useMeta も自動インポートです。すごい。
<script lang="ts" setup>
/** 上の項と同じ内容なので省略 **/
useMeta({
title: `${params.id} - Nuxt 3 x GAE Demo Page`,
meta: [
{ property: "og:title", content: `${params.id} - Nuxt 3 x GAE Demo Page` },
{ property: "og:description", content: `IDが${params.id}のページです` },
{
property: "twitter:title",
content: `${params.id} - Nuxt 3 x GAE Demo Page`,
},
{
property: "twitter:description",
content: `IDが${params.id}のページです`,
},
],
});
</script>
その他
CSSフレームワークのBulmaをグローバルに読み込んでboxの見た目を整えたりしていますが、今回の主旨とは関係ないので割愛します。
プロダクションビルド
一旦SSRの動作確認ができたので、ビルドを行います。といっても yarn build
するだけです。
その後、yarn start
でビルドされたアプリケーションが実際に動作することを確認できます。
ちなみにプロジェクトディレクトリを rootDir: ".project"
のように作業用ディレクトリを移動させている場合は、package.jsonのコマンドも "start": "node project/.output/server/index.mjs"
に書き換える必要があります。
Google App Engine へのデプロイ
SSRのビルドまで完了したので、実際にデプロイしてみます。
プロジェクトの準備
GCPのアカウントの作成や、課金をオンにするなどの手順については割愛します。
Google App Engine にアクセスし、プロジェクトがなければ作成します。
プロジェクト名を今回は nuxt3-gae-demo とし、アプリケーションの作成に進みます。
リージョンは東京であるasia-northeast1
を選びます(usの方が多少安いと思いますが、今回は速度と価格のバランスを試したいため)
リソースの言語はNode.js、Environmentは標準を選択してください(フレキシブルは無料で運用できません)。
Google Cloud SDKの導入
Cloud SDKをローカルに導入します。なお、ここではCIツールなどとの連携は説明しません。
「Cloud SDKをダウンロード」をクリックすると、Google Cloud SDK導入手順を確認できるページに飛びますので、OSごとの手順に従ってください。私の場合はWSL2なのでLinuxの手順に従います。
$ cd ~
$ curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-240.0.0-linux-x86_64.tar.gz
$ tar zxvf google-cloud-sdk-240.0.0-linux-x86_64.tar.gz -C ~
$ ./google-cloud-sdk/install.sh
$ gcloud init
gcloud init
を行うと、Googleアカウントでのログイン、その後プロジェクトの選択が求められます。このあたりは特に迷うことはないと思います。
app.yaml と .gcloudignore の作成
GAEにデプロイするためには app.yaml
が必要です。
ちなみにここの設定を間違えると想定以上の課金請求が来る可能性があるので、なるべくこの記述を鵜呑みにせずに自分でも調べてください。
runtime: nodejs14
instance_class: F1
automatic_scaling:
min_instances: 0
handlers:
- url: /.*
script: auto
secure: always
env_variables:
NUXT_HOST: "0.0.0.0"
NUXT_PORT: "8080"
NODE_ENV: "production"
instance_classは最小、min_instances: 0でデフォルトでインスタンスが起動し続けないようにします。
hostとportをセットしているので、package.jsonのスクリプトも "start": "HOST=0.0.0.0 PORT=8080 node ./project/.output/server/index.mjs"
に書き換えます。
さらに、gcloud app deploy
はデフォルトで現在のディレクトリのファイルをすべてアップロードしてしまうので、 .gcloudignore
で必要なファイル以外を除外します。
.gcloudignore で全部無視して必要なものだけ指定する - ぽ靴な缶 などの記事を参考にしながら、基本的には project/.output
ディレクトリ以下のみがアップロードされるように設定します。
# This file specifies files that are *not* uploaded to Google Cloud Platform
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
# Node.js dependencies:
node_modules/
.nuxt/
project/*
!project/.output*
デプロイしてみる
gcloud app deploy
のコマンドを叩いてファイルをアップロードします。
完了したら gcloud app browse
でサイトを開きます。これで先程 yarn start
したのと同じ画面が…
出ませんでした。
GAEのダッシュボードからエラーレポートを確認します。
unenvが足りないと言われています。https://console.cloud.google.com/debug でアプリケーションのデバッガを開くとデプロイされているファイルの一覧を見ることができるのですが、確かにunenvがありません。
で、ここからは完全にworkaroundというかたぶんそのうち直ると思われる事象なのですが、Nuxt3の依存関係に指定されているパッケージが、GAEで自動的にインストールされないので、yarn add
でdependencyに明示的に追加しなければならないようです。
unenvを追加してデプロイしても、また別のパッケージが足りないと言われてエラーが出るので、ひたすら追加していきます。
最終的に私の環境では、yarn add destr h3 ohmyfetch pathe unenv
で全ての依存関係が解消されました。
改めてgcloud app deploy
します。
無事に表示されました!!
Server Date / Client Date についてもちゃんと1秒ズレで表示されています。
servermiddleware でエッジキャッシュを設定する
本当は冒頭に貼った catnose さんの記事 のように Cloud CDNを設定したいのですが、この記事の主旨からは外れすぎてしまうので、一旦はGAEの基本機能であるエッジキャッシュを活用します。
レスポンスのヘッダーでCache-Control: public, max-age=86400などと指定するだけでレスポンスがGoogleのエッジサーバにキャッシュされます。
という上の記事の冒頭の記述と、
こちらのNuxt 2のServermiddleware を使ってヘッダーを付与している記事と、
公式ドキュメントのServer Middlewareの設定方法を参考に、
import type { IncomingMessage, ServerResponse } from "http";
export default (
_req: IncomingMessage,
res: ServerResponse,
next: () => void
) => {
res.setHeader("Cache-Control", "public, max-age=86400");
next();
};
こんなファイルを作成します。自動で読み込まれます。
localhostでResponse Headerに cache-controll が追加されていることを確認したら、yarn build && gcloud app deploy
。
再度サーバーにアクセスし、リロードしてみると、
このように2回目のアクセスでは Server Dateが更新されず、Client Dateだけが更新されるようになりました!
また、初回アクセス時にはasyncData
の解決で1秒待たされますが、2回目以降は一瞬でリロードされます。クライアントサイドのみ動作していることがわかります。
OGP確認
ついでにOGPも確認してみます。
動的ルーティングになっているページもちゃんとOGPが表示されているので、サーバーサイドでレンダリングされていますね。
成果物
GitHub: https://github.com/ytr0903/nuxt3-gae-demo (コミットログやReadmeなどは適当なので、あくまで参考程度に)
GAEサイト: https://nuxt3-gae-c.an.r.appspot.com (運用コストなどの関係で近いうちに削除する予定ですのであしからず)
結論:Nuxt 3のプロダクション運用は現実的か
このように、Nuxt 3のSSRモードをGoogle App Engineで本番運用できる状態にするのは、意外と簡単に進められるということがわかりました。
Nuxt 2 + GAE での開発例は検索するとかなりたくさんあり、基本的にはその知見を活用できるので、ビルドさえできてしまえば、デプロイ時にあまり困ることはないと思います。
これなら本番運用も問題なくできる……と言いたいところですが、実際にはNuxt 3でちゃんとしたアプリケーションを開発するのはまだまだ大変です。
この記事のデプロイまでの流れがスムーズに進んだのは、ベーシックな機能しか使っていない、外部ライブラリをdayjsとbulmaくらいしか導入していない、の2点が理由として大きいです。
実はこの記事を書く前から、FirebaseとGraphQL(Apollo Client)を利用したアプリケーションのNext.jsからNuxt 3へのリプレースにもチャレンジしているのですが、
このような複雑なライブラリをNuxt 3で導入しようとすると、途端に開発時・ビルド時・デプロイ後のそれぞれで原因不明のエラーがたびたび発生し、しかもそれがVue・Vite・Nuxt・SSR・rollup……など原因になり得る箇所がたくさんあり、何十時間も苦戦を強いられているのが実情です。
エラーの多くは https://github.com/nuxt/framework/issues で既に提起されていることが多く、調べれば解決策が見つかったりもしますが、修正までに時間がかかったり、アップデートによってまた別の問題が起きたり……と非常に不安定です。
このあたり、Nuxt 2では Nuxt Communityで提供されているモジュールを導入することで、主要なライブラリを導入する際に起きる問題をほとんど回避でき、むしろVueやReact/Nextよりも楽に導入できていたのですが、Nuxt 3はまだほとんどのモジュールが非対応です。
逆に言えば、この各種モジュールの対応が進んでいけば自然とここに苦しめられることはなくなっていくので、一過性の問題だとも言えるでしょうし、このあたりの問題を自力である程度解決できる方であれば、本番運用はともかく、その準備として開発をスタートしても問題ないラインには達していると感じました。
おわりに
現状のNuxt 3はベータ版がようやく公開されたばかりで、既存のバグや未対応の機能もたくさんありますが、それらを差し引いても既に魅力的な開発体験が提供されています。
とにかくNuxt.jsの一番の魅力は「細かい設定なしに開発できるので、プロダクトの価値の提供に集中できる」という部分で、ここはVue/ViteやReact/Nextどと比較しても圧倒的に優れている部分でした。
それがバージョン3になり、Vue 3・Vite・TypeScript・Composition API・TSXといった、新しいスタンダードを基本機能として取り込んだことで、ますます盤石なオールインワンフレームワークになった、という印象を持ちました。
現時点でも十分にその凄さを体感することはできますので、この記事を読んで興味を持った方は、ぜひNuxt 3での開発を試してみてください!
Discussion