AWS Chatbot と Bedrockエージェント の連携をIaCする！

# はじめに

こんにちは！ **yu-Matsu**です！

先月話題になっていた、**AWS Chatbot が Amazon Bedrock に対応した件**に関して、商用展開の際のパッケージ化などを想定して **Terraform での IaC** を試してみました。

https://aws.amazon.com/jp/about-aws/whats-new/2024/09/aws-chatbot-amazon-bedrock-agent-microsoft-teams-slack/

ちなみに、Bedrockエージェント自体のIaCに関しては、以前に別で記事を書いていますので、ご興味がある方はご覧ください。

https://qiita.com/yu-Matsu/items/17c7f026810c4a496c20


# 完成イメージ
以下のような、Slack上でずんだもんと会話できるようなChatbotが完成イメージとなります。ユーザーの問いかけに対して、自分で回答できない場合はWeb検索をして回答してくれます。


<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/5a0e8bb4-8512-3d5a-6cd7-4aa12fe4f7a1.png' width='500'>




# 事前準備
### Slack側

まずは、Slack側の設定です。

すでにワークスペースを作成済みとして、今回Chatbotを配置するチャンネルを作成します。「パブリック」チャンネルとして作成します。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/34bfeb14-301d-60db-a2b0-a9322dad57a4.png' width='400'>

チャンネル作成が完了したら、Slackの左メニューからチャンネルの詳細を開きます。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/a331918e-eecf-91cb-fc42-273bfe560282.png' width='300'>

後で利用するので、チャンネル詳細下部の「チャンネルID」を控えておきます。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/c3b6197d-ba20-a815-51d5-05db3941b8b1.png' width='300'>

### AWS側
次に、AWS Chatbotのチャットクライアントの設定を行います。この設定だけはTerraformでコード化出来ないため、コンソール上で実施する必要があります。

下画像のように、チャットクライアントとして「Slack」を選択します。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/55fa9078-db60-8fe3-79d1-c14790b01d6c.png' width='500'>


(事前にSlackのアカウントにログインしている前提) Slackワークスペースへのアクセス権利のリクエスト画面が開くので、「許可する」を押下。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/0b0d4b22-9616-775a-26b5-e34b6008c145.png' width='400'>

すると、ChatbotクライアントにSlackのワークスペースが追加されています。
ここまで出来たらAWS側の準備は完了です。（後で使うので、「ワークスペースID」を控えておきます）

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/a10f19c5-ae15-200b-931d-9cf12b5178de.png' width='500'>

最後に、今までの作業で控えておいた**SlackのチャネルID、ワークスペースID**と、Web検索に**tavily**を利用するため、こちらのAPIキーを**Secrets Manage**に登録しておきます。

https://tavily.com/

|シークレット名  |key  |value |
|---|---|---|
|tavily-apikey  |apiKey  |tavilyのAPIキー|
|slack_secrets |channel_id  |控えておいたチャンネルID  |
||workspace_id|控えておいたワークスペースID|

以上で事前準備は完了です！

# TerraformによるIaC
## ディレクトリ構成
Terraformのコードの紹介に入る前に、ディレクトリ構成は以下のようになっています。

```ディレクトリ構成
terraform/
├── src/               # Lambdaのソースコードを管理
│   ├── layer/
│   │   └── lambdaで利用するライブラリ
│   └── web_search/
│       └── index.py
├── provider.tf        # Terraformの設定ファイル
├── datasource.tf　　　 # datasource関連のリソース定義
├── agent.tf           # Bedrockエージェントのリソース定義
├── chatbot.tf         # Chatbotのリソース定義
├── lambda.tf          # Action用Lambdaのリソース定義
└── iam.tf             # IAMの定義
```

## コードについて
### provider.tf
まずはTerraformの設定に関するファイルになります。
```terraform:provider.tf
provider "aws" {
  region = "us-west-1"
}

terraform {
  required_version = "~> 1.9.4"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.70.0"
    }
  }
}
```
今回はモデルに **Claude 3.5 Sonnet v2** を使いたいため、リージョンにオレゴン（us-west-2）を指定していますが、東京（ap-northeast-1）でも大丈夫です。Terraformのバージョンは1.9.4、AWSプロバイダーのバージョンは5.70.0を指定しています。

