🕊️

たったこれだけで!?Nuxt3をLambda@Edgeへデプロイ

2022/05/16に公開

NuxtをLambda@Edgeにデプロイするのはかなり手間な印象がありましたが、Nuxt3からはとても簡単です。サーバーレスなSSR環境として今後流行るのではないでしょうか。

はじめに

タイトルにあるNuxt3とはVue.jsの有名なフレームワークのNuxt.jsのバージョン3のことを指します。このNuxt3にはNitro[1]と呼ばれるサーバーエンジンが搭載されており、下図にイメージを示すようにNitroを利用することでNuxt3はVercel、Netlify、AWS Lambda等様々な環境用にビルドしてシームレスにデプロイすることができます。

以前のバージョンであるNuxt2でもserverless-expressを使用する事でLambdaを利用したサーバーレス構成を取ることができました。

しかし、このserverless-expressを使用した構成は自身でカスタマイズする部分もあり、少々複雑な構成になるため運用コストもかかってしまうデメリットがありました。

そこで登場するのがNitroです。
Nitroを利用すると下記のような構成がほとんどカスタマイズすることなく簡単に実施することができます。

今回はこのNitroの仕組みを利用してNuxt3をLambda@Edgeで配信する手順についてご紹介していきます。

Nitroについて

https://nitro.unjs.io

Nitroについてもう少しだけ説明しておきましょう。Nitroを利用すると任意環境向けにビルドできるように記述しましたが、Nitroは単に任意環境向けにビルドするためだけのツールではありません。NitroはNuxt3の中核となるサーバーエンジンで、Nuxt3が提供する次の機能はNitroの仕組みによって実現されています。

  • 任意環境向けのビルド
  • 自動インポート
  • ルート処理
  • ストレージレイヤー
  • キャッシュ
  • アセットハンドリング
  • TypeScriptサポート

デプロイ先には事前定義されたプリセットを指定することで簡単に任意環境を選択することができます。現時点(2022/5/16)で次のプリセットが存在します。

  • aws-lambda
  • azure
  • azure-functions
  • cloudflare
  • digital-ocean
  • firebase
  • heroku
  • layer0
  • netlify
  • netlify-edge
  • netlify-builder
  • render-com
  • stormkit
  • vercel

ここで注意してほしいのですが、プリセットのリストにaws-lambdaというプリセットが存在しますが、これはAPI Gateway向けのプリセットとなっておりLambda@Edgeでは利用することができません。

https://nitro.unjs.io/deploy/providers/aws.html

それを解決するべく現在私がAWS Lambda@Edge向けのプリセットaws-lambda-edgeに関するPRを作成しています。
https://github.com/unjs/nitro/pull/240

ただし、この記事を書いている段階ではマージされていないため、この記事ではaws-lambda-edgeプリセットを利用しない手順をご紹介します。

手順

次の環境で実施しました。

  • Node.js v18.0.0
  • Nuxt.js v3.0.0-rc.3
  • AWS CDK v2.24.1
devcontainer定義を確認する
.devcontainer/devcontainer.json
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/javascript-node
{
	"name": "Node.js",
	"build": {
		"dockerfile": "Dockerfile",
		// Update 'VARIANT' to pick a Node version: 18, 16, 14.
		// Append -bullseye or -buster to pin to an OS version.
		// Use -bullseye variants on local arm64/Apple Silicon.
		"args": { "VARIANT": "18-bullseye" }
	},

	// Set *default* container specific settings.json values on container create.
	"settings": {
		"editor.defaultFormatter": "esbenp.prettier-vscode",
		"editor.formatOnSave": true,
		"editor.codeActionsOnSave": {
			"source.fixAll": true
		}
	},

	// Add the IDs of extensions you want installed when the container is created.
	"extensions": [
		"dbaeumer.vscode-eslint",
		"esbenp.prettier-vscode",
		"Vue.volar"
	],

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],

	// Use 'postCreateCommand' to run commands after the container is created.
	// "postCreateCommand": "yarn install",

	// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
	"remoteUser": "node",
	"features": {
		"aws-cli": "latest"
	}
}
.devcontainer/Dockerfile
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/javascript-node/.devcontainer/base.Dockerfile

# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
ARG VARIANT="18-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}

# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
#     && apt-get -y install --no-install-recommends <your-package-list-here>

# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"

# [Optional] Uncomment if you want to install more global node modules
RUN su node -c "npm install -g aws-cdk"

Nuxt3アプリケーションを作成

