Nuxt SSR を AWS Lambdaにデプロイする方法 (Typescript)

# 背景
既存のSPAのサイトにおいて、画面を動的に組み立てると同時にSEO効果を上げるために、
SSR化にすると決めました。

# やり方
いろいろ情報を調べましたが、aws-serverless-expressの情報がほとんとでした。
ただ、aws-serverless-expressは既に廃止され、その後を受け継いだのは@vendia/serverless-expressです。
今回は@vendia/serverless-expressを使って、nuxtのSSRをaws lambdaにデプロイします。

## プロジェクト全体像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150479/43060cab-6a3d-6618-a5bd-25d98d4f38d3.png)

## Nuxt
Nuxtのサンプルプロジェクトを作成して、ssrをtrue、targetをserverにして、
`nuxt build`を実行すれば.nuxtフォルダにserverサイトとclientサイドのファイルが出力されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150479/dc54d41b-8113-b6bd-2670-c829d615096e.png)

devオプションをfalseにしないと、lambdaからnuxtのコンテンツを返すことができません。
nuxt.config.ts
```typescript
import type { NuxtConfig } from "@nuxt/types";

const config: NuxtConfig = {
  build: {},
  buildModules: [
    "@nuxt/typescript-build",
    [
      "@nuxtjs/dotenv",
      {
        path: "config/",
        filename: `.env.${process.env.ENV || "local"}`,
      },
    ],
  ],
  ssr: true,
  target: "server",
  css: [],
  env: {},
  head: {
    title: "nuxt-community/typescript-template",
    meta: [
      { charset: "utf-8" },
      { name: "viewport", content: "width=device-width, initial-scale=1" },
      {
        hid: "description",
        name: "description",
        content: "A boilerplate to start a Nuxt+TS project quickly",
      },
    ],
    link: [{ rel: "icon", type: "image/x-icon", href: "/assets/favicon.ico" }],
  },
  srcDir: "src",
  loading: { color: "#0c64c1" },
  modules: ["@nuxtjs/axios"],
  plugins: ["~/plugins/truncate"],
  router: {
    base: "/",
  },
  telemetry: false,
  dev: false,
};

export default config;

```

インストールしたnode moduleは以下です。

package.json

```json
{
  "name": "nuxt-community-typescript-template",
  "description": "A boilerplate to start a Nuxt+TS project quickly",
  "version": "2.15.8",
  "private": true,
  "scripts": {
    "dev:local": "nuxt",
    "dev:local-http-proxy": "ENV=local-http-proxy nuxt",
    "dev:trial": "ENV=trial nuxt",
    "build:local": "nuxt build",
    "build:local-http-proxy": "ENV=local-http-proxy nuxt build",
    "build:trial": "ENV=trial nuxt build",
    "start:local": "nuxt build && nuxt start",
    "lint": "eslint --max-warnings 0 --ext .ts,.vue .",
    "http-proxy": "node proxy-server.js",
    "deploy:local-http-proxy": "ENV=local-http-proxy nuxt build && serverless offline --stage local start --nos3sync",
    "deploy:local-http-proxy:debug": "ENV=local-http-proxy nuxt build && SLS_DEBUG=* serverless offline --stage local start --nos3sync",
    "deploy:trial": "serverless deploy --stage trial",
    "deploy:trial:debug": "SLS_DEBUG=* serverless deploy --stage trial"
  },
  "dependencies": {
    "@nuxtjs/axios": "^5.13.6",
    "@nuxtjs/dotenv": "^1.4.1",
    "@vendia/serverless-express": "^4.5.4",
    "aws-lambda": "^1.0.7",
    "express": "^4.17.3",
    "nuxt": "2.15.8",
    "nuxt-property-decorator": "2.9.1"
  },
  "devDependencies": {
    "@nuxt/types": "2.15.8",
    "@nuxt/typescript-build": "2.1.0",
    "@nuxtjs/eslint-config-typescript": "9.0.0",
    "@types/aws-lambda": "^8.10.93",
    "eslint": "8.12.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-prettier": "^4.0.0",
    "http-proxy": "^1.18.1",
    "prettier": "^2.6.2",
    "serverless": "^2.72.2",
    "serverless-apigw-binary": "^0.4.4",
    "serverless-offline": "^8.5.0",
    "serverless-plugin-typescript": "^2.1.2",
    "serverless-s3-sync": "^2.0.0",
    "typescript": "^4.6.3"
  }
}

```

