Cloud RunでサーバレスMLOpsを試してみた

この記事は[MLOps Advent Calendar 2020](https://adventar.org/calendars/5089)の17日目の記事です。

もともとはGoogle Cloudからもうすぐ出てくる予定のFeature Storeの紹介でも書こうかと思ったのですが、まだ出てこないな...と思ってたら、Google Cloud ソリューションアーキテクトの中井さん（[@enakai00](https://twitter.com/enakai00)）がいい感じのサーバレスMLOpsソリューションをタイミングよく公開されてたので、紹介させていただくことにしました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">できた。<a href="https://t.co/COMDamTMQb">https://t.co/COMDamTMQb</a><br>This example shows how you can use Cloud Run and Cloud Workflows to create a simple ML pipeline. The ML usecase is based on the babyweight model example. <a href="https://t.co/S2oHVF9pfy">https://t.co/S2oHVF9pfy</a></p>&mdash; E. Nakai (@enakai00) <a href="https://twitter.com/enakai00/status/1338407033180016645?ref_src=twsrc%5Etfw">December 14, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# なぜサーバレスMLOpsか？

サーバレスMLOpsって言葉、中井さんのツイートを見て思いついたのですが、もうどこかで使われてますかね？　なぜサーバレスにしたいかという前提として、中井さんも書かれてた「k8s/GKEはそれほど手軽じゃない」問題があります。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">kubeflow pipelines とかだと、k8s の基盤を常時持つ必要がありますが、このやり方だと、必要な時だけ Cloud Run のインスタンスが立ち上がって、バックエンドのジョブも dataflow / AI Platform などのサーバーレスを使っているので、何もしてない時は、本当に何もありません。</p>&mdash; E. Nakai (@enakai00) <a href="https://twitter.com/enakai00/status/1338416095280025600?ref_src=twsrc%5Etfw">December 14, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

そこそこの規模の実運用環境であれば、[AI Platform Pipelines（Kubeflow Pipelinesのマネージドサービス）](https://cloud.google.com/blog/ja/products/ai-machine-learning/introducing-cloud-ai-platform-pipelines)がGoogle Cloudにおける定番のMLOps基盤になります。とはいえ、これ下は[GKE（マネージドk8s）](https://cloud.google.com/kubernetes-engine)で動いてます。つまり、VMを起動しておく必要があり、一定額のコストが発生します。

![AI Platform Pipelinesの構成](https://storage.googleapis.com/gweb-cloudblog-publish/images/2_stack.max-800x800.jpg)
AI Platform Pipelinesの構成

そこまでの規模も予算もないよ、って用途におすすめしたいのがサーバレスMLOpsです。中井さんが提案されたソリューションのように、Google Cloudであれば[Cloud Run](https://cloud.google.com/run)を使ってすべてをサーバレスで構築すれば、パイプラインを起動してないときはインスタンス代も発生しない（ストレージ代のみ）、というお手軽MLOpsが実現できます。あ、インスタンス止めるの忘れてた！　課金されてた...って残念なことも起きません。

![Screen Shot 2020-12-17 at 17.17.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/13863467-04ae-d7cd-f9d6-15735da9e8ae.png)

# サンプルの概要

中井さんが公開されたサンプル[workflows-ml-pipeline-example](https://github.com/enakai00/workflows-ml-pipeline-example)はこんな構成になっています。

![](https://github.com/enakai00/workflows-ml-pipeline-example/raw/main/docs/img/architecture.png)

このサンプルの例では、[babyweight](https://github.com/GoogleCloudPlatform/training-data-analyst/blob/master/blogs/babyweight_keras/babyweight.ipynb)という赤ちゃんの体重を予測する既存のモデルをパイプライン化しています。大きく分けると、

1. 上段：BigQueryからデータを取得、Cloud Dataflowで前処理してCloud Storageに保存
2. 下段：AI Platform Trainingでモデル学習、AI Platform Predictionにデプロイ

という2段階のパイプラインになっており、各段階をCloud Runで実行します。またサンプルの後半では、この前処理と学習のオーケストレーションを[Cloud Workflows](https://cloud.google.com/workflows)で自動化する例も出てきます。

# Cloud Runを動かしてみた

GCPプロジェクトを用意して、READMEに記載されているサンプルコードをコピペしていくだけですんなり動きました。`PROJECT_ID`に自分のプロジェクトIDをセットし、あとは他の環境変数の設定やGCSバケットの作成、そして関連するAPIを有効に設定して、サンプルコードをcloneします。

## Cloud Dataflowで前処理

で、まずは前処理を実行するCloud Runのコンテナイメージをビルドします。

```
gcloud builds submit --tag gcr.io/$PROJECT_ID/preprocess-service
```

このコンテナイメージには、サンプルに用意された以下のような前処理のコードが組み込まれます。

```services/preprocess/df_pipeline/pipe.py
def preprocess(limit, output_dir, job_name, region):
    options = {
        'region': region,
        'staging_location': os.path.join(output_dir, 'tmp', 'staging'),
        'temp_location': os.path.join(output_dir, 'tmp'),
        'job_name': job_name,
        'project': PROJECT_ID,
        'max_num_workers': 3,   # CHANGE THIS IF YOU HAVE MORE QUOTA
        'setup_file': './setup.py'
    }
    opts = beam.pipeline.PipelineOptions(flags=[], **options)
    RUNNER = 'DataflowRunner'
    p = beam.Pipeline(RUNNER, options=opts)
    query = """
SELECT
  weight_pounds,
  is_male,
  mother_age,
  plurality,
  gestation_weeks,
  FARM_FINGERPRINT(CONCAT(CAST(YEAR AS STRING), CAST(month AS STRING))) AS hashmonth
FROM
  publicdata.samples.natality
WHERE year > 2000
AND weight_pounds > 0
AND mother_age > 0
AND plurality > 0
AND gestation_weeks > 0
AND month > 0
    """
  
    if limit:
        query = query + ' LIMIT {}'.format(limit)
  
    for step in ['train', 'eval']:
        if step == 'train':
            selquery = 'SELECT * FROM ({}) WHERE ABS(MOD(hashmonth, 4)) < 3'.format(query)
        else:
            selquery = 'SELECT * FROM ({}) WHERE ABS(MOD(hashmonth, 4)) = 3'.format(query)

        (p 
         | '{}_read'.format(step) >> beam.io.Read(beam.io.BigQuerySource(query=selquery, use_standard_sql=True))
         | '{}_csv'.format(step) >> beam.FlatMap(to_csv)
         | '{}_out'.format(step) >> beam.io.Write(beam.io.WriteToText(os.path.join(output_dir, '{}.csv'.format(step))))
        )

    return p.run()
```

この前処理では、BigQueryでクエリを実行してデータを読み込み、欠損値（超音波診断結果がない場合）の対応や文字列変換、trainとevalへの分割等を行っています。

Cloud Runは、この前処理ジョブの起動をRESTで受け付けるエンドポイントとして動きます。そのために、このサンプルでは以下のようなコードを用いています。

```servies/preprocess/main.py
@app.route('/api/v1/preprocess', methods=['POST'])
def preprocess():

    <...snip...>

    result = pipe.preprocess(limit, output_dir, job_name, region)

    resp = {'jobName': job_name,
            'jobId': result.job_id(),
            'outputDir': output_dir}

    return resp, 200
```

Cloud Runでは、こんなふうにアノテーションを使ってエンドポイントURIと関数をマッピングできます。この例では、上述のDataflowコード`pipe.preprocess`を呼び出しているのが分かります。

このコンテナイメージのビルドが終わったら、Cloud Runにデプロイします。

```
gcloud run deploy preprocess-service \
  --image gcr.io/$PROJECT_ID/preprocess-service \
  --platform=managed --region=us-central1 \
  --no-allow-unauthenticated \
  --memory 512Mi \
  --set-env-vars "PROJECT_ID=$PROJECT_ID"
```

サーバレスなので、この段階ではインスタンスは生成されません。

# Kerasで学習

同様にして、学習用のコンテナイメージもビルドします。これには以下のようなKerasのモデルが組み込まれます。

```babyweight_model/trainer/model.py
def create_keras_model(learning_rate=0.001):
    wide, deep, inputs = get_wide_deep()
    feature_layer_wide = layers.DenseFeatures(wide, name='wide_features')
    feature_layer_deep = layers.DenseFeatures(deep, name='deep_features')

    wide_model = feature_layer_wide(inputs)

    deep_model = layers.Dense(64, activation='relu', name='DNN_layer1')(feature_layer_deep(inputs))
    deep_model = layers.Dense(32, activation='relu', name='DNN_layer2')(deep_model)

    wide_deep_model = layers.Dense(1, name='weight')(layers.concatenate([wide_model, deep_model]))
    model = models.Model(inputs=inputs, outputs=wide_deep_model)

    # Compile Keras model
    model.compile(loss='mse', optimizer=tf.keras.optimizers.Adam(lr=learning_rate))
    return model
```

このモデルでは、母親の年齢や妊娠週といった連続値は3層のMLPに入れ、性別や胎児数といったカテゴリ値は1層のNNに入れるという設計になっています。

![download (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/cf96aabc-f894-d0a7-7f8c-24cc20db1618.png)

このモデルに対して先程の前処理でCloud Storageに保存した学習データを使い学習を行いたいわけですが、そのためのCloud Runのエンドポイント定義は以下のようになります。

```services/train/main.py
@app.route('/api/v1/train', methods=['POST'])
def train():
    json_data = request.get_json()

    scale_tier = 'BASIC_GPU'
    region = 'us-central1'
    runtime_version = '2.2'
    python_version = '3.7'
    data_dir = None
    job_dir = None
    num_train_examples = '60000000'
    num_eval_examples = '50000'
    num_evals = '100'
    learning_rate = '0.0001'

    <...snip...>

        training_inputs = {
            'scaleTier': scale_tier,
            'packageUris': [package],
            'pythonModule': 'trainer.task',
            'region': region,
            'jobDir': job_dir,
            'runtimeVersion': runtime_version,
            'pythonVersion': python_version,
            'args': [
                '--data-dir', data_dir,
                '--num-train-examples', num_train_examples,
                '--num-eval-examples', num_eval_examples,
                '--num-evals', num_evals,
                '--learning-rate', learning_rate
            ]
        }
        job_spec = {'jobId': job_id, 'trainingInput': training_inputs}

        credentials = GoogleCredentials.get_application_default()
        api = discovery.build(
            'ml', 'v1', credentials=credentials, cache_discovery=False)
        api_request = api.projects().jobs().create(
            body=job_spec, parent='projects/{}'.format(PROJECT_ID))
        resp = None
        try:
            resp = api_request.execute()
        except googleapiclient.errors.HttpError as err:
            resp = {'message': err._get_reason()}
            return resp, 500

        return resp, 200
```

ここでは、Google Cloudが提供するマネージドのML学習環境である[AI Platform Training](https://cloud.google.com/ai-platform/training/docs?hl=ja)を呼び出しています。学習に使う計算リソースやリージョン、学習率等のパラメータを渡して、学習ジョブを開始してるのがなんとなく分かるかと思います。

この学習用のコードも、前処理コードと同様にコンテナイメージにビルドし、Cloud Runにデプロイします。Cloud Runのコンソールを見ると、それぞれのCloud Runエンドポイントがデプロイされていることが確認できます。

![Screen Shot 2020-12-17 at 17.56.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/d0aa2d0d-40eb-a59e-66a1-ea42be82dbc4.png)

## テスト

この状態で、前処理と学習のそれぞれのCloud Runエンドポイントを叩いてテストできます。まずは前処理の方。

```
curl -X POST -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
  -H "Content-Type: application/json" \
  -d "{\"limit\":\"1000\", \"outputDir\":\"$BUCKET/preproc\"}" \
  -s $PREPROCESS_SERVICE_URL/api/v1/preprocess | jq .
```

このジョブが完了すると、Cloud Storageのバケットには前処理済みの学習データが保存されます。

![Screen Shot 2020-12-17 at 18.33.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/1edb3e7b-36dc-4fc7-5144-e608c0854632.png)

つづいて、同様にして学習のエンドポイントも叩きます。学習ジョブが完了すると、AI Platformの`モデル`コンソールでは作成されたモデルの詳細を確認できます。

![Screen Shot 2020-12-17 at 18.39.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/8977bd1f-e22c-f2bd-67de-4346541bc60f.png)

# Cloud Workflowsでサーバレス オーケストレーション

さて、ここまではCloud Runを使ってVMを立てずに前処理や学習を起動する方法について見てきました。中井さんのサンプルコードでは、これらを組み合わせるパイプライン構築もサーバレスに実現されています。

このオーケストレーションには、サーバレス ワークフローツールである[Cloud Workflows](https://cloud.google.com/workflows)（ベータ版）を使用します。

![Screen Shot 2020-12-17 at 18.41.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/f9d19f87-2932-feb6-17b6-d91cefc045c4.png)

Google CloudではCloud Composerというワークフローツールがすでに提供されていますが、ComposerはGKEの上にApache Airflowを載せる構成。一方、Workflowsはサーバレス実装となっており、Cloud Runと同様のお手軽感でオーケストレーションを実装できる新サービスです。このWorkflowsについては[Yuttyさんによる日本語解説](https://medium.com/google-cloud-jp/advent2020jp-workflows-a4c56595d977)がありますので、合わせてご覧ください。

今回のサンプルコードでは、以下のようなYAMLファイルでワークフローが定義されています。

```workflows/ml_workflow.yaml
main:
  params: [args]
  steps:
    - assignLimit:
        steps:
          - assignDefault:
              assign:
                - limit: null
          - assignArgVal:
              switch:
                - condition: ${"limit" in args}
                  assign:
                    - limit: ${args.limit}
    - preprocessData:
        call: http.post
        args:
          url: PREPROCESS-SERVICE-URL/api/v1/preprocess
          body:
            "limit": ${limit}
            "outputDir": ${args.bucket + "/preproc"}
          auth:
            type: OIDC
        result: preprocess
    - sleepPreprocess:
        call: sys.sleep
        args:
            seconds: 60
    - getPreprocessStatus:
        call: http.get
        args:
          url: ${"PREPROCESS-SERVICE-URL/api/v1/job/" + preprocess.body.jobId}
          auth:
            type: OIDC
        result: preprocessStatus
    - waitPreprocess:
        switch:
          - condition: ${preprocessStatus.body.currentState == "JOB_STATE_DONE"}
            next: trainModel
          - condition: ${preprocessStatus.body.currentState == "JOB_STATE_FAILED"}
            return: ${preprocessStatus.body}
        next: sleepPreprocess
    - trainModel:
        call: http.post
        args:
          url: TRAIN-SERVICE-URL/api/v1/train
          body:
            "jobDir": ${args.bucket + "/trained_model"}
            "dataDir": ${preprocess.body.outputDir}
            "numTrainExamples": ${args.numTrainExamples}
            "numEvalExamples": ${args.numEvalExamples}
            "numEvals": ${args.numEvals}
          auth:
            type: OIDC
        result: train
    - sleepTrain:
        call: sys.sleep
        args:
            seconds: 60
    - getTrainStatus:
        call: http.get
        args:
          url: ${"TRAIN-SERVICE-URL/api/v1/job/" + train.body.jobId}
          auth:
            type: OIDC
        result: trainStatus
    - waitTrain:
        switch:
          - condition: ${trainStatus.body.state == "SUCCEEDED"}
            next: deployModel
          - condition: ${trainStatus.body.state == "FAILED"}
            return: ${trainStatus.body}
        next: sleepTrain
    - deployModel:
        call: http.post
        args:
          url: TRAIN-SERVICE-URL/api/v1/deploy
          body:
            "modelName": ${args.modelName}
            "versionName": ${args.versionName}
            "deploymentUri": ${train.body.trainingInput.jobDir + "/export"}
          auth:
            type: OIDC
        result: deploy
    - finish:
        return: ${deploy.body}
```

上から眺めていけば、前処理→学習→デプロイという流れになっており、それぞれでCloud Runのエンドポイントを呼び出してるのが分かるかと思います。このワークフローも、コンソール上で詳細や実行結果を確認できます。

![Screen Shot 2020-12-17 at 18.51.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38290/92301a38-2430-367d-13a0-5f43eea8cf29.png)

AI Platform Pipelines（Kubeflow Pipelines）のように、かっこいいUIでワークフロー表示したりメタデータや実験結果を管理したり...といった機能はありませんが、とりあえず前処理して学習してデプロイ、という流れの自動化にさくっと使えそうです。

# まとめ

以上、中井さんが公開されたサンプルコードを見ながら、Google CloudでつくるサーバレスMLOps環境について解説しました。Google Cloudの魅力はなんといっても[gVisor](https://ja.wikipedia.org/wiki/GVisor)に基づくさまざまなサーバレス環境にあるので、今後はこうしたサーバレスなML開発＆運用環境がどんどん増えてくるものと期待しています。

-----
Disclaimer この記事は個人的なものです｡ここで述べられていることは私の個人的な意見に基づくものであり､私の雇用者には関係はありません｡