以下の手順に沿って実施します。
https://v3.nuxtjs.org/getting-started/quick-start

次のコマンドを実行します。

terminal
npx nuxi init nuxt-app

このように最低限の構成がすぐに出来上がります。

まずは実行してみましょう

terminal
yarn install # 初回のみ実行します
yarn dev

http://localhost:3000/ にアクセスすることで次のような画面が確認できるはずです。

ここからSSRを確認できるよう簡単なアプリケーションにしていきます。

CSSフレームワーク導入

少しだけ見栄えを整えます。
こうした検証タイミングで簡単に扱えてオススメなのがpicocssです。
軽量かつほとんどクラスを書かずにスタイリングでき、HTMLを見るだけで直感的に分かるためオススメです。
https://picocss.com/

picocssをインストールしてプラグインフォルダを作成します。

terminal
yarn add -D @picocss/pico
mkdir plugins

picocssを読み込むプラグインを作成します。

plugins/picocss.ts
import "@picocss/pico/css/pico.min.css";
export default () => {};

アプリケーションページ作成

SSRを確認するためにトップページと動的ページを作成します。

まず、最初から用意されているapp.vueを削除してpagesディレクトリを作成します。

terminal
rm app.vue
mkdir pages

静的なTOPページを作成します。

pages/index.vue
<template>
  <h1>This is example</h1>
  <div>Please access https://your-domain.com/{anime-title}</div>
</template>

動的ページを作成します。
今回はURLパスパラメータに応じてアニメを検索できるアプリケーションにしてみます。
https://github.com/rocktimsaikia/anime-chan

pages/[[title]].vue
<script setup lang="ts">
interface Anime {
  anime: string;
  character: string;
  quote: string;
}
const title = ref(useRoute().params.title);
const fetchAnimes = async () =>
  $fetch<Anime[]>("https://animechan.vercel.app/api/quotes/anime", {
    params: {
      title: title.value,
    },
  });
const { data: animes } = useAsyncData("animes", () => fetchAnimes());
const search = async () => (animes.value = await fetchAnimes());
</script>

<template>
  <main class="container">
    <h1>search anime</h1>
    <div class="grid">
      <input v-model="title" />
      <button @click="search">search</button>
    </div>
    <table>
      <tr v-for="anime in animes">
        <td>{{ anime.anime }}</td>
        <td>{{ anime.character }}</td>
        <td>{{ anime.quote }}</td>
      </tr>
    </table>
  </main>
</template>

実行してみましょう。

terminal
yarn dev

NARUTOを検索してみます。
http://localhost:3000/naruto へアクセスすると次のような画面になるはずです。


これだけでこのアプリが出来上がるのですから、Nuxt3もPicocssもパワフルなのが良く分かります。

以上でNuxtアプリケーションの作成は完了です。
ここからはLambda@Edgeへのデプロイを設定していきます。

Lambda用にNuxtを設定

nuxt.config.tsのnitro設定を以下のように変更します。

nuxt.config.ts
 import { defineNuxtConfig } from "nuxt";

 // https://v3.nuxtjs.org/api/configuration/nuxt.config
 export default defineNuxtConfig({
+  nitro: {
+    preset: "aws-lambda",
+  },
 });

以上です。
ただし、これではLambda@Edgeに対応していません。
そのため後で薄いラッパーを作成してLambda@Edgeに対応させます。

AWS CDKアプリケーションを作成

AWSリソースを定義するためにAWS CDKアプリケーションを初期化します。

terminal
mkdir cdk
cd cdk
cdk init app --language=typescript

前項で薄いラッパーを作成すると説明しました。
これはCloudFrontイベントをaws-lambdaプレセットのインターフェースに合うように変換するラッパーです。今後不要になる予定ですが今は作成して保存してください。

cdk/wrapper.mjs
import { URLSearchParams } from "url";
import * as nitro from "./index.mjs";

export const handler = async (event) => {
  const request = event.Records[0].cf.request;
  const queryStringParameters = Object.fromEntries(
    new URLSearchParams(request.querystring).entries()
  );

  const response = await nitro.handler({
    path: request.uri,
    queryStringParameters,
    httpMethod: request.method,
    headers: normalizeIncomingHeaders(request.headers),
    body: request.body,
  });
  return {
    status: response.statusCode,
    headers: normalizeOutgoingHeaders(response.headers),
    body: response.body,
  };
};

