Google Cloud Run で社内フリードリンク在庫判定ボットを作ってみた。

はじめまして、データプロダクト開発チームの@yuu_itoです。

一緒に仕事をしている@toohskとフリードリンクの在庫判定をするSlackボットを作成しましたので紹介します。

フリードリンクとフリードリンク在庫判定ボットとは

Quipperでは福利厚生の一環として、社内でフリードリンクが提供されています。

フリードリンクの冷蔵庫はオフィスエリアの一角にあり定期的に充填されています。

既にWebカメラを搭載したRaspberry Piで定期的に様子を撮影しており、 わざわざ席を立って在庫の状況を見にいかなくても、 Slackの専用チャンネルより確認できます。(thanks to @yoshimaru46)

Raspberry Piから撮影される冷蔵庫の画像例

しかし、空っぽの写真だけが延々とチャンネルに流れていても意味がない (フリードリンクは大変人気で、入荷日以外は在庫が空状態で続くことも多いのです...)ので 在庫が空になり、その後飲み物が入荷したときだけ通知すればみんなが嬉しいのでは? と考えました。

システム構成

今回用意したシステムは以下の通りです。

コンポーネント図 処理の流れは以下の通りです。

  1. 冷蔵庫を撮影する。
  2. Slackのタイムラインに撮影した画像を投稿する。
  3. Slack Real Time Messaging APIからGoogle Apps Script(GAS) にSlackのチャンネルに流れているメッセージを送信する。
  4. 送られてくるメッセージから対象の画像のみを画像分類ロジック(Google Cloud Runで実装) に送信する。
  5. 事前に学習した画像分類モデルで推論し結果を返す。
  6. GASのキャッシュ機能(CacheService)を用いて直前の画像分類結果と比較し、 『在庫なし』から『在庫あり』へ状態が変化した場合のみ、Slackチャンネルに投稿(@here)する。
  7. Quipperメンバーに通知が飛ぶ。

Google Cloud Run について

実はボット開発開始時はGoogle Cloud Functionsというサーバーレスアーキテクチャのサービスを用いて、画像分類モデルの推論を動かしていました。 しかし、より新しいサービスとしてGoogle Cloud Runが発表されており業務利用時の検証を兼ねて移行してみました。

移行した主な理由はCloud Runがコンテナベースのアーキテクチャであることでした。それというのもCloud Functionsはランタイム(Python, Node.js, Go)からしか選べず、他の言語はもちろんOSなどのミドルウェアを選ぶことはできません。

一方、Cloud RunはDockerコンテナとして用意できればなんでも選べるため、 例えば

  • 最新バージョンのPythonをビルドしたコンテナイメージ
  • OS依存のライブラリを利用するコンテナイメージ

などを実行環境として採用することができます。 今後、ボットに複雑な学習や推論をさせてみたり、業務で新規開発を検討した場合に新しいミドルウェアやライブラリを候補にできるのはメリットだと思い、 このボットを人柱に 移行しました。

執筆時点ではベータ版ではありますが、すでにTokyoリージョンでの利用もできたのも理由のひとつです。

Google Cloud Functions から Cloud Run に移行するためにしたこと

1. Dockerfileの追加

基本的には公式ドキュメントが丁寧に用意されています。 Cloud Functionと違い、Cloud Runはコンテナ上で動作するため、Dockerfileを用意します。

Cloud Functions 利用時には意識しなかったアプリケーションサーバの起動コマンドをDockerfileに記載します。

さらに今回のボットでは、コンテナをビルドする際に必要な Slack、GCPなどのAPIを利用するためのキーファイルや Pypiからパッケージをインストールするための requirements.txt を コピーする処理をDockerfileに追記します。

## Dockerfile

FROM python:3.7
...
COPY requirements.txt .  # 利用するパッケージ
COPY models models  # 分類モデルを含んだディレクトリ
COPY google-serviceaccount.json .  # キーファイル
...
RUN pip install -r requirements.txt  # 利用するパッケージのインストール

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

2. Pythonコードの修正

Cloud Runでは単一のアプリケーションとして動作するようにエンドポイントを追加する必要があります。 しかし、追加するコードはわずかでエンドポイントとなるメソッドを用意し、もともとCloud Functionsで使っていたメソッドを呼び出すだけでした(簡単!)

## main.py

import flask
from flask import Flask, request
...

app = Flask(__name__)  # Cloud Run 用に追加

