🤔

SAMでSlack botを作成する際に起こり得る課題とその解決

2024/07/16に公開
1

読者のみなさん、こんにちは!
下記の記事で、AWS SAMを利用してSlack botを作成する方法について紹介しました。
https://zenn.dev/smartcamp/articles/f222ef915bc826

この記事では、さらに踏み込んだ内容としてSAMでSlack botを作成する際に起こり得る課題とその解決について、紹介したいと思います。

起こり得る課題

  • クレデンシャルなどの環境変数をセキュアに設定する方法がわからない
  • botのレスポンスが何回も実行される

クレデンシャルなどの環境変数をセキュアに設定する方法がわからない

前回の記事では、 CloudFormationの template.yaml の中に直接環境変数を記述する方法をとっていました。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  sample-sam-slack-app

  Sample SAM Template for sample-sam-slack-app

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3
    MemorySize: 128

Resources:
  SampleSAMSlackAppFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Environment:
        Variables:
          SLACK_BOT_TOKEN: "x"
          SLACK_SIGNING_SECRET: "x"
      Events:
        SampleSAMSlackApp:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /slack/events
            Method: post

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  SampleSAMSlackAppApi:
    Description: "API Gateway endpoint URL for Prod stage for Sample SAM Slack App function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/slack/events"
  SampleSAMSlackAppFunction:
    Description: "Sample SAM Slack App Lambda Function ARN"
    Value: !GetAtt SampleSAMSlackAppFunction.Arn
  SampleSAMSlackAppFunctionIamRole:
    Description: "Implicit IAM Role created for Sample SAM Slack App function"
    Value: !GetAtt SampleSAMSlackAppFunctionRole.Arn

このように template.yaml の中に環境変数を直接記載した場合、GitHubなどのリポジトリに template.yaml をコミットすることがセキュリティの関係上難しくなります。

解決策

SAMに限らず、クレデンシャルのような秘匿情報を環境変数を設定する必要がある場合、外部から値を都度取得してくるといったアプローチがよく行われると思います。
今回は、AWS System ManagerのParameter Storeを利用する方法を紹介します。

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-parameter-store.html

AWS Systems Manager の一機能である Parameter Store は、設定データ管理と機密管理のための安全な階層型ストレージを提供します。パスワード、データベース文字列、Amazon Machine Image (AMI) ID、ライセンスコードなどのデータをパラメータ値として保存することができます。

公式ドキュメントにあるように、秘匿情報を保存しておきたいという今回のユースケースにはぴったりです。

仮にCloudFormationを利用してParameter Storeのデータを作成しようとすると、結局テンプレートに秘匿情報そのものを書かなければならなり本末転倒であるため、ここではAWSのコンソールから手動で値を入れていきます。
Parameter Storeを開き、一覧の右上にあるCreate parameterボタンをクリックします。

基本的には、NameとValueを設定するだけです。
秘匿情報である場合、TypeとしてSecureStringを利用した方がよりセキュアであると思いますが、今回はStringを利用します。

次に template.yaml を修正します。

(省略)
      Environment:
        Variables:
          SLACK_BOT_TOKEN: !Sub "{{resolve:ssm:/sample-sam-slack-app/slack-bot-token:1}}"
          SLACK_SIGNING_SECRET: !Sub "{{resolve:ssm:/sample-sam-slack-app/slack-signing-secret:1}}"
(省略)

値を直接入れていた箇所を、Parameter Storeから解決するように記載するだけです。

Parameter Storeから値が解決されるタイミングは、CloudFormation実行時、ひいてはSAM CLIの実行時になるはずなので、SAM CLIで利用しているIAMロールなどには適宜ポリシーを設定してください。
https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/sysman-paramstore-access.html

botのレスポンスが何回も実行される

外部のAPIを叩く必要がある機能(たとえば生成AIなど)をSlack botで実現したい場合、外部のAPIの実行時間を考慮する必要があります。
前回の記事で設定した template.yaml では、Lambdaのタイムアウトを3秒に設定していましたが、生成AIのAPIなどでは3秒以上かかることが少なくないと思うため、これを一旦30秒に伸ばしてみます。

(省略)
Globals:
  Function:
    Timeout: 30
    MemorySize: 128
(省略)

その後、 app.py を編集します。
外部のAPIを叩くことを擬似的に再現するために、 time.sleep(10) で10秒間のスリープを追加してみます。

(省略)
@app.event("app_mention")
def say_hello(event, say):
    time.sleep(10)

    user_id = event["user"]
    say(f"Hi, <@{user_id}>!")
(省略)

その後変更をビルド・デプロイして、Slack Appにメンションを送ってみます。

すると、一度メンションを送っただけにも関わらず、3回もレスポンスが返ってきてしまいます。

https://api.slack.com/interactivity/handling#acknowledgment_response

