Next.jsのIncremental Static RegenerationをVercel以外でやってみる

本記事はNext.js Advent Calendar 2020の9日目です。

tl;dr

  • Vercel以外でもIncremental Static Regenerationは可能
  • 試した範囲ではフルに機能するのはコンテナで動かした場合のみ
  • AWSのサーバーレスで動かすのは現時点で絶望的

はじめに

早速ですが、みなさん、次世代のStatic Site Generation(SSG)と言っても過言ではないIncremental Static Regeneration(ISR)はご存知でしょうか。

一応知らない人のためにすごく簡単に説明をすると、『リクエストに対して静的にビルドされたページを返しつつ、有効期限が過ぎたら非同期で静的ページの再生成をSSRで行う』っていうものです。Cache Controlにおけるstale-while-revalidateと同じような考え方が適用されたものとも言えまして、Next.js 9.4から追加された機能です。

嬉しさについても簡単に述べるならば、SSGとSSRそれぞれ辛みがあるわけですがその辛みの一端を解消してくれそうな機能と言えます。つまり、これまでパフォーマンス的に、スケーラビリティ的に、運用的にはSSGのほうが楽ではあるものの、SSRでないと動的な処理などは実現できないことがあったわけです。一方でSSGはその性質上ページの数やコンテンツの数が多くなるとビルドが重くなるという課題もありました。それをISRでは言葉どおり段階的にしつつ、CDNのキャッシュを有効活用しつつ、静的ページの更新を自動的に行うので動的に近いこともできるようになった感じです。

さて、そんなISRですが現状ではVercelでしかサポートされていないような雰囲気を感じるじゃないですか。僕もそう思ってました。意外と他のプラットフォームで動かしてるって話を見かけないんですよね。もしかしたら当たり前に動くからってことなのかもしれませんが。

というわけで、他のプラットフォームでも動くと嬉しいよねってことでいろいろ試してみます。

試すクラウドサービスとしてはとりあえず今回は自分が一番使い慣れているAWSで試しますが、きっと他のクラウドでも有効だと思います。

サンプルアプリ

試すのに使ったソースコードこちらです。とてもシンプルな内容です。

export default function Index({current}) {
  return (
    <div>
        現在時刻は{current}です。
    </div>
  );
}

export async function getStaticProps() {
    const date = new Date();
    const current = date.toLocaleString()
  return {
    props: {
      current,
    },
    revalidate: 10,
  };
}

これだけです。内容的にはビルドされた時刻を取得して出力しているだけです。getStaticPropsなのでSSGなのですが、ポイントはrevalidate: 10です。この指定によりISRで10秒ごとにビルドし直されています。つまり、10秒間は時刻が更新されず、リビルドされたタイミングでそのときの時刻に更新されるというだけのものです。 f:id:Keisuke69:20201207220243p:plain

今回は使いませんが、動的ルーティングする場合はこれに加えてgetStaticPathsfallback: trueを指定する必要がありますね。

Netlify

さて、まずはNetlifyにデプロイしてみます。NetlifyといえばStaticなサイトのホスティングサービスってイメージの人も多いと思いますが実はNext.jsのServer Side Rendering(SSR)もできるようになってたりします。

公式ブログにあるようにNetlifyでNext.jsをSSRするにはNext on Netlify pluginをインストールするだけです。

そうするとGithubにNext.jsのアプリケーションをプッシュなりすると動き出すデプロイプロセスの中でうまいことやってくれます。実際にはファンクションとして吐き出してるようで、これを実行しているみたいですね。

というわけで早速デプロイしますが、結論を先に言うとうまくいきませんでした。まず、デプロイはうまく行きますし、実際に動きます。

でも期待した動きをしてくれず、ページのリロードごとに時刻が更新されます。つまりISRではなく毎回SSRで実行されてしまっているようです。正直なんでこんなことになるのかはわかりませんが深くは追っていないです。

ちなみに、このプラグインfallback: false以外を指定した場合はデプロイ自体失敗するという状況になりました。これも原因は追っていませんが、いずれにせよ現時点ではNetlifyでISRをするのは難しそうでした。

と思ったらタイムリーにこんなアナウンスが。

www.netlify.com

