LoginSignup
66
47

More than 3 years have passed since last update.

Bolt for Python が FaaS での実行のために解決した課題

Last updated at Posted at 2020-11-30

Bolt とは & 自己紹介

近年、Slack のプラットフォーム機能開発チームは、Slack 連携アプリを作るための公式フレームワークである「Bolt(ボルト)」の開発と普及活動に力を入れています。私はこの Bolt の開発を担当しています(特に Python と Java は大部分を私が手がけたので、思い入れもひとしおです)。

Bolt を使うと Web API を使ってメッセージやファイルを投稿して通知するだけのシンプルな連携ではなく、ボタンやモーダルを活用したインタラクティブなアプリを簡単に作ることができます。また、そのような UI 部品を使わない場合でも、Bolt を使うと Events API の受信とリスナー関数の実装を非常に簡単に実装することができます。

今年のアドベントカレンダーは、まだそこそこ空きがあるようなので、埋まらなかった日については、この Bolt のノウハウを中心に記事を投稿していこうと思っています。

それでは本題

この記事では、Bolt for Python を使ったアプリ FaaS (Function as a Service) 上で稼働しやすくするために実装した「Lazy Listeners」という機能について紹介します。

FaaS で Slack 連携を運用するときの課題

FaaS (Function as a Service) とは AWS Lambda や Google Cloud Functions のような、サーバー自体やそのキャパシティの管理をクラウドサービスに任せて、シンプルな関数の実行部分だけに集中することができるサービスです。

Slack の連携アプリは比較的シンプルなものやトラフィックが多くないものもよくあるので、FaaS で運用したいというニーズは珍しくありません。例えば、シンプルなスラッシュコマンドやショートカットを提供するだけのアプリであれば、非常に低コストで(場合によっては無料枠で)運用することができるでしょう。

しかし、FaaS で Slack 連携を動かすとき、以下のような点に注意する必要があります。

Slack からリクエストを受けたら 3 秒以内に応答する

Slack API サーバーから HTTP リクエストを受けるアプリケーションは、3 秒以内に HTTP レスポンスを返す必要があります(今後、ソケットモードという WebSocket ベースの連携方法もリリースを控えていますが、応答の仕方こそ違えど、この 3 秒以内の応答というルールは同様です)。

3 秒以内に応答を返さなかった場合は「タイムアウトエラー」という扱いになります。Events API の場合は 3 回まで同じイベントの再送が発生します。それ以外のユーザーアクションなどでは、エンドユーザーに固定のエラーメッセージが表示される結果になります(再送はされません)。

これへの対処のよくある実装は Slack の Web API 呼び出しなどを非同期処理に切り替えるやり方です。Bolt では、そのようなコードをできるだけシンプルにできるよう ack() というユーリティティを使うことで、即 HTTP レスポンスを返すコードの行(= await ack())とそれ以外の処理の非同期実行の部分を同じリスナー関数内に簡単に共存させられるようになっています。

以下は単純なボタンクリックイベントのハンドラーの例です。

app.action('button_click', async ({ ack, body }) => {
  await ack(); // この時点ですぐに 200 OK を返す

  // これ以降の処理がどれだけ時間がかかってもタイムアウトは発生しない
  await doSomething(body);
});

Python の場合は以下のようになります。

@app.action("button_click")
def action_button_click(ack, body):
    ack()  # この時点ですぐに 200 OK を返す

    # これ以降の処理がどれだけ時間がかかってもタイムアウトは発生しない
    do_something(body)

asyncio でやりたい場合も slack_bolt.App の代わりに slack_bolt.async_app.AsyncApp を使って以下のように書きます。

@app.action("button_click")
async def action_button_click(ack, body):
    await ack()  # この時点ですぐに 200 OK を返す

    # これ以降の処理がどれだけ時間がかかってもタイムアウトは発生しない
    await do_something(body)

Java の場合は、非同期実行の仕組みをユーザー自身が選びたいケースも多いだろうということで、インターフェースが少し異なっています。しかし、手軽にやりたい場合は、以下のように組み込みの ExecutorService を使うことができます。以下は Kotlin でのコード例です。

app.blockAction("button_click") { req, ctx ->
  // 組み込みの ExecutorService の例
  // 自前で管理する別の仕組みを使っても構わない
  app.executorService().submit {
    // この中の処理は別のスレッドで実行される
    doSomething(req.payload)
  }
  ctx.ack() // この時点ですぐに 200 OK を返す
}

