🕌

Nuxt4×AWSでSSRしたい

に公開

はじめに

はじめまして。ムー(Moooh)です。Zenn初投稿になります。
現職では Nuxt.js を用いた SPA(Single Page Application) の開発を中心に担当しています。
現在開発しているプロジェクトでは、管理者向け画面が大半を占めているため、SPA構成でも大きな問題はないと判断してきました。
しかし、今後は一般ユーザ向けに公開される画面が増える可能性があり、それに伴ってSPA特有の初回の待ちに対して改善要望が挙がっている状況です。
一般ユーザ向けに公開されるとなると何らかの改善が必須になると考えています。
そこで本記事では、SSR(Server-Side Rendering)とSPAを組み合わせたハイブリット構成への切り替え案の調査として、実際にSSRを試してみたいと思います。

構成

AWS環境でSSRを行う場合、主に以下の構成が考えられます。

  • EC2 / ECS → 常時起動が必要、運用負荷が比較的高い
  • ECS + ALB → 企業案件(中~大規模)に多いが、運用負荷や複雑さは増す
  • Lambda + CloudFront → サーバーレス構成のため運用負荷が低く、小規模案件やPoC程度なら十分
  • Amplify → 最小の設定でデプロイ可能だが、カスタマイズ性が低く理解する目的には向かない

今回は調査および理解を深めることを目的としているため、
「Lambda + CloudFront」の構成を採用することにしました。

Nuxt4

今回デプロイするアプリケーションは、「Welcome to Nuxt!」の画面を表示するだけのシンプルな構成とします。
そのため、必要最低限の設定のみ行います。
SSRをLambda上で動作させるため、サーバエンジンであるNitroのプリセットにはaws-lambdaを設定します。
また、今回は静的アセットはS3に配置する構成とするため、Lambda側で静的ファイルを配信しないように serveStatic はfalseに設定します。

nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'aws-lambda',
    serveStatic: false,
  }
})

ビルドすると、主に以下の構成でファイルが生成されます。

  • public/ 配下
    JavaScript・画像などの 静的アセット
  • server/ 配下
    サーバーサイド処理に必要なファイル

本構成では、
public/ 配下の静的アセットはS3にデプロイ し、
server/ 配下のサーバーサイド処理に必要なファイルはLambdaに組み込む形とします。

.output
├── public
│   ├── _nuxt
│   ├── favicon.ico
│   └── robots.txt
├── server
│   ├── chunks
│   ├── node_modules
│   ├── index.mjs
│   ├── index.mjs.map
│   └── package.json
└── nitro.json

CDK

S3

まず、静的アセットを保存するためのS3バケットを作成します。
本構成ではCloudFront経由でのみアクセスを許可するため、
パブリックアクセスを完全にブロックします。
また、通常S3バケットは空でないと削除できませんが、
検証後に手軽に削除できるようにするためバケット内オブジェクト自動削除とバケット削除オプションを有効にしています。
(本番環境用の環境構築では不要なオプションです)

import * as s3 from 'aws-cdk-lib/aws-s3';
import { RemovalPolicy } from 'aws-cdk-lib';

