Lean Baseball

No Engineering, No Baseball.

scikit-learnで作った雑なモデルをAPIにしてFlask + GAE + Github Actionsでいい感じにデプロイした話 - 迷ったらGAEスタンダードで

日ハムのサヨナラ勝ちで変な声が出た人です.

あ, 斎藤佑樹選手お疲れ様でした😇*1

それはさておき, 今日は毎年参加している「PyLadies Tokyo ○周年記念パーティー(今年は7周年)」でこんなLTをしてきました.

Flask + Google App Engine(GAE)でWeb APIをデプロイするまで - Github Actionsを添えて / Flask + App Engine + Github Actions - Speaker Deck

前回のエントリーにて, Streamlitで作ったなんちゃってAIプロダクト「AIオオタニサン本塁打予測」をFlaskでAPI化してGAEにデプロイしてみたものなのですが, ここまでのストーリーを「PyCon JP 2021」で話す予定(故に #PyLadiesTokyo で素振りをしました)なのと, このLTで端折った(&PyCon JP 2021本番でも多分話す余裕が無いであろう)ネタがいくつかあるので, 発表の振り返り + 補足ということでブログに残します.

TL;DR

  • 「scikit-learnでモデルを実装して軽いAPIにしてみた」程度ならFlask + GAEスタンダードで実装・運用しても良さそう(ただし強くおすすめはしない)
  • Pythonでサーバーサイドつくるなら, GAEのスタンダード環境でほぼほぼ困らないのでは説
  • GAEへのデプロイはGithub Actionsで楽にいける

おしながき

全体構成&前提条件

まず作ったAPIはこういうイメージです

$ curl --location --request POST 'https://{今回作ったGCPプロジェクトのID}.appspot.com/predict' \
--header 'Content-Type: application/json' \
--data-raw '{
    "throw": "R",
    "pitch_speed_mph": 95,
    "pitch_type": "FF"
}'
{"result":"HOME_RUN"}

投手の利き腕, 球速(mph, マイル/時), 球種(FFは4シーム, CUでカーブ, etc...)をパラメータとしてPOSTしたら, オオタニサンが本塁打を打てるか否かを返すなんちゃってAIです.

headerとかを見て分かる通り, JSONを戻すAPIとして作っています.

上記はGAEのサービス上で動いており, 環境へのデプロイはGithub Actionsを使って行っています.

f:id:shinyorke:20211003182312p:plain
今回作ったAPIの構成

  • mainにプッシュしたら(pull requestをmainにマージしたら)テスト -> デプロイ
  • main以外のブランチへのpushはテストのみ

といった非常にシンプルなCIです.

なお, Flask APIの実装は雰囲気で伝えるとこんな感じです.

今回は特に解説しませんが, pydanticでAPIのRequest/Responseに型を付けています(FastAPI風に書いてます&Validationもその仕組の元動いています).

from flask import Flask, request
from flask.json import jsonify
from flask_pydantic import validate

from usecase import LABEL_ATBAT
from usecase.api import Api
from interfaces.view.request import PitchingModel as Pitching
from interfaces.view.response import IndexModel as Index
from interfaces.view.response import BattingResultModel as Result
from entities.form import Form
from entities import AtBat

app = Flask(__name__)

# bussiness logic
usecase: Api = Api(
    model_file="./model/ohtani_hr_model_app.joblib",
    dataset_file="./dataset/predict_shohei_ohtani_features03_app_dataset.csv",
)


@app.get("/")
def index():
    """
    index
    :return: status
    """
    result: Index = Index(status="ok")
    return jsonify(result.dict())


@app.post("/predict")
@validate(body=Pitching)
def predict():
    """
    prediction HR
    :return: Result At bat
    """
    at_bat: AtBat = usecase.predict(form=Form(**request.get_json()))
    response: Result = Result(result=LABEL_ATBAT.get(at_bat))
    return jsonify(response.dict())


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8080, debug=True)

repository内に予め学習済みのモデル(sklearnのロジスティック回帰を使っています)があり, アプリ内でロードして使ってるという前提で御覧ください.

from usecase.ml.bat import Bat
from interfaces.ml import DataFrame, read_csv   # それぞれpandas.DataFrame, pandas.read_csvを継承したもの
from interfaces.ml.model import BatterModel, load_model
from entities.form import Form
from entities import AtBat


