SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

SAMでSlack botを作成しよう

こんにちは!スマートキャンプ株式会社の松下です。今日はAWS SAMを用いてSlack botを作成する方法と、発生する可能性のある課題について紹介したいと思います。

この記事は以下のZenn記事を統合し、再編集したものです。 https://zenn.dev/smartcamp/articles/f222ef915bc826 https://zenn.dev/smartcamp/articles/ead9a00fe79cab

AWS SAMとは

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html

AWS Serverless Application Model(AWS SAM)は、Infrastructure as Code(IaC)を使用してサーバーレスアプリケーションを構築するためのオープンソースフレームワークです。AWS SAMの短縮構文を使用すると、デベロッパーはデプロイ中にインフラストラクチャに変換されるAWS CloudFormationリソースと特殊なサーバーレスリソースを宣言します。

Slack botをサーバーレスアプリケーションとして構築する理由

https://api.slack.com/quickstart

Slack botを実装する場合の流れは基本的には下記のような形になります。

  1. Slackのプラットフォーム上でAppを作成
  2. Appの設定に基づいてSlackが送信してくるリクエストに対して、レスポンスを返すAPIを実装

2.のようなシンプルなAPIを作成したい場合において、サーバーレスアプリケーションはインフラをあまり考慮する必要がないという観点でメリットがあります。また、botの利用頻度があまり高くない場合、費用面でもメリットが出てくるでしょう。

AWS SAMを用いたSlack botの作成

それでは早速、AWS SAMを使用してSlack botを作成する手順を見ていきましょう。

SAM CLI のセットアップ

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/install-sam-cli.html

上記公式ドキュメントを参照してください。IAM Userなど必要なセットアップを行い、AWS CLIが実行できる状態であることが前提です。

インストール後は下記コマンドで、インストールが成功しているか、パスが通っているかを確認します。

$ which sam
/usr/local/bin/sam
$ sam --version
SAM CLI, <latest version>

SAM CLIによるプロジェクトの初期化

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-init.html

sam init コマンドを実行すると、SAMで作成したいアプリケーションについて、対話的に設定を行なうことができます。

$ sam init
  • 1 - AWS Quick Start Templates
  • 1 - Hello World Example
  • Use the most popular runtime and package type? (Python and zip)y
  • ディレクトリ名 → sample-sam-slack-app

対話式のインターフェースで上記のように選択していくと、下記のようなファイルテンプレート群が生成されます。

$ cd sample-sam-slack-app/
$ tree
.
├── README.md
├── __init__.py
├── events
│   └── event.json
├── hello_world
│   ├── __init__.py
│   ├── app.py
│   └── requirements.txt
├── samconfig.toml
├── template.yaml
└── tests
    ├── __init__.py
    ├── integration
    │   ├── __init__.py
    │   └── test_api_gateway.py
    ├── requirements.txt
    └── unit
        ├── __init__.py
        └── test_handler.py

注目すべきは下記のファイルです。

  • samconfig.toml: SAM CLIを実行する際に参照されるSAMの設定ファイル
  • template.yaml: SAMのデプロイ時に実行されるCloudFormationのテンプレートファイル

Slack App の実装

sam init で生成したコードにSlack Appとしての実装を行なっていきます。生成されたコードに含まれる実装を削除してしまいましょう。

$ rm -rf events/ hello_world/ tests/

次に src ディレクトリを作成し、アプリケーションのロジックを書く app.py を作成します。

$ mkdir src
$ touch src/app.py

app.pyではBolt for PythonというSlackが提供しているフレームワークを利用します。

https://slack.dev/bolt-python/ja-jp/tutorial/getting-started

このようなフレームワークを利用することで、認証を自前で実装する必要がなくなります。

Bolt for Pythonのドキュメントに書いてあるとおり、ここではvenvを利用して仮想環境を作成して作業します。(Python環境のセットアップについては省略します)

$ python3 -m venv .venv
$ source .venv/bin/activate
$ which python3
/Users/${ユーザー名}/sample-sam-application/sample-sam-slack-app/.venv/bin/python3

次に仮想環境内にBolt for Pythonをインストールします。

$ pip install slack_bolt

なお、SAM CLIではビルド時にrequirements.txtを参照するためあらかじめrequirements.txtも出力しておきます。

$ pip freeze > src/requirements.txt

次に src/app.py を編集します。

import os

from slack_bolt import App
from slack_bolt.adapter.aws_lambda import SlackRequestHandler

app = App(
    token=os.environ["SLACK_BOT_TOKEN"],
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
    process_before_response=True,
)


@app.event("app_mention")
def say_hello(event, say):
    user_id = event["user"]
    say(f"Hi, <@{user_id}>!")


def lambda_handler(event, context):
    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)

botに対してメンションをすると、メンションをしたユーザーに対して「Hi, @user_id」と返答メッセージを送信するシンプルなロジックになっています。環境変数の設定については後述します。

SAM CLIでのビルド

ここまで設定した段階で一度SAM CLIでのビルドを試してみましょう。 sam init コマンドで生成したディレクトリ構成を変更しているため、 template.yaml の変更が必要です。

template.yaml を下記のように修正してください。

@@ -12,31 +12,31 @@
     MemorySize: 128

 Resources:
-  HelloWorldFunction:
+  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: hello_world/
+      CodeUri: src/
       Handler: app.lambda_handler
       Runtime: python3.9
       Architectures:
         - x86_64
       Events:
-        HelloWorld:
+        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: /hello
-            Method: get
+            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
-  HelloWorldApi:
-    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
-    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
-  HelloWorldFunction:
-    Description: "Hello World Lambda Function ARN"
-    Value: !GetAtt HelloWorldFunction.Arn
-  HelloWorldFunctionIamRole:
-    Description: "Implicit IAM Role created for Hello World function"
-    Value: !GetAtt HelloWorldFunctionRole.Arn
+  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

