この記事はTech KAYAC Advent Calendar 2017の1日目の記事です。
昨日に引き続きハイサイ〜! ソーシャルゲーム事業部 で ぼくらの甲子園!ポケット のサーバサイドの担当をしております 谷脇 です。インターネットではマコピーだとか、 mackee だとか @mackee_w だとかで呼ばれています。会社もインターネットなので同様です。
去年のAdvent Calendarでは以下のような記事を書いております。
というわけで、 ぼくらの甲子園!ポケットの公式サイト を AWS Lambda@Edge を使って移行した話をします。
TL;DR
- ほぼ静的なコンテンツをホスティングするのにEC2を使うのは管理コストが高くなる
- 過去には動的な機能があったが、機能を削るにつれて見合わなくなり管理コストが高くなっていった
- 動的サイト時代の名残で、UserAgent文字列を見てリダイレクトしたり、アプリにディープリンクしていたりしていた
- なのでS3にそのまま移行は出来ない
- 基本的にS3を使いつつCloudFrontの機能であるLambda@Edgeを使って元のサイトの機能を保ったまま移行を行った
- 管理コストが減ってハッピー
- さらにTerraformを使って構築とデプロイの自動化もやると、運用がお手軽になるヨ
この記事の対象者
- ほぼほぼ静的コンテンツなんだけれど、リクエストに応じて出し分けしたい……
- Lambda@edgeのドキュメントにA/Bテストやる例もあります
- 応用して時限出し分けもできそう
- CloudFrontとLambda@edgeの構築をポチポチではなくパツイチで行いたい方
ストーリー
――今年初頭、アイディアとしてぼくらの甲子園!ポケット(以下ぼくポケ)の公式サイトをAWS S3にお引っ越しできないかというissueがGitHubに現れました。 というのも、もともとこの公式サイトはぼくポケのサービス開始前の事前登録サイトで用いられていたものが、サービス開始後にそのまま公式サイトになったもので、現在はほぼ静的コンテンツの配信サイトとして運用されていました。事前登録サイトの時には利用されていたメールアドレスを入力してもらって、サービス開始をお知らせする仕組みなどはコード上残っていましたが、もう使われていませんでした。
ぼくポケのサービスの方ではサーバはchefで管理され、冗長化構成を取っています。しかし、公式サイトのインスタンスは、一番初めはchefで構築されたものの、その後の運用ではミドルウェア設定は手でいじる状態になっており、chefに戻すのにも一苦労な状態でした。また、m1.small
1台がELBを介さずに直接リクエストを受けるという男気構成になっていたことも、長い期間今まで落ちずにリクエストをさばいてくれていたことを感謝するとともに、「これは早晩詰む」という思いを強くさせるのでした。
公式サイトは、そこまで更新頻度は高くないですが、コラボイベントやキャンペーンを行うときに更新が行われます。最近ですと、Mr.FULLSWINGとのコラボイベントを行った際にキャンペーンページを追加したことがありました。
件のEC2インスタンスではnginxとPerl5で記述されたアプリケーションがリクエストを捌いていました。ここで誤解してほしくないのは、Perl5だからレガシーというわけではありません。持続性が低い仕組みであることがレガシーなのです。ぼくポケのサービス側もまたPerl5で記述されていますが、安定して稼働しており、また新機能の追加も活発です。
この時点で、Perl5アプリケーションが行っていたことは以下の2つでした。
User-Agent
ヘッダを見てレンダリングするテンプレートを切り替える- Woothee を使ってPCかスマートフォンかを判定して、ページ切り替え
- テンプレートとは言いつつも既に純粋なHTMLになっていた
- リクエストURLからぼくポケのユーザー識別子を抜き出して、アプリへディープリンクするリダイレクトを行う
今時であれば、ワンソースでPCとスマートフォンどちらでも見られるページを作るところですが、今回は諸事情でフロントエンドの改修を行わずにサーバサイドの機能はそのままで、EC2インスタンスから公式サイトを剥がすことを考えました。
動的なURL書き換えやリダイレクトなどはS3だけでは行うことが出来ません。そこで、CloudFrontのエッジロケーションで処理を挟むことが出来るLambda@Edgeに着目し、利用してみることを検討し始めました。
要件
- S3にHTMLとCSSとJavaScriptをアップロードし、CloudFrontから配信する
- ついでにTLS証明書をAmazon Certificate Managerに移行してメンテナンスフリーにする
- 特定のページに関して、PCかスマートフォンを
User-Agent
ヘッダで判定して出し分けを行う - 特定のURLではリクエストから一部を抜き出して別のURLへリダイレクトを行う
テーマは「サーバレス化でメンテナビリティを上げて、サービスの持続性を向上させる」です。
Lambda@edgeの制限
AWS Lambda は、AWSのサービスで起こるイベントをフックにして関数を起動し、様々な処理を行うことが出来る便利なサービスです。ぼくポケでも、DBバックアップの定期実行やEC2インスタンスの退役をSlackに通知する用途などに使われています。Lambda関数を記述する言語は、JavaScript(Node.js)やPythonなどです。社内では他にも apex を用いてGoでLambda関数を記述しているケースもあるようです。
この移行プロジェクトでも、あまり使っていないNode.jsよりはGoで書こうと思いましたが、普通のLambda関数よりも厳しいLambda@Edgeの制限に阻まれます。
Lambda 関数の設定と実行環境 Lambda 関数および組み込みライブラリの最大圧縮サイズは 1 MB です。
とあります。Goのコンパイル後のバイナリの時点で1MBは超えてしまうため、Goは使えなさそうです。そこで、JavaScriptで書くことにしてみました。JavaScriptで書くのであれば、 Serverless Framework の採用も可能ですが、現時点ではLambda@Edgeのサポートはサードパーティのプラグインだけの状態でしたので、デプロイなども含めて自分で書いてみることにしました。
UserAgentごとにページを振り分け
Lambda@EdgeにはLambda関数を設定できるタイミングとして、ビューワーリクエスト, オリジンリクエスト, オリジンレスポンス, ビューワーレスポンスがあります。このうち、リクエストがあったときに、User-Agent
ヘッダを見て振り分けを行いたいので、ビューワーリクエストかオリジンリクエストを使うことになりますが、オリジンリクエストはエッジにコンテンツがある場合には走らないので、ビューワーリクエストで設定しました。
Lambda@Edgeで設定した場合に、Lambda関数にやってくるイベントは次のページに表されるような形式になっています。
Records[0].cf.request.headers.user-agent
にしっかりUserAgnet文字列が入っているので、これを元に判断することが出来ます。
幸いにもPerl5で書いていたときに使っていたライブラリwootheeは、様々なプログラミング言語に互換の機能を提供しています。JavaScript版もあるので、Lambda関数から以下のように、wootheeを使うことにしました。
const woothee = require('woothee'); exports.handler = (event, context, callback) => { const record = event.Records[0]; const { headers } = record.cf.request; const ua = headers['user-agent'][0].value; const parsedUA = woothee.parse(ua); let isSmartphone = false; if (parsedUA.category === 'smartphone') { isSmartphone = true; } // do something };
User-Agent
ヘッダを見て判定するのは出来ました。あとはURLを書き換える部分です。リクエストが有ったURLはevent.Records[0].request.uri
にありますが、これを書き換えた上でcallback
関数の引数に入れると、エッジからオリジンへのリクエストは、書き換えられたURLで飛ぶことになります。これで、Apacheやnginxのrewrite ruleのような挙動を実現できます。
exports.handler = (event, context, callback) => { const record = event.Records[0]; const { request } = record.cf; request.uri = originURI; callback(null, request); };
最後に仕上げとして、以下のような記述方法でrewriteするパスを定義できるようにしました。ここまできて「あれ? マコピーさんまたオレオレフレームワーク作ってませんか?」と言われてハッとなったのはいい思い出です。またってなんだよ。
※以下の設定例は実際の設定から一部改変しています
const rules = [ { condPath: ['/', '/index.html', '/index_smartphone.html'], rewrite: { pc: '/index.html', sp: '/index_smartphone.html', }, }, ];
URLから一部のパスを抜き出してリダイレクトする
公式サイトの機能として、アプリ向けのディープリンクの用途もありました。こちらもやります。
先程行ったように、リクエストが来たパスはevent.Records[0].request.uri
にあるので、これの一部を抜き出せばよいのですが、今回はちょっと趣味が入ってしまいますが、template文字列を使ってリクエストURLの一部を使って生成できるようにしてみました。設定例は以下のような感じです。
※以下の設定例は実際の設定から一部改変しています
let rules = [ { condPath: [paramPath`/app_deep_link/profile/${'identifier'}`], redirect: { pc: '/', sp: paramBind`${appCustomScheme}profile_scene/${'identifier'}`, }, }, ];
paramPath
でURLを受け取ると一部を名前を付きで抜き出す関数を作り、paramBind
で抜き出したパラメータを埋め込むようにしています。さすがにここまで来るとオレオレフレームワーク臭がだいぶしてくるのと、本筋からは外れるのであまり詳しくは説明しません。
なお、リダイレクトに関しては、以下のようにレスポンスオブジェクトを作ってcallback
に入れるとリダイレクトしてくれます。
exports.handler = (event, context, callback) => { callback(null, { status: '302', statusDescription: 'Found', headers: { location: [{ key: 'Location', value: path, }], }, }); };
テストとデプロイ
ステージング環境も用意しますが、ブラウザでデバッグしていると埒が明かないのでテストも書きました。見ての通りLambda関数はまさに関数ですし、今回の関数はAWSのサービスや外部通信をしない(そもそもビューワーリクエストは出来ない)ので、お手軽にテストを書くことが出来ます。AWS Console上にもLambda関数のテストを行う仕組みはあるのですが、手元でも行いたいと思ったのでこれでやっています。
describe('handler', () => { it('/: rewrite smartphone to pc', () => { const event = genEvent('/index_smartphone.html'); const request = _.cloneDeep(event.Records[0].cf.request); request.uri = '/index_pc.html'; const callback = genAssertCallback(null, request); handler(event, null, callback); }); };
デプロイですが、aws cliを直接叩くMakefileを書いてLambda関数の更新を行っています。また、アップロードするLambda関数を上げるときにzipで固めるのですが、このときにテストでしか使わないmochaや、eslintがnode_modulesに入ってしまうと容量がオーバーしてしまう可能性があります。なので、別のディレクトリにコピーして--only=production
でインストールしています。これでwootheeしかnode_modulesに入らなくなってハッピーです。これってもっといい方法がある気がするとは思うんですが、本職のJavaScript使いの方はどうしてらっしゃるんでしょうか?
test: init npm run lint npm run test build: test cp package.json build/ cp *.js build/ cd build && npm install --only=production pack: clean build cd build/ && zip -r ../${ARTIFACTS_FILENAME} * upload: pack aws lambda update-function-code \ --function-name ${FUNCTION_NAME} \ --zip-file fileb://${ARTIFACTS_FILENAME} \ --region ${REGION_NAME} aws lambda update-function-configuration \ --function-name ${FUNCTION_NAME} \ --timeout 1 \ --region ${REGION_NAME}
静的ファイルのS3バケットへのアップロードもMakefileに記述しています。
TerraformでCloudFrontを構築
さて、Lambda関数はできたので、今度はCloudFrontの設定にまいります。AWS Consoleをポチポチやってもいいのですが、動くドキュメントを残して他の人でも扱えるようにするために、何らかの手段で構築自動化をする必要がありました。CloudFrontを設定する手段としては、Terraformの他に AWS CloudFormation があります。こちらも検討したのですが、ぼくポケのサービス側のベンチマークを行う環境を一発で構築するのにTerraformを使っているため、使用技術の統一からTerraformを選択しました。
Terraformは、HashiCorpが開発している、インフラ構築ツールです。hcl
という形式の設定ファイルにAWS Consoleで設定するようなパラメータを書き連ねることで、フルマネージドサービスを含んだインフラを一発で構築できます。
この移行プロジェクトでは、TerraformでCloudFrontとS3を設定しました。また、ステージングと本番環境の設定を共通化するためにモジュールを使ってだいたいは共通の設定を使いつつ、一部を変更して設定しています。
Terraformの設定から、Lambda関数を設定するところを抜き出してみます。
resource "aws_cloudfront_distribution" "lp_cloudfront" { # ... cache_behavior { target_origin_id = "S3-${var.lp_bucket_name}" # ... lambda_function_association { event_type = "viewer-request" lambda_arn = "${var.lp_lambda_arn}" } } }
Lambda関数を表すARNは変数で設定していますが、この変数もローカルに置いたLambda関数のバージョン番号が書かれたファイルを読み取って設定しています。Lambda関数はバージョンを付けることが出来、またバージョンには別名をつけることが出来ます。ですが、Lambda@Edgeにはエイリアスを使用することが出来ません。直接バージョン番号を指定する必要があります。
また、ステージングで動作確認を行ってから本番に現在のステージングのバージョンを適用する形にしたかったため、ファイルに一旦バージョンを書き込んで上げるというふうにしています。
まとめ
どうでしょうか、みなさま具体的にユースケースの想像がついたでしょうか。他にもステージング用のCloudFrontにAWS WAFで制限掛けたりだとかトピックはいっぱいあるんですが、長くなっちゃっているのでこの辺にします。
あと作業ログをissueに残すのが大事だと感じました。実際のコードやドキュメント以外にも、当時の考えをうかがい知ることが出来ますし、こうやって記事にも出来ますしね。
もっと詳しく知りたい! という人は……そうですね、こちらから 応募していただいて、入社していただいて私に聞くのが良いと思います!
面白法人カヤックではインフラをコードで便利にして、楽しく運用したい!という人を募集しております!
明日12/2はHTMLファイ部のエース格、のびーの「Vue.jsとfirebaseとかかなぁ」です!