7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

いえらぶAdvent Calendar 2022

Day 6

画像サーバーをLambda移管した時のお話

Last updated at Posted at 2022-12-05

0.はじめに

無尽蔵にアクセスが考えられる画像に対して、まずnginxへアクセスを行い、キャッシュを挟んだ上でリバースプロキシでApacheへアクセスし、S3から画像を取得し、指定サイズにリサイズを行った上で返すアプリケーションを運用していました。

サーバーへのアクセス数は月間で数億あったため、水平スケールを拡大して数年間動いていました。

ただ、ある日突然のアクセススパイクが発生し、サーバーが応答しない事態が発生しました。

応急的にサーバーの水平スケールを拡大して対応しましたが、そもそもこれアプリケーション挟む必要あるのか...と考えてサーバーレスサービスへの移管を検討しました。

旧環境はオンプレ環境だったため、S3へのアクセスも物理的コストが高かったため、Lambdaを採用して環境を移管する事にしました。

1.Lambda環境の構築

以前はPHPでAWS SDKでS3から画像を取得⇒imagemagickでリサイズを行っていましたが、Python3.9を採用したため、PILとbotoで画像処理とS3からの画像取得を行いました。

※パスはお使いのS3に最適化させてください

lambda_function.py
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の料金が思ったより高かった)

  • アプリケーションで裁くにしても、スパイクの可能性があるようなサービスでは中間にサーバーレスを挟んだ方が、耐障害性が高い

こちらもよろしければお読みください

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?