class Api:
    def __init__(self, model_file: str, dataset_file: str):
        # modelはsklearnのモデルを継承しています&実態としては joblibでloadしています
        model: BatterModel = load_model(model_file)
        self.bat: Bat = Bat(model)
        self.df: DataFrame = read_csv(dataset_file)

    def predict(self, form: Form) -> AtBat:
        """
        Predict Baseball Play
        :param form: Form Object
        :return: Predict Result
        """
        return self.bat.predict_hr(form=form, df=self.df)

クリーンアーキテクチャ風に書いているので読みにくいかもですが,

  • Flaskのアプリ内でscikit-learnとpandasを使っている
  • Flaskのアプリ内では学習済みのモデルをloadして使っている(学習そのものはやらない)
  • これらはすべてGAE(Google App Engine)のサービスとして動いている

といった前提であると思っていただければ大丈夫です.

やったこと

やったこと(≒今日のLTのダイジェスト)です.

順序としては,

  1. APIを実装してGAEにデプロイするところまで雑にやる
  2. デプロイをGithub Actionsでやるように定義をいじったり権限を与える
  3. デプロイが自動化できたらテストを加えたりCIのワークフロー(順序・条件)を整える
  4. 完成

そんな感じです.

ひとまずAPIを実装

LT内でも触れましたが, 「Flaskでひとまず動くものがGAEでほしいよ」って感じなら, 公式の「App Engine スタンダード環境での Python 3 のクイックスタート」をそのまま写経するのが早いと思います.

cloud.google.com

サンプルコードを写経(もしくはDLしてそのまま動かす)みたいな感じで理解・納得行くまで触ったら大丈夫かなと思います.

gcloud app deploy

が手元で通ればあとはいい感じになります.

GAEスタンダード環境におけるPythonの制約事項

これは自分が触った感想・実感です.

  1. クイックスタートやサンプルにあるような「Flask・Djangoのアプリケーションを動かす」みたいなノリなら迷わずスタンダード環境でやる前提で開発
  2. 「scikit-learnで学習したモデルをAPI上でロードして使う」ぐらいの超軽い機械学習API程度ならGAEでやって良さそう, 他の機械学習系はアンチパターンな気がするのでやめておけ
  3. FastAPIのように, asyncを前提としたFWを使う時は一工夫が必要

クイックスタートやサンプルにあるような「Flask・Djangoのアプリケーションを動かす」 は, そもそもGAEの存在そのものがそのようなユースケースを想定して作られているPaaSなので特に異論はないと思います(想定してるからexampleやクイックスタートがあるわけで).

「scikit-learnで学習したモデルをAPI上でロードして使う」ぐらいの超軽い機械学習API程度ならGAEでやって良さそう は大きな誤解が生まれそうなので, ちゃんと説明すると

  • 「学習済みのモデルに, リクエストパラメータとしてもらったデータを食わせて何か答えさせる」的な, ガチャとかif文的な使い方をする軽いAIならGAEでいいと思う(AIオオタニサン本塁打予測がまさにそれ)
  • ↑のようなAPIが, 期間限定だったりどこかのタイミングで作り直す計画があるならガンガン使おう, 永続的に使うのは正直気が引ける
  • もっと複雑な機械学習・ディープラーニング, 特にモデルを定期更新したり学習も兼ねるようなML Opsが必要な場合は絶対にやめておくべき

個人的には,

「GAEスタンダード環境でscikit-learnが動くのかな」という興味本位でやったら動いたラッキー!

...ぐらいな感じでやりました.

これは, 個人開発なプロダクトや賞味期限が短いWebアプリ・サービスであればこの使い方を推奨するのですが, ML・AIが絡むプロジェクトって毎回そんな軽いわけではない*2のであくまでも「手っ取り早く成果がほしい時の最終手段」としてあればいいかなっていう認識*3です, 永続的に使うのは正直気が引けるの部分です.

