はじめに
こんにちは! yu-Matsuです!
先月話題になっていた、AWS Chatbot が Amazon Bedrock に対応した件に関して、商用展開の際のパッケージ化などを想定して Terraform での IaC を試してみました。
ちなみに、Bedrockエージェント自体のIaCに関しては、以前に別で記事を書いていますので、ご興味がある方はご覧ください。
完成イメージ
以下のような、Slack上でずんだもんと会話できるようなChatbotが完成イメージとなります。ユーザーの問いかけに対して、自分で回答できない場合はWeb検索をして回答してくれます。
事前準備
Slack側
まずは、Slack側の設定です。
すでにワークスペースを作成済みとして、今回Chatbotを配置するチャンネルを作成します。「パブリック」チャンネルとして作成します。
チャンネル作成が完了したら、Slackの左メニューからチャンネルの詳細を開きます。
後で利用するので、チャンネル詳細下部の「チャンネルID」を控えておきます。
AWS側
次に、AWS Chatbotのチャットクライアントの設定を行います。この設定だけはTerraformでコード化出来ないため、コンソール上で実施する必要があります。
下画像のように、チャットクライアントとして「Slack」を選択します。
(事前にSlackのアカウントにログインしている前提) Slackワークスペースへのアクセス権利のリクエスト画面が開くので、「許可する」を押下。
すると、ChatbotクライアントにSlackのワークスペースが追加されています。
ここまで出来たらAWS側の準備は完了です。(後で使うので、「ワークスペースID」を控えておきます)
最後に、今までの作業で控えておいたSlackのチャネルID、ワークスペースIDと、Web検索にtavilyを利用するため、こちらのAPIキーをSecrets Manageに登録しておきます。
シークレット名 | 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の設定に関するファイルになります。
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でエージェントのリソース定義をします。
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 で定義しています。
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 を選択しています。
data "aws_bedrock_foundation_model" "agent" {
model_id = "anthropic.claude-3-5-sonnet-20241022-v2:0"
}
次にアクショングループのリソース定義ですが、こちらはaws_bedrockagent_agent_action_group で行います。
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 を定義しておくことで、デプロイ時にエージェントのエイリアスを作成するようにします。
resource "aws_bedrockagent_agent_alias" "this" {
agent_id = aws_bedrockagent_agent.this.id
agent_alias_name = "slack_chatbot_agent"
}
BedrockエージェントのTerraformのリソース定義は、アクショングループのみの利用であればこれで以上です! もちろんKnowledge baseの利用も可能ですので、詳細は冒頭でご紹介した、私の過去の記事をご覧ください。
lambda.tf
アクショングループで指定するWeb検索用Lambdaの定義になります。
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ディレクトリ配下で定義していまして、その内容は以下になります。
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化しています。
################
# 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ロールの定義は以下になります。シークレットの値を取得する必要があるため、必要な権限を付与しています。
##################
# 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を実行できませんので気をつけます。(今でもたまに忘れる...)
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のコードに関してです。こちらはかなり単純な定義になっています。
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キーと同様にシークレットから取得します。
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ロールの定義は以下になります。
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しているリソースがあります)
問題なさそうなので、terraform apply を実行してデプロイしていきます。
無事デプロイが完了しました!
試しにBedrockエージェントのコンソールを見てみると、ちゃんと作成されています。
後ほどSlackの設定で必要になってくるので、エージェントの詳細画面を開いて、エージェントのARNとエイリアスIDを控えておきます。
Slackの設定
AWSのリソースのデプロイが完了しましたので、Slackの設定を行います。
Slackの作成したチャンネルで、以下のようなメッセージを送ります。先ほど控えておいたエージェントのARNとエイリアスIDが必要になります。
@aws connector add コネクター名 BedrockエージェントのARN BedrockエージェントのエイリアスID
メッセージを送信した際に、チャンネルに「AWS」を招待します。
すると、接続が成功して以下のようなメッセージが返ってきます。これで接続完了です!
SlackとChatbotの接続手順はたったのこれだけです。
動作確認
最後に動作確認をしてみます。
接続完了時のメッセージにChatbotの使い方が簡単に説明されていまして、
@aws ask コネクター名 メッセージ
でメッセージを送ることができます。
まずは簡単な挨拶から。
ずんだもんが返事を返してくれました!
次に、エージェントの動作確認をしたいので、記事投稿時付近のタイムリーな質問をしてみたいと思います。「2024年度衆院選の開催日はいつ?」と質問してみます。
公示日、投票日ともに正しい情報を返してくれました!
選挙に行くようなメッセージも含まれていますね。偉いぞ!
まとめ
AWS Chatbot + Bedrockエージェントを合わせてIaC化してみましたが、簡単なSlackのチャットボットであればかなりお手軽に作成できるようになっていると感じました。
今までは、Slack boltを使ってbotサーバーをECS上にたてて、slack側でもややこしいアプリの設定をしなければならなかったのですが、その手間が省かれました。Terraformでコード化するにしてもかなりコードが簡素になったように思います。
ただ、Slack上では「AWS Chatbotのアプリ」として存在しているため、アイコンやアプリ名を変更出来ないのがネックですし、複雑なChatbot設定をしようとすれば今まで通りbotサーバーをたてることになりそうです。
本記事はこれで以上となります。最後までご精読いただきありがとうございました!
Appendix
コンソール上でのエージェントの動作確認
記事スペースの都合上で省略しましたが、簡単にエージェントの動作確認結果を載せておきます。
エージェントの詳細画面の右側にテストスペースがあるので、web検索が昨日するか試してみます。
記事本編と同じ内容の質問をしてみます。
正しい回答が返ってきており、トレース情報を見ると、Web検索のアクションが動作していることが分かります。