0.はじめに
無尽蔵にアクセスが考えられる画像に対して、まずnginxへアクセスを行い、キャッシュを挟んだ上でリバースプロキシでApacheへアクセスし、S3から画像を取得し、指定サイズにリサイズを行った上で返すアプリケーションを運用していました。
サーバーへのアクセス数は月間で数億あったため、水平スケールを拡大して数年間動いていました。
ただ、ある日突然のアクセススパイクが発生し、サーバーが応答しない事態が発生しました。
応急的にサーバーの水平スケールを拡大して対応しましたが、そもそもこれアプリケーション挟む必要あるのか...と考えてサーバーレスサービスへの移管を検討しました。
旧環境はオンプレ環境だったため、S3へのアクセスも物理的コストが高かったため、Lambdaを採用して環境を移管する事にしました。
1.Lambda環境の構築
以前はPHPでAWS SDKでS3から画像を取得⇒imagemagickでリサイズを行っていましたが、Python3.9を採用したため、PILとbotoで画像処理とS3からの画像取得を行いました。
※パスはお使いのS3に最適化させてください
import boto3
import base64
import io
import boto3
import botocore.exceptions
import re
from PIL import Image
from io import BytesIO
# Lambda実行処理
def lambda_handler(event, context):
path = event['requestContext']['http']['path']
# 拡張子が入ってなかった時用にjpgを足す
if path[-1] == '.':
path = path + 'jpg'
pathSplit = path.split('/')
fileName = pathSplit.pop()
# この辺はリサイズするサイズをURLに組み込んでいるため発生する処理
fileNameSplit = re.findall(r'(\d+)_(\d+)_(\d*)_(\d*)_(\d+)(\.(jpg|jpeg))', fileName)
width = fileNameSplit[0][2]
height = fileNameSplit[0][3]
img = get_img_from_s3(fileNameSplit[0][0] + '_' + fileNameSplit[0][1] + fileNameSplitm[0][5])
pilImg = get_pil_img(img)
originWidth, originHeight = get_origin_image_size(pilImg)
# リサイズの要否
if (judge_resize(width, height, originWidth, originHeight)):
pilImg = resize_image(pilImg, width, height, originWidth, originHeight)
# base64に変換
base64img = pil_to_base64(pilmg, format="jpeg")
response = {
'headers': { "Content-Type": 'image/jpeg' },
"statusCode": 200,
"body": base64img,
'isBase64Encoded': True
}
return response
# s3からファイルを取得する
def get_img_from_s3(path):
s3 = boto3.client('s3')
bucket_name = '[バケット名]'
file_path = path
try:
responce = s3.get_object(Bucket=bucket_name, Key=file_path)
except s3.exceptions.NoSuchKey as e: # 画像が無かった場合
path = noimage_path()
responce = s3.get_object(Bucket=bucket_name, Key=path)
return responce['Body'].read()
# 画像が無い時のパスを返す
def noimage_path():
return '[画像が無い時に表示する画像]'
# 画像をIOバッファ上で展開する
def get_pil_img(img):
image_data = io.BytesIO(img)
return Image.open(image_data)
# base64への変換処理
def pil_to_base64(img, format="jpeg"):
buffer = BytesIO()
img.save(buffer, format, subsampling = 0, quality = 95)
img_str = base64.b64encode(buffer.getvalue()).decode("ascii")
return img_str
# 元の画像サイズを取得する
def get_origin_image_size(pil_image):
return pil_image.size
# リサイズが必要か判断する
def judge_resize(width, height, originWidth, originHeight):
if ((len(width) == 0 or len(height) == 0) or (int(width) == 0 or int(height) == 0)) or (int(width) >= int(originWidth) and int(height) >= int(originHeight)):
return False
else:
return True
# リサイズ処理
def resize_image(pil_image, width, height, originWidth, originHeight):
srcRatio = int(originWidth) / int(originHeight)
if (int(width) / int(height)) >= srcRatio:
width = int(height) * srcRatio
else:
height = int(width) / srcRatio
img_resized = pil_image.resize((int(width), int(height)))
return img_resized
(ソース結構ぼかしてます、すみません)
URLの中に、表示させたい横幅と縦幅をパラメーターとして挿入して、PILでリサイズを行っています。
起きた問題
- 画質の劣化が起きた
img.save(buffer, format, subsampling = 0, quality = 95)
ここでqualityを指定していなかったためデフォルト値が入りましたが、最高値の95を入れ、subsamplingを0にすることで解消しました
(subsamplingは-1の方が良かったかもしれない)
-
メモリの設定値の基準を決めるのが難しい
画像の容量にはある程度の幅を決めて、アップロードできないようにしていますが、縦幅、横幅に関して制限を設けていなかったため、
容量はそこまで大きくないが、サイズが異様に大きい画像を表示させようとした時にLambdaのメモリ限界に達しました。
これはサイズの大きい画像の時だけはアクセス先のLambda関数を分ける事で対応予定です。 -
ソースの更新がかなり面倒
EC2でPILをパッケージ化してソースコードと合わせてzipアーカイブする必要があるため、かなり手間
開発環境を作ろうにも、そもそもLambdaそのものにIAMロールを付与してるため、結局Lambdaに上げる事でしか挙動が確認できない
2.API Gatewayの設置(実際はしなかった)
自分の頭が古くて、LambdaでHTTPアクセスをするためには、API Gatewayがセットだろうと考えていたのですが、
月数億のアクセスにAPI Gatewayを挟むと、それだけで数十万円ほど掛かってしまう事に気づきました。
色々調べていたところ、Lambda関数にURLが発行できるようになっており、セキュリティ的には微妙かもしれませんが、Lambda関数URLを発行して対応する事にしました。
3.HTTPアクセスができない
API Gatewayはもとより、Lambda関数URLもhttpsのみのアクセスに限定されており、httpからのアクセスができません。
ですが、オンプレ環境のログを見ているとhttpでのアクセスも一定数存在する事が確認できました。
(本来アプリケーションが一元化されている場合は問題無いですが、今回は画像サーバーのドメインを指定する箇所が複数アプリケーションに及んでいるケースでした)
そこで何もキャッシュしない空のCloudFrontを挟むことにしました。
アクセス元⇔CloudFront間はhttp+httpsのアクセスを許可し、
CloudFront⇔オリジン間はhttpsのみのアクセスにする事によって、実質的にhttpでアクセスする事が可能となりました。
-
リクエストヘッダーのhost部分があるとLambda関数でエラーを返す
これは仕様のようですが、リクエストヘッダーに元のCloudFrontのホストがあると、Lambdaが正常に処理を行いません。
(これはAPI Gatewayでも同様でした)
なので、リクエストヘッダーポリシーでhostを除き、必要なもののみを渡すように変更します。 -
無駄な料金が発生する
キャッシュはしないがCloudFrontへのアクセスが発生するため、料金的にはややもったいない構造になるため、本来はアクセス元でhttpsへのアクセスを強制した方が良いです。
4.結果的に
-
オンプレサーバーは解雇する事ができ、AWS料金も圧倒的に安く済んだ
-
サーバーレスは圧倒的にスパイクに強い
-
知識が無いと、驚くべき料金が発生する可能性がある(主にAWS)
(API Gatewayの料金が思ったより高かった) -
アプリケーションで裁くにしても、スパイクの可能性があるようなサービスでは中間にサーバーレスを挟んだ方が、耐障害性が高い
こちらもよろしければお読みください