SSRをやめる。OGP対応はLambda@Edgeでダイナミックレンダリングする。

2018.11.20

## 求めていること

SSRは極力やりたくない。

SSRのメリットは初回読み込み時のブラウザレンダリング速度向上とSEO・OGP対応と言われる。
レンダリング速度をシビアに要求されてない。SEOもgooglebotが対応してればいいや〜。という場合、結局の所OGP対応（facebook, twitter）の為だけにSSRを選ぶことになる。

SPAの静的配信って大きなメリットなので、それを捨ててまでSSR…なんとかOGP対応だけしてSSRを避けられないものか。

対応するページが少なければPrerender-SPA-Plugin等を使ってビルド時に全ページ出力するのが早いが、ここではCGM/UGCのようにページ数増加が激しいサイトを想定する。

## 結論

[ダイナミックレンダリング](https://developers.google.com/search/docs/guides/dynamic-rendering?hl=ja)する。Googleが公式に認める方法なのでクローキングに当たる心配も無さそう。

[prerender.io](https://prerender.io/)のような外部サービスを使う方法もあるが、今回はLambda@Edge（CloudFrontに紐づくAWS Lambda）で実装する。

この例ではvueプロジェクトを作りS3にアップし公開しているが、実際はCloudFrontからhttp(s)アクセス出来る公開サーバであれば、Firebase HostingだろうとNetlifyだろうと対応可能。


## 前提

* [aws-cli](https://docs.aws.amazon.com/ja_jp/streams/latest/dev/kinesis-tutorial-cli-installation.html)インストール済み
    * `aws configure`等で`~/.aws/{config,credentials}`にprofile=defaultで設定したIAMユーザを使用
    * 当該IAMユーザには検証用の特権ポリシー`AdministartorAccess`を付与してある
* [npm](https://www.google.co.jp/search?q=npm)インストール済み
* [serverless](https://serverless.com/)インストール済み
* [vue-cli](https://cli.vuejs.org/guide/)インストール済み


## 実装検証フロー

1. SPAサービスをS3に配置して静的ホスティングする
2. CloudFrontをS3の前に置く
3. serverlessを利用してLambda＠Edgeを２種作成
4. LambdaとCloudFrontを繋げる
5. ブラウザからの確認


### 1. SPAサービスをS3に配置して静的ホスティングする

* この例ではvueプロジェクトを新規作成して、S3にアップする。
* CloudFrontとS3をつなぐのに静的ホスティングは本来必要ないが、Lambda@Edgeの実装を簡易にするために利用する。

1. vue-cliでプロジェクトを作成（設定はvue-routerをいれてればあとは何でも良い）

    ```sh
    $ npm i -g vue-cli
    $ vue create test-project
    
    Vue CLI v3.1.3
    ? Please pick a preset: Manually select features
    ? Check the features needed for your project: Babel, TS, PWA, Router, Vuex, CSS Pre-processors, Linter
    ? Use class-style component syntax? Yes
    ? Use Babel alongside TypeScript for auto-detected polyfills? Yes
    ? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
    ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS
    ? Pick a linter / formatter config: TSLint
    ? Pick additional lint features: Lint on save
    ? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
    ? Save this as a preset for future projects? Yes
    ? Save preset as:
    
    $ cd test-project
    $ npm install
    ```

1. ローカルテスト

    ```sh
    $ npm run serve
    ```
   
   * ブラウザで`http://localhost:8080`へアクセスできることを確認
   * また`http://localhost:8080/about`ページがあることを確認（無ければ適当に作成）

1. ビルド

    ```sh
    $ npm run build
    ```

1. AWSマネジメントコンソールで、S3の管理画面から新規バケット。パブリックアクセスを有効にする（４つのチェックを外す）。リージョンはどこでも。この例のバケット名は`test-project`とする
    <img width="340" alt="スクリーンショット 2018-11-16 17.18.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/195818/aa110944-a761-f278-db7e-a91a76284972.png">
1. 静的ホスティングを有効にする（`バケットTOP > プロパティ > Static Website Hosting`）
 <img width="240" alt="スクリーンショット 2018-11-16 17.18.00.png" src="https://qiita-image-store.s3.amazonaws.com/0/195818/955122d4-d0c7-b01b-325c-714d67e40359.png">
   - ~~エラードキュメントはCloudFrontで設定するので空でおｋ~~
   - **エラードキュメントはindex.htmlを設定する**

1. ビルドしたvueプロジェクトをup（all usersへread権限をつける）

    ```sh
$ aws s3 cp ./dist s3://test-project --profile default --recursive --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers
```

1. 完了、ブラウザアクセスで確認
   * 静的ホスティング設定で表示されるエンドポイントにアクセスする。
   * `/`にはダイレクトアクセス出来るが`/about`にはダイレクトアクセスできるが、HTTP status codeが403になっているはず。



### 2. CloudFrontをS3の前に置く

1. AWSコンソールのCloudFront管理画面から、新規ディストリ作成 `Create Distribution`ボタン
2. 新規作成時の設定項目多いが、基本デフォルトのままなので、変えるところのみを記述する。
    * **Select a delivery method for your content.** ... `Web`
    * **Origin Domain Name** ... クリックするとS3のバケットが表示されるが、**それは使わない**。S3の静的ホスティング設定で得たエンドポイントをコピペする。
    * **Viewer Protocol Policy** ... 本記事の趣旨とは関係ないけど、`Redirect HTTP to HTTPS`または`HTTPS Only`に変えてる
    * **Cache Based on Selected Request Headers** ... `Whitelist`
        * **Whitelist Headers** ... inputに**`x-need-dynamic-render`**と入力し`Add Custom >>`ボタンを押下 :star:本記事のキモ1
    * **Object Caching** ... `Customize` テスト時にキャッシュされないよう各TTLを0にしておく
        * **Minimum/Maximum/Default TTL** ... `0`

1. ディストリ作成後、調整・追加する設定
    * Error Pages設定
        1. `ディストリ一覧から作成したディストリのID > Error Pagesタブ`
        1. `Create Custom Error Response`ボタン
        1. <img width="240" alt="スクリーンショット 2018-11-20 12.35.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/195818/c4cf8852-16a7-e072-a6ac-a0f2f0bdc59e.png">

1. 完了、ブラウザで確認 
    * ディストリ一覧や詳細の`Domain Name`の項目にhttpsをつけてアクセスする
    * `https://hogehoge.cloudfront.net/`というURLになるはず。
    * S3へのhttpアクセスと違い、`/`, `/about`ともにダイレクトアクセス出来る
    * CloudFrontのデプロイまで多少時間かかる。

ここまでが通常の静的配信セッティングとなる。

### 3. serverlessを利用してLambda＠Edgeを２種作成

Lambda@Edgeを使って、`クローラやbotによる`、`htmlページ`へのアクセスの場合にダイナミックレンダリングをする。

Lambda@Edgeは、CloudFrontのDistribution単位ではなく、`Behavior`単位での設定になる。Behaviorは、指定されたURLパスにマッチしたときのキャッシュの振る舞い。
Lambda@Edgeは次の2種作り、次のフローで処理する。同一Behaviorに設定する必要がある。（後述）

1. [**ビューワーリクエスト**](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html)をトリガーに、ダイナミックレンダリング要否を判定する。
    * このイベントは当てはまる`Behavior`へのリクエストが発生した時まずはじめにトリガーされる
    * ダイナミックレンダリングが必要な場合は、httpヘッダ**`x-need-dynamic-render`**が`"true"`に設定される。**このヘッダの状態によりキャッシュが分離される**（Cache Based on Selected Request Headersの指定により）  :star:本記事のキモ2
2. **オリジンリクエスト**をトリガーに、ダイナミックレンダリングしてレスポンスを生成する
    * このイベントは当てはまる`Behavior`のキャッシュが無くてオリジンからデータを取得する直前にトリガーされる
    * 上記ヘッダがついてる場合のみ処理する

#### 1. ビューワーリクエスト ~ ダイナミックレンダリング要否を判定

* Lambda用のディレクトリを掘って、serverlessコマンドでLambda雛形生成

    ```sh
    $ pwd
    /path/to/test-project
    $ mkdir lambda-edge-viewer-request-fixer
    $ cd lambda-edge-viewer-request-fixer
    $ sls create -t aws-nodejs --name viewer-request-fixer
    ```

* serverless設定を修正

 <details><summary>serverless.yml (開閉)</summary><div>

```yml:serverless.yml
service: viewer-request-fixer # NOTE: update this with your service name

provider:
  name: aws
  runtime: nodejs8.10
  profile: default # AWS profileここで指定しちゃってるけど、ほんとはコマンドから指定した方が良い
  region: us-east-1
  role: LambdaEdgeRole

functions:
  vrFixer:
    handler: handler.vrFixer
    memorySize: 128
    timeout: 1

resources:
  Resources:
    LambdaEdgeRole:
      Type: AWS::IAM::Role
      Properties:
        AssumeRolePolicyDocument:
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
                  - edgelambda.amazonaws.com
              Action:
                - sts:AssumeRole
        Policies:
          - PolicyName: ${opt:stage}-serverless-lambdaedge
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                    - logs:DescribeLogStreams
                  Resource: "arn:aws:logs:*:*:*"
                - Effect: "Allow"
                  Action:
                    - "s3:PutObject"
                  Resource:
                    Fn::Join:
                      - ""
                      - - "arn:aws:s3:::"
                        - "Ref": "ServerlessDeploymentBucket"
```
</div></details>

* Lambdaの処理を作成する

 <details><summary>handler.js (開閉)</summary><div>

```js:handler.js
"use strict";
/**
 * Lambda@Edge: Viewer Request trigger
 *
 * 指定Behavior全てのリクエストで実行
 * Dynamic Renderingをする必要のあるリクエストに専用ヘッダをつける
 *
 * bot and suffix samples
 * https://gist.github.com/thoop/8165802
 */

const crawlers = [
  // 検証する場合、DNS ルックアップを行う https://support.google.com/webmasters/answer/80553
  "Googlebot",
  // IPリスト取得して制限が可能 https://developers.facebook.com/docs/sharing/webmasters/crawler?locale=ja_JP
  "facebookexternalhit",
  // IP制限が可能かも https://developer.twitter.com/en/docs/tweets/optimize-with-cards/guides/troubleshooting-cards#15_seconds
  "Twitterbot",
  // 検証ツールがあるぽい https://www.bing.com/webmaster/help/which-crawlers-does-bing-use-8c184ec0
  "bingbot",
  "msnbot"
  // "Baiduspider"
];

const excludeSuffixes = [
  "jpg",
  "png",
  "gif",
  "jpeg",
  "svg",
  "css",
  "js",
  "json",
  "txt",
  "ico"
];

const HTTP_HEAD_NEED_DR = "x-need-dynamic-render";

module.exports.vrFixer = async (event, context, callback) => {
  // https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#lambda-event-structure-request
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  // is html
  const suffix =
    request.uri == null || request.uri == "/"
      ? ""
      : request.uri
          .split("?")
          .shift()
          .split(".")
          .pop()
          .toLowerCase();
  const maybeHtml = !excludeSuffixes.some(es => es === suffix);

  const isCrawler = crawlers.some(c => {
    return headers["user-agent"][0].value.includes(c);
  });

  if (isCrawler && maybeHtml) {
    request.headers[HTTP_HEAD_NEED_DR] = [
      {
        key: "X-Need-Dynamic-Render",
        value: "true"
      }
    ];
  }

  console.log(
    `isCrawler "${isCrawler}", maybeHtml "${maybeHtml}", uri "${request.uri}"`
  );

  callback(null, request);
};

```
</div></details>

* デプロイ実行。serverlessによりもろもろ設定した上でlambdaを追加・更新してくれる。

    ```sh
$ sls deploy -v --stage dev
```


#### 2. オリジンリクエスト ~ ダイナミックレンダリングしてレスポンスを生成

* Lambda用のディレクトリを掘って、serverlessコマンドでLambda雛形生成

    ```sh
    $ cd /path/to/test-project
    $ mkdir lambda-edge-origin-request-clawler
    $ cd lambda-edge-origin-request-clawler
    $ sls create -t aws-nodejs --name origin-request-clawler
    ```

* serverless-webpackでビルドするので、`package.json`, `webpack.config.js`配置

 <details><summary>package.json (開閉)</summary><div>

```json:package.json
{
  "name": "lambda-edge-origin-request-clawler",
  "version": "1.0.0",
  "description": "",
  "main": "handler.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@serverless-chrome/lambda": "^1.0.0-55",
    "chrome-remote-interface": "^0.26.1"
  },
  "devDependencies": {
    "babel-loader": "^8.0.4",
    "serverless-plugin-chrome": "^1.0.0-55",
    "serverless-webpack": "^5.2.0",
    "webpack": "^4.26.0",
    "webpack-cli": "^3.1.2"
  }
}
```
</div></details>

 <details><summary>webpack.config.js (開閉)</summary><div>

```js:webpack.config.js
//
const path = require("path");
const webpack = require("webpack");
const slsw = require("serverless-webpack");

module.exports = {
  mode: "production",
  entry: slsw.lib.entries,
  target: "node",
  // デフォルトはmain.jsになっちゃうので、handler.jsになるよう指定
  output: {
    libraryTarget: "commonjs",
    path: path.resolve(__dirname, ".webpack"),
    filename: "[name].js"
  },
  module: {}
};
```
</div></details>

* `$ npm install` 実行


* serverless設定を修正

 <details><summary>serverless.yml (開閉)</summary><div>

```js:serverless.yml
service: origin-request-crawler # NOTE: update this with your service name

plugins:
  - serverless-plugin-chrome
  - serverless-webpack

custom:
  chrome:
    flags:
      - --headless
  webpack:
    webpackConfig: "webpack.config.js" # Name of webpack configuration file
    includeModules: false # Node modules configuration for packaging
    packager: "npm" # Packager that will be used to package your external modules

provider:
  name: aws
  runtime: nodejs8.10
  profile: default # AWS profileここで指定しちゃってるけど、ほんとはコマンドから指定した方が良い
  region: us-east-1
  role: LambdaEdgeRole

functions:
  orCrawler:
    handler: handler.orCrawler
    memorySize: 256
    timeout: 10

resources:
  Resources:
    LambdaEdgeRole:
      Type: AWS::IAM::Role
      Properties:
        AssumeRolePolicyDocument:
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
                  - edgelambda.amazonaws.com
              Action:
                - sts:AssumeRole
        Policies:
          - PolicyName: ${opt:stage}-serverless-lambdaedge
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                    - logs:DescribeLogStreams
                  Resource: "arn:aws:logs:*:*:*"
                - Effect: "Allow"
                  Action:
                    - "s3:PutObject"
                  Resource:
                    Fn::Join:
                      - ""
                      - - "arn:aws:s3:::"
                        - "Ref": "ServerlessDeploymentBucket"

```
</div></details>

* Lambdaの処理を作成する


 <details><summary>handler.js (開閉)</summary><div>

```js:handler.js
"use strict";
/**
 * Lambda@Edge: Origin Request trigger
 *
 * オリジンを見に行く時に起動
 *
 * ヘッダをチェックして、対象ならhtmlをDynamic renderingして返す
 */

// "@serverless-chrome/lambda"はserverless-plugin-chromeにより自動でhandlerに渡される
// Not use "puppeteer"
const CDP = require("chrome-remote-interface");

// S3 Static Website Hosting Endpoint
const S3_ENDPOINT = "http://hogehoge.amazonaws.com";

const HTTP_HEAD_NEED_DR = "x-need-dynamic-render";

function sleep(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
}

module.exports.orCrawler = async (event, context, callback, chrome) => {
  // https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#lambda-event-structure-request
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  // guard: crawler only
  if (
    headers[HTTP_HEAD_NEED_DR] == null ||
    headers[HTTP_HEAD_NEED_DR][0].value !== "true"
  ) {
    console.log("Skip cause not needed");
    return callback(null, request);
  }

  // Dynamic rendering start..
  console.log("Dynamic rendering start..");
  let client = null;

  const WAIT_MS_MIN = 500;
  const WAIT_MS_MAX = 9000;
  const START_TIME = new Date().getTime();
  let reqIds = new Set();

  function isMinWaitOver() {
    return new Date().getTime() - START_TIME > WAIT_MS_MIN;
  }
  function isMaxWaitOver() {
    return new Date().getTime() - START_TIME > WAIT_MS_MAX;
  }

  try {
    //github.com/cyrus-and/chrome-remote-interface/wiki/Dump-HTML-after-page-load
    client = await CDP();
    const { Network, Page, Runtime } = client;

    // setup handlers
    Network.requestWillBeSent(params => {
      console.log(
        `Network.requestWillBeSent: ${params.type} ${params.requestId} ${
          params.request.url
        }`
      );
      if (params.type == "XHR") {
        reqIds.add(params.requestId);
      }
    });
    Network.responseReceived(params => {
      console.log(
        `Network.responseReceived: ${params.type} ${params.requestId} ${
          params.response.url
        }`
      );
      if (params.type == "XHR" && reqIds.has(params.requestId)) {
        reqIds.delete(params.requestId);
      }
    });

    await Network.enable();
    await Page.enable();
    await Network.setCacheDisabled({ cacheDisabled: true });
    // ページ遷移
    await Page.navigate({ url: `${S3_ENDPOINT}${request.uri}` });
    await Page.loadEventFired();

    while (!isMaxWaitOver()) {
      console.log(` ... wait loop, reqsize:${reqIds.size}`);
      if (isMinWaitOver()) {
        if (reqIds.size === 0) {
          break;
        }
      }
      await sleep(200);
    }

    const result = await Runtime.evaluate({
      expression: "document.documentElement.outerHTML"
    });
    const html = result.result.value || "";
    const response = {
      body: html.replace("<html ", '<html data-le="1" '),
      bodyEncoding: "text",
      headers: {
        // Response header
      },
      status: 200
    };
    console.log("Dynamic rendered! " + html.length + " bytes");
    callback(null, response);
  } catch (err) {
    console.error(err);
    callback(err, { status: 503 });
  } finally {
    if (client) client.close();
  }

  console.log("EOL: handler....");
};
```
</div></details>

* デプロイ実行。serverlessによりもろもろ設定した上でlambdaを追加・更新してくれる。

    ```sh
$ sls deploy -v --stage dev
```

### 4. LambdaとCloudFrontを繋げる

1. Lambda管理画面へアクセス（リージョンはバージニア）
1. 2つのLambdaが無事作られてるか確認
1. 2つとも各々CloudFrontへの紐づけをする
    1. Lambdaの管理画面、`Lambda > 関数 > [対象のLambda]`を選択し、詳細画面へ
    1. `アクション`から`Lambda@Edgeへのデプロイ`を選択

        <img width="240" alt="スクリーンショット 2018-11-20 17.26.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/195818/f2076031-87a0-2028-156f-4598045a7630.png">

    1. ダイアログを下図の様に入力

        <img width="260" alt="スクリーンショット 2018-11-20 17.29.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/195818/bd2e0567-b554-26fe-d8bd-815179732da2.png">
        * `CloudFrontイベント`がオリジンリクエストになっているが、それぞれ対象のイベント（ビューワーリクエスト、オリジンリクエスト）を設定する。
    1. デプロイ
    1. 以後、`sls deploy`で新しいlambdaをデプロイするたびに、このLambda@Edgeデプロイフローを実施する。


### 5. ブラウザからの確認

* chromeのDevTool、More toolsからNetwork conditionsを表示してUser agentをGooglebot等に切り替えてアクセス。
* responseのhtml構造が変わっている（が表示は変わらない）ことや、crawler時に追加したhtmlタグのdata属性`data-le="1"`が付いていることを確認できる。


## 所感

* 以外と難しくないし、シンプルな構成かつサーバレスなので運用楽そう。
* SSRのサーバコスト vs CloudFront+S3+Lambdaコスト　は後者の方が安く済みそう（印象）

### もっと詳しい調査が必要なところ

* Lambdaでのheadless chromeの動作が安定するのか不明
* Lambdaのコールドスタートに時間がかかるので、実際使う場合はserverless-plugin-warmupとか使ってウォームアップする
* Lambda起動の条件をもっと絞る必要がある
    * CloudFrontのBehaviorでのみそれが出来る。
    * 1つのリクエストで複数のBehaviorをまたぐような設定は出来ない
    * vue SPAプロジェクトで対応するなら、
        * Path pattern `*.*` はLambdaを起動しないようする（Behavior最上部）
        * Path pattern `Default(*)`は本記事通りのLambda設定
    * にすることで、拡張子付きのファイルをskipしつつ、vueルーティングにのみLambdaを適用できそう
    * もし`.html`をパスにつけてるなら、上記Path patternのさらに上に`*.html`を設定してLambdaを`Default(*)`同様に設定すれば対応可能か。
* 料金
    * Lambdaの実際の運用時コストはいまいちわからない。
    * 試算したところ、viewer-reqが100万req/日で月10ドル程、origin-reqが3万req/日で月3ドル程
* S3がパブリック・アクセスできる問題
    * S3をプライベートにして、LambdaでS3のファイルを取得、headless-chromeでそれを読み込むことは出来なくは無さそうだけど、未確認
    * S3ホスティングのエンドポイント漏洩で問題になるのってアクセス大量に来たときの料金くらい？それならS3バケットのレプリカを作ってCloudFrontですぐ切り替えられるようにしとけば、変なアクセスきたらバケット入れ替えて削除、とかで対応は出来るか。
* キャッシュ設定
    * S3静的ホスティング側でいい感じのキャッシュヘッダつけてくれてるのか、それともCloudFront Behaviorでカスタム設定する必要があるのか。

### SSRの代用になるかも

* 一般ユーザのアクセスもダイナミックレンダリングしちゃえば、後付SSRになる。
* SPAは通常通り静的配信できるので便利。
* 認証部分とか検討対応が必要そうなところはありそう。



## 参考
* [Firebase + SPA + CloudFront + Lambda で SSR なしに OGP 対応](https://qiita.com/zaru/items/e7c2c11f8a9267dc8bb7)
    * serverlessまわり全体的に超参考にさせてもらいました。ありがとうございます。