GCP - Slackボットのパフォーマンスについて(コールドスタート対応)

[先日の](https://qiita.com/mitonattou919/items/f749348db775cae35fff)に引き続き、Slackの3秒の壁を越えるための施策について、考えてみたいと思います。

今回は、FaaSの特性であるコールドスタートについて、どうにかならないか考えてみます。


## 概要

### Slack Event APIのエラーハンドリング
SlackのEvent APIは、HTTP200を受け取れなかった際にエラーと判定し、都合3回リトライします。
受け付けるこちら側としては、極力エラーを発生させないようにしつつ、リトライが発生して処理が複数
回実行された場合においても、処理結果が問題とならないように、作り込んだ方が良さそうです。

#### Event APIのエラー判定
[Slackのドキュメント](https://api.slack.com/events-api#the-events-api__field-guide__error-handling)

|原文|内容|
|:--|:--|
|we are unable to negotiate or validate your server's SSL certificate|SSLネゴシエートに失敗など|
|we wait longer than 3 seconds to receive a valid response from your server|3秒以上待たされたとき|
| we encounter more than 2 HTTP redirects to follow|フォローしなくちゃいけないリダイレクトが2つ以上|
|we receive any other response than a HTTP 200-series response (besides allowed redirects mentioned above)|HTTP2xx 以外のレスポンス|

#### エラー判定時のリトライ
[Slackのドキュメント]([Slackのドキュメント](https://api.slack.com/events-api#the-events-api__field-guide__error-handling))

|原文|内容|
|:--|:--|
|1. the first retry will be sent nearly immediately|即時|
|2. the second retry will be attempted after 1 minute|1分後|
|3. the third and final retry will be sent after 5 minutes|5分後|




## 対策案の検討

### インスタンスをアイドル状態にしておけないか
まずは最小起動数などで、インスタンスをアイドル状態にしておけないか調べてみました。
結論的には、出来なさそうです。

1. Cloud Functions
    Cloud Functionsは、最大インスタンス数(--max-instances)はありますが、
    最小インスタンス数に該当しそうなパラメタはなさそうですね。

    また、AWS Lamdaの[Provisioned Concurrency](https://aws.amazon.com/jp/blogs/news/new-provisioned-concurrency-for-lambda-functions/)のような機能も現時点では無いですね。


2. Cloud Run
    Cloud Runの方は、最小インスタンス数を指定する --min-instancesというパラメタがありますが、
    フルマネージドでは使うことが出来ないようです。

    以下はgcloud run deploy の結果。
    >ERROR: (gcloud.run.deploy) The `--min-instances` flag is not supported on the fully managed version of Cloud Run. Specify `--platform kubernetes` or run `gcloud config set run/platform kubernetes` to work with Cloud Run for Anthos deployed on VMware.

    [ドキュメント](https://cloud.google.com/run/docs/about-instance-autoscaling?hl=ja#idle-instance)の以下の部分がそれを表しているのでしょうか。わからんわ。
    >ただし、Cloud Run（フルマネージド）の場合、CPU は使用できません。



### 定期的に起こしてあげる。
やり方は色々あると思いますが、今回はCloud Monitoringの稼働時間チェックでやってみました。
完全にコールドスタートを封じ込めることは難しいですが、緩和という意味ではかなり効果があると思います。

1. Cloud Functions
    実際に運用しているチーム向けの勤怠管理ボット(Cloud Functions)で試してみました。

    #### 概要
    Monitoringの稼働時間チェックを組み込み、定期的に関数を叩いてもらって、
    なるべくインスタンスがお寝んねしないようにします。

    #### 稼働時間チェックの設定
    Monitoring - 稼働時間チェック - 稼働時間チェックの作成 より、作成します。

    |区分|項目|内容|デフォルト値|設定値|備考|
    |:--|:--|:--|:--|:--|:--|
    |タイトル|タイトル|稼働時間チェックの名前|なし|任意||
    |ターゲット|Protocol|ターゲットのプロトコルを指定|HTTP|HTTPS||
    |ターゲット|リソースの種類||URL|←||
    |ターゲット|Hostname|チェック対象のURLのベース部分(https://bba.com/articleであれば、bba.com)|なし|Cloud FunctionsのURL||
    |ターゲット|Path|チェック対象のURLのパス部分(https://bba.com/articleであれば、/article)|なし|〃||
    |ターゲット|Check Frequency|チェック間隔|1m|15m|一旦15分間隔で、運用しながらチューニング|
    |ターゲット|Regions|リクエストの送信元|グローバル|米国|Slack APIしか相手にしないので、米国のみでOK|
    |レスポンスの検証|Response Timeout|リクエストが完了するまでの待機時間|10s|3s|Slack APIのタイムアウト値に合わせる|
    |アラートと通知|アラートを作成する|この稼働時間チェックが失敗したときに通知するアラートを作成するか|On|Off||


    #### 稼働時間チェックの効果
    稼働時間チェックを適用したボットは、勤怠管理ツールということもあり、
    朝と夕方に処理が集中し、それぞれの間の数時間は全く使われないという
    特性があります。

    適用前は朝or夕の最初の人がほぼ毎回タイムアウトを食らう状況でしたが、
    適用後はかなり「緩和」することができました。

    ||適用前|適用後|
    |:-:|:-:|:-:|
    |タイムアウト発生頻度|1日に2回くらい|10日に1回くらい|


    #### それでもタイムアウトする要因

    ##### パターン1 インスタンスが落ちている

    稼働時間チェックで定期的に、関数を叩いてもらっても、インスタンスの居眠りは完全には防げませんでした。
    インスタンスがお寝んねする条件については、ドキュメントからは明確な数値を見つけられませんでしたが、
    1分間隔にしても寝るときは寝てしまっていたので、諦めるしかないですね。


    ![Active instances for slack-bot-function.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/6e50239e-f0dd-5964-4e51-48605b4f68d4.png)

    ##### パターン2 新しくインスタンスが生成されている

    ユーザのリクエスト同士や、ユーザのリクエストと稼働時間チェックのリクエストがかち合ったりなどして、
    新しいインスタンスが生成されるケースも、1インスタンスあたりの多重度が1なので致し方ないですね。
    また、最小インスタンス数のようなパラメタも無さそうなので、ある程度許容する他ないですね。

    ![Active instances for slack-bot-function (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/5d2b9e31-1e49-fdd7-9460-3b9dd769f6aa.png)




2. Cloud Run
    Cloud Runもリクエストがないと勝手にインスタンスがお寝んねしてしまいます。
    ただこちらは、少なくとも今回確認した限りにおいては、リクエストが来なくなって15分きっかりにインスタンスの停止処理が始まることがログ上確認できました。
    ドキュメント上、明記されている訳ではないので、何とも言えませんが、ひとつの目安とすることはできそうです。

    Cloud Functionsと異なり、インスタンスごとに多重度の設定が出来ますので、
    上記パターン2のような状況にはなりませんので、稼働時間チェックの間隔をなるべく
    短くしてあげることで、ほぼほぼコールドスタートを封じ込めることが出来ると思われます。


## 測定
では実際に、Apache BenchでCloud FunctionsとCloud Runのパフォーマンスを測ってみます。

### 構成概要
ボットへのメンションを契機にEvent APIを発行し、Web APIでユーザが投稿したチャンネルへ返す感じにのシンプルな構成で、Cloud FunctionsおよびCloud Runをデプロイしてみました。
しかしながら、Apache Benchでの測定では、Event APIへのレスポンスを返すところまでとしました。

![無題のプレゼンテーション.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/09aaeff5-5d3c-b592-0fa5-d6c0209d7ef2.png)


### Slack Appsの設定(参考)
上記の構成で必要な権限は以下のとおりです。

|区分|項目|設定値|備考|
|:--|:--|:--|:--|
|OAuth & Permissions|Bot Token Scopes|app_mentions:read|ボットへのメンションを読み取り可能とする|
|OAuth & Permissions|Bot Token Scopes|chat:write|Web API経由でのチャットへの書き込みを許可|
|Event Subscriptions|Subscribe to bot events|app_mention|ボットへのメンションを契機にEvent API発砲|

### ランタイムやフレームワークなど
Functionsは素で入っているFlaskとし、Runの方はFlaskと、興味本位でFastAPIで比べてみることとします。

|項目|Cloud Functions|Cloud Run<br>(Flask）|Cloud Run<br>(FastAPI)|
|:--|:--|:--|:--|
|ベースイメージ|Ubuntu 18.04|python:3.8-slim-buster|←|
|ランタイム|Python3.8|←|←|
|Webフレームワーク|Flask 1.0.2|Flask 1.1.2<br>(gunicorn20.0.4)|FastAPI 0.61.1<br>(uvicorn0.11.8)|
|サードパーティライブラリ|Requests 2.21.0|Requests 2.24.0|←|

拙いですが、[検証用コード](https://github.com/mitonattou919/Slack-Testing)です。
一応、ちゃんと動作します。

### チューニングパラメタ

Functionsはインスタンス数を自動 or 1、Runの方はインスタンスは1で固定とし、インスタンスあたり20多重で比べてみることとします。

|項目|Cloud Functions|Cloud Functions|Cloud Run<br>(Flask）|Cloud Run<br>(FastAPI)|
|:--|:--|:--|:--|:--|
|リージョン|us-east4|←|←|←|
|CPU(インスタンスあたり)|500ms|←|1|←|
|メモリ(インスタンスあたり)|128MiB|←|256MiB|←|
|最小起動インスタンス数|-|←|-|←|
|最大起動インスタンス数|auto|1|1|←|
|多重度(インスタンスあたり)|1|←|20|←|



### 負荷がけ条件
以下のような組み合わせで、Apache Benchから負荷がけしてみます。

|同時実行数|リクエスト数|
|:-:|:-:|
|1|100|
|2|200|
|3|300|
|5|500|
|10|1,000|


### 結果
負荷がけにあたっては、以下のようにコールドスタートから始まるようにしています。

- Cloud Functions ： 毎回デプロイしなおしてから
- Cloud Run ： 毎回15分以上待って、インスタンスが停止したことを確認してから


1. スループット(秒あたりレスポンス)
    大体想定とおりの結果となりました。
    Cloud Functionsは、同時実行数が増えるに従い、新たなインスタンスを起動してスケーリングしますので、
    同時実行数が増加に伴い、劣化していきます。
    FlaskとFastAPIでは、ややFastAPIの方が良さそうです。

    ![無題のプレゼンテーション (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/bd206527-9984-9eb9-baf1-268d84091c40.png)


2. 平均レスポンス(ミリ秒)
    スループットと同様です。

    ![無題のプレゼンテーション (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/f0482c45-b653-4c72-8412-e73a03cb6c2f.png)


3. 99パーセンタイル(ミリ秒)
    あくまで今回の環境設定においてですが、インスタンス数を増加して同時実行数を増やすしかない
    Cloud Functionsと比較して、Cloud Runでは最初のコールドスタートさえなんとかすれば、
    以下のようにかなりの低レイテンシーを実現することができそうです。

    ![無題のプレゼンテーション (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/bdaeae28-bab0-a69d-67b4-690fa8210e2d.png)


4. インスタンス数
    Functionsのインスタンス数は、同時実行数10のときには、15までいってました。

    ![無題のプレゼンテーション (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/98763d01-76f0-7f7a-ffe7-e02fb6789417.png)

5. インスタンスの起動にかかる時間
    ログで確認ができるCloud Runのみグラフ化してみました。
    こちらは、Flaskの方が良さそうです。

    とりあえず、さくっと作った環境なので、色々と削ることは出来ると思います([ドキュメント](https://cloud.google.com/run/docs/tips#optimizing_performance))。

    ![無題のプレゼンテーション (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525959/b55a6298-c290-2ce3-2a9a-1aad3f1ecdf6.png)


## 結論
Slackボットのように、そこそこ厳しいレイテンシーが要求され、かつ、それなりの同時実行が想定される場合においては、
ほぼほぼコールドスタートを封じ込むことが出来そうな、Cloud Runで実装するほうが良さそうです。

ただし、あくまで私のように、インスタンスの自動スケールが不要な場合に限られますね。