const assetsBucket = new s3.Bucket(this, 'AssetsBucket', {
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

Lambda

SSRの肝となるHTMLファイルの生成はLambdaで行い、
CloudFrontからはLambda関数URLを通して実行される構成にします。
HTMLファイルの生成に必要なサーバーサイド処理は.output/server内のindex.mjsに出力されているため、そのままLambdaのhandlerとして指定します。
Node.jsのバージョンに関してはNuxt.jsの推奨バージョン以上であれば問題ないと思います。

Node.js - 20.x or newer (but we recommend the active LTS release)

ローカル環境ではv24を使用しているため、Lambdaのラインタイムも24に揃えています。

メモリサイズは小さすぎる場合、
HTMLファイルの生成に時間がかかりパフォーマンスがSPAと同等、あるいはそれ以下になる可能性があります。
そのため、今回は一旦1024Mを設定し、パフォーマンスを見ながら調整する方針とします。

CloudFrontからのリクエストのみ受け付けたいため認証タイプはAWS_IAMを設定します。

import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Duration } from 'aws-cdk-lib';

const ssrFunction = new lambda.Function(this, 'NuxtSsrFunction', {
  runtime: lambda.Runtime.NODEJS_24_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset(path.join(process.cwd(), '.output/server')),
  memorySize: 1024,
  timeout: Duration.seconds(15),
});
const ssrFunctionUrl = ssrFunction.addFunctionUrl({
  authType: lambda.FunctionUrlAuthType.AWS_IAM,
});

CloudFront

本構成ではCloudFrontが「入口」および「振り分け」の役割を担います。
まず、今回のケースではCloudFrontがアクセスするオリジン(リクエストの送信先)は、以下の2つに分かれます。

  • SSR用オリジン(Lambda Function URL)
  • 静的アセット用オリジン(S3バケット)

以前L2 ConstructではLambda関数URLに対するOAC(※)のサポートがありませんでしたが、
現在はS3・Lambda関数URLのいずれもOACに対応しています。
※OAC (Origin Access Control):CloudFrontからオリジンにアクセスすることを許可し、直接アクセスを遮断する仕組み

import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';

const ssrFunctionOrigin = origins.FunctionUrlOrigin.withOriginAccessControl(ssrFunctionUrl);
const assetsBucketOrigin = origins.S3BucketOrigin.withOriginAccessControl(assetsBucket);

次に、BehaviorおよびCacheの設定を行い、Distributionを作成します。
今回は独自ドメインを使用するため、事前にドメインおよびACM証明書を用意しています。

静的アセットへのアクセスルールはadditionalBehaviorsに定義します。
パフォーマンス最適化のためにキャッシュは有効にしています。

静的アセット以外へのリクエストはdefaultBehaviorに定義しているルールが適用されます。
将来的に、ユーザや処理によって画面表示が変わるケースを想定し、キャッシュは無効にしています。(現時点ではいかなる場合もWelcome画面が表示されるため意味はありませんが...)

Lambda関数URLをオリジンとして使用する場合、
originRequestPolicyにはOriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADERにする必要があるようです。
これはViewer(クライアント)から渡される情報のうちHostヘッダーを除外する設定です。
ViewerのHostとOriginのHostが不一致となるケースを避けるため、
CloudFrontが適切なHostヘッダーを自動的にセットしてくれます。

defaultBehaviorとadditionalBehaviorsの両方で、
allowedMethodsにはAllowedMethods.ALLOW_GET_HEAD_OPTIONSを、
viewerProtocolPolicyにはViewerProtocolPolicy.REDIRECT_TO_HTTPSを設定しました。
これらの設定は不要なHTTPメソッドを受け付けないため(閲覧専用なサイトのため)と、
APIほどの厳密な制御は不要だがHTTPアクセスを許容しない理由によるものです。

import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';

const nuxtSsrDistribution = new cloudfront.Distribution(this, 'NuxtSsrDistribution', {
      domainNames: ['sample.com'],
      certificate: certificate,
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
        cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
        origin: ssrFunctionOrigin,
        originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      additionalBehaviors: {
        '/_nuxt/*': {
          allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
          origin: assetsBucketOrigin,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        '/favicon.ico': {
          allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
          origin: assetsBucketOrigin,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        '/robots.txt': {
          allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
          origin: assetsBucketOrigin,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        }
      },
    })

Lambda関数URLの認証タイプをAWS_IAMに設定しているため、
許可されていないリクエストは403エラーとなり実行できません。
そのため、先ほど作成したDistributionに限定して以下のアクションを許可します。

  • lambda:InvokeFunctionUrl
  • lambda:InvokeFunction

これによりLambda関数URLへの直接アクセスは拒否し、
Distributionを経由したリクエストのみ実行可能という状態を実現できます。

import * as iam from 'aws-cdk-lib/aws-iam';

ssrFunction.addPermission('AllowCloudFrontInvokeFunctionUrl', {
  principal: new iam.ServicePrincipal('cloudfront.amazonaws.com'),
  action: 'lambda:InvokeFunctionUrl',
  sourceArn: nuxtSsrDistribution.distributionArn,
})

ssrFunction.addPermission('AllowCloudFrontInvokeFunction', {
  principal: new iam.ServicePrincipal('cloudfront.amazonaws.com'),
  action: 'lambda:InvokeFunction',
  sourceArn: nuxtSsrDistribution.distributionArn,
})

最後に.output/public配下のフォルダおよびファイルを、静的アセット用のS3バケットへデプロイします。デプロイのたびに最新のアセットファイルが配信されるようにキャッシュをクリアするように設定します。今回はすべてのパスを対象とするため、distributionPathsには/*を指定します。

new s3deploy.BucketDeployment(this, 'DeployNuxtAssets', {
  destinationBucket: assetsBucket,
  sources: [s3deploy.Source.asset(path.join(process.cwd(), '.output/public'))],
  distribution: nuxtSsrDistribution,
  distributionPaths: ['/*'],
});

デプロイ

CDKをデプロイ後、
サイトにアクセスすると「Welcome to Nuxt!」画面が正しく表示されました。

また、ブラウザの開発者モードで確認すると、
HTMLファイル内にデータが埋め込まれていることが分かります。
このことから、クライアントサイドでの描画されるのではなく、
サーバーサイドでHTMLが生成されている(SSRが行われている)ことを確認できました。

<!DOCTYPE html>
<html>
    <head>
        <title>Welcome to Nuxt!</title>
         <!-- 省略 -->
    </head>
    <body>
        <div id="__nuxt">
            <div>
                <div></div>
                <div class="antialiased bg-white dark:bg-[#020420] dark:text-white flex flex-col items-center justify-center min-h-screen place-content-center sm:text-base text-[#020420] text-sm" data-v-f6817b3d>
                    <div class="flex flex-col mt-6 sm:mt-0" data-v-f6817b3d>
                        <h1 class="flex flex-col gap-y-4 items-center justify-center" data-v-f6817b3d>
                            <a href="https://nuxt.com?utm_source=nuxt-welcome" target="_blank" class="gap-4 inline-flex items-end" data-v-f6817b3d>
                            <!-- 省略 -->
                    </div>
                </div>
            </div>
        </div>
        <div id="teleports"></div>
        <!-- 省略 -->
        </body>
    </html>

参考までに、同じアプリケーションをSPAとしてビルドし、S3 + CloudFront構成でデプロイして比較してみました。
その結果、初回レスポンスとして返ってくるHTMLは<div id="__nuxt"></div>のみが含まれており、SSR時のようなデータの埋め込みは確認できませんでした。

<html>
    <head>
       <!-- 省略 -->
    </head>
    <body>
        <div id="__nuxt"></div>
        <div id="teleports"></div>
       <!-- 省略 -->
    </body>
</html>

また、ブラウザの開発者ツールでResponse Headersを確認すると、
X-Cacheヘッダーの値からCloudFrontのキャッシュ状況を確認できます。
今回の構成では、HTMLファイルはMiss from cloudfront(キャッシュされていない)、
静的アセットであるjsやfaviconについてはHit From cloudfront(キャッシュが有効)となっていることが確認できました。
これによりCloudFrontのキャッシュ設定も意図通りに正しく反映されているといえます。

まとめ

今回は、Nuxt.jsを用いたSSRをAWS(Lambda + CloudFront)上で動かすところまでを確認しました。
正直なところ、「Welcome」画面を表示するだけの最小構成では、
SPAとSSRの体感差はほとんどありません...笑
ただし、実際に手を動かしてデプロイまで確認できたことは大きな収穫でした。
今後はハイブリッド化、パフォーマンス計測やコスト面の検証など調査・検証すべき項目はまだ多く残っていますが、少しずつクリアしていきたいと思います。

参考にさせていただいた記事

https://zenn.dev/winteryukky/articles/1c8b48155d5f29
https://qiita.com/Yohei_Suda/items/df628ea5244bfbe7378c

Discussion