最後の, FastAPIのように, asyncを前提としたFWを使う時は一工夫が必要 ですが, 個人的に

  • とある仕事で「FastAPI + Cloud SQLのpostgresql」につなぐようなAPIを作ったらGAEで動かずどハマりした.
  • ↑で, stackoverflowのコメントを参考にworkerを変更したらFastAPIは動いたが, DB接続は相変わらず不安定にorz
  • 考えてもわからんかったので, 結局Flask + pg8000でつなぐアプローチ(公式サンプルと同じ構成)で作り直した

経験があり, 「非同期に特にこだわりがなければ最初からFlaskとかDjangoで作ったほうが早いじゃん?」っていうパターンもあったりするので注意が必要です.*4

Github Actionsを使ってGAEにdeploy

公式の, 便利すぎるソリューションを使いました.

github.com

READMEの通りにcredentialを使って環境変数(credentialの中身をGCP_SA_KEY, プロジェクトIDをGCP_PROJECT_ID)を仕込めばデプロイは秒でできるようになりました.

# ci.yaml
name: CI
on:
  push:
    branches:
      - main
jobs:
  deploy-gae:
    name: App Engine Deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Deploy an App Engine app
        id: deploy-app
        uses: google-github-actions/deploy-appengine@v0.3.1
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          deliverables: app.yaml
          credentials: ${{ secrets.GCP_SA_KEY }}
          version: v1

この例ではmainブランチにpushした時にやる感じにしています.

テストを加える

デプロイが安定化したらCIによる自動テストを加えます.

テストは,

  • mypyによる型チェック
  • pytestによるロジックのテスト

をやっています.

バージョン管理にpoetryを使っているので, 事前にpoetryで環境作ってからテストを順番にやっていきます.

# ci.yaml
name: CI
on: push
jobs:
  test:
    name: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@master
      - name: Install
        uses: abatilo/actions-poetry@v1.5.0
        with:
          python_version: 3.9.7
          poetry_version: 1.1.7
          args: install
      - name: Run mypy
        uses: abatilo/actions-poetry@v1.5.0
        with:
          python_version: 3.9.7
          poetry_version: 1.1.7
          args: run python -m mypy .
      - name: Run pytest
        uses: abatilo/actions-poetry@v1.5.0
        with:
          python_version: 3.9.7
          poetry_version: 1.1.7
          args: run python -m pytest .
  deploy-gae:
    name: App Engine Deploy
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Deploy an App Engine app
        id: deploy-app
        uses: google-github-actions/deploy-appengine@v0.3.1
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          deliverables: app.yaml
          credentials: ${{ secrets.GCP_SA_KEY }}
          version: v1

ここでデプロイの条件を加えていて,

  • needs: test とあるように, testが通らないとデプロイができなようにしている
  • if: github.ref == 'refs/heads/main' とあるように, mainブランチへのpushの時のみデプロイを走らせる(他のブランチへのpush時はスキップ)している

これを応用すると, 「特定のブランチにpushしたときにQA環境(STG環境)へデプロイ」とかも行けるようになります*5.

結び

というわけで, 今日LTした内容にちょっとした補足を加えて紹介しました.

これの完成版はPyCon JP 2021で話すので来る方はお楽しみください.

最後になりましたが, PyLadies Tokyo 7周年おめでとうございました!

参考文献&おすすめ資料

以下は自分の仕事・書いた内容です(手前味噌)

tech.jxpress.net

tech.jxpress.net

Github Actionsはこちらも参考にしました

qiita.com

*1:個人的には戦力外か育成契約だと思ってましたが引退申し出は色んな意味で優しいなって思った.

*2:通常は試行錯誤の連続で, サービス化が決まった後もアーキテクチャを色々考える必要があります. このエントリーのネタアプリみたいに簡単な例ばかりではなかったりする.

*3:期間限定のサイト・アプリや実験目的で終わったら捨てるぐらいのノリならGAEでいいと思います.

*4:非同期がもたらす体験が必要か否か, 必要な場合どこで解決するか?という問いでもあると思っていて, GAEを使う場合は「そもそもGAEの前で非同期云々の体験は解決できそう」な気がするのでまあそういうことかなと思います, 認識違ってたらごめんなさい.

*5:QA/STG環境へのデプロイはGCPのcredentialとprojectを分けることになります, GAEに関係なくGCP利用時は本番と開発でそもそもプロジェクトとcredentialを分けるのがベストプラクティスになります.