このアナウンスではISRは現状SSRとして処理する方法でサポートしてるってあるので現状ではひとまずそういうことみたいです。

AWSのサーバーレス

続いてAWSのサーバーレス環境にもデプロイしてみます。デプロイ方法とかはこちらの記事に書いた内容でいきます。つまりServerless FrameworkのServerless Next.js Componentを使うパターンです。

ちなみにこの記事の末尾では現時点ではISRはサポートされていないと書きました。これは公式のReadmeなどの記述をもとにそう書いたのですが実際にどうなのかやってみます。とりあえずServerless Frameworkをインストールして初期セットアップします。

$ yarn global add serverless
$ serverless config credentials --provider aws --key <AWS_ACCESS_KEY> --secret <AWS_SECRET_KEY>

ちなみに僕はRemote Containersの環境を使っている関係でglobalを指定していますがそうじゃない人はなくても大丈夫だと思います。あと今回はAWSの環境にデプロイするのでAWSの認証情報を設定しています。

そしてserverless.ymlを以下のような感じで用意します。

myNextApplication:
  component: "@sls-next/serverless-component@1.18.0"

用意したらおもむろにデプロイコマンドを実行します。間違ってもserverless deployとか実行しないように。初回はCloudFrontのディストリビューションの作成などもあるので少し時間がかかります。

$ serverless

そもそもデプロイに失敗するかと思いきやすんなり言ってしまった。そしてアクセスすると普通に表示される。

これはもしや実はいけるのかもと思い、期待に胸ふくらませつつ10秒後にリロード。

なにも変化がなかった。

それ以降、何度リロードしても変化がない。つまりISRはおろかSSRもしてくれていない(そりゃそうかも)。単に初回に生成した静的ページのみがレスポンスされる状況だ。

実は淡い期待を抱いていたのだがこれはどういうことなのだろうか。なんとなくLambdaファンクションの生存期間内であれば再生成とかしてくれるのではないかとも思っていた。詳細を深堀りするにはServerless Next.js ComponentがデプロイするLambdaファンクションの中身とか追う必要がありそうなんですが、時間がないのでひとまずパス。一旦諦めます。

Container

さて、サーバーレスがいまいちな結果に終わったので今度はコンテナです。ただ、正直これは何も試さなくても動くでしょって感じですね。

というのも、公式のブログではISRをフルサポートしているものとしてnext startとVercelのプラットフォームがあげられていますし、開発時にISR試すときは普通にローカルでnext startを動かしているはずです。つまり手元のMacでは動くのです(もちろんWinでも大丈夫だと思う)。それはつまり、Node.jsの環境をセットアップしたコンテナイメージでも動くということです。

したがってやることはNext.jsのServer Side Renderingの環境を用意するのと同じと言えます。

早速やっていきます。今回はとりあえずコンテナイメージを用意してFargateというAWSのコンテナ実行環境サービスの上で動かすことだけを目的とします。

ひとまずDockerfileをこんな感じで用意しました。端的に言うとNode.jsのイメージを用意してソースコードをコピーしてビルドしてってだけですね。

FROM node:current-alpine AS base
WORKDIR /base
COPY package*.json ./
RUN yarn install && yarn cache clean
COPY . .

FROM base AS build
ENV NODE_ENV=production
WORKDIR /build
COPY --from=base /base ./
RUN yarn build

FROM node:current-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /build/package*.json ./
COPY --from=build /build/.next ./.next
COPY --from=build /build/public ./public
RUN yarn add next && yarn cache clean

EXPOSE 3000
CMD yarn start

最終的なコンテナイメージのサイズを小さくするためにマルチステージビルドをしています。単純にコピーしてビルドしても当然動くけどゴミも多いのでマルチステージビルドしてビルド用と実行用のイメージをわけるのがいいと思います。ゴミの多いところには悪い人が集まりやすいので。

普段、開発環境としてRemote Containersを使っていてそのときはnode:15.3.0というイメージを使っています。これはGitとか諸々が含まれたイメージです。そしてUbuntuベース。

一方でFargateで実行する用としてはalpineベースのイメージでビルドする感じにしています。

まあDockerfileの細かいところは本題ではないのでツッコミあるかもしれないが一端そこは置いておいて。

