Ruby on Lambdaを使ってRubyKaigi用のデモアプリを作った話

f:id:vasilyjp:20190703114023j:plain

こんにちは、開発部の塩崎です。 最近はCloudFormation・Embulk・Digdagを使った仕事をすることが多く、一番使う言語がYAMLになりました。

今年福岡で開催されたRubyKaigi 2019ではZOZOテクノロジーズはRubyスポンサーとして協賛させていただきました。 カンファレンス中のスポンサーブースの出し物として、DroidKaigi 2019と同様にファッションチェックアプリの展示を行いました。 DroidKaigiの展示と全く同じでは芸がないと考え、今回のRubyKaigiのためにRuby on Lambdaでランキング機能を作成しました。 本記事では、そのランキング機能の説明をしたいと思います。

ファッションチェックアプリのランキング機能とは

まず、ファッションチェックアプリの説明をします。 このアプリはDroidKaigi 2019のために作成されたデモアプリです。 スマートフォンのカメラで全身画像を撮影し、機械学習を用いてファッションの採点を行います。 機械学習モデルの作成に当たっては、当社の運営するファッションコーディネートアプリWEARのデータを利用しました。

techblog.zozo.com

今回作成したのは、このファッションチェックアプリのランキングサイトです。 ブース来訪者にはファッションチェックアプリで採点された結果をTwitterに投稿してもらいます。 ランキングサイトは、その投稿結果をTwitterから自動的に集計し、ランキング形式で表示します。

開発の経緯

RubyKaigiにスポンサーとして参加するにあたって何かしらの企画をやることを考えていました。 昨年はRubyに関するアンケートを行いましたが、今年も同じものをやってもつまらないので、何か他の企画をやろうということになりました。

また、各社ともスポンサーブースに対する力の入れ方が年々上がっております。 魅力的な展示を行うために、今まで以上にこだわる必要がありました。 DroidKaigiでデモアプリを作成したら来場者のウケが良かったとの広報からの助言があり、RubyKaigiでもそれを真似してデモアプリを作ることにしました。

RubyKaigi用に作成するデモアプリですので以下の2点を要件にしました。

  • Rubyを使っている(MUST)
  • 最近にリリースされた「何か」を活用している(SHOULD)

というわけで、昨年のre:InventでRubyサポートが発表されたばかりのAWS Lambdaを使い、ファッションチェックアプリのランキングサイトを作ることにしました。

技術構成

f:id:vasilyjp:20190703114105p:plain

全体のアーキテクチャを上図に示します。 また、ソースコードはこちらのGitHubリポジトリで公開しております。

https://github.com/st-tech/fashion_check_ranking

このアプリケーションは大きく2つのパートに分かれています。 1つめはクローラーで、Twitterから採点結果に関するツイートを取得し、それをDynamoDBに保存します。 2つめはランキングサイトで、DynamoDBに保存された情報をランキング形式で表示するWebサーバーです。

以下では、これら2つの詳細を紹介します。

クローラー

クローラーはまず、Twitter APIを使ってRubyKaigiに関するハッシュタグである#rubykaigiと#zozotechの付いたツイートをすべて取得します。 そして、その中から正規表現を使って採点結果に関するツイートを抜き出します。 最後に、該当するツイートからユーザー名・アイコンURL・採点結果の点数を取得し、DynamoDBに保存をします。

この機能はAWS Lambdaの上で動作していて、定期的にLambda関数を実行するためにALBのヘルスチェック機能を利用しています。

※ なぜCloudWatch EventsではなくALBを使っているのかは後ほど説明します。

参考:
Twitter API
クローラー部分のソースコード

ランキングサイト

ランキングサイトはDynamoDBに書かれたランキング情報を表示するためのWebサーバーです。 画像・JavaScript・フォントなどの静的ファイルはS3に配置し、動的コンテンツはRuby on Lambdaで生成しています。 小ネタですが、ALBのターゲットグループにLambdaを登録する機能も昨年のre:Inventで発表されたばかりなので試してみました。

Lambdaから呼ばれるハンドラーとRackとの間を繋ぐためのコードをAWSが公開していたので、それを使いました。 理論的にはRackサーバー上で動作するどのフレームワークも動作するはずですが、今回は軽量なフレームワークであるSinatraを利用しました。

参考:
serverless-sinatra-sample
ランキングサイト部分のソースコード

実装に当たって苦労したところ

ここからは実装する時に、苦労したところ・工夫したところを紹介します。

