AWS
CloudFront
lambda

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

先日AWS re:Inventに参加したところ、サーバーレスの講演が異常に人気でした。(re:Inventの感想はこちら
そんな波にのっかろうと思い、弊社の休眠しているサービスをどうにかサーバーレスに移行できないか?トライしてみました。

要件と構成

Wordpressで作られている YADORU を cloudfront, s3, lambda@edge を用いてサーバーレスにすることを目指しました。
ただし、検索部分だけは、まだサーバーで処理させたいと考えました。

構成図

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

[ ec2オリジン ]
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

[ s3 behavior 詳細 ]
s3 behavior.png

[ ec2 behavior 詳細 ]
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

問題点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をテンプレートエンジンとして使って、サーバーレスにできないか?と思い挑戦してみました。
完全にはできなかったものの、かなり近しい状態まで持っていけました。
サーバーレスの参考にしていただければ幸いです。