Lobiで画像のWebp変換による通信量削減と調査のためにAWS Athenaを利用した話

Lobiチームの吉村(moulin)です。 今回は、Lobiのチャットの投稿画像やユーザアイコンなどの画像ファイルをwebp形式で配信して通信量を削減した話について紹介します。

TL;DR

  • 画像配信について
  • 画像変換サーバのwebp変換対応
  • AWS Athenaを使ったCloudFrontのログの集計

画像配信について

Lobiの画像配信ではAWS CloudFrontを利用しています。ユーザーから画像のリクエストがあった場合、CloudFrontにキャッシュが存在すればキャッシュを返します。無い場合は画像変換サーバにリクエストを渡して、画像を生成してもらいます。画像変換サーバが生成した画像をCloudFrontが受け取り、画像をユーザに配信します。一度ユーザに配信がされるとCloudFrontはその画像をキャシュするため、次からは同じ画像のリクエストが来た場合は画像変換サーバーにリクエストは行かずに、そのままキャッシュを返します。

f:id:tkyshm:20170227193231p:plain

今回紹介する話は、画像変換サーバーがwebpに変換できるよう対応したことと、CloudFrontとユーザ間のデータ転送量をCloudFrontのログから調査する話になります。

画像変換サーバのwebp変換対応

webp変換では次のことをしました: - libwebpパッケージのインストールとImageMagickの再ビルド - 画像変換アプリケーションの改修

1つ目では、再ビルドされた新しいImageMagickのAMIイメージを作り、そのAMIイメージで立ち上げた画像変換サーバに切り替えます。 既存の画像変換サーバはlibwebpのパッケージが無い状態でbuildされたImageMagickがインストールされていたため、 拡張子にwebpを指定してもwebpに変換することは出来ません. 一度libwebpがインストールされた状態でImageMagickをbuildし、webp画像へ変換できるようにする必要があります。

次の2つ目は、画像変換をするアプリケーション側の修正になります。 アプリケーションの応答時間に影響が出ないかどうかを事前調査して、問題ないかをざっくり確認しました。

  • 用意したJPEG画像

    • 2126x1423 8-bit sRGB
    • 1.054MB
  • 画像変換

    • 長辺200にリサイズ
    • 画像フォーマットの変換
    • アスペクト比維持
  • 試行回数は20回

jpg -> png jpg -> webp jpg -> jpg
ave(msec) 317.348 314.565 316.087
middle(msec) 297 295 288
max(msec) 467 472 621
min(msec) 277 269 272
標準偏差 53.941 49.166 77.781

上記の結果から、平均的に見るとwebp変換の処理にかかる時間については他と変わらなかったので、問題無さそうと判断しました。 ※ pngからの変換も確認しましたが、そちらも殆ど変わりませんでした。

また、画像変換サーバはAWSのオートスケールによって、CPU使用率に応じて台数が増減します。 そのため、どの程度平均して画像変換サーバの台数が増えているかの監視をして、問題ないかを判断する必要があります。 結果としては、台数はおよそ20%ほど平均して増えました1。 目的の通信量の削減量は、局所的にみると34%程度削減されたため、2台分増やすだけの十分な価値はあると判断しました。

影響力の高い対象の画像全てにwebp対応すれば、全体的の30-40%くらいの削減が見込めそうだということも推定できており、 ユーザにとってはかなり嬉しい対応になるはずなので、順次対応していきたいと思ってます。

AWS Athenaを使ってCloudFrontのログを集計

画像配信にはAWSのCloudFrontを利用してます。そのため、ユーザへの画像転送量を調査するためにはCloudFrontのログを集計する必要があります。 しかし、今まで全てのCloudFrontのログはs3に保管されており、s3のbucketの中から特定の日付のある時間のログファイルを取ってくる必要がありました。 しかも、その時間指定したファイルを取得するだけでも20分くらい掛かるという問題がありました。

そこで、Athenaを使おうという話になります。

Amazon Athena はインタラクティブなクエリサービスで、Amazon S3 内のデータを標準的な SQL を使用して簡単に分析できます。Athena はサーバーレスなので、インフラストラクチャの管理は不要です。実行したクエリに対してのみ料金が発生します。

Athena は簡単に使えます。Amazon S3 にあるデータを指定して、スキーマを定義し、標準的な SQL を使ってデータのクエリを開始するだけです。多くの場合、数秒で結果が出てきます。Athena を使用すると、分析用データを準備するための複雑な ELT ジョブは不要になります。これによって、誰でも SQL のスキルを使って、大型データセットをすばやく、簡単に分析できるようになります。

今回のユースケースに非常にマッチしてます。 Athenaを利用する際、以下のことを考慮する必要があります。

  1. 現行で保存されていくCloudFrontのログをAthenaが利用できるオレゴンリージョンのs3 bucketへ運ぶ
  2. パーティショニングできるディレクトリ構造でログを保存