クローラーを10秒ごとに起動したい問題

今回のアプリケーションの要件として、ブースの来訪者が別のブースへ行く前にランキングの結果を反映したいというものがありました。 そのため、10秒ごとくらいにクローラーを起動し、投稿内容を取得する必要がありました。 一方、CloudWatch Eventsでは1分ごとにLambda関数を呼び出すのが限界で、10秒ごとという要件を実現することはできません。

そこで、ALBのヘルスチェック機能に目をつけました。 定期実行したいLambda関数をALBのターゲットグループにいれ、ヘルスチェック間隔を10秒に設定しました。 結果的にこの方法はうまくいき、約10秒間隔でLambda関数を実行できました。

しかし、厳密に10秒間隔になることはなく、5〜20秒間隔くらい(実測値)になるときもありました。 どのような条件で本来設定した間隔からずれるのかの原因究明は結局できませんでした。 今回のアプリケーション的に致命的にはならないため、深追いはしませんでした。

このような事情があるため、このALBをcron代わりに使用する作戦を本番環境で行うことはオススメしません。 そもそもの話ですが、ALBのヘルスチェック機能はcronのために作られてはいません。

ALBを外部公開してしまったら、想定外のアクセスがあった

ALBをうっかり外部公開するというミスを犯してしまったために、意図せずにクローラーが起動するという現象が発生しました。 クローラーを起動するためのALBを作るときに、外部公開用WebサーバーのALB設定をコピーしてしまったために発生しました。

何者かがALBに対してアクセスをしてきて、そのアクセス回数分だけクローラーが起動してしまいました。 そのために、Twitter APIのアクセス頻度制限に引っかかるということが発生しました。 ALBのDNS名は十分に長く、ランダムなものになっていましたが、IPアドレスを直に指定してのアクセスがありました。 ログからアクセスのあったURLを調査したところ、WordPressの脆弱性を突くようなものでした。 IPアドレスに対してしらみつぶしにアクセスをしながら脆弱性のあるサーバーを見つけるBotが居ると思います。

この問題に対してはセキュリティグループを適切に設定することで外部アクセスを禁止する対応を行いました。

このことからもALBをcron代わりに使用することはオススメできません。 そもそもの話ですが、ALBのヘルスチェ(略)

マルチバイト文字の配信問題

ここから先はランキングサイトで発生した問題です。

CSSファイルを配信するために、config.ruに以下のように書き込んで、静的配信用のディレクトリを設定しました。

set :public_folder, Proc.new { File.join(root, 'public') }

この状況でCSSファイルの中に日本語(マルチバイト文字)が入っているときに、ファイルの配信に失敗してしまいました。 CloudWatch Logsでログを確認したところ、以下のエラーが出力されていることが確認できました。

Error raised from handler_method {
    "errorMessage": "\"\\xE3\" from ASCII-8BIT to UTF-8",
    "errorType": "Function<Encoding::UndefinedConversionError>",
    "stackTrace": [
    ]
}

このエラーはエンコーディングがASCII-8BITである文字列をUTF-8に変換しようとする時のエラーです。 xxdで確認したところ、この\xE3はCSSファイルにUTF-8で書かれている ヒラギノ角ゴ という文字列の先頭1バイトのバイトに一致していることが分かりました。 \xE3というバイト表現はASCIIコード外なため、正常に文字コードの変換を行うことができないようです。 ASCII-8BITが何であるのかは以下の記事が詳しいので、このあたりに興味ある人は読んでみるといいかもしれません。

Ruby: US-ASCII-8BITというエンコードを理解する(翻訳)

今回のケースでは静的ファイルのエンコーディングはUTF-8しかないことが分かっていました。 そのため、 String#force_encoding で強制的にエンコーディングをUTF-8にする対応を取りました。 Lambda関数の返り値であるresponseハッシュを生成している部分を以下のように書き換えました。

def handler(event:, context:)
  # 略
  response = {
    'statusCode' => status,
    'headers' => headers,
    'body' => body_content.force_encoding('UTF-8') # force_encoding追加
  }
  # 略
  response
end

バイナリー配信問題

次はバイナリーファイルを配信しようとしたときに発生した問題です。 なお、このアプリケーションで配信していたバイナリーファイルは、フォントとファビコン画像です。

バイナリーファイルもCSSファイルと同様に以下のように配信していました。

set :public_folder, Proc.new { File.join(root, 'public') }

