CloudFrontとLambda@Edgeを使ってWordpressをサーバーレス化した話


先日AWS re:Inventに参加したところ、サーバーレスの講演が異常に人気でした。（re:Inventの感想は[こちら](https://qiita.com/vankobe/items/1117226fd880ea9c48c3)）
そんな波にのっかろうと思い、弊社の休眠しているサービスをどうにかサーバーレスに移行できないか？トライしてみました。

## 要件と構成
Wordpressで作られている [YADORU](https://yadoru.jp) を cloudfront, s3, lambda@edge を用いてサーバーレスにすることを目指しました。
ただし、検索部分だけは、まだサーバーで処理させたいと考えました。

### 構成図

![Web App Reference Architecture (1).png](https://qiita-image-store.s3.amazonaws.com/0/68146/89b0985a-986a-34a3-8f3c-c36a2dfdde99.png "Web App Reference Architecture (1).png")


### 流れ
1. 各ページをhtmlファイルとして保存し、s3にuploadする
2. cloudfrontのオリジンとしてs3およびec2を設定する
3. cloudfrontのbehaviorを設定する
4. 以下3つの振り分けを行うlamdaを作成する
 - querystringに`?s=` のクエリが含む => ec2
 - UserAgentがスマートフォン => s3の `/sp` フォルダ
 - a,b以外 => s3の `pc` フォルダ
5. cloudfrontにlambdaを設定する
6. route53にてdomainの向き先をcloudfrontに変更する

## 実装1: 各ページをhtmlファイルとして保存し、s3にuploadする
まずは、既存サイトのページをhtmlファイルにしてs3にuploadします。これは以下の手順で行いました
1. sitemapからurl一覧を抽出
2. urlを一つずつ、スマートフォン、PCのUserAgentを使ってcurlで叩き、urlのpathのディレクトリ構造を保持した状態でディレクトリ名、ファイル名を指定して保存
3. スマートフォンのUserAgentで得られたhtmlを `/sp`、PCのUserAgentで得られたhtmlを `/pc` にそれぞれアップロード

#### 問題点: ディレクトリでありファイルでもあるpathが存在する
このサイトでは、カテゴリ一覧のurlが階層構造になっていました。
例えば、以下のようなurlが存在します
https://yadoru.jp/pregnancy ... 妊娠に関する記事一覧
https://yadoru.jp/pregnancy/pregnancy-sign ... 妊娠兆候に関する記事一覧
この場合、 `/pregnancy` は「ディレクトリでありファイルでもあるpath」となります。
通常webサーバーでは、 `/pregnancy` のファイルは `/pregnancy/index.html` におけばよいので、今回もそのようにしました

## 実装2: cloudfrontのオリジンとしてs3およびec2を設定する
create originから、ec2のurlおよびs3を設定します
s3オリジンにおいては、s3のwebhostingのドメイン
ec2オリジンにおいてはそのドメイン（ここではelbのドメイン）
を指定します。

[ s3オリジン ]
![origin.png](https://qiita-image-store.s3.amazonaws.com/0/68146/5e670ce7-8f64-ba95-da75-d07b77aa6a2a.png "origin.png")


[ ec2オリジン ]
![origin for elb.png](https://qiita-image-store.s3.amazonaws.com/0/68146/6badbc95-2f22-fc3f-6c1e-3224ba939649.png "origin for elb.png")

### 問題点: index.htmlが表示されない
s3はwebsite hostingにすることで、index.htmlを表示できます
しかし、originとしてs3のbucketを設定してもその設定は有効にはなりません。
cloudfrontのオリジンを指定する際、フォームにフォーカスするとs3バケットの一覧が表示されますが、それを選択してしまうとindex.htmlが表示されません。また、`cloudfront-is-mobile-viewer` などのcloudfrontが付与してくれるヘッダーも渡ってきません。
website hostingのドメイン名をしていすることで、ディレクトリが指定された際に、index.htmlを表示してくれるようになります。

```
[bucket名].s3-website-[region名].amazonaws.com
# 例) test-bucket.s3-website-ap-northeast-1.amazonaws.com
```

## 実装3: cloudfrontのbehaviorを設定する
s3オリジンをデフォルトに指定します。
pathが `search` を含んでいれば ec2オリジンに流すようにします
ヘッダーのwhitelistにモバイル判定に用いるヘッダーを追加すること、クエリのwhitelistに `s` を追加することを忘れないようにします。

[ behavior ]
![cloudfrontのbehavior.png](https://qiita-image-store.s3.amazonaws.com/0/68146/10c9811c-8ca7-00ff-a627-0310621f1ff5.png "cloudfrontのbehavior.png")

[ s3 behavior 詳細 ]
![s3 behavior.png](https://qiita-image-store.s3.amazonaws.com/0/68146/25751b81-59d3-57b7-0da5-33e723a530a2.png "s3 behavior.png")

[ ec2 behavior 詳細 ]
![ec2 behavior.png](https://qiita-image-store.s3.amazonaws.com/0/68146/06166a25-b5c3-c0eb-61b9-a251274e3562.png "ec2 behavior.png")


## 実装4: 振り分けを行うlamdaを作成する
以下のようなlambda関数を作成します。この時、lambda関数はus-east-1（N. Virginia）に作成しなければいけません。
以下の関数は
- querystringに `s` を含んでいれば、 `/search` を付与したパスにリダイレクト => ec2オリジンに
- mobileと判定すれば、パスに `/sp` を付与する => s3オリジンの `/sp` に
- それ以外なら、パスに `/pc` を付与する => s3オリジンの `/pc` に
を実行しています。

```
const querystring = require('querystring');
exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const headers = request.headers;
    const params = querystring.parse(request.querystring);;

    const desktopPath = '/pc';
    const mobilePath = '/sp';

    const userAgent = request.headers['user-agent'][0]['value'];
    const path = request.uri;

    if(params.s !== null) {
        const response = {
            status: '302',
            statusDescription: 'Found',
            headers: {
                location: [{
                    key: 'Location',
                    value: '/search/'+ params.s,
                }],
            },
        };
        callback(null, response);
        return
    } else if(headers['cloudfront-is-mobile-viewer']
               && headers['cloudfront-is-mobile-viewer'][0].value === 'true') {
        request.uri = mobilePath + request.uri;
    } else if (headers['cloudfront-is-tablet-viewer']
               && headers['cloudfront-is-tablet-viewer'][0].value === 'true') {
        request.uri = mobilePath + request.uri;
    } else {
        request.uri = desktopPath + request.uri;
    }
        
    callback(null, request);
};
```

## 実装5. lambda関数をcloudfrontに設定する
s3オリジンのbehaviorのorigin requestに先ほど作成したlambda関数を設定します。
（viewer requestでも可能ですが、その場合は `cloudfront-is-mobile-viewerヘッダー` ではなく `user-agent` でモバイル判定を行うことになります。しかしこの場合、キャッシュが存在していたとしてもlambda関数が実行されてしまい無駄が生じてしまいます）
![lambdaの設定.png](https://qiita-image-store.s3.amazonaws.com/0/68146/2cc2c67b-a28b-f11a-cf3e-615fc17d8123.png "lambdaの設定.png")


### 問題点1: cloudfrontのbehaviorにおけるpath patternの方が先に評価される
cloudfrontのbehaviorにおけるpath patternの方が先に評価されてしまうため、以下のようにparamsにsを含むものは `/search` に流すように以下の設定をしようとしましたが結果s3の `/search` に流されてしまいました。
これは、origin requestを viewer requestに変えても同じでした。

```
if(params.s !== null) {
   request.uri = "/search" + request.uri;
}
```

### 問題点2: エンドスラッシュの有無に関するリダイレクトがうまく動かない
フォルダのindex.htmlを表示したい時、
`[s3 webhostingドメイン]/pregnancy` でアクセスすると `[s3 webhostingドメイン]/pregnancy/` にリダイレクトされるのですが、 `[cloudfrontドメイン]/pc/pregnancy/` にリダイレクトされるようになってしまいました。
そこで、以下のlambda関数を origin response に指定することで回避しました。
リダイレクトのLocationが `/pc` か `/sp` ではじまるパスだった場合、それらを取り除くようにしてます。

```
exports.handler = (event, context, callback) => {
    const response = event.Records[0].cf.response;
    const status = response.status;
    const location = response.headers.location;
    
    if(status === "302" && location !== null){
        const match = location[0]['value'].match(/^\/(pc|sp)(\/.*)/);
        if(match !== null){
            response.headers.location[0]['value'] = match[2];
        }
    }

    callback(null, response);
};
```

## 実装6: route53にてdomainの向き先をcloudfrontに変更する
route53のAレコードをcloudfrontに向けます

### 問題点: ssl certificateもus-east-1で生成しなければならない
cloudfrontで割り当てられるドメインではなく、カスタムのドメインを用いる場合、ssl certificateもus-east-1で再度生成しなければなりません。
「certificateあるはずなのに、出てこない」とはまりました。

## 結果
以下のurlで検索以外サーバーレスを実現できました。
https://d1nb4cler3jaeb.cloudfront.net
現状ec2においてmobile判定を `user-agent` で行なっているのでmobileで検索すると、pcの検索結果が表示されてしまいます。 これは、`cloudfront-is-mobile-viewer`で判定をするように今後修正予定です。


### レスポンスタイム
「既存のec2」、「viewer requestにlambdaを設定したケース」「origin requestにlambdaを設定したケース」でレスポンスタイムを計測してみました

```
[既存のec2]
初回: 約200ms
2回目以降: 約40ms

[viewer requestにlambdaを設定したケース]
初回: 約370ms
2回目以降cloudfront Refresh: 約140ms
2回目以降cloudfront Hit: 約60ms

[origin requestにlambdaを設定したケース]
初回: 約320ms
2回目以降cloudfront Refresh: 約170ms
2回目以降cloudfront Hit: 約20ms
```

## 最後に
今回はwordpressをテンプレートエンジンとして使って、サーバーレスにできないか？と思い挑戦してみました。
完全にはできなかったものの、かなり近しい状態まで持っていけました。
サーバーレスの参考にしていただければ幸いです。