とりあえずこちらでイメージのビルドを行います。プロジェクトフォルダに移動した上でこんな感じで。細かいところは各自の環境にあわせてください。イメージがビルドできたらDockerHubなりECRなりにプッシュします。今回はDockerHubにしました。

$ docker build --pull --rm -f "Dockerfile" --target production -t keisuke69/isrdemo:latest "."
$ docker push keisuke69/isrdemo:latest

ではこれを早速Fargateで起動します。細かい設定は特にせずウィザードでイメージを指定してポチポチと作っただけなので手順は割愛します。TaskDefinitionを貼ろうかと思ったけどすごく長くなるのでこれも割愛で。ただ、ほぼデフォルトです。3000番ポートでコンテナを起動して、ALBは80番でListenしています。で、コンテナにリクエストをforwardしてるだけですね。難しいことはやっていません。余力があればCDKなりを後日用意します。

さて、作成したらブラウザからアクセスします。

f:id:Keisuke69:20201208154707p:plain

はい、普通にアクセスできますね。何度かリロードしても10秒立ってバックグラウンドでリビルドされるまでは時刻が変わりません。わかりにくいのでGIFにしてみましたw

f:id:Keisuke69:20201208155547g:plain

GIFにしてもわかりにくかったw

目を凝らすとChromeの更新ボタンがクリックされてるのがわかると思います。で、何回かは時刻が変更ないまま。つまりこれは前回ビルドされた静的ファイルがレスポンスされているということです。でrevalidateオプションで指定した秒数(今回は10秒)が経過するとバックグラウンドで静的ファイルがリビルドされるのでそのときの時刻に更新されるということです。

今回はコンテナで試しましたがもちろんEC2などの仮想マシンやオンプレのサーバでも同様です。コンテナに関してもFargateでなくてもKubernetesとかのコンテナ基盤さえあればどこでも動くと思います。

実際には手前にNginx入れるのもよくある構成かと思います。

あと、AWSだと静的ファイルはS3でホスティングして配信ってパターンもあると思います。このあたりはデプロイ処理を作り込む必要がありますね。

また、実際にはキャッシュのために手前にCDNを入れることも多いでしょう。

とりあえずコンテナでも普通に動くことは確認取れたので何らかの事情でVercelを使えない場合、もしくは普段利用しているクラウドサービスでサービスしたいなどの場合はひとまずコンテナを利用すれば特に問題はないかと思います。

うん、まあわかってた。

だが、コンテナを用意するのはともかく、コンテナ基盤を用意するとかちょっと面倒だよなーって思うときありますよね。

やっぱりサーバーレスでやりたい。

AWSのサーバーレス #2

というわけでサーバーレスを再考します。先ほどのServerless Next.js Componentを使う場合は期待した動きにはなりませんでした。なのでなんとかLambdaを使ってできないかと試行錯誤してみます。

Serverless Next.js ComponentはLambda@Edgeが使われるのですが、ISRはサーバーサイドで非同期にリビルドが行われます。つまりバックグラウンドで処理が行われる必要があると想像できます。

とすると、プロセスが常時起動している必要があると言えますよね。コンテナであればnext startしたイメージを起動させっぱなしにしておけばいいのですが、Lambda@Edgeに限らずLambdaの実行モデルは関数型であり、そのライフサイクルは短命です。したがって、そもそもLambdaの実行モデルとは相性が悪そうです。でも、一応Lambdaの実行時間は最長で15分なのでそれでなんとかなる気もしてきます。

なので、まずはNext.jsのアプリをLambdaファンクションとして実行してみたいと思います。ひとまずは簡単にexpressを使ったカスタムサーバを用意してこれでNext.jsアプリを稼働させるようにします。

その上で、これも以前に紹介したaws-serverless-expressを使います。これはexpressを使ったWebアプリケーションをLambdaで実行可能にするというものです。これも詳しくはこちらで。

ただ、このaws-serverless-expressなんですが11/30付けでawsからvendiaという企業のリポジトリに移管されて名前もawsが外れてserverless-expressになっていました。新しいリポジトリこちらです。

オリジナルの作者がAWSを退職してVendiaという会社に移ったからのようです。なお、余談ですがこのVendiaという会社はCEO含めてAWSの元サーバーレス関係者が多い企業でした。