このときもまた配信エラーが発生してしまいました。 CloudWatch Logsには以下のエラーが吐き出されていました。

Error raised from handler_method {
    "errorMessage": "source sequence is illegal/malformed utf-8",
    "errorType": "Function<JSON::GeneratorError>",
    "stackTrace: [
    ]
}

Lambdaが内部的にJSONへシリアライズをしているようで、そのときに問題が発生しているようです。 配信しようとしていたバイナリーがUTF-8的に不正なバイト列だったことに起因するものです。 実際に以下のコードで確認したところ、同様のエラーが発生しました。

bin = File.read('問題のファイル')
JSON.generate({'bin' => bin}) # エラー発生

この問題に対しては、静的ファイルはS3で配信する対応を取りました。 そしてその前段にCloudFrontを配置し、静的コンテンツと動的コンテンツの振り分けはCloudFrontに担当させました。

後日談になりますが、ALBのドキュメントをみたら以下のように書かれていました。 isBase64Encodedプロパティをtrueにすれば、Lambdaからバイナリーファイルの配信をすることもできそうです。 機会があれば試してみたいと思います。

To include a binary content in the body of the response, you must Base64 encode the content and set isBase64Encoded to true. The load balancer decodes the content to retrieve the binary content and sends it to the client in the body of the HTTP response.

ALBのドキュメント

関数インスタンス再利用問題

最後の問題はLambdaによって、関数インスタンスが再利用された問題です。

クローラーによってDynamoDBのデータが更新されても、ランキングページの表示が変わらないという現象が発生しました。 最初はCloudFrontが動的コンテンツまでキャッシュをしてしまっているのかと思いましたが、MaxTTLは0に設定しているため、キャッシュはしないはずです。 また、CloudWatch Logsで確認したところ、Lambda関数はブラウザからのアクセス毎に起動しています。

この原因はLambdaが関数インスタンスを再利用しているためでした。 LambdaのFAQには以下のように書かれており、場合によっては関数インスタンスが再利用されるようです。

Q: Will AWS Lambda reuse function instances?

To improve performance, AWS Lambda may choose to retain an instance of your function and reuse it to serve a subsequent request, rather than creating a new copy. To learn more about how Lambda reuses function instances, visit our documentation. Your code should not assume that this will always happen.

AWS Lambda FAQs

DynamoDBからの読み出し結果をクラスインスタンス変数にキャッシュして使いまわしているコードがあったので、この部分が原因でした。 そのため、クラスインスタンス変数を使用しないように修正するという対策を行いました。 グローバル変数やクラス変数も同様の問題の発生する可能性があるので、これらの変数を使う時には要注意です。

来場者の反響

スポンサーブースに訪れた多くの方にこの企画を楽しんでいただけました。 3日間で210ツイートもしていただいたことに感謝しています。 ブース来訪者の方とRubyに関する話をする時の取っ掛かりにもなったので、そういう意味でもこの企画をやってよかったと思いました。

1日目はランキング表示件数を上位15人にしていましたが、ランキング圏外だからツイートしないという方が何人かいました。 そこで、2日目以降はランキング表示数を倍の上位30位にしたところ、さらに多くの方にツイートしてもらうことができました。

(1日目: 62ツイート 2日目: 82ツイート)

初日よりも2日目のほうがブースの来場者が少なかったことを考えると、この修正は大成功でした。

一部修正が間に合わず、2日目の午前中は502 Bad Gatewayになってしまったこともありました。 RubyKaigiが開催される都市は美味しいものが多く、本会議後のイベントも多いため、当日に修正する時間があまりありません。 このことは来年以降の教訓にしていきたいです。

まとめ

RubyKaigi 2019用のデモアプリをRuby on Lambdaで作り、スポンサーブースの企画としました。 以前のRubyKaigiでMatzさんが「Rubyをキメると気持ちイイ」という名言を残しましたが、やはりRubyは書いていて楽しい言語であることを実感できました。 特に最近はYAML職人になっていたので、久しぶりにキメるRubyの気持ちよさは最高でした。

来年もスポンサーをするかもしれないので、そのときは来年のZOZOテクノロジーズブースにも是非おこしください!!

ZOZOテクノロジーズでは一緒にサービスを作っていく仲間を募集しています。 Rubyをキメながら開発ができるサービスもありますので、ご興味のある方は以下のリンクから是非ご応募ください。

tech.zozo.com

カテゴリー