Cloud RunとFastAPIで、ChatBotをミニマムスタートしよう

date:

2020-08-29

author:

Kazuya Takei

location:

PyCon JP 2020

links:

#pyconjp_4

イントロ

今回のお題

  • Google Cloud Run上に

  • FastAPIをベースにしたWebアプリケーションとして

  • Slackで簡易なをボットを提供する

という話をします。

今回のお題

  • Google Cloud Run上に

  • FastAPIをベースにしたWebアプリケーションとして
    ↑ここがメイン予定
  • Slackで簡易なをボットを提供する

という話をします。

Who am I

Kazuya Takei

  • NIJIBOX Co., Ltd

  • Server-side engineer

@attakei

  • Twitter

  • GitHub

  • and more

https://attakei.net/_static/images/icon-attakei@2x.png

Who am I

Pythonista

  • Sphinx extensions

    • sphinx-revealjs

    • sphinxcontrib-gtagjs

https://attakei.net/_static/images/icon-attakei@2x.png

免責

  • 「ミニマムスタート」を主眼にしているため、
    一部の実装などを意図的に省いています。
  • 所属とは無関係の発表です。
    個人の嗜好性を多分に含んだ内容となっています。

SlackのChatOps事情

よくある、SlackのChatOps

  • Slackbot/ワークフロー

  • サードパーティのSlackアプリ

  • 各種カスタムIntegration

Slackbot/ワークフロー

Slackbot

  • 特定のメッセージに反応するもの

  • 反応内容を複数用意すると、ランダムで選ばれる

ワークフロー

  • 各種トリガーに、フォーム表示とメッセージ送信をする

  • 「チャンネルにメンバーが増えた時にメッセージ」ぐらいの簡単なやつ向け

サードパーティのSlackアプリ

Appディレクトリに登録されている様々なコラボレーション用アプリ

  • Googleカレンダー

    • 当日の予定の案内

    • 予定の時間帯にステータス変更

  • Polly

    • 簡単なアンケート

  • GitHub/BitBucket/etc

    • Issue/PRの連携

各種カスタムIntegration

混み入ったことをしたいなら

  • カスタムBot

  • Incoming/Outgoing Webhooks

  • Slash Commands

各種カスタムIntegration

混み入ったことをしたいなら

  • カスタムBot

  • Incoming/Outgoing Webhooks

  • Slash Commands <= 今回はこれ

なぜSlash Commands?

  • Slackをトリガーに出来る

  • アプリケーションの実装が楽

  • Incoming Webhookを含んでる

  • 単体でもいいし、カスタムBotにも組み込める

= ミニマムスタートに丁度いい

Slack Commands

Slash Commandsって?

/ から始まるショートカットコマンドを定義して、 特定のアクションを実行できるようにしたもの。

ビルトインのSlash Commands

普段、自分がよく使っているもの

  • /feed : RSSフィードを使用する

  • /remind : リマインダー機能を使用する

  • /invite : チャンネルに招待する

  • /archive : チャンネルをアーカイブする

自作Slash Commandの仕組み

※基本的なもの

  • コマンドを実行する

  • 指定されたURLへリクエストを行う

  • URLは3秒以内にレスポンスを返す

  • レスポンスを元に、メッセージとして表示する

自作Slash Commandの仕組み

レスポンスボディが...

  • 空(0byte)
    => 何もしない
  • テキスト
    => テキストのまま表示
  • json
    => Webhookと同じ
../_images/simple-flow.png

自作Slash Commandの仕組み

リクエストの中身(一部)

名前

中身

team_domain

チーム名

pyconjp-fellow

user_name

ユーザー

attakei

text

コマンドの引数

--

response_url

Webhook用URL

--

-> Next ->

ローカル開発する

基本構成

  • Python 3.8系

  • Poetryでパッケージ管理

  • ngrokを使用

ngrok

NATやファイヤーウォールなどを越えて、 ローカルホストを外部公開できるサービス。

HTTPSのURLも利用可能

※今回の例では、最初に ngrok プロセスを用意して、 ローカルサーバは常時固定ポートを使用します。

ngrok

こんな感じに動く

$ ngrok http 8000

Session Status                online
Account                       Kazuya Takei (Plan: Free)
Update                        update available (version 2.3.35, Ctrl-U to update)
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://8e1d754c956e.ngrok.io -> http://localhost:8000
Forwarding                    https://8e1d754c956e.ngrok.io -> http://localhost:8000

ngrok

補足として...

  • 無料プランの場合、

    • コマンドのたびに公開URLが変わる

    • 同時に用意できるプロセスは1個まで

  • 不便な場合は有料プランをどうぞ

FastAPIでSlashCommandを作る

FastAPIとは