オレゴンへ移すのはリージョンを跨いだ際に発生するデータ転送量で課金を無くすためです。 パーティショニングするのは全データスキャンをしてしまわないようにするためです。 Athenaの課金は、クエリ実行時にスキャンされるログデータのサイズ総量で決定されます。 パーティショニングがされていないと、全データをスキャンしてしまうため、遅い上に課金料も膨れ上がります。 必要最低限のデータスキャンに留められるようにパーティショニングをするべきなのです。

オレゴンへ移す仕組みと、パーティショニングで実際にやったことを順に紹介していきます。

s3に運ぶlambda関数

パーティショニング可能なオブジェクト名に変更してCloudFrontのログをコピーする必要があったため、Labmda関数を間に挟んで別リージョンへコピーしました。 以下、東京からオレゴンに運ぶlambda関数のサンプルコードになります2

import boto3
import re

bucketPrefix = "athena-"
bucketStorageClass = "STANDARD"

pattern = "^.*?\\.(\\d+?)-(\\d+?)-(\\d+?)-\\d+?\\..*?gz"
s3 = boto3.client('s3')

def handle(e, ctx):
    for record in e['Records']:
        bucket = str(record["s3"]["bucket"]["name"])
        objectKey = str(record["s3"]["object"]["key"])
        src = "%s/%s" % (bucket, objectKey)
        distBucket = "%s%s" % (bucketPrefix, bucket)

        m = re.match(pattern, objectKey)
        if m is None:
            print("Error failed to match pattern cloudfront objectKey: %s" %
                  (objectKey))
            return

        g = m.groups()
        year = g[0]
        month = g[1]
        date = g[2]
        distKey = "%s/%s/%s/%s" % (year, month, date, objectKey)

        # 1. copy
        try:
            resp = s3.copy_object(
                Bucket=distBucket, CopySource=src, Key=distKey)
            print("Success to copy object: objectKey=e%s, response=%s" %
                  (objectKey, resp))
        except Exception as e:
            print("Error failed to exec copy_object with boto3: %s" % e)
            return

パーティショニング

以下でテーブルを作成します。

CREATE EXTERNAL TABLE IF NOT EXISTS cloudfront_logs_assets (
  date DATE,
  time STRING,
  xEdgeLocation STRING,
  scBytes INT,
  cIp STRING,
  csMethod STRING,
  csHost STRING,
  csUriStem STRING,
  scStatus INT,
  csReferer STRING,
  csUserAgent STRING,
  csUriQuery STRING,
  csCookie STRING,
  xEdgeResultType STRING,
  xEdgeRequestId STRING,
  xHostHeader STRING,
  csProtocol STRING,
  csBytes INT,
  timeTaken INT,
  xForwardedFor STRING,
  sslProtocol STRING,
  sslCipher STRING,
  xEdgeResponseResultType STRING,
  csProtocolVersion STRING
) PARTITIONED BY (dt string)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
WITH SERDEPROPERTIES (
  'serialization.format' = "\t",
  'field.delim' = "\t"
) LOCATION 's3://athena-cflogs/assets/'

先のlambda関数ではdt=YYYY-mm-dd-hhとHiveフォーマットでS3に移動しているのでPARTITIONED BY (dt string)の指定でパーティションできます。

テーブルを作成し、ログもs3に保存されたら、次のクエリを実行してパーティションを構築します。

MSCK REPAIR TABLE cloudfront_logs_assets

また、上記のクエリは全データに対してパーティショニングをするため、個別で実施したい場合は以下のクエリを実行します。

ALTER TABLE cloudfront_logs_assets ADD PARTITION (dt='2017-01-27-02') location 's3://athena-cflogs/assets/dt=2017-01-27-02')

参考までに調査時に実行したクエリを一つ載せておきます。 画像のpath毎のアクセスカウントのランキングを出したい場合、次のようなクエリを投げています。

SELECT regexp_extract(csuristem, '^\/.*?\/.*?\/') , count(*) AS count
FROM cloudfront_logs_assets
WHERE dt>='2016-12-28-00' AND
      dt<='2016-12-28-23'
GROUP BY regexp_extract(csuristem, '^\/.*?\/.*?\/')
ORDER BY count DESC
LIMIT 15

また、AthenaはPresto Distributed SQL Engineをベースとしてるため、クエリに関してはPrestoのdocumentを参照すれば殆ど困ることはないです。

感想

想像していたよりも転送量が削減が出来て良かったことと、Athenaによる集計が非常に便利で強力だというのが知見として得られました。 s3にとりあえずログを置いているけど集計に困っている、みたいなケースにはAthenaはベストなソリューションだと思いました。 ユーザにとって転送量削減は嬉しいことなので、順次対応していきたいです。

カヤックのエンジニアと一緒に働きたい方いつでも募集中です!


  1. ちょうどwebp対応の後すぐに、AWSのCloudFrontがローカルエッジ対応をしてcacheのhit率が上がったため、画像変換サーバにいくリクエストが減ってました。平均CPU使用率は増加したけれど、結果的には20%程減りました。

  2. 実際に運用しているコードはgo言語で書き直しています。Lobi内ではpythonはあまりかかれないためgoにしています。読みやすさの観点でpythonで紹介しています。