Slack Bot を Lambda に移行してみる
Zenn 読者の皆さん、こんにちは。
今回は Slack Bot を Lambda への移行したときの話を紹介したいと思います。
本記事のサマリ
- ローカルPC上で動いていた Slack Bot を Lambda(Sererless Framework) に移行した
- Slack Bolt を使ってサクッと移行できたものの、Slack の3秒ルールへの考慮が必要となり、結局 Python で作り直した
- Lambda 化や Python 化に至るまでに発生したいくつかのトラブルを紹介
今回実現した Chatbot の構成です。
はじめに
弊社では、Slack 上で利用できる OpenAI の API を利用したヘルプデスク Bot を運用しています。Slack のインターフェースを使って、OpenAI の API を呼び出し、返ってきた結果を Slack に返すというものです。ブラウザに切り替えることなく Slack 上で ChatGPT とやりとりができるので、個人的にはとても重宝しています。
こちらの Bot は、元々これを作ってくれた有志(社員)の方が自宅のPC上で動かしており、夜間などPCが停止している時間帯は Bot が利用できない運用でした。構成図にするとこんな感じです。
個人的には(業務に直接関係ないやり取りも含めて)Bot を使うことが多くなってきており好きなタイミングで利用できないことに不便を感じてきたため、作成者の方にソースコードを提供いただき、会社のAWSに移行させてもらうことにしました。
AWS への移行
移行にあたっての最初の基本方針としては、以下で考えました。
- 24h365d 使えるようにする
- (コスパ観点から)Lambda を使う
- 移行元のソースコードは極力修正したくない
- 移行作業は極力簡単な方法でやりたい
結果としては
- 24h365d 使えるようにする ⇒ o
- (コスパ観点から)Lambda を使う ⇒ o
- 移行元のソースコードは極力修正したくない ⇒ x(Python 化のため全面的に改修)
- 移行作業は極力簡単な方法でやりたい ⇒ o(serverless framework を使って楽にデプロイできた)
となりました。
結局 Python で作り直したのですが、どういった流れを経て Python にたどり着いたのか、以下で詳しく紹介します。
基本方針を踏まえた最初の移行
提供してもらった元々稼働していた Bot は Javascript + Bolt で作成されており、幸運にも Bolt が Lambda にも対応していることがわかりましたので、一旦そのまま Lambda にデプロイしてみることにしました。
こちらを参考にしつつ、Serverless Framework を使ってデプロイを行いました。
トラブル1. Bot が返事をしてくれないことがある
移行後最初に出くわしたトラブルがこちらでした。
結論から言うととてもシンプルなのですが、Bot 側が Slack に返事をする前に Lambda が終了してしまっていたことが原因でした。Lambda はすべての処理が終わった段階でリソースが回収されてしまうため、Node.js における非同期処理の場合にその処理が完了していないのに Lambda のリソースが回収されてしまっていた、という話でした。
元々の Bot はソケット通信の常時起動プロセスで動いており、Slackへの返信部分に await をつけていませんでした。なので、この箇所に await をつけることで、Bot が返事をしてくれるようになりました。
トラブル2. Bot が返事をしてくれないことがある(2)
await 対応後も、Bot が返事をしてくれないことがありました。Lambda のログを見ると、以下のようなエラーが出ていました。
Task timed out after 6.01 seconds
これも単純な問題で、Serverless Framework の AWS Lambda におけるデフォルトタイムアウト値が6秒になっているためでした。Lambda 自体のタイムアウトは最大15分なので、その範囲内であればタイムアウトを広げることができます。
provider:
name: aws
runtime: nodejs14.x
runtimeManagement: auto # optional, set how Lambda controls all functions runtime. AWS default is auto; this can either be 'auto' or 'onFunctionUpdate'. For 'manual', see example in hello function below (syntax for both is identical)
memorySize: 512 # optional, in MB, default is 1024
timeout: 10 # optional, in seconds, default is 6
versionFunctions: false # optional, default is true
tracing:
lambda: true # optional, enables tracing for all functions (can be true (true equals 'Active') 'Active' or 'PassThrough')
トラブル3. 複数回返事をされる
タイムアウト値を広げたところで、今度は複数回返信されるようになってしまいました。
どうやら Slack は3秒のタイムアウト値を持っていて、その間に ack を返さないとリトライ処理が発生してしまうようです。その結果、複数回リクエストを受けたため複数回返信してしまう結果になっているようでした。
OpenAI の API は必ずしも3秒以内に返答を返してくれるわけではないので、
その場合は Lambda が複数回実行されてしまうことになります。
「先に ack を返しておいて、そのあとゆっくり OpenAI からの返信を待てばいいのでは」と思うかもしれませんが、HTTPのレスポンスを返却した時点で Lambda のインスタンスが破棄されてしまうため、この方法もうまくいきません。
ここでようやく登場となるのが、Lazy listeners です。
この Lazy listers が実現していることは以下の通りです。
- インターネットフェイシングな関数は Slack API サーバーに対して HTTP レスポンスを必ず 3 秒以内にする
- 受け取ったペイロードの必要な情報を非同期処理に引き渡してメインの時間がかかる処理はそちらで実行する
少し噛み砕いて言うと、「一旦3秒以内に Slack に返事(ack)を返しつつ、別の Lambda によってゆっくり処理をして返信する」実装になっていて、まさに「先に ack を返しておいて、そのあとゆっくり OpenAI からの返信を待てばいいのでは」を実現してくれています。Lazy listeners の実装に関する詳細は、Slack Bolt 開発者の方が詳細な記事を書いてくださっているので、そちらを参照いただければと思います。
ここで残念な事実が発覚するのですが、Slack Bolt for Javascript は現時点で Lazy listeners に対応しておらず、結局 Python で Bot を作り直すことになりました。
def respond_to_slack_within_3_seconds(body, ack):
text = body.get("text")
if text is None or len(text) == 0:
ack(f":x:Usage: /start-process (description here)")
else:
ack(f"Accepted! (task: {body['text']})")
import time
def run_long_process(respond, body):
time.sleep(5) # 3 秒より長い時間を指定します
respond(f"Completed! (task: {body['text']})")
app.command("/start-process")(
# この場合でも ack() は 3 秒以内に呼ばれます
ack=respond_to_slack_within_3_seconds,
# Lazy 関数がイベントの処理を担当します
lazy=[run_long_process]
)
トラブル4. これまでの文脈を考慮してくれない
ChatGPT のAPIコールを単純に実装すると毎回ゼロベースでの回答になってしまいます。AIとのやりとりの中で深堀りや関連質問をするという使い方が多いかなと思いますので、この実装はマストかなと思います。方法としては、同じスレッド内のこれまでのやりとりを一緒に送ってやればOKです。
for message in threadMessages:
if message["user"] == botUserId:
threadContent.append({
"role": "assistant",
"content": message["text"],
})
else:
threadContent.append({
"role": "user",
"content": message["text"],
})
No module named ‘pydantic_core._pydantic_core’
というエラーが発生する
トラブル5. Lambda 実行時に、以下のエラーが発生しました。
No module named ‘pydantic_core._pydantic_core’
このエラーは、fastapi のバージョンが原因で発生するエラーのようです。fastapi の利用バージョンを下げてかつ外部ライブラリを zip レイヤに含めて Lambda にアップロードするやり方がいくつかの記事で紹介されていますが、私の場合は openapi のバージョンを少し下げることで zip レイヤを含めることなく解決できました。
requirements.txt
slack_bolt
openai==0.27.0
OpenAI のバージョンが新しすぎると、Lambda 側の環境で提供される依存ライブラリが対応しなくなる、ということだと理解しています。
Slack Bot の python コード全量
今回 Slack Bolt for Python を使って Bot を作り直したソースコード全量を共有しておきます。
import os
from slack_bolt import App
import openai
import commonmarkslack
app = App(
signing_secret=os.environ["SLACK_SIGNING_SECRET"],
token=os.environ["SLACK_BOT_TOKEN"],
process_before_response=True,
)
openai.api_key = os.environ["OPENAI_API_KEY"]
parser = commonmarkslack.Parser()
renderer = commonmarkslack.SlackRenderer()
def get_bot_user_id():
try:
result = app.client.auth_test(
token=os.environ["SLACK_BOT_TOKEN"]
)
return result["user_id"]
except Exception as e:
print(e)
botUserId = get_bot_user_id()
def respond_to_slack_within_3_seconds(body, ack):
text = body.get("text")
if text is None or len(text) == 0:
ack(":x: Usage: /start-process (description here)")
else:
ack(f"Accepted! (task: {body['text']})")
def fetch_thread_messages(channel, thread_ts):
thread_messages_response = app.client.conversations_replies(
channel=channel,
ts=thread_ts,
)
#console.log(result.messages);
print(thread_messages_response["messages"])
return thread_messages_response["messages"]
import time
def run_long_process(body, say):
mention = body["event"]
text = mention["text"]
channel = mention["channel"]
threadMessages = []
if "thread_ts" in mention:
thread_ts = mention["thread_ts"]
threadMessages = fetch_thread_messages(channel,thread_ts)
else:
thread_ts = mention["ts"]
threadContent = []
threadContent.append({
"role": "system",
"content": """あなたは利用者のカウンターパートとして親身になって相談に乗ってください。""",
})
threadContent.append({
"role": "user",
"content": text,
})
for message in threadMessages:
if message["user"] == botUserId:
threadContent.append({
"role": "assistant",
"content": message["text"],
})
else:
threadContent.append({
"role": "user",
"content": message["text"],
})
res = openai.ChatCompletion.create(
model="gpt-4o",
messages=threadContent
)
resText = res.choices[0]["message"]["content"].strip()
slack_md = renderer.render(parser.parse(resText))
say(text=slack_md, channel=channel, thread_ts=thread_ts)
app.event("app_mention")(
ack=respond_to_slack_within_3_seconds,
lazy=[run_long_process]
)
if __name__ == "__main__":
app.start()
# AWS Lambda
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
def handler(event, context):
slack_handler = SlackRequestHandler(app=app)
return slack_handler.handle(event, context)
その他注意事項
ローカル環境でのテストができない
こちらの公式チュートリアルでは、ngrok を使ってローカル環境でテストする方法が紹介されていますが、Lazy listeners を実装するとローカルでテストできなくなってしまいます。ローカル環境で Lambda のインスタンスを作成できないためです。一方で、Bot の本質的な処理(今回でいうとChatGPT とのやり取り)は Lambda にデプロイする前にローカル環境でテストした方がよいので、Lazy listeners の実装する前に動作確認するようにした方がよいかと思います。もしくは Lazy listeners 実装後でもローカルテストできるように実装しておけるならそうしましょう。
serverless deploy が終わらない
Serverless Framework を使ってデプロイする際、MFAを求めるプロンプトがしれっと出ていますのでMFAコードを入力してください。結構わかりにくい&入力しづらかったです。
Deploying serverless-bolt to stage dev (us-east-1)
Compiling with Typescript...am::XXXXXXXXXXXX:mfa/akihiko.ito:
Using local tsconfig.json - tsconfig.json
Typescript compiled.
Warning: Package patterns at function level are only applicable if package.individually is set to true at service level or function level in serverless.yaml. The framework will ignore the patterns defined at the function level and apply only the service-wide ones.
上記の2行目にしれっとプロンプトが出ているんですが、実際は「デプロイしています、、、」みたいな文言も出ていて待ってたらいいのかな、と思ってしまいました。
まとめ
安定して動かすまでは少し苦労するものの、Slack Bot と Lambda の組み合わせは非常に良い組み合わせだと思います。特に Python 版は Lazy listeners という Lambda に特化した実装が公式でサポートされているため、一度 Slack Bot のベースを作ってしまえば OpenAI に限らず活用方法は無限大かなと考えます。ぜひ皆さんも試してみてください。
余力がある方、腕に自信がある方は、既存の実装を使わずに Lazy listeners を実装してみると面白いかもしれません。
Discussion