Python製Webアプリケーションフレームワーク

  • 名前の通り「高速なAPIサーバー」向け

  • Starlette というASGIフレームワークを土台にしている

    • 特に何も考えずに、 async/await 構文が使える

  • FlaskBottle っぽい

../_images/logo-fastapi.png

最小の構成

ミニマムな main.py

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def hello_world():
    return {"msg": "Hello, World!"}

最小の構成

サーバーを起動する

$ pip install fastapi uvicorn
$ uvicorn main:app

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [2719205] using statreload
INFO:     Started server process [2719212]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     127.0.0.1:46564 - "GET / HTTP/1.1" 200 OK

動作確認

$ http localhost:8000

HTTP/1.1 200 OK
content-length: 23
content-type: application/json

{
    "msg": "Hello, World!"
}

最小の構成との比較

Flask

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return json.dumps({
        "msg":"Hello, World!"
    })

Fast API

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def hello_world():
    return {"msg": "Hello, World!"}

※見た目の関係で、意図的にコードを省いています

機能サンプル

ページネーション用のクエリパラメータ

from fastapi import FastAPI

app = FastAPI()

@app.get("/items/")
async def get_items(p: int = 1):
    """ ``/items/``         => ``p`` = 1
        ``/items/?p=3``     => ``p`` = 3
        ``/items/?p=three`` => バリデーションエラー
    """
    return {"msg": "Hello World"}

機能サンプル

$ http localhost:8000/items/ p==3
HTTP/1.1 200 OK

{
    "msg": "Hello World"
}
$ http localhost:8000/items/ p==three
HTTP/1.1 422 Unprocessable Entity

{
    "detail": [
        {
            "loc": [
                "query",
                "p"
            ],
            "msg": "value is not a valid integer",
            "type": "type_error.integer"
        }
    ]
}

その他機能

  • OpenAPIの提供

  • Pydanticによる恩恵各種

  • ミドルウェア

  • 各種セキュリティ要求機能(Basic認証,OAuth2)

  • Dependency Injection

  • Background Task機能

  • Starletteが出来ること

FastAPIでChatBotローカル開発(1)

フォルダ構成

+ chatbot.py
+ poetry.lock
+ pyproject.toml
  • pyproject.toml はウィザードに従い作成

  • poetry.lock はそこから自動生成

pyproject.toml

重要なところだけ抜粋

[tool.poetry]
name = "chatbot"

[tool.poetry.dependencies]
python = "^3.8"
# 本体
fastapi = "^0.60.1"
# POSTリクエスト時にformdataを受け付けるのに必要
python-multipart = "^0.0.5"
# ASGIサーバー
uvicorn = "^0.11.7"

chatbot.py

from fastapi import FastAPI, Form

app = FastAPI()

@app.post("/slash-commands/hello")
async def hello(text: str = Form(...), user_name: str = Form(...)):
    return {
        "text": f"Hello {user_name}, I recieved `{text}`"
    }

ルーティング関数のデフォルト引数が Form の場合、 POSTリクエストから取る

動きを確認

$ http --form localhost:8000/slash-commands/hello text=Example user_name=attakei
HTTP/1.1 200 OK
content-length: 46
content-type: application/json
date: Fri, 28 Aug 2020 18:14:23 GMT
server: uvicorn

{
    "text": "Hello attakei, I recieved `Example`"
}

実際に動かす

  • 試したいワークスペースにSlashCommandを作成して

  • コマンドとURLの組み合わせを登録すれば

  • 🎉 準備完了です 🎉

→→ デモ?

FastAPIでChatBotローカル開発(2)

よりChatOpsっぽいコマンド

/dig を作ります

dig = DNSの問い合わせをするツール

$ dig example.com

;; QUESTION SECTION:
;example.com.                   IN      A

;; ANSWER SECTION:
example.com.            48418   IN      A       93.184.216.34

Slack上のコマンドイメージ

/dig example.com

(1)を拡張して作る、 /dig

# import を省略
# dns-pythonを使います

app = FastAPI()

@app.post("/slash-commands/dig")
async def dig(text: str = Form("")):
    # DNSPythonを使ってDNS情報を取得する
    name = dns.name.from_text(text)
    query = dns.message.make_query(name, dns.rdatatype.A)
    msg = dns.query.udp(query, "8.8.8.8")
    return {
        "text": f"Answer for `A` of `{name}`\n```{msg}```"
    }

試してみる

$ http --form localhost:8000/slash-commands/dig text=example.com
HTTP/1.1 200 OK
content-length: 207
content-type: application/json
date: Fri, 28 Aug 2020 18:18:19 GMT
server: uvicorn

{
    "text": "Answer for `A` of `example.com.`\n```id 31266\nopcode QUERY\nrcode NOERROR\nflags QR RD RA\n;QUESTION\nexample.com. IN A\n;ANSWER\nexample.com. 20986 IN A 93.184.216.34\n;AUTHORITY\n;ADDITIONAL```"
}

