iMind Developers Blog

iMind開発者ブログ

GCPのCloud RunでサーバーレスなTensorFlowの予測処理

概要

TensorflowでMNISTの手書き文字認識するWebアプリをFlaskで実装し、Cloud Run上で結果を表示するところまで実装する。

モデルの訓練はローカルで行う。

前半はモデルの生成部分。後半はFlask部分の実装とCloud Runでの実行について扱う。

余談

この手の処理だとAI PlatformとかServing等を検討したいところだけど、今回の要件は月に数回だけ実行されて実行時間は数十秒かかっても構わないという要件だったので、それならCloud Runでのんびりモデル読み込んで実行すればいいじゃんという話になりました。一般的にはあまりオススメできない構成だと思います。

AWS Lambdaも検討しましたが、動くことは動くけどDeployment Package Sizeの制限(512MB)が厳しくCloud Runの方が楽に動かせました。

Cloud Runで気になる点はメモリ2GBという制限。そこを超えないサイズのモデルである必要があります。

バージョン情報

  • tensorflow==2.0.1
  • Flask==1.1.1

プロジェクトのフォルダ構成

Webアプリを動かすappと、モデルの生成を行うtrainの2つのディレクトリ(それぞれ別のDockerfileとrequirements.txtを持つ)を作成する。

.
├── app
│   ├── Dockerfile
│   ├── app.py
│   ├── index.html
│   └── requirements.txt
├── docker-compose.yml
└── train
    ├── Dockerfile
    ├── requirements.txt
    └── train.py

docker-compose.yml

docker-composeの記述はこんな感じ。

version: '2'

services:

  tf-cloudrun-app:
    build: app
    command: "python app.py"
    user: "${UID}:${GID}"
    volumes:
      - ./app:/usr/app/
    ports:
      -  8000:8000
    mem_limit: 1800m

  tf-cloudrun-train:
    build: train
    tty: true
    command: "bash"
    user: "${UID}:${GID}"
    volumes: 
      - ./train:/usr/app/

train用の環境はbashだけ立ち上げてtrainのコマンドは中に入って打つ想定。

Cloud Runの制限に引っかからないようにmem_limitを1800m(なんとなくきもち小さめ)にしている。

train/train.py

適当な手書き文字認識のモデルを作るコード。

import tensorflow as tf
from tensorflow.keras import layers

# load mnist digits
(x_train, y_train), (x_val, y_val) = tf.keras.datasets.mnist.load_data()
x_train = x_train.reshape([60000, 28, 28, 1]) / 255.
x_val = x_val.reshape([10000, 28, 28, 1]) / 255.

# model 
model = tf.keras.models.Sequential([
    layers.Convolution2D(64, (4, 4), activation='relu', input_shape=(28, 28, 1)),
    layers.MaxPooling2D((2, 2)),
    layers.Dropout(0.1),
    layers.Convolution2D(64, (4, 4), activation='relu'),
    layers.MaxPooling2D((2, 2)),
    layers.Dropout(0.3),
    layers.Flatten(),
    layers.Dense(256, activation='relu'),
    layers.Dropout(0.5),
    layers.Dense(64, activation='relu'),
    layers.BatchNormalization(),
    layers.Dense(10, activation='softmax')
])
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

# tensoboard callback
tensorboard_callback = tf.keras.callbacks.TensorBoard(log_dir="logs")

# fit
model.fit(
    x_train,
    y_train,
    epochs=100,
    validation_data=(x_val, y_val),
    callbacks=[tensorboard_callback])

# 保存
tf_conv = tf.lite.TFLiteConverter.from_keras_model(model)
lite_model = tf_conv.convert()
with open('digit_model.tflite', 'wb') as w:
    w.write(lite_model)

# tensorflow 1.4系の場合はこっち
# model.save('digit_model.h5')
# tf_conv = tf.lite.TFLiteConverter.from_keras_model_file('digit_model.h5')
# lite_model = tf_conv.convert()
# with open('digit_model.tflite', 'wb') as w:
#     w.write(lite_model)

train/requirements.txt

requirementsのファイル作ったはいいけど特に追加で入れるものはなかったや。

# 特に記述なし

train/Dockerfile

Dockerfileはtensorflowの公式のイメージを使います。

自前でalpineからセットアップしようかと思ったけど、やってみるといろいろハマりどころが多いしバージョン変わると別のとこでハマるしでいいことがなかったので、公式を使うのが1番という気持ちになりました。

CMDはdocker-compose側で実行するので省略。

FROM tensorflow/tensorflow:2.0.1-py3

COPY . /usr/app/
WORKDIR /usr/app/
RUN pip install -r requirements.txt

trainの実行

.envにUIDとGIDを記述しておく。

echo "UID=`id -u`" > .env
echo "GID=`id -g`" >> .env

build

$ docker-compose build

up