function normalizeIncomingHeaders(headers) {
  return Object.fromEntries(
    Object.entries(headers).map(([key, keyValues]) => [
      key,
      keyValues.map((kv) => kv.value).join(","),
    ])
  );
}
function normalizeOutgoingHeaders(headers) {
  return Object.fromEntries(
    Object.entries(headers).map(([key, values]) => [
      key,
      values.split(",").map((value) => ({ value })),
    ])
  );
}

次にスタックを定義します。
個々の宣言についてはインラインコメントを参照ください。

cdk/lib/cdk-stack.ts
import { spawnSync } from "child_process";
import { copyFileSync } from "fs";
import { CfnOutput, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3deployment from "aws-cdk-lib/aws-s3-deployment";
import { Construct } from "constructs";

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

    // Nuxtプロジェクトをビルド
    spawnSync("yarn", ["build"], {
      stdio: "inherit",
      cwd: "..",
    });
    // 先ほど作成したwrapperをNuxtのビルド後のserverフォルダへコピー
    copyFileSync("./wrapper.mjs", "../.output/server/wrapper.mjs");

    // wrapperをハンドラに設定したLambda@Edge関数を作成
    const edgeFunction = new cloudfront.experimental.EdgeFunction(
      this,
      "EdgeFunction",
      {
        runtime: lambda.Runtime.NODEJS_16_X,
        handler: "wrapper.handler",
        code: lambda.Code.fromAsset("../.output/server"),
      }
    );

    // S3とCloudFront Distributionを作成
    const bucket = new s3.Bucket(this, "Bucket", {
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    const s3Origin = new origins.S3Origin(bucket);
    const distribution = new cloudfront.Distribution(this, "Distribution", {
      // デフォルトのパスはLambda@Edgeで処理
      defaultBehavior: {
        origin: s3Origin,
        edgeLambdas: [
          {
            functionVersion: edgeFunction.currentVersion,
            eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
          },
        ],
      },
      // _nuxt/以下のパスにアクセスした場合はS3オリジンへアクセス
      additionalBehaviors: {
        "_nuxt/*": {
          origin: s3Origin,
        },
	// 必要に応じてここにオリジンを追加 (e.g images/*)
      },
    });

    // S3へ静的コンテンツをデプロイ
    new s3deployment.BucketDeployment(this, "Deployment", {
      sources: [s3deployment.Source.asset("../.output/public")],
      destinationBucket: bucket,
      distribution,
    });

    // アクセスできるCloudFrontのURLを出力
    new CfnOutput(this, "URL", {
      value: `https://${distribution.distributionDomainName}`,
    });
  }
}

AWS CDKのEdgeFunctionコンストラクトを使用すると自動的にLambda関数をus-east-1リージョンへデプロイしてくれます。その代わり、オリジナルのスタック自体にはデプロイ先を明示する必要があります。

cdk/bin/cdk.ts
 #!/usr/bin/env node
 import "source-map-support/register";
 import * as cdk from "aws-cdk-lib";
 import { CdkStack } from "../lib/cdk-stack";

 const app = new cdk.App();
 new CdkStack(app, "CdkStack", {
+   env: {
+     region: "ap-northeast-1",
+   },
  /* If you don't specify 'env', this stack will be environment-agnostic.
   * Account/Region-dependent features and context lookups will not work,
   * but a single synthesized template can be deployed anywhere. */

  /* Uncomment the next line to specialize this stack for the AWS Account
   * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

  /* Uncomment the next line if you know exactly what Account and Region you
   * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },

  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
 });

デプロイ

CDKのコマンドでデプロイします。

terminal
yarn cdk deploy --all

CloudFrontのデプロイは時間がかかります。
初回は15分程度かかるかもしれません。

デプロイ完了後 https://xxxxxx.cloudfront.net/naruto へアクセスしてみてください。
ページコンテンツとjsコンテンツが問題なくダウンロードできています。

また、ページコンテンツもSSRされています。

最後に

以上となります。
今後なくなるであろうwrapperを除くと100行にも満たないコードの変更でLambda@Edgeにデプロイできました。
Lambda@Edgeのプリセットが用意されればwrapperを作成する必要もなくなり、よりスマートなデプロイ方法にできるはずです。
Lambda@EdgeにデプロイすることでエッジロケーションでのSSRによって低レイテンシーなアクセスができてサイトのパフォーマンスも良くなるのではないでしょうか。
Nuxt3最高です🥰

今回紹介したソースコードはこちらに公開しています。
https://github.com/WinterYukky/nuxt3-lambda-edge-example

脚注
  1. ナイトロと読みます、ニトロではありません ↩︎

Discussion