SAM CLIでのデプロイ

ここまで変更できたら、デプロイを試してみましょう。

$ sam deploy

を実行しましょう。CloudFormationのStackのChangeSetが表示されます。今回は初めて作成することになるので新規作成のような表示になっていると思います。

Deploy this changeset? [y/N] と聞かれるため、y を入力してデプロイを開始します。

Successfully created/updated stack - sample-sam-slack-app in ap-northeast-1 と表示されればデプロイ成功です。

CloudFormation outputs from deployed stack には template.yaml で設定した出力(例:API GatewayのURL)などが出力されていると思います。 SampleSAMSlackAppApi の値は、Slack Appの設定に必要であるためメモしておきましょう。

ここで試しに表示されたURLについてCurlでリクエストを投げてみましょう。

$ curl -X POST '${URL}'
{"message": "Internal server error"}

環境変数の設定がされておらず、認証も当然通らないためInternal server errorが返ってきますが、APIの処理自体が動いていることはわかります。

Slack Appの設定

ここからSlackプラットフォーム上にSlack Appを作成して設定していきます。

https://slack.dev/bolt-python/ja-jp/tutorial/getting-started

上記のBolt for PythonのドキュメントにSlack Appの作成方法が記載されているので、Slack Appを作成してください。

その後、Slack Appの「OAuth & Permissions」を開いてください。

「Scopes」までスクロールし、bot tokenに下記のようにScopeを追加します。

Scopeの追加後には、「Install to Workspace」ボタンからインストールしましょう。

Bot Tokenが取得できるようになります。

これがLambda関数に設定したい SLACK_BOT_TOKEN の値になります。

次に、Basic Informationを開きましょう。

「App Credentials」の中の、「Signing Secret」が SLACK_SIGNING_SECRET の値になります。

template.yaml を編集して環境変数として設定しましょう。今回は便宜上、値を直接記載しますが、うっかりコミットしないように気をつけてください。(この問題に関しては後述します)

@@ -20,6 +20,10 @@
       Runtime: python3.9
       Architectures:
         - x86_64
+      Environment:
+        Variables:
+          SLACK_BOT_TOKEN: "${トークンの値}"
+          SLACK_SIGNING_SECRET: "${シークレットの値}"
       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

次にSlackからのイベントを受け取るための設定をします。

「Event Subscriptions」を開き、Onにします。

「Request URL」にデプロイ時に出力されたAPI GatewayのURLを、パス(/slack/events)まで含めて入力します。URLを入力した時点で、Slack側からVerifyのリクエストが発行され、うまくいっている場合はVerifiedと表示されます。

また、その下の「Subscribe to bot events」にも設定が必要です。「Add Bot User Event」ボタンを押して app:mention を設定しておきましょう。

この設定を行なうと、画面下部の「Save Changes」が有効になり、設定を保存できるようになったと思います。

ここまで来ればもうSlack Appは動く状態になっています。任意のSlackチャンネルにSlack Appを追加して、メンションを飛ばしてみましょう。

SAMを用いてSlack botを作成したときに発生する可能性のある課題

次に、SAMを用いてSlack botを作成したときに発生する可能性のある課題について説明します。

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

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

「Slack Appの設定」の項目では、 template.yaml に直接クレデンシャルを記載していました。実際の本番アプリケーションとして運用する場合、クレデンシャルをコミットすることはセキュリティ上好ましくありません。

解決策

今回は、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、ライセンスコードなどのデータをパラメータ値として保存することができます。

AWSのコンソールから手動で値を入れ、SAMのデプロイ時にCloudFormationの処理で値を解決するようにします。Parameter Storeを開き、一覧の右上にあるCreate parameterボタンをクリックして、パラメータを作成します。

次に 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}}"
(省略)

なお、CloudFormationの実行ユーザーがParameter Storeにアクセスできる必要があるため、ロールに適切なポリシーを設定してください。 https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/sysman-paramstore-access.html

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

今回設定した template.yaml では、Lambdaのタイムアウトを3秒に設定していましたが、時間がかかる処理を行なう場合があるためいったん30秒に伸ばしてみます。

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

その後、 app.py を編集します。時間がかかる処理を擬似的に再現するために、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 関数を用いた上記のコードは、うまく動作しない例です。 AWS Lambdaは一度レスポンスを返してしまうと、その後の処理が継続することがどうやら保証されていないため、ack() で一度リクエストに対してレスポンスを返してしまうと、その後の処理が継続されないことがあるようです。

そこで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そのものを実行し直す実装になっているからだと思われます。

(省略)
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
                  - lambda:GetFunction
                Resource: "*"
(省略)

このようにして明示的にIAM Roleを新しく定義し、Lambdaに指定するようにします。ログ出力のために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の引数として、後から実行したい処理の関数を配列で渡してやります。

ただし、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 CLIでの後片付け

$ sam delete

上記のコマンドを実行することで、SAM CLIによって作成されたCloudFormationのスタックが削除されます。

Slackプラットフォーム上のSlack Appは手動で削除してください。

おわりに

以上、AWS SAMを用いてSlack botを作成する方法と、発生する可能性のある課題について紹介しました。AWS SAMを使用してSlack botを作成することで、サーバーレスアプリケーションの利点を活かしつつ、比較的簡単にbotを実装できます。しかし、クレデンシャル管理や非同期処理の扱いなど、いくつかの課題にも直面する可能性があります。これらの課題に対してはAWS Systems Manager Parameter Storeの活用やBolt for PythonのLazy Listenerの使用など、適切な解決策を実装することで対処できます。

本記事が、AWS SAMを用いたSlack bot開発の参考になれば幸いです。