### agent.tf

Bedrockエージェントに関するTerraformのコードです。まず、**aws_bedrockagent_agent**でエージェントのリソース定義をします。

```terraform:agent.tf
resource "aws_bedrockagent_agent" "this" {
  agent_name              = "slack_chatbot_agent"
  agent_resource_role_arn = aws_iam_role.agent.arn
  description             = "Slackチャットボット用エージェント"
  foundation_model        = data.aws_bedrock_foundation_model.agent.model_id
  instruction             = <<EOT
ずんだもんという少女を相手にした対話のシミュレーションを行います。
明るい性格で、語尾は「〜のだ」、「〜なのだ」です。
彼女の発言サンプルを以下に列挙します。
<example>
こんにちは、僕はずんだもんなのだ。ずんだ餅の精なのだ。
よろしくお願いしますなのだ！
ずんだ餅のさらなる普及を夢見ているのだ。
ずんだ餅は好きなのだ？
ずんだ餅を食べたことはあるのだ？
ずんだ餅はおいしいのだ！
ずんだアローに返信することが出来るのだ！
ずんだもんの魅力で子どもファンをゲットなのだ！
</example>
上記例を参考に、ずんだもんの性格や口調、言葉の作り方を模倣してください。
また、ユーザーの質問に回答できない場合は、web検索を行って回答してください。
EOT
}
```

エージェントへの指示であるinstruction（いわゆるシステムプロンプト）　はここで指定します。今回はずんだもんのキャラ付けをしているのと、ユーザーの質問対して**必要であればWeb検索を行うように指示**しています。
ここでエージェントのIAMロールを指定するのですが、こちらは別途 iam.tf で定義しています。

```terraform:iam.tf
resource "aws_iam_role" "agent" {
  name = "chatbot-agent-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "bedrock.amazonaws.com"
        }
        Condition = {
          StringEquals = {
            "aws:SourceAccount" = data.aws_caller_identity.this.account_id
          }
          ArnLike = {
            "aws:SourceArn" = "arn:aws:bedrock:us-west-2:${data.aws_caller_identity.this.account_id}:agent/*"
          }
        }
      }
    ]
  })

  managed_policy_arns = [ aws_iam_policy.agent.arn ]
}

resource "aws_iam_policy" "agent" {
  name = "chatbot-agent-policy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action   = "bedrock:InvokeModel"
        Effect   = "Allow"
        Resource = data.aws_bedrock_foundation_model.agent.model_arn
      }
    ]
  })
}
```
また、agent.tfでもiam.tfでもエージェントで指定するモデルの情報をdatasourceで持ってきており、こちらの定義は以下になります。モデルは **Claude 3.5 Sonnet v2** を選択しています。
```terraform:datasource.tf
data "aws_bedrock_foundation_model" "agent" {
  model_id = "anthropic.claude-3-5-sonnet-20241022-v2:0"
}
```


次に**アクショングループ**のリソース定義ですが、こちらは**aws_bedrockagent_agent_action_group** で行います。

```terraform:agent.tf
resource "aws_bedrockagent_agent_action_group" "this" {
  action_group_name          = "web-search"
  agent_id                   = aws_bedrockagent_agent.this.id
  agent_version              = "DRAFT"
  skip_resource_in_use_check = true
  action_group_executor {
    lambda = aws_lambda_function.this.arn
  }
  function_schema {
    member_functions {
      functions {
        name        = "web_search"
        description = "与えられたqueryをもとにweb検索を行い、検索結果を返す"
        parameters {
          map_block_key = "query"
          type          = "string"
          description   = "web検索用query"
          required      = true
        }
      }
    }
  }
}
```
今回は「web-search」という名前のアクショングループを作成することにします。
action_group_executor として、後ほどご紹介するWeb検索用のLambdaのARNを指定しています。
また、アクショングループの詳細設定として、**function_schema**（AWSコンソール上で言うところの「**関数の詳細で定義**」）を指定可能なので、こちらで記載しています。