HTTP トリガーの FaaS は非同期処理が使えない環境

・・と、Bolt のアプリがプロセスとしてずっと動いているような環境であれば、上記のやり方で良いのですが、FaaS で動かす場合は少し事情が異なります。HTTP リクエストをトリガーに起動する Function の場合、HTTP レスポンスを返した時点で、その実行中の環境は(プラットフォーム側によって)いつ破棄されてもおかしくない状態となるためです。

この環境下では、先ほどの例に出てきた doSomething() のような Node の async 関数、Python asyncio での async 関数、Java や Python のスレッドを使った非同期処理の実行は、その完了を待たずに強制終了してしまう可能性があります。それ以外にも ack() 以外の say()respond() といった Node や Python で非同期処理を前提とするユーリティリティメソッドでも同様の問題が発生します。

これへの対処として Bolt for JavaScript v2 から導入されたのが processBeforeResponse というフラグです(Python にも同名の設定が存在します。Java は自動的に ack() が応答を返すインターフェースではないので、このオプションは存在しません)。

デフォルトでは false になっています。false の場合は ack() がリスナー関数の実行完了を待たずに HTTP レスポンスを返します。FaaS で動作させるときは、これを明に true に設定することで、全ての処理が終わるまで HTTP レスポンスを返さないように挙動を制御することができます。これにより、処理の途中でランタイムが終了することがなくなり、安定したリスナー関数の実行が可能になります。

では、これで万事解決でしょうか?・・・いえ、察しの良い方はお分かりの通り、このオプションを true にしたアプリケーションは、最初に説明した 3 秒タイムアウトの問題を再び気にする必要が出てきます。また、3 秒という制約に対して影響しうる FaaS の cold start 問題によるオーバーヘッドも気にする必要があるでしょう。

では、どのような手段が取れるでしょうか?

時間のかかる処理を分ける

改めて、ここでやりたいことは:

  • インターネットフェイシングな関数は Slack API サーバーに対して HTTP レスポンスを必ず 3 秒以内にする
  • 受け取ったペイロードの必要な情報を非同期処理に引き渡してメインの時間がかかる処理はそちらで実行する

です。

おそらくパッと思いつくアイデアは「間にキューを挟む」といったやり方でしょう。AWS であれば SQS や Kinesis Data Streams などに enqueue し、HTTP レスポンスだけ返してしまえば、あとから別の Lambda 関数が SQS からメッセージを取り出して処理を継続することができます。これは、API Gateway と Lambda に加えて SQS を協調させるように設定・実装すれば、比較的容易に実現できるでしょう。

しかし、Slack 連携アプリを作る度に毎回このような構成をとるのも少し面倒です。やりたいことは同期処理の Lambda と非同期処理の Lambda を実行したいだけであり、キューが欲しいわけではありません。チームの中でも特にこの問題に関心を持っていた私は「もっと手軽に対応できないものだろうか?」と昨年から考えてきていました。

そして、今年の春、Bolt for Python の開発をしているときにふと閃きました。「Bolt for Python のデザインであれば、同じペイロードを複製して ack() を担うリスナーと、ack() 以外のことをできる非同期のリスナーを実行させることができるのではないか?」と。結果、このアイデアはうまく実装することができ、この記事のテーマである「Lazy Listeners」という機能になりました。

Lazy Listeners がやっていること

Lazy Listeners は、以下のように acklazy にリスナー関数の処理を書き分けることができる仕組みです。以下の例は lazy にずいぶんたくさん関数を渡していますが、普通は一つだけで十分でしょう。

def ack_within_3_seconds(ack, body):
    # モーダルの送信へのエラー応答など ack() で返す必要があるものはここでやる
    # 他にやることなければただ ack() するだけ
    ack()

def notify_to_helpdesk_team(body, client):
    # いつも通り client にこのリクエスト処理用の token はセットされている
    client.chat_postMessage(
        channel=helpdesk_team_channel_id,
        blocks=build_notification_message_blocks(body),
    )

def call_very_slow_api(body):
    # 実行時間の制約なく処理を記述できる
    # AWS 上なら AWS Lambda の最大実行時間まで実行可能
    send_to_backend(body)

def open_modal_as_necessary(client, body):
    if is_modal_requested(body):
        client.views_open(
            # trigger_id は有効期限があるので注意
            trigger_id=body["trigger_id"],
            view=build_modal_view(body),
        )