This must be sent within 3 seconds of receiving the payload. If your app doesn't do that, the Slack user who interacted with the app will see an error message, so ensure your app responds quickly.

これはSlack側の仕様で、3秒以内にSlack側からのリクエストにレスポンスを送信できないと、エラーになるためです。
エラーになった後の挙動については上記ドキュメントに記載がありませんが、リトライを行う挙動になっているようです。

解決策

3秒以内にレスポンスを返す必要があるため、以下の二段階に処理を分けるような実装を考えます。

  1. Slackからのリクエストに対してひとまずすぐにレスポンスを返す
  2. 時間がかかる処理を行なってから、本来返したかったレスポンス(メッセージの送信など)を返す

Bolt for PythonなどのBoltフレームワークでは1.を実現するために、 ack という関数が実現されています。

(省略)
@app.event("app_mention")
def say_hello(event, say, ack):
    ack()

    time.sleep(10)

    user_id = event["user"]
    say(f"Hi, <@{user_id}>!")
(省略)

このように実装すれば実現可能かと思いますが、これは今回の例ではうまく動作しません。
ack() で一度リクエストに対してレスポンスを返してしまうため、その後の継続実行がLambdaのリソース上保証されないためであると思われます。(注:AWS公式ドキュメントでこの辺の挙動を説明しているドキュメントを発見できませんでした。詳しい方がいれば教えてください。)

そこで、Bolt for Pythonに実装されているLazy Listnerを利用します。
一度レスポンスを返した後に、処理を行うための仕組みです。
https://slack.dev/bolt-python/ja-jp/concepts#lazy-listeners

まずは、ライブラリをインストールします。

$ pip install python-lambda

その後、Lambdaに適用しているIAMロールの権限として "lambda:InvokeFunction""lambda:GetFunction" を付与してやる必要があると公式ドキュメントには記載があります。
これは、Lazy Listnerの実装においては後か実行される処理をもう一度Lambdaを実行し直すことで実現しているからだと思われます。(注:Bolt for Pythonの実装を読む限りではそうなっているように見えましたが、もし間違っていれば教えてください)

(省略)
Resources:
  SampleSAMSlackAppFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Environment:
        Variables:
          SLACK_BOT_TOKEN: !Sub "{{resolve:ssm:/sample-sam-slack-app/slack-bot-token:1}}"
          SLACK_SIGNING_SECRET: !Sub "{{resolve:ssm:/sample-sam-slack-app/slack-signing-secret:1}}"
      Role: !GetAtt SampleSAMSlackAppFunctionRole.Arn
      Events:
        SampleSAMSlackApp:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /slack/events
            Method: post
  SampleSAMSlackAppFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: LambdaBasicExecution
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"
        - PolicyName: LambdaInvokePolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                Resource: "*"
(省略)

このようにして明示的にIAM Roleを新しく定義し、Lambdaに指定するようにします。
"lambda:GetFunction" に関してはなぜか指定しなくても動作したため、含めていません。(詳細がわかる方いればお願いします。)
従来までの明示的にIAM Roleを指定していなかったときは、ログ出力のためにCloudWatch Logs用の権限が付与されていたので、これも含めて定義します。

その後、下記のようにコードも修正します。

(省略)
def say_hello(event, say, ack):
    time.sleep(10)

    user_id = event["user"]
    say(f"Hi, <@{user_id}>!")


app.event("app_mention")(ack=lambda ack: ack(), lazy=[say_hello])
(省略)

lazyの引数として、後から実行したい処理の関数を配列で渡してやります。

これらを行なった後に、sam buildsam deploy を行うことで、時間がかかる処理でも3秒以内に処理を返すことができ、botからのレスポンスが何度も送信される問題が解決します。
ただし、Lambdaのコールドスタートなどが絡む場合、 そもそものack()を3秒以内に返せない場合もあります。
このようなときは、本質的ではありませんが、下記のようにSlackが送ってくるリトライリクエストを無視するという方法もあります。

(省略)
def ignore_retry_request(request, ack, next):
    if "x-slack-retry-num" in request.headers:
        return ack()
    next()


app.use(ignore_retry_request)
(省略)

app.use() を利用することでミドルウェア的に全てのリクエストに対して処理を通すことができます。

おわりに

以上、SAMでSlack botを作成する際に起こり得る課題とその解決について記載しました。
特にSlackからのリクエストに3秒以内に処理を返す必要があるという課題については、みなさんが遭遇する確率も低くないと考えています。
なぜなら、シンプルにメンションを返すだけのようなSlack botを作成したいというユースケースはあまり多くなく、実際は外部のAPIやデータベースに接続するような実装が必要になるユースケースが多いと思うからです。
是非、今回の記事を記憶の片隅にでも留めておいていただけるといつか役に立つ日が来るかもしれません。

SMARTCAMP Engineer Blog

Discussion