さて、では早速用意していきます。まず以下のようなファンクションをlambda.jsとして用意しました。内容的にはNext.jsのカスタムサーバを用意しつつserverless-expressで起動するような感じでしょうか。

"use strict";

const next = require("next");
const express = require("express");
const dev = "production";
const loaded_next = next({ dev });
const handle = loaded_next.getRequestHandler();
const serverlessExpress = require("@vendia/serverless-express");

function load(event, context) {
  loaded_next.prepare().then(() => {
    const app = express();
    app.get("*", (req, res) => handle(req, res));

    const server = serverlessExpress.createServer(app);
    
    serverlessExpress.proxy(server, event, context);
  });
}

exports.handler = (event, context) => {
  load(event,context)
};

今回もServerless Frameworkでデプロイするので以下の内容でserverless.ymlを用意します。

service: isr-demo
plugins:
  - serverless-offline
  - serverless-apigw-binary

frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1

functions:
  isr-demo:
    handler: lambda.handler
    timeout: 30
    memorySize: 512
    events:
    - http: ANY /
    - http: 'ANY {proxy+}'

custom:
  apigwBinary:
    types: #list of mime-types
      - '*/*'

package:
  individually: true

必要なパッケージを追加します。

$ yarn add serverless-express
$ yarn add express

あとはデプロイするだけです。

$ serverless deploy

この状態で作成されたAPIのエンドポイントにアクセスしてみたのですが、残念ながらうまく動きません。ブラウザから実行してみるとしばらく待たされた上に、個人的にエンジニアとして見せたら恥ずかしいと思っているInternal Server Errorが出ています。

f:id:Keisuke69:20201209144738p:plain

Lambdaファンクションのログには以下のようなメッセージが出力されています。

{
    "errorType": "Runtime.UnhandledPromiseRejection",
    "errorMessage": "Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'",
    "reason": {
        "errorType": "Error",
        "errorMessage": "EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'",
        "code": "EROFS",
        "errno": -30,
        "syscall": "unlink",
        "path": "/var/task/.next/BUILD_ID",
        "stack": [
            "Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'"
        ]
    },
    "promise": {},
    "stack": [
        "Runtime.UnhandledPromiseRejection: Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'",
        "    at process.<anonymous> (/var/runtime/index.js:35:15)",
        "    at process.emit (events.js:314:20)",
        "    at processPromiseRejections (internal/process/promises.js:209:33)",
        "    at processTicksAndRejections (internal/process/task_queues.js:98:32)"
    ]
}

Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'と言っているのですが、これはつまりLambdaファンクションのランタイムが動く環境は読み取りしか許可されていないのでNext.jsが行う処理の中で呼ばれるunlinkというシステムコール(ファイルの名前とかリンクを削除するやつ)がエラーになってるってことですね。実際にはこの処理がどういうときに行われているのかは読み解いていないです。

とりあえず、.next以下を触っているので出力先を変更できないかと思ったのですがnext.config.jsdistDirをLambdaで読み書き可能な一時的な領域である/tmpを指定してみたものの、うまく設定できず単に/var/task/tmpで同じエラーが。

というわけで普通にやると動かなかったです。そもそもISRとかの前にNext.jsのアプリを実行できない。

なんとなくビルド、パッケージング、デプロイの全体で考えないとうまくいかなさそうです。

AWSのサーバーレス #3

普通にやったらダメだったので今度はLambdaで最近サポートされたコンテナイメージでのパッケージングと実行で試してみます。なお、コンテナをサポートと言ってもFargate(とか他のコンテナ基盤向け)のイメージをそのまま動かせるわけではない。逆もまた然り。What is portability?

さて、コンテナイメージを試す理由は用意されたランタイム(つまりLambda側が利用するコンテナ)ではなく自分でビルドしたものを使うことで書き込みエラーを回避できるのではないかという淡い期待からです。

というわけでやっていくのですが、Lambdaでコンテナイメージを使ってデプロイするためにベースイメージをこれまでとは異なり、AWSが提供しているものを利用します。1から自分で用意することも可能ですが結構面倒そうなのでAWSが提供しているイメージを使うほうが楽だと思います。

というわけでDockerfileを以下のように書き換えます。

