Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

Ruby on Lambdaで実現する、Eightの大規模画像処理基盤

Eight事業部 Platform Unit / Engineering Manager の 藤井洋太郎(yotaro) です。

私のチームはいわゆる技術基盤を担当するチームで、パフォーマンス改善、アーキテクチャ刷新、セキュリティ対応といった課題と日々向き合っています。

今回の記事では、昨年11月に行われた AWS re:Invent にて発表された「Ruby on Lambda」を活用した、Eightの画像処理バッチ基盤の改善について紹介します。

Eightにおける画像処理

Eightサービス内では名刺画像をはじめ多くの画像を扱っていますが、UX向上のためにそれらの画像に対して様々な処理を行っています。

1つの画像に対してぼかし、サムネイル、フォーマット(jpeg/webp)変換などを行い数十ケースの画像を生成しています。

画像処理は以下の図のように画像追加・更新時にSQSにジョブを追加し、EC2バッチサーバーで稼働する Rails プロセスが非同期でポーリングし実行する構成となっていました。

f:id:yotaro-fujii:20190415033355p:plain

スケーラビリティ/パフォーマンス課題の顕在化

この処理タスクはアクセスが増える昼過ぎにピークを迎えるのですが、Eightのバッチ機構の問題もありスケールアウトするためには運用者の手運用が必要になってしまうという課題があり、対応が後手に回り滞留してしまうことが少なからずありました。

また滞留が発生すると、画像取得APIで同期的な画像処理を行うことになり、レスポンス劣化も発生してしまうという影響がありました。

LambdaがついにRuby対応

過去何度か Lambda などへの移行を試みたものの Ruby を使えないというメンテナンサビリティな点において課題があり踏み切れずにいました。
が、ついに昨年の re:invent2018 で Ruby 対応が発表され、この波に乗らない手はないということでEightのバッチ基盤の Lambda 移行に踏み切ることに!

Lambdaを使ったバッチ機構のリプレイス案

今回 Lambda に移行したい画像処理は非同期処理だけでなくAPIサーバーなど他のサービスでも行われているため、以下のように共通処理として gem に切り出し、Rails プロセスや Lambda プロセスからも利用できる構成を考えました。

f:id:yotaro-fujii:20190415033418p:plain

Ruby on Lambda化する際に必要な作業

早速Eightの画像処理機構を Lambda に移行しようとしたのですが、その際に技術的につまづいた点や、構築する上での工夫ポイントを共有します。

  1. native extensions に依存した gem の作成
  2. 実行バイナリを含めた Lambda の作成
  3. デプロイのコスト
  4. 構成管理やローカル実行環境
  5. CI/CD環境の構築

native extensionsに依存する gem を使った Lambda 実装

Ruby on Lambdaではgemを使った開発もできますが、native extensions に依存する gem を利用する場合は Lambda の実行環境と同等の環境で bundle(ビルド)する必要があります。
今回移行対象の処理でも ImageMagick に依存する rmagick gem を利用していたため、このケースにあたります。

Lambda の実行環境は Amazon Linux ですが、ローカルで擬似的に実行できる Docker イメージ(lambci/lambda:build-ruby2.5)がOSSとして公開されています。この Docker コンテナ上でビルドしパッケージングすることで、正しく動作する Lambda を作成できます。

# rmagick など native extensions を必要とする gem を Gemfile に記述
$ vi Gemfile

# docker container 上で bundle install
# ボリュームマウントすることでホスト上の vendor/bundle にインストールできる

$ docker run -v `pwd`:/var/task -it lambci/lambda:build-ruby2.5 bundle install --path vendor/bundle