最後に、**aws_bedrockagent_agent_alias** を定義しておくことで、デプロイ時にエージェントのエイリアスを作成するようにします。

```terraform:agent.tf
resource "aws_bedrockagent_agent_alias" "this" {
  agent_id         = aws_bedrockagent_agent.this.id
  agent_alias_name = "slack_chatbot_agent"
}
```

BedrockエージェントのTerraformのリソース定義は、アクショングループのみの利用であればこれで以上です！ もちろんKnowledge baseの利用も可能ですので、詳細は冒頭でご紹介した、[私の過去の記事](https://qiita.com/yu-Matsu/items/17c7f026810c4a496c20)をご覧ください。

### lambda.tf

アクショングループで指定するWeb検索用Lambdaの定義になります。

```terraform:lambda.tf
resource "aws_lambda_function" "this" {
  function_name    = "web-search-action"
  role             = aws_iam_role.action_lambda.arn
  filename         = data.archive_file.web_search.output_path
  source_code_hash = data.archive_file.web_search.output_base64sha512
  handler          = "index.lambda_handler"
  runtime          = "python3.9"
  timeout          = 300
  layers        = ["${aws_lambda_layer_version.this.arn}"]

  environment {
    variables = {
      TAVILY_API_KEY = jsondecode(data.aws_secretsmanager_secret_version.tavily_apikey.secret_string)["apiKey"]
    }
  }
}

resource "aws_lambda_layer_version" "this" {
  layer_name = "web-search-layer"

  filename         = data.archive_file.web_search_layer.output_path
  source_code_hash = data.archive_file.web_search_layer.output_base64sha512

  compatible_runtimes = ["python3.9"]
}
```
ソースコード自体はsrcディレクトリ配下で定義していまして、その内容は以下になります。

```python:src/web_search/index.py
import json
import os
from tavily import TavilyClient

def lambda_handler(event, context):
    agent = event['agent']
    actionGroup = event['actionGroup']
    function = event['function']
    parameters = event.get('parameters', [])

    api_key = os.environ['TAVILY_API_KEY']
    client = TavilyClient(api_key=api_key)
    
    # パラメータから検索クエリを取得するのだ
    query = next((param['value'] for param in parameters if param['name'] == 'query'), '')

    try:
        # Tavilyで検索を実行するのだ
        response = client.search(
            query=query,
            search_depth="basic",
            max_results=3
        )

        print(response['results'][0])
        
        # 検索結果をフォーマットするのだ
        search_results = []
        for result in response['results']:
            search_results.append(
                f"タイトル: {result['title']}\n内容: {result['content']}\n"
            )
        
        response_body = {
            'TEXT': {
                'body': "\n\n".join(search_results)
            }
        }

    except Exception as e:
        response_body = {
            'TEXT': {
                'body': f"検索中にエラーが発生したのだ: {str(e)}"
            }
        }

    function_response = {
        'actionGroup': actionGroup,
        'function': function,
        'functionResponse': {
            'responseBody': response_body
        }
    }

    action_response = {
        'messageVersion': '1.0',
        'response': function_response
    }

    return action_response
```
インポートしている**requests**ライブラリは、ランタイム3.9以降からは標準で利用出来なくなったため、Lambda Layerを作成する必要があります。

tavliyのAPIキーは、事前準備で用意したシークレットから取得しています。
このソースコードと、Lambda Layerの作成に必要なライブラリを、Terraformのdatasourceリソースを使ってzip化しています。

```terraform:datasource.tf
################
# secrets
################
data "aws_secretsmanager_secret" "tavily_apikey" {
  name = "tavily-apikey"
}

data "aws_secretsmanager_secret_version" "tavily_apikey" {
  secret_id = data.aws_secretsmanager_secret.tavily_apikey.id
}

################
# lambda archive
################
data "archive_file" "web_search" {
  type        = "zip"
  source_dir  = "${path.module}/src/web_search/"
  output_path = "${path.module}/src/web_search.zip"
}

################
# lambda layer
################
data "archive_file" "web_search_layer" {
  type        = "zip"
  source_dir  = "${path.module}/src/layer/python"
}
```

LambdaのIAMロールの定義は以下になります。シークレットの値を取得する必要があるため、必要な権限を付与しています。

```terraform:iam.tf
##################
# lambda
##################
resource "aws_iam_role" "action_lambda" {
  name = "bedrock-action-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })

  managed_policy_arns = [ aws_iam_policy.action_lambda.arn ]
}

resource "aws_iam_policy" "action_lambda" {
  name = "bedrock-action-lambda-policy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action   = "logs:CreateLogGroup"
        Effect   = "Allow"
        Resource = "arn:aws:logs:us-west-2:${data.aws_caller_identity.this.account_id}:*"
      },
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:us-west-2:${data.aws_caller_identity.this.account_id}:log-group:/aws/lambda/*:*"
      },
      {
        Effect = "Allow",
        Action = [
          "secretsmanager:GetSecretValue"
        ],
        Resource = "arn:aws:secretsmanager:us-west-2:605664253368:secret:*"
      }
    ]
  })
}
```

最後に、私はたまに忘れてしまうのですが**Lambda Permission**の設定を **aws_lambda_permission** で定義します。これがないと、BedrockエージェントからLambdaを実行できませんので気をつけます。（今でもたまに忘れる...）


```terraform:lambda.tf
resource "aws_lambda_permission" "this" {
  action         = "lambda:invokeFunction"
  function_name  = aws_lambda_function.this.function_name
  principal      = "bedrock.amazonaws.com"
  source_account = data.aws_caller_identity.this.account_id
  source_arn     = "arn:aws:bedrock:us-west-2:${data.aws_caller_identity.this.account_id}:agent/*"
}
```

### chatbot.tf

最後に、AWS Chatbotのコードに関してです。こちらはかなり単純な定義になっています。

```terraform:chatbot.tf
resource "aws_chatbot_slack_channel_configuration" "this" {
  configuration_name = "bedrock_chatbot_slack"
  iam_role_arn       = aws_iam_role.chatbot.arn
  slack_channel_id   = jsondecode(data.aws_secretsmanager_secret_version.slack_secrets.secret_string)["channel_id"]
  slack_team_id      = jsondecode(data.aws_secretsmanager_secret_version.slack_secrets.secret_string)["workspace_id"]
  logging_level      = "ERROR" 
}
```
SlackのチャンネルIDと team id(ワークスペースID)は、tavilyのAPIキーと同様にシークレットから取得します。

```terraform:datasource.tf
data "aws_secretsmanager_secret" "slack_secrets" {
  name = "slack_secrets"
}

data "aws_secretsmanager_secret_version" "slack_secrets" {
  secret_id = data.aws_secretsmanager_secret.slack_secrets.id
}
```

ChatbotのIAMロールの定義は以下になります。

```terraform:iam.tf
resource "aws_iam_role" "chatbot" {
  name = "chatbot-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
          Effect = "Allow",
          Principal = {
              Service = "chatbot.amazonaws.com"
          },
          Action = "sts:AssumeRole"
      }
    ]
  })

  managed_policy_arns = [ 
    "arn:aws:iam::aws:policy/AmazonBedrockFullAccess",
    "arn:aws:iam::aws:policy/AWSResourceExplorerReadOnlyAccess",

  ]
}

resource "aws_iam_policy" "chatbot" {
  name = "chatbot-policy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
            "cloudwatch:Describe*",
            "cloudwatch:Get*",
            "cloudwatch:List*"
        ],
        Effect = "Allow",
        Resource = "*"
      }
    ]
  })
}

```

## デプロイ

Terraformのコードが作成し終わったら、いよいよデプロイをしてみたいと思います。

まず、**terraform plan** を実行して、コードに問題がないか、想定されるリソースが作成されるかを確認します。（一部検証のためterraform importしているリソースがあります）

![スクリーンショット 2024-10-27 23.35.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/23d84d3c-3d6b-ff9e-ce6b-2380c3a265b8.png)


問題なさそうなので、**terraform apply** を実行してデプロイしていきます。

![スクリーンショット 2024-10-27 23.38.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/1dd03dea-5d66-a58c-3f99-9a3e0621aa0f.png)


**無事デプロイが完了しました！**

試しにBedrockエージェントのコンソールを見てみると、ちゃんと作成されています。

![スクリーンショット 2024-10-27 23.43.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/4be1e0e2-9d50-4f3f-e13d-64f59fb0eafe.png)


後ほどSlackの設定で必要になってくるので、エージェントの詳細画面を開いて、**エージェントのARNとエイリアスID**を控えておきます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/60244db3-a29c-9c14-6c14-824b393336e8.png" width=600>

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/7a74275f-2ec3-4c73-0a90-85676125355c.png" width=400>


# Slackの設定
  
AWSのリソースのデプロイが完了しましたので、Slackの設定を行います。

Slackの作成したチャンネルで、以下のようなメッセージを送ります。先ほど控えておいたエージェントのARNとエイリアスIDが必要になります。

```
@aws connector add コネクター名 BedrockエージェントのARN BedrockエージェントのエイリアスID
```
メッセージを送信した際に、チャンネルに「AWS」を招待します。

![スクリーンショット 2024-10-26 18.22.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/81c007f5-580e-48e1-ca06-d0809113b858.png)

すると、接続が成功して以下のようなメッセージが返ってきます。これで接続完了です！


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/be84be2e-b5b0-3373-e087-735558570588.png" width=400>

SlackとChatbotの接続手順はたったのこれだけです。


# 動作確認

最後に動作確認をしてみます。

接続完了時のメッセージにChatbotの使い方が簡単に説明されていまして、
```
@aws ask コネクター名 メッセージ
```
でメッセージを送ることができます。

まずは簡単な挨拶から。



<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/4916a977-964a-0518-93e9-582938cfc462.png" width=550>

ずんだもんが返事を返してくれました!


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/52c24574-dffe-443d-4a81-195b0d4cc733.png" width=500>

次に、エージェントの動作確認をしたいので、記事投稿時付近のタイムリーな質問をしてみたいと思います。「2024年度衆院選の開催日はいつ？」と質問してみます。

![スクリーンショット 2024-10-28 0.18.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/38d208ad-6710-4337-d529-e9ed9ee6ae1a.png)


公示日、投票日ともに正しい情報を返してくれました！
選挙に行くようなメッセージも含まれていますね。偉いぞ！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/d140ed4f-a7ee-6c4c-e9c7-7cf6f0dbeb9a.png" width=500>



# まとめ
AWS Chatbot + Bedrockエージェントを合わせてIaC化してみましたが、簡単なSlackのチャットボットであればかなりお手軽に作成できるようになっていると感じました。

今までは、Slack boltを使ってbotサーバーをECS上にたてて、slack側でもややこしいアプリの設定をしなければならなかったのですが、その手間が省かれました。Terraformでコード化するにしてもかなりコードが簡素になったように思います。

ただ、Slack上では「AWS Chatbotのアプリ」として存在しているため、アイコンやアプリ名を変更出来ないのがネックですし、複雑なChatbot設定をしようとすれば今まで通りbotサーバーをたてることになりそうです。

本記事はこれで以上となります。最後までご精読いただきありがとうございました！



# Appendix
### コンソール上でのエージェントの動作確認
記事スペースの都合上で省略しましたが、簡単にエージェントの動作確認結果を載せておきます。

エージェントの詳細画面の右側にテストスペースがあるので、web検索が昨日するか試してみます。
記事本編と同じ内容の質問をしてみます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/e3d47ba9-bd97-c08a-f4a6-8f9759937fbe.png" width=400>

正しい回答が返ってきており、トレース情報を見ると、Web検索のアクションが動作していることが分かります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/6c5f60da-1ed7-aa62-d141-5a83eddfd1dd.png" width=500>

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/84870511-6eb3-151b-4247-ce1ce5bdc52f.png" width=600>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/b32f82ca-5517-6965-6f1e-a7c8a0646d84.png" width=600>