TensorFlow を低コストのサーバレス（AWS SAM）で実行する

## はじめに

以前、 SAM を使って TensorFlow をサーバレスで実行する方法について記事を書きました

ざっくり言うと、超低コストで AI を動かそう、と言うことです

https://qiita.com/RyoWakabayashi/items/0734f7f0a76a5dad3770

しかし、その時から SAM がバージョンアップして、 `sam init` のときの質問などが変わってしまいました

また、最終的に出来上がったコードを GitHub に上げていなかったため、今どうすればいいのか少し迷子になりました

というわけで、改めて TensorFlow を SAM でビルド、デプロイする方法とコードをまとめておきます

実装コードはこちら

https://github.com/RyoWakabayashi/tensorflow-lambda

今回は `sam init` ではなく、このリポジトリーのコードを使ってデプロイする手順を紹介します

Lambda と SAM の解説は過去の自分に任せます

- [Lambda とは](https://qiita.com/RyoWakabayashi/items/0734f7f0a76a5dad3770#aws-lambda-%E3%81%A8%E3%81%AF)
- [SAM とは](https://qiita.com/RyoWakabayashi/items/0734f7f0a76a5dad3770#sam-%E3%81%A8%E3%81%AF)

## 必要な環境

- asdf
私はいつも asdf を使っているので、 asdf で言語やツールを入れています
もちろん、 asdf を使わずにインストールしても問題ありません
Windows だと使えませんしね

https://asdf-vm.com/

- docker
TensorFlow の実行環境をコンテナで作るため、 Docker が必要です
Docker Desktop が有償化されたため、私は Rancher Desktop を使っています

https://www.docker.com/

https://rancherdesktop.io/

https://qiita.com/RyoWakabayashi/items/158e6957d362709dc4d6

- curl
API をテストするのに使います

## セットアップ

リポジトリーをクローンしてきて、クローン先のディレクトリーに移動します

```bash
git clone https://github.com/RyoWakabayashi/tensorflow-lambda.git
cd tensorflow-lambda
```

asdf を使う場合は各種プラグインを追加してインストールしてください

```bash
asdf plugin-add aws-sam-cli \
  ; asdf plugin-add direnv \
  ; asdf plugin-add jq \
  ; asdf plugin-add nodejs \
  ; asdf plugin-add python \
  ; asdf plugin-add yarn
asdf install
```

## モデルのダウンロード

TensorFlow （Keras） で実行するモデルファイルをダウンロードします

```bash
python download_models.py
```

今回は MobileNetv2 を使っています

この辺りはこちらの記事を参考にしています

https://qiita.com/kyamamoto9120/items/f1cda89ffc7cb5254f17

download_models.py
```python
# coding: utf-8

import os

from tensorflow.keras.applications.mobilenet_v2 import MobileNetV2

os.makedirs("sam/classifier/models", exist_ok=True)

model = MobileNetV2(weights="imagenet")
model.save("sam/classifier/models/model.h5", include_optimizer=False)
```

学習済モデルはコンテナ実行中にダウンロードすると初回起動が遅くなるので、
必ずビルド時にダウンロードするようにコンテナを実装しましょう

## ビルド

sam ディレクトリーに移動します
SAM 関連の定義は sam ディレクトリー配下に置いています
ルートディレクトリーは主に pre-commit などの設定ファイルを置いています

```bash
cd sam
```

Docker が起動していることを確認してから、ビルドを実行します

```bash
$ sam build
Building codeuri: ...
...
Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
[*] Deploy: sam deploy --guided
```

Docker　コンテナのビルドが開始されます

しばらく待つと、 `Build Succeeded` と表示されます

### コンテナの内容

SAM の定義は以下のようになっています

sam/template.yml
```yml
...
Globals:
  Function:
    Timeout: 120
    MemorySize: 1000 # 必要な量に調整する

Resources:
  RestApi:
    Type: AWS::Serverless::Api
    Name: tensorflow-sample-api
    Properties:
      StageName: v1
      BinaryMediaTypes:
        - image~1jpeg # バイナリの JPG 画像を受け付けるようにする

  TensorflowSampleFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image # 関数を Docker コンテナで定義する
      FunctionName: tensorflow-sample
      Events:
        Inference:
          Type: Api
          Properties:
            RestApiId: !Ref RestApi
            Path: /predictions
            Method: post
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./classifier

Outputs:
  Api: # デプロイ実行後、 API の URL を表示する
    Description: "API Gateway endpoint URL for v1 stage for Tensorflow sample function"
    Value: !Sub "https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/v1/predictions"
```

`PackageType: Image` が肝ですね
`Metadata` で Docker コンテナの定義先を指定しています

sam/classifier/Dockerfile
```Dockerfile
FROM tensorflow/tensorflow:2.9.1

ENV PYTHONUNBUFFERED=TRUE
ENV PYTHONDONTWRITEBYTECODE=TRUE

# awslambdaric を実行するスクリプト
COPY entry.sh "/entry.sh"
RUN chmod +x /entry.sh

# hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip

# モデルファイルのコピー
RUN mkdir -p /opt/ml
COPY models /opt/ml/model
RUN chmod -R +r /opt/ml/model

WORKDIR /work

COPY ./requirements.txt /work/

# hadolint ignore=DL3006
RUN pip install --no-cache-dir --requirement requirements.txt

# 実行コードのコピー
COPY ./prg /work/prg
WORKDIR /work/prg
RUN chmod +x /work/prg/lambda.py
ENV PATH="/work/prg:${PATH}"

# awslambdaric を実行する
ENTRYPOINT [ "/bin/bash", "/entry.sh" ]

CMD ["lambda.lambda_handler"]
```

entry.sh は awslambdaric を使って Lambda サービスと実行コードのインタフェースの役割をします
これが動いていないと、コードが実行できません

sam/classifier/entry.sh
```bash
#!/bin/bash

exec /usr/local/bin/python -m awslambdaric "$1"
```

実行コードは以下のようにしています

sam/classifier/prg/lambda.py
```
# coding: utf-8

"""
メインモジュール
"""

import base64
import json
import logging
import traceback
from datetime import datetime
from io import BytesIO

import tensorflow as tf
from PIL import Image
from tensorflow.keras.applications.mobilenet_v2 import (
    decode_predictions,
    preprocess_input,
)
from tensorflow.keras.models import load_model
from tensorflow.keras.preprocessing.image import img_to_array

logging.basicConfig()
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# モデルのロード（起動時のみ実行される）
MODEL_PATH = "/opt/ml/model/model.h5"
model = load_model(MODEL_PATH, compile=False)


def lambda_handler(event, context):
    """画像分類応答"""

    try:

        begin_time = datetime.now()

        # API Gateway からはBase64になってくるため、デコードする
        img = Image.open(BytesIO(base64.b64decode(event["body"])))

        # MobileNetV2 用のサイズに変換する
        img = img.resize((224, 224))
        img = img_to_array(img)
        img = img[tf.newaxis, ...]
        img = preprocess_input(img)

        predictions = model.predict(img)
        predictions = decode_predictions(predictions, top=3)

        # 実行結果をレスポンス（JSON）用に整形する
        predictions = list(
            map(
                lambda prediction: {
                    "class": prediction[1],
                    "score": float(prediction[2]),
                },
                predictions[0],
            )
        )

        # 一定スコア以下の推論結果は排除する
        predictions = list(
            filter(
                lambda prediction: prediction["score"] > 0.3,
                predictions,
            )
        )

        logger.info("Elapsed time: %s", datetime.now() - begin_time)

        return {
            "statusCode": 200,
            "body": json.dumps({"success": True, "predictions": predictions}),
        }

    except Exception:  # pylint: disable=broad-except
        traceback.print_exc()
        return {
            "statusCode": 500,
            "body": json.dumps({"success": False}),
        }
```

## ローカル実行

デプロイ前にローカル実行します

```bash
$ sam local start-api
Mounting TensorflowSampleFunction at http://127.0.0.1:3000/predictions [POST]
...
2022-08-01 16:38:42  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
```

ローカルの 3000 番ポートで API が起動します

別のターミナルを起動時、ローカルの API に対してリクエストを投げます

`@imgs/sample.jpg` に部分は JPG であれば何でも良いです

```bash
$ curl -XPOST \
  http://127.0.0.1:3000/predictions \
  -H "Content-Type: image/jpeg" \
  --data-binary @imgs/sample.jpg | jq
{
  "success": true,
  "predictions": [
    {
      "class": "laptop",
      "score": 0.45820939540863037
    },
    {
      "class": "notebook",
      "score": 0.39051398634910583
    }
  ]
}
```

うまく動いていれば、 JSON が返ってきます

## デプロイ

初回デプロイ時はコンテナ用のリポジトリーなどの設定を作るため、ガイド付きで実行します

```bash
sam deploy --guided
```

基本的にデフォルトのまま答えていけば良いです

`TensorflowSampleFunction may not have authorization defined, Is this okay? [y/N]: ` だけは `y` を入力してください
今回はサンプルなので、認証を入れていません
本番運用時は API に認証を入れるようにしてください

しばらくすると、以下のように API の URL が表示され、デプロイが正常終了します

Value の `some-endpoint` になっているところが環境毎に違う値になります

```bash
...
-------------------------------------------------------------------------------------------------------------------------------
Outputs                                                                                                                       
-------------------------------------------------------------------------------------------------------------------------------
Key                 Api                                                                                                       
Description         API Gateway endpoint URL for v1 stage for Tensorflow sample function                                      
Value               https://some-endpoint.execute-api.ap-northeast-1.amazonaws.com/v1/predictions                                
-------------------------------------------------------------------------------------------------------------------------------
```

設定ファイルに設定を保存したので、2回目以降は `--guided` を付けずに実行できます

## テスト

デプロイ後に表示された URL に対してリクエストを投げ、テストします

```bash
$ curl -XPOST \
  https://some-endpoint.execute-api.ap-northeast-1.amazonaws.com/v1/predictions \
  -H "Content-Type: image/jpeg" \
  --data-binary @imgs/sample.jpg | jq
{
  "success": true,
  "predictions": [
    {
      "class": "laptop",
      "score": 0.45820939540863037
    },
    {
      "class": "notebook",
      "score": 0.39051398634910583
    }
  ]
}
```

実行ログを確認します

```bash
$ sam logs --stack-name tensorflow-sample
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:29.240000 OpenBLAS WARNING - could not determine the L2 cache size on this system, assuming 256k
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:31.837000 2022-08-01 08:00:31.837143: I tensorflow/core/platform/cpu_feature_guard.cc:193] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations:  AVX2 FMA
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:31.837000 To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:32.964000 START RequestId: 003de742-9b67-4d66-8dca-a5ba9aefed1a Version: $LATEST
1/1 [==============================] - 2s 2s/stepcf2 2022-08-01T08:00:34.687000 1/1 [==============================] - ETA: 0s
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:34.688000 Downloading data from https://storage.googleapis.com/download.tensorflow.org/data/imagenet_class_index.json
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:34.771000 8192/35363 [=====>........................] - ETA:35363/35363 [==============================] - 0s 0us/step
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:34.772000 [INFO]  2022-08-01T08:00:34.772Z        003de742-9b67-4d66-8dca-a5ba9aefed1a      Elapsed time: 0:00:01.795632
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:34.774000 END RequestId: 003de742-9b67-4d66-8dca-a5ba9aefed1a
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:34.774000 REPORT RequestId: 003de742-9b67-4d66-8dca-a5ba9aefed1a    Duration: 1809.27 ms    Billed Duration: 7092 ms        Memory Size: 1000 MB    Max Memory Used: 539 MB Init Duration: 5282.14 ms
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:36.518000 START RequestId: 405baf63-5782-4120-bdaa-9da3858ee1e8 Version: $LATEST
1/1 [==============================] - 0s 97ms/step2 2022-08-01T08:00:36.702000 1/1 [==============================] - ETA: 0s
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:36.703000 [INFO]  2022-08-01T08:00:36.703Z        405baf63-5782-4120-bdaa-9da3858ee1e8      Elapsed time: 0:00:00.175178
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:36.704000 END RequestId: 405baf63-5782-4120-bdaa-9da3858ee1e8
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:36.704000 REPORT RequestId: 405baf63-5782-4120-bdaa-9da3858ee1e8    Duration: 180.51 ms     Billed Duration: 181 ms Memory Size: 1000 MB    Max Memory Used: 544 MB
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:37.918000 START RequestId: 84105e41-83c1-47aa-a0e9-c211c5dde432 Version: $LATEST
1/1 [==============================] - 0s 133ms/step 2022-08-01T08:00:38.138000 1/1 [==============================] - ETA: 0s
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:38.139000 [INFO]  2022-08-01T08:00:38.139Z        84105e41-83c1-47aa-a0e9-c211c5dde432      Elapsed time: 0:00:00.210684
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:38.140000 END RequestId: 84105e41-83c1-47aa-a0e9-c211c5dde432
2022/08/01/[$LATEST]88c82f652d364d80b86a25b4689d1cf2 2022-08-01T08:00:38.140000 REPORT RequestId: 84105e41-83c1-47aa-a0e9-c211c5dde432    Duration: 216.40 ms     Billed Duration: 217 ms Memory Size: 1000 MB    Max Memory Used: 559 MB
```

3回実行すると、 Billed Duration が以下のようになっています

- 7092 ms
- 181 ms
- 217 ms

初回だけはモデルのロードに時間がかかってしまいますが、2回目以降は1秒未満で実行できていることが分かります
また、メモリ使用量は以下のようになっています

- 539 MB
- 544 MB
- 559 MB

メモリは 1GB あれば十分そうです

## 費用

### API Gateway

API Gateway の料金表はこちら

https://aws.amazon.com/jp/api-gateway/pricing/

100万リクエスト あたり 4.25 USD

1万回りクエストの場合、 0.04 USD しかかかりません

ただし、リクエスト 100 万件までは無料利用枠なので実質無料になります

### Lambda

Lambda の料金表はこちら

https://aws.amazon.com/jp/lambda/pricing/

Lambda は割り当てたメモリ量と実行時間で課金されます

GB-秒あたり 0.0000166667USD となっているので、

今回の関数を 1 万回実行した場合、

1 GB * 7 sec * 10,000 回 * 0.0000166667 USD = 1.167 USD

となります（厳密な 1GB ではありませんが）

ただし、 Lambda には 月当たり 100万リクエストと 40万 GB-秒 の無料利用枠があります

つまり、月に 1 万回程度しか実行しない場合は実質無料になります

### ECR

ECR　の料金表はこちら

https://aws.amazon.com/jp/ecr/pricing/

GB/月あたり 0.10USD

今回デプロイしたイメージは 467.98 GB でした

従っておよそ1月 0.5 USD になります

しかし、 ECR にも無料利用枠があり、 月々 500 MB までは無料です

と言うことで、こちらも実質無料になります

ただし、複数世代リポジトリーに保持していると 500 MB を超えてしまうため、
古いイメージは適宜削除しましょう

## まとめ

SAM でサーバレス構成を作ることによって、 TensorFlow を低コストで動かすことができました

大規模で大量リクエストの場合は考慮が必要ですが、
リクエスト数が少ない場合はかなり有用ですね