# lambda_handler.rb, vendor/bundle をzip化してアップロード
$ zip -r zip_function ./*

実行バイナリ(webp対応のImageMagick)を含めた Lambda の作成

Eightでは通信量削減や表示速度の向上を目的とし、webpフォーマットへの変換処理を行っています。
が、 通常の Lambda 環境にビルトインされている ImageMagick は webp フォーマットに対応していません。
そのためには webp 変換可能な ImageMagick をビルドし Lambda 関数と一緒にデプロイする必要があります。 これらの実行バイナリの作成も、先程と同様 Lambda と同等の実行環境でビルドする必要があります。

下記の例のように必要なライブラリを Lambda 関数と同じディレクトリにインストールし、パッケージングすればOKです。

## Lambda関数が展開される/var/task/ディレクトリで作業
$ cd /var/task

## libwebpのinstall
$ curl -sLO https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.0.0.tar.gz \
  && tar zxvf libwebp-1.0.0.tar.gz \
  && cd libwebp-1.0.0 && ./configure --prefix=/var/task && make && make install \
  && rm -rf ../libwebp-1.0.0.tar.gz ../libwebp-1.0.0

## webp compatibleなImageMagickのビルド
$ curl -sLO https://www.imagemagick.org/download/releases/ImageMagick-6.9.10-34.tar.gz \
  && tar zxvf ImageMagick-6.9.10-34.tar.gz \
  && cd ImageMagick-6.9.10-34 && ./configure --prefix=/var/task --with-webp=yes \
  && make && make install \
  && rm -rf ../ImageMagick-6.9.10-34.tar.gz ../ImageMagick-6.9.10-34

# lambda_handler.rb, vendor/bundleに加え、 bin/, libもzip化してアップロード
$ zip -r zip_function ./*

しかし、次はデプロイコストが課題に...

上記までの方法で、実行バイナリを含めた複雑な要件の Lambda でも動かすことが可能になりましたが、デプロイコストで大きな課題が残ります。

  1. 実行バイナリ(webp対応のImageMagick)を含めない場合 → 約5MB
  2. 実行バイナリ(webp対応のImageMagick)を含めた場合 → 約50MB

なんと、約10倍のサイズに...!
実行バイナリ自体を更新する頻度は多くないと思いますが、Lambda関数を修正するたびに毎回パッケージング & デプロイしなければなりません。

これは地味に辛い。。。

そこで、「Lambda Layers」 機能を使ってこの課題を解決をします。

Lambda Layers とは

Lambda Layers も昨年のre:inventで発表された新機能で、簡単に言うと「複数のLambda関数でライブラリを共有できる仕組み」です。今回のケースでは、webp に対応した ImageMagick を Layers で一元管理させることで Lambda 関数から切り離して管理することができるようになります。
ポイントとしては、

  1. LayersのコードはLambda実行環境の /opt に展開される
  2. /opt 以下のパスはライブラリ検索パスにデフォルトで含まれている

といった点でしょうか。上記を踏まえて webp 対応の ImageMagick を Layers に移行させます。

とはいっても、先程の実行バイナリをビルドする際に指定したディレクトリを /var/task ではなく /opt などにするだけです。

# Lambda Layerが展開される /opt 以下で作業
cd /opt

## libwebpのinstall
curl -sLO https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.0.0.tar.gz \
  && tar zxvf libwebp-1.0.0.tar.gz \
  && cd libwebp-1.0.0 && ./configure --prefix=/opt && make && make install \
  && rm -rf ../libwebp-1.0.0.tar.gz ../libwebp-1.0.0

## webp compatibleなImageMagickのビルド
curl -sLO https://www.imagemagick.org/download/releases/ImageMagick-6.9.10-34.tar.gz \
  && tar zxvf ImageMagick-6.9.10-34.tar.gz \
  && cd ImageMagick-6.9.10-34 && ./configure --prefix=/opt --with-webp=yes \
  && make && make install \
  && rm -rf ../ImageMagick-6.9.10-34.tar.gz ../ImageMagick-6.9.10-34

上記でビルドされたものをパッケージング(zip)化し Layers へアップロード。あとはAWSコンソール上から、Lambda と Layers を紐付けるだけで完了です。

ここまでの流れをフレームワークを使って構成管理する & ローカル実行環境を構築する

継続的に開発を行う上で、上記の流れを手作業で行うのはかなり骨が折れる作業です。 そこで Serverless アーキテクチャの構成管理、ローカル実行、デプロイが可能なフレームワークを導入します。
候補として挙げられるのは以下2つでしょうか。

  • AWS SAM(Serverless Application Model)

  • Serverless Framework

ここでは両者を比較することはせず、Eightですでに導入実績のあった Serverless Framework を使って構成管理していきます。

Docker Imageを自作し、Serverless Frameworkをインストールする

本記事でも何度も登場している Docker ですが、native extensions や Layers を管理するためにもちろん必要となります。 また、Serverless Framework の機能にあるローカル実行する環境としても利用します。 ここでは以下のような独自の Docker Image を作成し管理するようにしました。

  1. LayersはDocker Imageとして管理する
    システム依存な Layers は Dockefile に記述し Docker Image 内に閉じ込める。 Layers に更新があった場合は Docker Image を再ビルドすればよい。
  2. Serverless Framework を Docker Image にインストールする
    Docker コンテナ上でビルド、デプロイ、ローカル実行を行えるようにする。
  3. Lambda関数, serverless.yml, vendor/bundle はイメージに含めない
    コンテナ立ち上げ時に、ホスト上のディレクトリを /var/task にマウントする。 こうすることで実装が容易になり gem の追加、インストールも柔軟に行える。

Dockerfileの例は以下のようになります

FROM lambci/lambda:build-ruby2.5
# 作業環境の構築に必要な環境変数の定義
ENV PKG_CONFIG_PATH /opt/lib/pkgconfig
# Lambda layer相当の環境を作成
WORKDIR /opt
## libwebpのinstall
... (Layers作成時と同様の処理)
## webp compatibleなImageMagickのビルド
... (Layers作成時と同様の処理)

# serverless frameworkのインストール
RUN curl -sL https://rpm.nodesource.com/setup_6.x | bash - \
  && yum -y install nodejs && yum -y clean all \
  && npm install -g serverless

WORKDIR /var/task/
CMD ["/bin/bash"]

Serveress Frameworkでの Lambda, Layersの管理

serverless.yml の設定を以下のようにすることで、Lambda と Layers を簡単に紐付けることができます。

layers:
  webpimagemagick:
    path: copy_layers # /opt のシンボリックリングを貼った上でdeployする
    description: WebP compatible ImageMagick

functions:
  func1:
    handler: func1.execute
    layers:
      - {Ref: WebpimagemagickLambdaLayer}
    events:
      s3: card-images

  func2:
    handler: func2.execute

ビルドした Docker Image のコンテナ上での開発作業例

  • serverless(sls) deploy : functionsとlayers等のデプロイ
$ sls deploy
...snip
Serverless: Uploading service aws-ruby-function.zip file to S3 (4.02 MB)...
Serverless: Uploading service webpimagemagick.zip file to S3 (47.44 MB)...
...snip

api keys:
  None
endpoints:
  None
functions:
  func1: eight-image-proccessor-dev-func1
  func2: eight-image-proccessor-dev-func2
layers:
  webpimagemagick: arn:aws:lambda:ap-northeast-1:xxxxx:layer:webpimagemagick:9
Serverless: Removing old service artifacts from S3...

functions と layers がそれぞれ別にパッケージング化されデプロイされていることがわかります。

  • serverless(sls) invoke local -f function名 : 関数の(ローカル)実行
# function.rb
def hello(event:, context:)
  {
    statusCode: 200,
    body: JSON.generate('Go Serverless v1.0! Your function executed successfully!')
  }
end
$ sls invoke local -f hello
{"statusCode":200,"body":"\"Go Serverless v1.0! Your function executed successfully!\""}

ローカル環境での Lambda の動作確認もできます🎉

CI/CD環境

上記 Docker Image を活用することで CI/CD 環境も容易に構築することが可能になります。 Eightでは、CircleCI で自動テスト実行し、特定のブランチにマージされたら、自動で各AWS環境にデプロイする形を取っています。
設定などは長くなるため割愛しますが、以下のような構成となっています。 f:id:yotaro-fujii:20190415033522p:plain

最終構成

最終的な構成は以下のようになり、当初理想としていた構成を作り上げることができました。 f:id:yotaro-fujii:20190415033534p:plain

ここまでの作業を簡単にまとめると、

  • native extensions、外部ライブラリのビルド、layers を利用することで複雑な要件の Lambda 関数を作ることが可能に
  • Docker 環境やフレームワークを有効に活用し、Lambda Function や Layers を管理することで継続的な開発が可能に

運用面

当初課題となっていた、運用面でも以下の点で大きく改善されました。

  • ピークタイムでも Lambda が自動でスケールし滞留がほぼ0に
    • 運用者が手動で行っていた運用コストもなくなった
  • 滞留がなくなることで、画像取得APIでのパフォーマンス劣化も解消
  • スケーラビリティが確保されたことで、機能改善がしやすくなった
    • 画像フォーマットやサイズなどの拡張
    • 画像の品質の最適化など

おわりに

Eightの大規模な画像処理基盤を Lambda に移行する話を長々と書いてきましたが、Ruby で Lambda を扱えるようになったことで、業務において利用できるシーンがぐっと増えるのではないでしょうか。
本記事のようにサービス全体をサーバレス化するだけではなく一部の機能を Lambda 化するなどミニマムにスタートすることも可能です。

今回の Ruby サポートだけでなく、Layers といった便利な機能も次々にリリースされ、また Serverless Framework といった周辺技術もさらに盛り上がった印象です。 Eightの別の機能でも Lambda 含むサーバレス技術をもっと使っていく機会が増えていきそうで楽しみです!

資料でも公開しています!

本内容は、先月27日にAWS Japanさんで開催された AWS Serverless Tech/事例セミナー で発表させていただいた「Ruby on Lambdaで変わる大規模サービスの裏側」の資料でも詳細に公開していますので参考にしていただければと思います。 

speakerdeck.com

© Sansan, Inc.