$ docker-compose up

trainの実行

$ docker-compose exec tf-cloudrun-train bash
$ python train.py

これでスペックにもよるけど小一時間程度でモデルが出来上がります。

app/app.py

続いてwebserver側の実装。

毎回モデルを読み込んでpredictするという不経済なことをやっている。

import io
import base64
import flask
import flask_wtf
import wtforms
import numpy as np

import tensorflow as tf
from PIL import Image

app = flask.Flask(__name__, template_folder='.')
app.secret_key = 'o-reha-jaian-ga-kidai-sho'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024

@app.route('/', methods=['GET', 'POST'])
def index():
    result = None
    image_base64 = None

    form = FileForm()

    # POST時はモデルを読み込んで予測処理
    if flask.request.method == 'POST' and form.validate_on_submit():
        # 画像の読込みと整形
        fs = flask.request.files['image']
        image = Image.open(fs.stream).convert('L')
        buf = io.BytesIO()
        image.save(buf, format="JPEG")
        buf.seek(0)
        image_base64 = base64.b64encode(buf.getvalue()).decode()
        image = np.array(image.resize([28, 28])) / 255.
        image = image.astype(np.float32).reshape(1, 28, 28, 1)
        # モデルの読込みと推測
        interpreter = tf.lite.Interpreter(model_path="digit_model.tflite")
        interpreter.allocate_tensors()
        input_details = interpreter.get_input_details()
        output_details = interpreter.get_output_details()
        interpreter.set_tensor(input_details[0]['index'], image)
        interpreter.invoke()
        result = interpreter.get_tensor(output_details[0]['index'])[0]
        # 結果を文字列に整形
        result = '推測結果={}, {}'.format(np.argmax(result), list(result))

    return flask.render_template(
            'index.html',
            form=form,
            image=image_base64,
            result=result)

class FileForm(flask_wtf.FlaskForm):
    image = wtforms.FileField('Image File',
            [wtforms.validators.DataRequired(message='ファイルを指定してください')])

if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=5000)

app/index.html

HTMLテンプレートの記述。

最低限の入力フォームと結果表示だけを記述している。

<form method="post" enctype="multipart/form-data">
  {{ form.csrf_token }}
  {{ form.image(class="form-input") }}
  <button>解析開始</button>
</form>

{% if image %}
  <p><img src='data:image/jpeg;base64, {{ image }}' />
{% endif %}


{% if result %}
  <p>{{ result }}
{% endif %}

app/Dockerfile

trainとほぼ同じ内容で、最後にgunicornでサーバーを起動するコマンドを入れています。

FROM tensorflow/tensorflow:2.0.1-py3

COPY . /usr/app/
WORKDIR /usr/app/
RUN pip install -r requirements.txt

CMD exec gunicorn --bind :$PORT --workers 1 --threads 2 app:app

app/requrements.txt

gunicornやFlask、Pillow等の必要なライブラリを入れています。

WerkzeugはFlaskの依存で入るものだと思うけど、これを記述している時点でバージョンの噛み合わせでエラーが起こったのでバージョン指定して動くようにしています。

gunicorn==19.9.0
Flask==1.1.1
Flask-WTF==0.14.2
Cython==0.29.15
Pillow==6.0.0
Werkzeug==0.16.1

cloud runへのbuild

下記を実行。名前は適当にhelloにしています。

$ PROJECT_ID="ここに利用しているproject idを記述"

$ cd app # Dockerfileを配置したappディレクトリで実行する
$ gcloud builds submit --tag gcr.io/$PROJECT_ID/hello

これでContainer Registryにイメージが登録される。

続いてイメージをcloud runにデプロイ。

$ gcloud beta run deploy --image gcr.io/$PROJECT_ID/hello

実行すると下記のような選択肢がでるので1を選択して続行。

 [1] Cloud Run (fully managed)
 [2] Cloud Run for Anthos deployed on Google Cloud
 [3] Cloud Run for Anthos deployed on VMware
 [4] cancel

リージョンはasia-northeast1(東京)を選択。

Please specify a region:
 [1] asia-east1
 [2] asia-northeast1
 [3] europe-north1
 [4] europe-west1
 [5] europe-west4
 [6] us-central1
 [7] us-east1
 [8] us-east4
 [9] us-west1
 [10] cancel

デプロイが終わるとURLが表示されるので、そこにリクエストして利用する。

コールドスリープなので1回目のリクエストは少し時間がかかります。

自前で描いた数字(黒背景)をアップロードすると、こんな画面が表示されます。

f:id:mwsoft:20200406035241p:plain

後片付け

cloud runのサービスの削除

$ gcloud beta run services delete hello

Container Registryからの削除

$ gcloud container images delete gcr.io/$PROJECT_ID/hello

改定履歴

Author: Masato Watanabe, Date: 2020-04-06, 記事投稿