shoetBlog
技術や好きなことについて発信しています。
Next.jsのSSR StreamingをLambdaFunctionURLsで検証する
2024/12/19 17:10:54
Next.jsAWS Lambda
Next.jsのSSR StreamingをLambdaFunctionURLsで検証する

SSR Streamingは何が嬉しいか?

従来のSSR(ServerSideRendering)では、ページの構成要素がサーバー側で完成するまでは、クライアントにレスポンスを返すことができないため、ユーザービリティが悪くなってしまいます。

SSR Stramingでは、画面上の部品が完成する度にレスポンスを返してくれるので、初期表示が早くなり(画面が真っ白な状態が短い)、ユーザービリティが向上します。

なぜAWS LambdaFunctionURLsで検証するか

  • 前提として個人開発のため、低いランニングコストでデプロイできるLambdaを使用したいためです。
  • API GatewayではSSR Stramingが対応していないためです。

    Amazon API GatewayとApplication Load Balancerを使用してレスポンスペイロードをストリーミングすることはできません

  • そのためLambdaFunction + アクセスするエンドポイントの提供までを担ってくれるLambdaFunctionURLsを使って検証します。
  • インフラの構築はAWS CDKで実装を行います。

リポジトリ

本記事で紹介するソースコードは、以下のリポジトリにまとめています。
https://github.com/shoet/aws-cdk-sandbox/tree/main/ts-nextjs-lambda-example
各ステップでファイルを抜粋して解説しますので、合わせてご覧ください。

検証概要

1. Next.jsアプリの構築
    1-1. Standaloneモードでビルド
    1-2. Suspense で囲んだRSC(React Server Components)を使う
    1-3. fetch リクエストに cache: "no-store" を指定し、SSRストリーミングが機能するようにする
    1-4. npm run devでローカル動作時にTransfer-Encoding: chunkedが返るかを確認
2. Dockerfileの作成
    2-1. Lambda Web Adapterを取り込み
3. LambdaをDockerイメージでデプロイする
4. LambdaFunctionURLsをデプロイ
5. レスポンスヘッダーTransfer-Encoding: chunkedを確認

以下では、それぞれの段階について詳細を解説していきます。
また、本記事ではAWS CDKの詳細な使用手順については割愛します。

1. Next.jsアプリの構築

1-1. Standaloneモードでビルド

Lambdaへのデプロイ成果物のサイズ削減のため、Next.jsをStandaloneモードでbuildします。
このモードは、output: "standalone"を next.config.js で指定し、npm run buildすると、実行に必要な最低限のファイルが .nextフォルダにまとまります。

application/next.config.js

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  output: "standalone",
};

export default nextConfig;

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/application/next.config.ts

1-2. Suspense で囲んだRSC(React Server Components)を使う

React Server Components(RSC)を使い、SSR Streamingを体験する上で、Suspense を活用します。
Suspense ブロック内のコンポーネントがロードされるまで、読み込み中状態(ローディングUI)を先に返し、各コンポーネントが読み込み完了次第ストリームに乗せて返却していきます。
fallback引数にはローディング中に表示するコンポーネントを渡します。

application/app/page.tsx

export default function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <Suspense fallback={<LoadingComponent />}>
          <AsyncComponent />
        </Suspense>
        <Image
...

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/application/src/app/page.tsx#L11-L13

1-3. fetch リクエストに cache: "no-store" を指定し、SSRストリーミングが機能するようにする

SSR Streamingを発動させるにはfetch関数のcacheオプションでno-storeを指定する必要がありました。
こちらに関しては、ドキュメントに明示されている箇所を見つけることはできませんでしたが、no-storeにしないとキャッシュされてSSR Streamingの発動に至らない、のかと解釈しています。
※no-storeを指定すると、データ取得時にキャッシュが一切利用されず、毎回サーバーから最新データを取得するようになります。 application/app/AsyncComponent/index.tsx

export const AsyncComponent = async () => {
  await fetch("https://api.blog.shoet.team/blogs", { cache: "no-store" });
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return <div>Complete!</div>;
};

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/application/src/app/AsyncComponent/index.tsx#L2

1-4. npm run devでローカル動作時にTransfer-Encoding: chunkedが返るかを確認

ローカルで npm run dev した際に、Network タブや curlコマンドなどでレスポンスヘッダーを確認すると、Transfer-Encoding: chunked が確認できます。

2. Dockerfileの作成

2-1. Lambda Web Adapterを取り込み

