この記事はうるる Advent Calendar 2019 17日目の記事です。
はじめに
この記事では、私が働いている会社、株式会社うるるで運営しているサービス幼稚園・保育園向け写真販売サービス えんフォトにおいて、写真アップロードの速度を爆速にするためにAWSのServerlessアーキテクチャを導入したお話をします。
サービスの内容や、導入に至った経緯などもお話ししたいところですが
話がとても長くなってしまいそうなので、アーキテクチャ導入にあたって考えたことや、細かい処理の説明にのみフォーカスし、記事を書きたいと思います。
※社内のエンジニア向けにLTした際のスライドも活用していますのでご了承ください。
対象の読者
- AWS(特にサーバーレス)について興味がある人/これから学びたいと思っている人
- 画像のアップロード速度に困っている人
- 画像を扱うサービスの開発を担当している人
注意点
かなり長めの記事です。
読み切るのに根気がいるかと思いますが、いいねやストックをすることで数回に分けて読むことをお勧めします。
システム構成(一部)
当アプリケーションは Laravel5系で構築されており、インフラ(IaaS)は主にAWSで構成されています。
webサーバーはAWS Elastic Beanstalkで管理しているEC2インスタンスです。
写真の保存ストレージはAWS S3、写真のサムネイルの作成は開発時にはすでにLambdaを用いて処理させていました。
DBはAWS RDS for MySQLを使用しています。
また今回のアーキテクチャには深く関わりませんが、当サービスでは、顔写真の検索にAWS Rekognitionを用いております。
移行前のアーキテクチャと構成
処理の流れ
- クライアントサイドでアップロード処理を実行すると、サーバーへ写真や紐付けなどの情報がPOSTされます。
- Laravel上で受け取った写真の拡張子やサイズなどが、制限内に収まっているかどうかをチェックします。
- 写真が正しいものと判定されたら、写真に関するデータをRDSに保存します。(写真名とか、リレーションIDとかそんな奴らです)
- DBへの保存後、s3へ写真をPUTします。この際はLaravel上でS3のAPIを使用します。
- S3へ写真が上がると、次はLambdaが起動します
- Lambdaは、写真のトリミングとサムネイルの作成のファンクションが順番に起動します。
※トリミング・・・4:3や16:9などの比率でアップロードされた写真を、現像写真サイズ用(89:127)にトリミングをします。
※サムネイル・・・ユーザーへの表示用に軽量化した写真です。
移行前の課題
移行前の構成における問題点は、画像のチェックやDBへのデータ保存、S3への写真転送など、一通りの処理が終わった後にクライアントへレスポンスが返るスタイルでした。
一枚の写真だけであれば対して問題は起きませんが、流石に1日に何千何万枚という写真がアップロードされ、サーバー上でその写真の処理をしていくと、いくつか問題が発生します。
そのうちの1つが、レイテンシーの発生でした。
真ん中より左側が、サーバーレス移行前のレイテンシーです。
日常的にもそうですが、瞬間的に負荷が上がるタイミングなどでは特にめちゃくちゃ飛び出ます。
サーバー台数を増やしたり、EC2のインスタンスサイズをあげたりすればこの負荷はもう少し分散することができますが
サーバーレスであれば瞬間的なアップロードにも対応でき、なおかつそれぞれの処理も速い状態を維持できるというメリットがあったので、サーバーレスの導入を検討しました。
何よりもサーバーレスを使いたかったのです。(笑)
サーバーレス化の後のレイテンシは見ての通りです。
効果は抜群でした。
新アーキテクチャ決定までにやったこと
当初、考案したアーキテクチャ
まず、やりたかったことは以下です。
EC2サーバーを一切介さないというのが最大の目標として考えていました。
写真は一枚一枚が重いですし(数MB)、事業拡大とともに扱う写真枚数も右肩上がりになります。事業拡大に負けないくらいのシステムするというのも今回の目標でしたので、スケーラブルという魅力のあるサーバーレスに写真の処理を全任せすることを目指しました。
しかし、いくつかの障害にぶつかることとなります。
API Gatewayの問題点
しかし、Lambdaの呼び出しペイロード制限6MBに直面しました...
画像の送信サイズは20MBまであげたかったので、この方法は採用できませんでした。
DynamoDBの断念
- Rekognition(顔認証)のデータ
- 顔認証のデータが大量にあり、RDSのストレージを圧迫する原因となっている
- オートスケールするDynamoに入れ、Lambda + Dynamoで顔認証を実装し、なんかイケてそうなアーキテクチャにしたかった
Dynamo使いたい
- DynamoDB + Laravelがイマイチ?
- ライブラリにより、ORMなどをDynamoでもサポートするようにはできる
- 不可能ではない。というか多分できる。
- ただ、Mysql + Dynamoは管理しづらいかもしれない
- 開発期間が足りない
- Step FunctionとかAPIGatewayとかAuroraとか、不確定要素が多い中で一人で進めるプロジェクト
- リリース時期の目標がある中で、Dynamoの検証を進める時間が足りなそうだった(開発期間3ヶ月くらい)
- 何よりも優先度が低かった
DynamoDBについては、完全に使いたい欲が先行していました。
本来であれば、アプリケーション全体をサーバーレスへ対応させるため、MySQLを丸っとDynamoDBへ移行したかったですが
流石に検証期間が短すぎるため、一部だけ移行という方法を考えましたが
それでも今回は優先度が低かったため、見送りになりました。
Aurora Serverlessの検討
DynamoDBは、RDS+Lambdaの相性の悪さから考えた案でしたが
その次にAurora Serverlessを検討しました。
DataAPI
このDataAPIという、サーバーレスにとって最高の機能を使えば大体の問題は解決すると考えました。 ただし、大問題が...ユースケース(公式ドキュメント抜粋)
- 使用頻度の低いアプリケーション
- 新規アプリケーション
- 可変ワークロード
- 予測不可能なワークロード
- 開発およびテスト用データベース
- マルチテナントアプリケーション
開発およびテスト用データベース だと...
本番環境向きではないと、公式が示しています。
これはあかんと思い、AWSのサポートに直接聞いてみました。
すると以下のような回答が返ってきました。
本番アプリケーションの要件に合致してたら使っていいよ。でもちゃんとテストしてね!
なるほど...テストしてみて使えそうだったら使ってもいいのか。
そう思い、早速検証に入りました。
AuroraとAuroraServerlessの検証
AuroraとAuroraServerlessを対象に、サーバーレスに耐久できるかどうか検証をしました。
検証内容については本記事とテーマが少しずれてしまうため、また別の記事で紹介させてください。
結果は、AuroraServerlessの圧勝という結果でした。
Auroraってだけあり、パフォーマンスもそれなりに高く、安定して動いてくれました。
本番環境でも問題なく動かせるのではないかという結果を導くことにも成功しました。
しかし、ここでも大きな問題が発生しました...
Aurora Serverlessの開発環境
開発環境でAuroraServerlessを使おうとする際の方法は以下2つでした。
- 実際のAWSリソースを準備しローカルからアクセスする
- Dockerコンテナで仮想的にAuroraServerlessを構築する
AuroraServerlessは割と新しいサービスで、コンテナはかなり難易度が高かったため、実際のリソースを使うことにしました。
しかし、AuroraServerlessの制限でAuroraにパブリックIPを割り当てることができませんでした...
開発環境からAuroraServerlessにアクセスするには、パブリックIPを割り当てる必要があったため、これは大打撃です。
時間をかけAuroraServerlessを検証し、やっと使える!というところまできたところでこれはかなりショッキングな制限でした。
ショックすぎて、AWSサポートに問い合わせてどうにかならないか聞きましたが、ドキュメント以上のことは返ってきませんでした...
そりゃそうだ、、、
AuroraServerless自体、新しいのでAWS側でも細かいところまでは回答ができないみたいだ。
Aurora Serverlessの断念
DataAPIという強力な機能のためにいろんなものを犠牲にしようとしました。
AWS新サービス特有の情報の少なさ、ユースケース外の使用、問題が発生した時はマンパワーで頑張るという覚悟。
しかし、開発環境を整えることができないという問題により選択肢から除外せざるを得ませんでした。
結果的には、特にDBのようなアプリケーションの基盤となるようなものに関しては、ナレッジの少ない選択肢を選ぶのにはリスクが非常に高いので、採用しなくて正解だったとも言えます。
この当時、精神的に色々やられましたがなんとか諦めをつけることができました。
まさに、失恋した気分でした。
RDS + Lambdaの検証
AuroraServerlessは諦めましたが、RDSは諦めることはできません。(というかこれで行くしかない)
Auroraへの移行も考えましたが、社内事情などもありながら、とりあえず現状通りRDSMySQLでトライしてみることにしました。
一般的に、RDS + Lambdaはアンチパターンと言われがちですが
Lambda自体の運用実績はあり、同時起動数がそこまで高くなっていないことは、モニタリングからも明白でした。
では実際にどのくらいの瞬間風速で同時起動数が跳ね上がるのかを検証してみました。
現状のサービス規模であれば、コネクション問題を回避することが可能であるということが検証により明らかになりました。
また、ある程度事業拡大しても、耐えられることができるということまで示すことができたため、RDS+Lambdaを採用することに決まりました。
このように、紆余曲折を経てアーキテクチャの決定までこぎつけることができました。
新アーキテクチャと構成
以下の画像が新アーキテクチャです。
※お絵描きが下手なのは見逃してください...
どうでしょうか。大まかに説明します。
まずクライアントからS3に直接写真を突っ込み、それに反応し、AWS StepFunctionsのステートマシンが起動します。
ステートマシン内で、それぞれの処理をするLambdaファンクションが順々に実行されていき、DBへのデータ登録やサムネイルなどが作成され終了です。
① 写真のアップロード
本記事のタイトルは「アップロード速度を爆速にした話」でしたね。
(長すぎて忘れてしまっていたらすみません...)
実はこの部分こそが、写真のアップロード速度を爆速にできた要因となっています。
写真のアップロード速度は、ブラウザ上でsubmitしてから、レスポンスが返ってくるまでの時間です。
移行前の構成では、レスポンスを返すまでに色々な処理が走ったため、待ち時間が発生してしまっていました。
ですので、これをシンプルにS3に写真を保存したら即レスポンスにするのが理論上、最も速いです。
写真を保存するだけなら、サーバー経由でもできますが、それは最速ではないです。
S3に直接
これが最速です。速くするからには最速にこだわりました。
速いは正義です。
ですが、S3に直接送信するためには、セキュリティの問題が存在しました。
当初Cognitoなどの認証サービスを使用することでセキュリティを担保することを考えましたが、このためだけに認証サービスを入れるのはお金も時間もコストがかかりすぎました。
そこで見つけたのが S3の署名付きURLでした。
(参考:https://dev.classmethod.jp/cloud/aws/generate-s3-pre-signed-url-by-aws-cli/)
この署名付きURLを用いてアップロードを行うことで、セキュリティを担保しながらS3への直接アップロードを実現しました。
基本的にはSDKの使い方そのままなので非常に簡単でした。
サンプルコード
// PHP-SDK sample code
$s3Client = new Aws\S3\S3Client([
'profile' => 'default',
'region' => 'us-east-1',
'version' => '2006-03-01',
]);
$cmd = $s3Client->getCommand('PutObject', [
'Bucket' => 'my-bucket',
'Key' => 'testKey'
]);
$request = $s3Client->createPresignedRequest($cmd, '+20 minutes'); // ここでURLの有効期限を設定する
出力すると以下のようなURLになります。
echo $request;
https://my-bucket.s3.us-east-1.amazonaws.com/testKey?
X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=AKIAxxxxxxxxxxxxxxxxxxxxxx
X-Amz-Date=20191027T091851Z&
X-Amz-SignedHeaders=host&
X-Amz-Expires=1800&
X-Amz-Signature=33265d9ee979db5axxxxxxxxxxxxxxxxxxxxx
クライアントサイド(JavaScript)では、このエンドポイントへ向けて、写真をPUTすることでアップロードすることができます。
② ステートマシン(Step Functions)の起動
S3からステートマシンを起動させるにはCloudTrailとCloudWatchLogsを使用します。
詳しくは公式ドキュメントに記載してありますので割愛させていただきます。
https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-cloudwatch-events-s3.html
③ ステートマシンによる、写真の処理
今回のサーバーレス化を達成するためのコアとなる部分です。
StepFunctionsについては、色々な入門的な記事があるので、そちらを参考にしてみてください
(https://qiita.com/kooohei/items/150d03775536fe7ce273)
基本的には、LambdaやDynamoDB、SQSなどのAWS各種サービスを使い、ワークフローを構築できるというサービスです。
並行処理や条件分岐、失敗時のリトライや、エラーをCatchしての処理などの機能が備わっています。
移行前のアーキテクチャでは、LambdaをS3トリガーで単一的使っていました。
そこには、エラー時に捕捉できず、失敗したらそのままにするしかない、などというサーバーレス特有の問題がありました。
StepFunctionsを使うことで、自由にリトライさせることもできますし、並列処理を用いて、サムネイルを数パターン作らせるとか、Rekognitionも同時に実行することなど、色々な処理を構築することができることが魅力的です。
※StepFunctionsで構築したワークフローをステートマシンと言います。
ステートマシンの定義
今回は、割と単純な構成です。
各種データのDBへの保存、トリミング、サムネイル
失敗時には、レコードをエラーステータスに更新する。
といった処理を行っています。
ステートマシン定義の一部
今回構築したワークフローは比較的単純なので、最初の部分のみステートマシンの定義方法を紹介します。
※ステートマシンの定義を記述したファイルをステートファイルと呼びます。
ポイントはRetryの部分です。
待機時間に関しては、図中の説明の通りですが、今回のアーキテクチャ特有のエラーハンドリングが2つあります。
Lambda.TooManyRequestsException
これは、急激に大量のLambdaを起動した際に発生するエラーになります。
瞬間的な負荷にも耐えなければいけないですが、こちらはAWSが制限を設けているので、ある程度仕方ないエラーです。
この場合は、ちょっとだけ時間をあけてリトライさせて回避しています。
Lambda.ENILimitReachedException
こちらは、VPCのENIの制限です。(ENIの説明は省きます)
Lambda + RDSを使用した場合、RDSと同じVPC内に起動させますが、LambdaとRDSの接続をする際に、ENIが1つ生成されます。
しかも、このENIはLambdaコンテナが消えても一定時間残り続けるので、短い時間に大量のLambdaが起動してしまうとENIが爆増し、制限に達してしまいエラーとなります。
ENIの消滅に時間がかかるので、待機時間も長めにとっています。
ただこちらは、ちょうどリリースする直前にAWSが改善してくれました。
この発表には飛び跳ねました。
https://aws.amazon.com/jp/blogs/news/announcing-improved-vpc-networking-for-aws-lambda-functions/
このように、エラーに対して適切なハンドリングを設定できたのがStepFunctionsの魅力です。
失敗するとレコード中のエラーステータスを更新するようにしておりますが、エラーの種類によって実行Functionを切り替えて、ステータスコードを別のものにするなどもできた方がデバッグに役立ったかもしれません。
(エラー内容をfunctionへ受け渡すこともできたような記憶も...)
CICD
さて、上記で写真を爆速でアップロードするための仕組みが整いました。
ここからはCICDの仕組みの構築です。
我々エンジニアは、作ればOKという世界でありませんよね。
作ったものを安全に正確にデプロイ(世の中へ供給)する必要があります。
ServerlessFrameworkによるサーバーレスデプロイ
StepFunctionsやLambdaのデプロイにはServerless Frameworkを使用しています。
サーバーレスであるLambdaはもちろんGCP、AzureなどのサービスをCLIからデプロイできるツールです。
こちらも細かい使い方は省きますが、設定ファイルであるserverless.yml
の設定をご紹介します。
(参考:https://qiita.com/horike37/items/b295a91908fcfd4033a2)
Lambdaの定義
ここでは1つのFunctionを紹介します。
functions:
storePhotoData:
handler: storePhotoData.handler
environment: ${self:custom.db}
layers:
- { Ref: LambdaLayer }
tracing: Active
vpc: ${self:custom.vpc}
description: create storePhotoData for ${opt:env_name}-${opt:stage}
memorySize: 2048
timeout: 10
reservedConcurrency: ${self:custom.reservedConcurrency.storePhotoData}
role: storePhotoDataLambdaFunctionRole
設定自体は普通ですが、yamlの環境変数を用いて、ステージング、本番などの各環境別にそれぞれの設定値を入れています。
environment: ${self:custom.db}
これとかです。
これは、ServerlessFrameworkの機能であるカスタム変数を利用し、各デプロイ環境ごとに値をセットしています。
(参考:https://www.queue-inc.tokyo/n/na08a319b5194)
- reservedConcurrency・・・Lambdaの同時実行数 -> 勝手に増えすぎないように制限
- db・・・RDSの接続情報
- VPC・・・セキュリティグループとサブネットの情報
実は、弊社ではえんフォトと同様、写真販売サービスクラプリというのも運営しており、基本的な構成はえんフォトと同じなので、同時にデプロイできるようにしています。
えんフォトとクラプリ、本番・ステージング・開発環境でそれぞれ別の値をセットしたいため、カスタム変数を使っています。
都度、OS上の環境を読み込むため、基本的にはCircleCIの環境変数に事前にセットしておくことでデプロイします。
また、tracing: Active
にも注目です。
後述の監視で紹介しますが、AWS X-Rayを有効にするための設定です。
StepFunctionsの定義
Serverless FrameworkはデフォルトでStepFunctionsが使えないので以下のプラグインを入れることで対応します。
https://serverless.com/plugins/serverless-step-functions/
stepFunctions:
stateMachines:
enphotoSpica:
events:
- cloudwatchEvent:
event:
source:
- aws.s3
detail-type:
- AWS API Call via CloudTrail
detail:
eventSource:
- s3.amazonaws.com
eventName:
- PutObject
requestParameters:
bucketName:
- ${opt:env_name}-${opt:stage}
definition:
StartAt: store
States:
store:
Type: Task
Resource:
Fn::GetAtt: [storePhotoData, Arn]
Retry:
- ErrorEquals:
- Lambda.TooManyRequestsException # 呼び出し頻度のエラー
IntervalSeconds: 5
MaxAttempts: 5
- ErrorEquals:
- Lambda.ENILimitReachedException # ENIの制限エラー
IntervalSeconds: 30
MaxAttempts: 2
- ErrorEquals:
- States.ALL
IntervalSeconds: 1
MaxAttempts: 3
Catch:
- ErrorEquals:
- States.ALL
ResultPath: $.error
Next: updatePhotoDataToError
Next: Trimming
基本的には、ステートファイルの定義をyaml形式に変えたものになります。
CircleCIによる自動デプロイ
ServerlessFrameworkの準備ができたら、CircleCIで自動デプロイをするようにします。
references:
commands:
install_npm_dependencies: &install_npm_dependencies
working_directory: ~/app
command: |
sudo apt-get update
sudo apt-get -y install graphicsmagick imagemagick
sudo npm install -g serverless@1.49.0
cd ./serverless && npm install
cd ./layers/nodejs && npm install
sudo ln -fns /node-v${NODE_VERSION}-linux-x64/bin/serverless /usr/local/bin/serverless
sudo ln -fns /node-v${NODE_VERSION}-linux-x64/bin/sls /usr/local/bin/sls
which serverless
which sls
serverless --version
sls --version
大まかな流れは
serverlessの実行に必要なソフトウェアのインストール -> serverless deploy
の実行です。
デプロイは以下のように行います。CircleCIの適切な場所でコマンドを実行してください。
sls deploy --force --env_name enphoto --stage prod
--force
コマンドは、キャッシュなどで変更点がある場合でも反映してくれない事象が昔あったので、名残でつけています。
監視
さて、サーバーレスの実行、そして自動デプロイまで整いCIのための基盤は整いました。
ここからは、それを正しく運用するための監視基盤を紹介します。
(それほど大したものではありませんが)
AWS X-Ray
LambdaにX-Rayを簡単に導入できるため、お試しの意味も込めて導入しました。
Lambdaのエラーなどを可視化することができます。
単純な絵ですね...
(画像は全部緑ですが)失敗したりしていると色が変わってくれます。
また、実行時間のアベレージも表示してくれて便利です。
###エラーログのSlack通知
StepFunctionは基本的に大量実行に向いていないようで、ログがあまり充実していないです。
コンソールから見ることができるログも1000回までで、それ以前のログは遡れないです。
そのため、エラーが発生した時にはSlackに通知し、できるだけ早く調査対応を行えるようにしました。
クラスメソッドさんのこちらの記事を参考にさせていただきました。
https://dev.classmethod.jp/cloud/aws/step-functions-cloudwatch-event-slack/
Lambdaのソースコードは以下です。
#coding: UTF-8
from __future__ import print_function
import json
import urllib
import urllib2
import base64
import zlib
import datetime
import os
import time
slack_api_token = "xoxp-11111111111-22222222222-33333333333-4444444444"
url = "https://slack.com/api/chat.postMessage"
color = '#FFCC33'
channel = os.environ['slackChannel']
username = os.environ['slackUsername']
icon = os.environ['slackIcon']
def lambda_handler(event, context):
#ステートマシン実行ARN取得
sf_exec_arn = event['detail']['executionArn']
#ステートマシンのステータス取得
sf_status = event['detail']['status']
#メッセージ整形
message = "status : " + sf_status + "\n" + "result : " + sf_exec_arn
params = {
'token': slack_api_token,
'channel': channel,
'username': username,
"icon_emoji": icon,
"attachments": json.dumps([{
"color": color,
"text": message
}])
}
params = urllib.urlencode(params)
req = urllib2.Request(url)
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
req.add_data(params)
res = urllib2.urlopen(req)
body = res.read()
最後に
いかがでしたでしょうか。
割と長めの記事を書いたので、書くのも疲れましたが、最後まで読んでいただいた方々も疲れたと思います。
読んでいただきありがとうございました。
アップロード速度
肝心のアップロード速度がどうなったか気になりますよね。
こんな感じです!どーん
特定の写真で実際に計測した結果ですが、4倍速という結果になりました!
元々が少し遅めだったということもありますが、お客様には非常に好評をいただいております。
嬉しいですね。
AWS サポートの大切さ
プロジェクト開始時、最初は色々と自力でなんとかしようと踏ん張っていた時期がありました。
ですが、どうしても答えが見つからなかったり、ベストプラクティス的な問題にぶち当たったり、色々な困難がある中で、勇気を出してAWSサポートへ問い合わせをしてみた結果
とても参考になるアドバイスをくれることが結構ありました。
AWSの中の人が書いた記事などのリンクまで張ってきてアドバイスをくれたり、とても親切に対応してくださいました。
さすが天下のAWSさま。
これからも十分に活用させていただき、より良いサービスを作っていきたいと思います!
RDSの改善(Proxy)
実はつい先日、RDSの改善の発表があり、RDS + Lambdaアンチパターンが解消されようとしています。
https://qiita.com/G-awa/items/b9138cc1c9e4867a905e
これはやばい、いち早く導入して、不安要素を取り除きたいと思います!
まとめ
自己紹介が遅れましたが、私は新卒3年目の中堅(?一歩?二歩?手前の)エンジニアです。
サービス成長のために、自らサーバーレスによる改善を提案し、実行させていただきました。
AWSに関する書籍や、ネット記事、AWSカンファレンスなどにも参加し、サーバーレスについてほとんど何も知らない状態から色々な情報をインプットし、成し遂げることができました。
情報取集を含めると最初に動き始めてからは半年くらいの時間をこのプロジェクトに捧げました。
実際の稼働は3、4ヶ月ほどです。
その間には、特に社内のインフラエンジニアの先輩方には非常にお世話になりました。
リリース1ヶ月前くらいからは毎日終電前まで残業に付き合ってくださいました。
この場を借りてお礼申し上げます。
また、最後まで読んでいただいた皆さま、ありがとうございます。
ぜひ少しでもお役に立てた場合はいいねを押してください!!
終わり
Advent Calendar 17日目でした。
明日18日目は ryuichi fukudaさんによる記事を乞うご期待!
https://adventar.org/calendars/4548