FROM amazon/aws-lambda-nodejs:12
COPY pages/ ./pages
COPY public/ ./public
COPY styles/ ./styles
COPY lambda.js package.json ./

RUN npm install && npm run build
CMD [ "lambda.handler" ]

LambdaにコンテナイメージをデプロイするにはDockerHubではダメでECRに保存しておく必要があります。というわけでイメージをビルドしつつECRにプッシュします。数字の箇所はAWSアカウントIDなので自分で実行する場合は御自身のものに置き換えてください。

$ docker build --pull --rm -f "Dockerfile" -t isrdemo:latest "."
$ docker tag isrdemo:latest 1234567890.dkr.ecr.ap-northeast-1.amazonaws.com/isrdemo:latest
$ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin 1234567890.dkr.ecr.ap-northeast-1.amazonaws.com
$ docker push 1234567890.dkr.ecr.ap-northeast-1.amazonaws.com/isrdemo:latest

ECRにプッシュしたイメージを指定してLambdaファンクションを作りますが、この部分は普通の手順なので割愛します。

あとはAPI Gatewayの設定をするのですが先程と同様の定義でLambda関数をここで作成したものに変更するだけで大丈夫です。

さて実行しますがうまく行きません。先程同様に恥ずべきInternal Server Errorが出ています。

ログを見てみます。

2020-12-09T05:37:53.445Z   f1332f51-c7ae-4609-81ee-c88b23daacb8  ERROR Unhandled Promise Rejection   
{
    "errorType": "Runtime.UnhandledPromiseRejection",
    "errorMessage": "Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'",
    "reason": {
        "errorType": "Error",
        "errorMessage": "EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'",
        "code": "EROFS",
        "errno": -30,
        "syscall": "unlink",
        "path": "/var/task/.next/BUILD_ID",
        "stack": [
            "Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'"
        ]
    },
    "promise": {},
    "stack": [
        "Runtime.UnhandledPromiseRejection: Error: EROFS: read-only file system, unlink '/var/task/.next/BUILD_ID'",
        "    at process.<anonymous> (/var/runtime/index.js:35:15)",
        "    at process.emit (events.js:314:20)",
        "    at processPromiseRejections (internal/process/promises.js:209:33)",
        "    at processTicksAndRejections (internal/process/task_queues.js:98:32)"
    ]
}

なるほど。一緒ですね。やはりNext.jsの処理においてLambdaの実行環境内のファイルシステムへの書き込みが失敗しています。Lambdaファンクションとしてコンテナイメージを使った場合もこの辺は変わらないようですね。

もうちょっといろいろやってみようと思ったのですが、Next.jsに関する僕の知識不足もあってひとまず時間切れな感じがあります。悔しいけど。

なんというか、サーバーレスってアプリエンジニアが楽することができるプラットフォームだと思うのだけど、『楽するために苦労する』感がすごくなってきたのでここらでやめて冬休みの宿題としたいと思います。

というか、Serverless Next.js Componentへのコントリビューションというほうが現実的かもしれない。

まとめ

はい、というわけで結論からするとVercel以外でもIncremental Static Regenerationは可能です。ただし、満足に動くのはコンテナで動かした場合のみ、という結果でした。

2020/12/10 追記

ところで、今回時間切れで検証できなかったことがある。それはISRの元となった考え方であるCache Controlにおけるstale-while-revalidateについてだ。

これは何かというとブラウザやCDNにおけるキャッシュにおいて、一定期間をキャッシュからレスポンスするが指定時間を経過したら非同期でオリジナルをfetchしてキャッシュをレスポンス/更新するというもの。つまりISRと同じものだ。

ということはこのCache ControlをサポートするCDNであれば実はNext.jsのISRを使わずとも同じことができるのではないかと思ったのだ。これができれば、Next.js側ではISRをせずとも通常のSSRでよくなるので、ISRと同じようなことをするにあたってホスティングする場所の選択肢が広がると思ったんだが。

ちなみにAWSCDNであるCloudFrontは現状ではstale-while-revalidateには対応していない(stale-if-errorには対応している)。世のCDNすべてを確認したわけではないがFastlyとCloudflareは対応している模様。

©Keisuke Nishitani, 2020   プライバシーポリシー