## Serverless Framework
AWS Lambdaを操るなら、Serverless Frameworkを一番おすすめです。
理由としては主に以下です。
・yamlの静的な設定だけでインフラ設定が可能。(CDKなどプログラミング可能なものは複雑になりやすくかつテストしにくいと個人的に感じています)
・いろいろなプラグインがあって、Cloudformationを書くよりずっと簡単

serverless-offlineを使えば、lambdaにデプロイしたnuxtをローカルで動作確認することができます。
サーバーサイトの処理はLambdaに処理させたいですが、静的コンテンツはS3に入れることでLambdaの処理量を抑える。
serverless-s3-syncプラグインの設定でS3に転送するファイルを確認できます。

provisionedConcurrency設定でLambdaの同時実行数を簡単に設定できます。
定期的にLambdaを呼び出す暖気方法よりAWSはprovisionedConcurrencyの設定を推奨しています。

serverless.yml
```yaml
service: nuxt-ssr-frontend

provider:
  name: aws
  runtime: nodejs14.x
  stage: ${opt:stage, self:custom.defaultStage}
  region: ap-northeast-1
  memorySize: 256
  logRetentionInDays: 14
  deploymentBucket:
    name: nuxt-ssr-deployment-bucket-${self:provider.stage}
  lambdaHashingVersion: "20201221"
  versionFunctions: false
  tracing:
    lambda: true
    apiGateway: true
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - xray:PutTraceSegments # X-Rayにセグメントドキュメントをアップロードする権限を付与する
            - xray:PutTelemetryRecords # X-Rayにテレメトリをアップロードする権限を付与する
          Resource: "*"

functions:
  nuxt:
    handler: server/lambda.handler
    provisionedConcurrency: 2
    events:
      - http:
          path: /
          method: ANY
      - http:
          path: /{proxy+}
          method: ANY

package:
  patterns:
    - ".nuxt"
    - "!.nuxt/dist/client"

plugins:
  - serverless-plugin-typescript
  - serverless-apigw-binary
  - serverless-s3-sync
  - serverless-offline

custom:
  defaultStage: local
  commonResourceService: nuxt-ssr-common-resource
  s3Bucket: ${self:service}-s3bucket-${self:provider.stage}
  serverlessPluginTypescript:
    tsConfigFileLocation: "./tsconfig.server.json"
  apigwBinary:
    types:
      - "*/*"
  s3Sync:
    - bucketName: ${self:custom.s3Bucket}
      bucketPrefix: _nuxt/
      localDir: .nuxt/dist/client/
      params: # optional
        - "**/*.*":
            CacheControl: "no-store"
    - bucketName: ${self:custom.s3Bucket}
      bucketPrefix: assets/
      localDir: src/static/assets/
      params: # optional
        - "**/*.*":
            CacheControl: "no-store"

resources:
  Resources:
    ApiGatewayRestApi:
      Type: AWS::ApiGateway::RestApi
      Properties:
        Name: ${self:service}-restapi-${self:provider.stage}
    ApiGatewayResponse:
      Type: 'AWS::ApiGateway::GatewayResponse'
      Properties:
        ResponseParameters:
          gatewayresponse.header.WWW-Authenticate: "'Basic'"
        ResponseType: UNAUTHORIZED
        RestApiId: !Ref ApiGatewayRestApi
        StatusCode: '401'
    ## Specifying the S3 Bucket
    WebAppS3Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.s3Bucket}
        AccessControl: Private
        WebsiteConfiguration:
          IndexDocument: index.html
          ErrorDocument: index.html
    ## Specifying the policies to make sure all files inside the Bucket are avaialble to CloudFront
    WebAppS3BucketPolicy:
      DependsOn:
        - WebAppS3Bucket
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket:
          Ref: WebAppS3Bucket
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal:
                AWS:
                  Fn::Join:
                    - " "
                    - - "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity"
                      - Fn::ImportValue:
                          Fn::Sub: ${self:custom.commonResourceService}-${self:provider.stage}-cloudFront-origin-access-identity
              Action:
                - s3:GetObject
              Resource: arn:aws:s3:::${self:custom.s3Bucket}/*

  Outputs:
    ApiGatewayRestApi:
      Value:
        Ref: ApiGatewayRestApi
      Export:
        Name: ${self:service}-server-${self:provider.stage}
```