LambdaWebAdapter(aws-lambda-web-adapter) Web アプリを AWS Lambda で実行できます。
本リポジトリでは、Dockerfileにてマルチステージビルドを行い、最終ステージで LambdaWebAdapter を Next.js のビルド成果物とともに配置しています。
また、Lambdaではエフェメラルストレージとして唯一の使用可能なストレージが/tmpになるため、Next.jsがcacheディレクトリとして/tmpを使用するようにするための措置としてNext.jsのcacheディレクトリのシンボリックリンク作成と、起動時の処理をrun.shに切り出しています。

application/Dockerfile

FROM node:18-alpine AS base


FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install


FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build


FROM base AS runner
# Install Lambda Web Adapter
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.2 /lambda-adapter /opt/extensions/lambda-adapter
WORKDIR /app
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Next.jsのcacheディレクトリをLambdaで使えるようにする
COPY --from=builder /app/run.sh ./run.sh
RUN ln -s /tmp/cache /app/.next/cache
RUN chmod +x ./run.sh

ENV NODE_ENV=production
ENV PORT=3000

ENTRYPOINT ["sh"]
CMD ["run.sh"]

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/application/Dockerfile

application/run.sh

#!/bin/bash -x

# Next.jsのcacheディレクトリをLambdaで使えるようにするための起動スクリプト

if [ ! -d '/tmp/cache' ]; then
  mkdir -p /tmp/cache
fi

exec node server.js

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/application/run.sh

3. LambdaをDockerイメージでデプロイする

AWS CDKでLambdaをDockerイメージでデプロイします。
ポイントとしては以下になります。

  • response_streamをLambdaの環境変数に指定
  • Lambda関数をDockerイメージでデプロイする(DockerImageFunctionクラス)
  • Dockerイメージをビルドする端末に合わせてarchitectureを指定(macOS Apple SiliconのためARM_64を指定)

infrastracture/lib/lambda-function-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  DockerImageFunction,
  DockerImageFunctionProps,
  DockerImageCode,
} from "aws-cdk-lib/aws-lambda";
import { FunctionUrl } from "aws-cdk-lib/aws-lambda";

export class LambdaFunctionStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const lambdaProps: DockerImageFunctionProps = {
      code: DockerImageCode.fromImageAsset("../application"),
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      timeout: cdk.Duration.seconds(30),
      environment: {
        AWS_LWA_INVOKE_MODE: "response_stream",
      },
    };

    const nextjsFunction = new DockerImageFunction(
      this,
      "NextJsLambdaFunction",
      lambdaProps
    );

...
  }
}

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/infrastracture/lib/lambda-function-stack.ts

4. LambdaFunctionURLsをデプロイ

AWS Lambdaには、関数URL(Function URL) を発行して直接呼び出す機能があります。
API Gatewayを経由せず、Lambda単体でHTTP(S)エンドポイントが提供される仕組みです。
ResponseStreamを有効にするには、API Gatewayでは未対応のため、LambdaFunctionURLsが最適となります。
また、ここでもinvokeModeにresponse_streamを指定します。
infrastracture/lib/lambda-function-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  DockerImageFunction,
  DockerImageFunctionProps,
  DockerImageCode,
} from "aws-cdk-lib/aws-lambda";
import { FunctionUrl } from "aws-cdk-lib/aws-lambda";

export class LambdaFunctionStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    ...

    new FunctionUrl(this, "NextJsLambdaFunctionUrl", {
      function: nextjsFunction,
      authType: cdk.aws_lambda.FunctionUrlAuthType.NONE,
      invokeMode: cdk.aws_lambda.InvokeMode.RESPONSE_STREAM,
    });
  }
}

https://github.com/shoet/aws-cdk-sandbox/blob/main/ts-nextjs-lambda-example/infrastracture/lib/lambda-function-stack.ts

5. レスポンスヘッダーTransfer-Encoding: chunkedを確認

デプロイが完了したら、LambdaFunctionURLsが発行するURLにアクセスし、ブラウザのデベロッパーモードからレスポンスヘッダーを確認します。
Next.jsのSSR StreamingがLambdaFunctionURLs上でも動作していることを確認できました。

ここまで読んでくださった方、ありがとうございます!
LambdaFunctionURLsとの組み合わせで、“サーバーレス”でもパフォーマンスと利便性を両立できることを、ぜひ体感してみてください!

コメント
profile
匿名ユーザー(ID: )
Markdown
Preview
エンジニア。
エンジニアリングで価値提供できるよう、
日々自己研磨。
AWSAWS LambdaChormiumCloudFrontDockerECSGitHubGoGraphQLLambdaLocalStackNext.jsOpenAIPlanetScaleReactReduxServerlessFrameworkTypeScriptUpstashViteWebSocket