app.shortcut("helpdesk-request")(
    ack=ack_within_3_seconds,
    lazy=[notify_to_helpdesk_team, call_very_slow_api, open_modal_as_necessary],
)

cold start 問題についてですが、ライブラリのロードはほぼ無視できるレベルのコストです。Bolt for Python(slack-bolt)は Python Slack SDK(slack-sdk)以外に必須の依存ライブラリがありません。そして Python Slack SDK も必須の依存ライブラリがありません。

上記のサンプル例の場合だと ack_within_3_seconds はほとんど何もしませんので、他のライブラリの初期化コストが異常に高いなどの状況でもなければ cold start 時にも 3 秒を超えるようなことはまずないでしょう。

一つの AWS Lambda 関数だけで

lazy に指定されている関数は、すべてそれぞれ専用の AWS Lambda 関数の起動として実行されます。引数に渡せる bodyclient などは、そのまま同じものが複製されます。ack の関数との違いは ack() を実行できないことだけです。なお、lazy の関数は配列で渡しますが、実行順序はありません。可能になったタイミングでそれぞれバラバラに実行されます。

この lazy 用に新しく別の Lambda 関数を追加する必要はありません。一つだけの AWS Lambda 関数をそのまま使い回してうまく動作します。lazy のものは HTTP トリガーではなく、内部からの起動になります。

Lazy Listeners は汎用的な仕組み

Lazy Listeners は AWS Lamdba などの環境を想定して考案されましたが、結果的にその実装は FaaS 専用の仕組みというわけではありません。その処理の仕組みを LazyListenerRunner というインタフェースの実装を指定するだけで Bolt 側に手を入れることなく、切り替えることができます。デフォルトでは lazy listener function はスレッドを使って同一のアプリ内で非同期実行されます。asyncio の場合も task として別途実行されます。AWS Lambda の場合は、それを Lambda 関数起動に切り替えています。

実装に興味を持った方は ListenerRunnerLazyListenerRunner の実装を追ってみるとどのように実装されているかわかると思います。簡単にいうと lazy 実行時には request についているマークをチェックし、ack 関数のスキップ、実行対象の lazy listener 関数の特定を行っています。

Lazy Listeners の使いどころ

基本的には AWS Lambda で動かしたいというユースケースが使いどころになります。

しかし、先ほど AWS Lambda 専用の仕組みではないと書いたように、Lambda で動かさない場合にも使うことができます。もし「Lazy Listenersの記述方式がわかりやすい・設計しやすい」「同じペイロードに対して複数の非同期処理を実行したい」などの場合、FaaS のユースケースに限らず Lazy Listeners を使ってみてもよいでしょう。

Lazy Listeners の今後

この記事執筆時点(v1.0.1)では、スレッド、asyncio、AWS Lambda に対応しています。将来のバージョンで Google Cloud Functions にも対応予定です。

Python 以外の Bolt に実装するかは未定です。Bolt for JavaScript は(そのような使い方をしている人がどれくらいいるかはともかく)実はリスナーを複数指定できるようになっており、その設計との相性を考えると Python と同じような形では入れられないだろうと考えています。

また、Java も ack / lazy のようなインターフェースが使いやすいかは、何とも言えないところがあります。AWS Lambda クライアントをラップしたユーティリティを提供する程度になるかもしれません。

2021 年はソケットモードの時代

そして、来年にはソケットモードのリリースが控えています。ソケットモードは HTTP エンドポイントで Slack からのリクエストを受けるのではなく、RTM API と同じように WebSocket でつないで、そのコネクションのメッセージでペイロードを受け取り、応答も WebSocket で送信する仕組みです。

ソケットモードのアプリを FaaS で動かすことはまずないでしょう。ソケットモードは AWS であれば ECS や Lightsail Containers のようなコンテナベースのサービスの相性が良いのはないかと思います。実際、私は開発中の Socket Mode 対応の Bolt for Python アプリを Lightsail Containers で稼働させて、様子を見ていたりしています(すでに問題なく動くようになっています!)。

ソケットモードがリリースされても、これまでの HTTP エンドポイント方式がなくなることはありませんが、おそらく FaaS で新しく始めるという人は少なくなるだろうなと予想しています。

最後に

長い記事を最後まで読んでいただきありがとうございました。関連する GitHub issue などのリンクを置いておきますので、興味がある方はアクセスしてみてください。

66
47
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
66
47