LambdaでNuxtを起動するために、下記ラッピングするソースが必要です。
server/lambda.ts
```typescript
import { APIGatewayEvent, Context, Handler } from "aws-lambda";
import serverlessExpress from "@vendia/serverless-express";
import express from "express";
import { Nuxt, Builder } from "nuxt";
import config from "../nuxt.config";
import { customDomainAdaptorMiddleware } from "./middleware";

let cachedServer: Handler;

async function bootstrap() {
  if (!cachedServer) {
    const expressApp = express();
    const nuxtApp = new Nuxt(config);
    // Build only in dev mode (Lambdaにデプロイする場合は使えない)
    if (config.dev) {
      const builder = new Builder(nuxtApp);
      await builder.build();
    } else {
      await nuxtApp.ready();
    }

    // Give nuxt middleware to express
    expressApp.use(customDomainAdaptorMiddleware);
    expressApp.use(nuxtApp.render);

    cachedServer = serverlessExpress({ app: expressApp });
  }

  return cachedServer;
}

export const handler = async (
  event: APIGatewayEvent,
  context: Context,
  callback: any
) => {
  event.path = event.path || "/"; // http://localhost:8081 でアクセスすると event.pathがundefinedになるための対応
  const server = await bootstrap();
  return server(event, context, callback);
};

```
server/middleware.ts
```typescript
import express from "express";

export const customDomainAdaptorMiddleware = (
  req: express.Request,
  _: express.Response,
  next: express.NextFunction
) => {
  const apigatewayHeader = req.headers["x-apigateway-event"];
  if (apigatewayHeader === undefined) {
    next();
    return;
  }

  req.url = req.originalUrl = `/${req.url}`.replace("//", "/");

  next();
};

```
nuxt.d.ts
```typescript
declare module "nuxt" {
  const Nuxt: any;
  const Builder: any;
  export { Nuxt, Builder };
}
```

## Cloudfront
cloudformationのコードを割愛します。
ローカルでcloudfrontの動きを仮想するために、以下シンプルなプロキシサーバーを作りました。
proxy-server.js
```javascript
/* eslint-disable @typescript-eslint/no-var-requires */
var http = require("http"),
  httpProxy = require("http-proxy");

//
// Create a proxy server with custom application logic
//
var proxy = httpProxy.createProxyServer({});

//
// Create your custom server and just call `proxy.web()` to proxy
// a web request to the target passed in the options
// also you can use `proxy.ws()` to proxy a websockets request
//
var server = http.createServer(function (req, res) {
  // You can define here your custom logic to handle the request
  // and then proxy the request.
  proxy.web(req, res, { target: "http://127.0.0.1:3000/local" });
});

console.log("listening on port 8081");
server.listen(8081);

```

キャッシュ戦略についてまだ検討中です。
今考えているのは
・静的コンテンツのcache時間を長くして、更新が発生する時に、キャッシュクリア
・サーバーサイドでは状態を持っていなくて、vue storeに持っているので、基本的に返すHTMLページはみんな同じなので、短時間のキャッシュにしたいです。
　・ただ、一部クエリパラメータによって、返すページが変わるようなものがあるので、パス + パラメータの組み合わせをキャッシュキーにするつもりです。

# 性能
cloudfrontによってキャッシュされた時はもちろん非常に早いです。数十ms。
キャッシュが切れて、Lambdaのウォームスタートでも3~4秒はかかります。もしサーバーサイドでさらにAPI呼び出していれば、
6~7秒かかります。さすがにキャッシュをしなければ我慢できない性能です。
※lambdaのメモリを上げれば、CPUのコア数も増えます。もうちょっとスケールアップできるかもしれません。