# Cloud Functions の時点で用意していたメソッド
def predict_from_http(request):
    """ requestから画像データを取得、分類結果を返す
    Args:
        request (flask.Request): HTTP request object.
    Returns:
    """
    ...

    return flask.make_response(
                flask.jsonify(predict_info),
                200)

# 以下 Cloud Run 用に追加したメソッド
@app.route('/', methods=['POST'])
def endpoint():
    return predict_from_http(request)

画像分類モデルについて

今回使ったモデルはシンプルなCNNモデルで構築しました。 学習データはこれまでWebカメラで撮影した冷蔵庫のデータ(600枚程度)を手動でラベリングしました。 (在庫が空の場合はempty,1つでもボトルが残っている場合はnot_empty) 学習の処理はGoogle Colaboratory上で行っています。

モデル構築、検証時の内容も面白い話があるのですが、 書き出すとボリュームが出てくるので今回は割愛します。 (反響あれば、次回以降で記事にするかも?!)

動作結果

入荷された場合の通知

ハマったところ

学習した分類モデルのファイルやPythonパッケージのインストール、キーファイルの配置が漏れていたために ビルド、コンテナの登録まで問題に気づけず、実行時にエラーとなっていました。 思い返すと、Cloud Functions利用時は配下のファイルを全部まとめてzipしアップロードしてくれていたので 意識していませんでした。

問題に気づいた後は、開発時にローカルのDocker環境でビルド、 実行して試すことでCloud Runへ登録する前に気付けくことができるようになりました。

そして実はCloud RunよりもGASのほうが悩まされ、時間がかかっていました。 主にキャッシュの動作確認や、変数のtypoによるundefined errorが多発してしまいました。

キャッシュについてはCloudRunとの連携部のエンドポイントとは別に キャッシュされている情報を確認する口を用意しました。(動作結果の画像を参照)

TypoについてはJSで書かず、TypeScriptで書けばtslintなどで事前に確認できたなと反省しています...

まとめ

  • Cloud RunとCloud Functions
    • Cloud Functions
      • さくっと始めやすい。
      • テストは難しい。
      • ランタイムに依存することで自由度は低め。
    • Cloud Run
      • Dockerの理解があると始めやすい。
      • ローカルで動作確認ができるため、検証が容易。
      • コンテナにビルドできればなんでも使える!

在庫判定ボットで快適なオフィス生活に!(していきたい)

負荷試験との向き合い方

こんにちは。SRE の近藤(@chaspy)です。

先日、より高い信頼性でサービスを提供するために、スタディサプリ小中高大のサービスの最後の砦であるデータベース、MongoDB のインスタンスクラスのスケールアップを行いました。また、スケールアップをするにあたり、負荷試験を行いました。

本記事では、データベースインスタンスのスケールアップの際に行なった負荷試験に対する考え方と、得た学びを紹介します。

なぜスケールアップするのか

サービスの急成長に伴い、アクセス数もデータ量も増加しています。数年前に「しばらくは大丈夫」と判断できるインスタンスクラスにスケールアップをしたデータベースも、高負荷時には性能が劣化してしまう問題に遭遇しました。

私たちの MongoDB は AWS 上に EC2 インスタンスとしてセルフホストしており、MongoDB Cloud Manager を使って運用の一部を自動化しています。可用性のために3台でクラスタを組んでおり、同じスペックの Primary / Secondary インスタンスと、読み書きされない待機用の Hidden Secondary で構成されています。

この構成では、一部の Read Query を Secondary に逃したとしても、(実際に、一部の Query は secondaryPrefered モードで動作しています)書き込みは Primary で行う必要があり、いつか I/O がボトルネックになってしまうことは明白です。

長期的には Data Restructuring Project による学習データの切り離し*1 による効果を期待していますが、それ以外に I/O の問題を解決するためには、シャーディングか、スケールアップしかありません。

水平方向のスケールであるシャーディングはシステムが今以上に複雑になってしまうことによるリスクがあること、また、負荷の軽減が急務であることから、今回は実績のあるスケールアップを行うことにしました。スケールアップする前は i3 という I/O に特化したインスタンスタイプでしたが、最近リリースされた、さらに I/O 性能が高い i3en タイプに変更することにしました。

しかし、本当にスケールアップで問題は解決するのでしょうか? Fact Based で考えるためには、負荷試験が必要です。

*1:学習データがデータ容量としても、書き込みの頻度としても高い

続きを読む