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

# 0.はじめに

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

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

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

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

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

# 1.Lambda環境の構築

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

**※パスはお使いのS3に最適化させてください**
```python: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でリサイズを行っています。

### 起きた問題
- 画質の劣化が起きた
```python
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の料金が思ったより高かった）
- アプリケーションで裁くにしても、スパイクの可能性があるようなサービスでは中間にサーバーレスを挟んだ方が、耐障害性が高い

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

https://qiita.com/ryoya1122/items/9927cba929ba33860c43