試してみる

Answer for `A` of `example.com.`
```id 31266
opcode QUERY
rcode NOERROR
flags QR RD RA
;QUESTION
example.com. IN A
;ANSWER
example.com. 20986 IN A 93.184.216.34
;AUTHORITY
;ADDITIONAL```

3秒しか猶予がない

# import を省略

app = FastAPI()

@app.post("/slash-commands/dig")
async def dig(text: str = Form("")):
    name = dns.name.from_text(text)
    query = dns.message.make_query(name, dns.rdatatype.A)
    # もし、ここで想定外の時間がかかったら?
    msg = dns.query.udp(query, "8.8.8.8")
    return {
        "text": f"Answer for `A` of `{name}`\n```{msg}```"
    }

3秒しか猶予がない

対策をしないといけない

Background Task機能

FastAPIに標準提供されている機能。

レスポンスとは非同期に処理させたいことを、 バックグラウドのイベントループに引き渡すことが出来る。

これにより、 レスポンスを早期に返してメイン処理を別途実行することが可能になる

Background Task機能

# import を省略

app = FastAPI()

async def query_dns(name: str, url: str):
    name = dns.name.from_text(name)
    query = dns.message.make_query(name, dns.rdatatype.A)
    await asyncio.sleep(5)  # 時間のかかる仮定
    msg = dns.query.udp(query, "8.8.8.8")
    slack = slackweb.Slack(url)
    slack.notify(text=f"Answer for `A` of `{name}`\n```{msg}```")
@app.post("/slash-commands/dig")
async def dig(
      bg: BackgroundTasks,
      text: str = Form(""),
      response_url: str = Form("")
):
    # 直接処理せずに、移譲する
    bg.add_task(query_dns, text, response_url)
    # 空のレスポンスを返せば、まずは何もしない
    return Response()

Background Task機能

図解

../_images/with-background.png

asyncioのイベントループ上で処理しているだけなので、依存関係が増えない。 (ライブラリ的にも連携サービス的にも)

デモ?(擬似的なもの)

…これで、真っ当にミニマムなチャットボットが出来ました。

-> Next ->

Cloud Runで運用する

Cloud Runとは

コンテナ化されたアプリケーションをすばやく安全にデプロイ、スケーリングできる、フルマネージド型のコンピューティング プラットフォーム

../_images/logo-cloudrun.png

(Cloud Runのトップページより)

Cloud Runとは

  • 割と気軽に

  • コンテナWebアプリの実行を

  • ドメイン付きで

実現するサービス

3行で説明するCloud Runの使い方

  • GCPの契約をして、プロジェクトで必要なサービスを有効化する

  • ローカルでDockerアプリを開発してから、イメージをpush

  • サービスを起動する

図解

../_images/flow-deployment_basic1.png

全てGCPでやる感じだと、こう

../_images/flow-deployment_full_gcp1.png

Cloud Run向けDockerfile(例)

# ------------------
# Build stage
# ------------------
FROM python:3.8-slim as buildenv

RUN mkdir -p /build/chatbot
COPY chatbot/* /build/chatbot/
COPY poetry.lock pyproject.toml /build/
WORKDIR /build
RUN poetry build
# ------------------
# Running stage
# ------------------
FROM python:3.8-slim
COPY --from=buildenv /build/dist/chatbot-*-py3-none-any.whl /
RUN pip install /chatbot-*-py3-none-any.whl

CMD ["uvicorn", "chatbot:app", "--port", "8080"]

Cloud Runのメリット/デメリット

メリット:

  • マシン管理が不要になる

  • 従量課金

  • HTTPS+FQDNが自動で付与される
    ( {指定名+ランダムな文字列}.a.run.app )

Cloud Runのメリット/デメリット

デメリット:

  • サーバー初動が遅い(微妙な頻度でタイムアウトする

  • 運用にDockerの知識が少々必要

  • DDoS怖い

Cloud Runの未知の部分

請求対象期間は、

「リクエストを受け付けてインスタンスが起動した時刻」

から

「インスタンスが最後にリクエストの処理をした時刻」

  • 非同期処理している間は?

  • インスタンスはいつ停止になる?

まとめ

発表振り返り

  • FastAPIの標準機能は強力で、ChatBotの実装を機能面・制約面に対する対応が比較的容易でした

  • Cloud Runベースの運用も比較的容易。ただし、この種のサービスは初動がネック

※やりきってみたい

ブラウザやCLIで確認してたネット系コマンドをSlashCommandsで提供するSlack App

  • dig

  • whois

  • openssl (HTTPS validation)

各種リンク集 - Slack

各種リンク集 - FastAPI

各種リンク集 - Cloud Run

各種リンク集 - その他

各種リンク集 - その他