はじめに

前々回前回に続き、EC2 インスタンスの情報収集を題材に Agents for Amazon Bedrock を試しています。今回はリージョンをパラメーターで指定できるようにしてみました。具体的には次のような動作です。

  • リージョンを指示した場合: 指示したリージョンを対象として API を実行
  • 複数のリージョンを指示した場合: 指示した複数のリージョンを対象として API を実行
  • 「全リージョン」と指示した場合: DescribeRegions API を使ってリージョンを取得し API を実行

コードはこちらのブランチに置いています。

パラメーターを配列で渡したい場合のポイント

結論からいうと、配列を渡したい場合でもパラメーターは文字列で定義し、カンマで分割して配列に変換するようにしましょう。

アクショングループに定義する API スキーマと Lambda 関数を見ていきます。全体像は以下を参照ください。

API スキーマ

まずスキーマです。/count/{regions} のように、プレースホルダーを設置する形で paths を払い出してみました。

openapi: 3.0.0
info:
  title: Lambda
  version: 1.0.0
paths:
  /count/{regions}:
    get:
      summary: Check EC2 instances count in specified regions
      description: パラメーターで指定されたリージョンに存在するインスタンスの総数、実行中のインスタンスの数を集計してJSONで返します。パラメーターが指定されなかった場合は全リージョンが対象になります。
      operationId: get_instances_count_by_regions
      parameters:
        - name: regions
          in: path
          description: リージョン名
          required: true
          schema:
            type: string
      responses:
        ※省略※
  /check-without-owner/{regions}:
    get:
      summary: Check EC2 instances without owner tag in specified regions
      description: パラメーターで指定されたリージョンに存在するインスタンスに対してOwnerタグが付与されているか確認し、リージョン名、インスタンスID、インスタンス名を収集してJSONで返します。パラメーターが指定されなかった場合は全リージョンが対象になります。
      operationId: get_instances_without_owner_by_regions
      parameters:
        - name: regions
          in: path
          description: リージョン名
          required: true
          schema:
            type: string
      responses:
        ※省略※
  /check-open-permission/{regions}:
    get:
      summary: Check EC2 instances with open permission in specified regions
      description: パラメーターで指定されたリージョンに存在するインスタンスに対してインバウンド通信で0.0.0.0/0が許可されているかを確認し、リージョン名、インスタンスID、インスタンス名、許可されているプロトコル、開始ポート、終了ポート、どのセキュリティグループからの許可かを示すセキュリティグループ名を収集してJSONで返します。パラメーターが指定されなかった場合は全リージョンが対象になります。
      operationId: get_instances_with_open_permission_by_regions
      parameters:
        - name: regions
          in: path
          description: リージョン名
          required: true
          schema:
            type: string
      responses:
        ※省略※

失敗談ですが、検証をはじめた当初は parameters は以下のように配列として定義していました。

parameters:
  - name: regions
    in: path
    description: リージョン名の配列
    required: true
    schema:
      type: array
      items:
        type: string
        description: リージョン名

しかし配列で定義したからといって、そのまま配列として解釈してくれるわけではありません。実際には以下のようにカンマ区切りの文字列として渡されます。

"parameters": [
  {
    "name": "regions",
    "type": "array",
    "value": "ap-northeast-1,us-east-2"
  }
]

このため関数側で value をカンマで分割して配列に変換するのが正しいのですが、そうせずにそのまま配列として処理しようとすると悲劇が起こります。文字列を 1 文字ずつ配列の要素として解釈して、1 文字ずつ API が実行されるのです。。

104769_1

Lambda 関数

API スキーマで定義したことをコードにも反映します。

import json

import boto3

client = boto3.client("ec2")


def handler(event, context):
    body = run(event)
    response = {
        "messageVersion": "1.0",
        "response": {
            "actionGroup": event["actionGroup"],
            "apiPath": event["apiPath"],
            "httpMethod": event["httpMethod"],
            "httpStatusCode": 200,
            "responseBody": {"application/json": {"body": body}},
        },
    }
    return response


def run(event):
    global results
    results = []
    apiPath = event["apiPath"]
    r = get_regions(event.get("parameters", []))
    regions = r.split(",") if r else []
    if not regions:
        regions = [
            region["RegionName"] for region in client.describe_regions()["Regions"]
        ]
    for region in regions:
        if apiPath == "/count/{regions}":
            get_instances_count(region)
        elif apiPath == "/check-without-owner/{regions}":
            get_instances_without_owner(region)
        elif apiPath == "/check-open-permission/{regions}":
            get_instances_with_open_permission(region)
        else:
            print('Error: apiPath "{}" not supported'.format(apiPath))
            break
    return json.dumps(obj=results, ensure_ascii=False)


# API 1
def get_instances_count(region_name: str):
    ...


# API 2
def get_instances_without_owner(region_name: str):
    ...


# API 3
def get_instances_with_open_permission(region_name: str):
    ...

# パラメーターからリージョン名を取り出すためのヘルパー関数
def get_regions(parameters) -> str:
    return next(
        (
            parameter["value"]
            for parameter in parameters
            if parameter["name"] == "regions"
        ),
        "",
    )

以下では get_regions() でパラメーターからリージョン名を取り出し、カンマで分割して配列に変換しています。

r = get_regions(event.get("parameters", []))
regions = r.split(",") if r else []

その結果が空の配列だった場合は、DescribeRegions API を実行して取得した全リージョンの名前をセットします。このように、パラメーターが空の場合にデフォルト値が呼ばれるような動作は、Instruction で重点的に言及したほうがよいでしょう。

if not regions:
    regions = [
        region["RegionName"] for region in client.describe_regions()["Regions"]
    ]

あとは apiPath に対して API をマッピングするだけです。

apiPath = event["apiPath"]
...
for region in regions:
    if apiPath == "/count/{regions}":
        get_instances_count(region)
    elif apiPath == "/check-without-owner/{regions}":
        get_instances_without_owner(region)
    elif apiPath == "/check-open-permission/{regions}":
        get_instances_with_open_permission(region)
    else:
        print('Error: apiPath "{}" not supported'.format(apiPath))
        break

Instruction

リージョンの取り扱いについて説明を追加します。

あなたはAWSに精通したソリューションアーキテクトです。いくつかの関数を使い分け、ユーザーの要求に日本語で回答してください。
ただし、コードやデータ構造については日本語ではなくそのまま回答してください。

タスク1:
もし、例えば「us-east-1、ap-northeast-1のインスタンスの数を教えてください」というように、特定のリージョンのインスタンスの数について聞かれたら、リージョン名をパラメーターとして受け取り”/count/{regions}”というapiPathに紐づく機能を呼び出してください。「全リージョン」を指示された場合は、パラメーターを空で設定して関数を呼び出してください。

タスク2:
もし、例えば「us-east-1、ap-northeast-1リージョンで、Ownerタグの付与されていないインスタンスの情報を教えてください」というように、特定のリージョンでOwnerタグが付与されていないインスタンスの有無について聞かれたら、リージョン名をパラメーターとして受け取り”/check-without-owner/{regions}”というapiPathに紐づく機能を呼び出してください。「全リージョン」を指示された場合は、パラメーターを空で設定して関数を呼び出してください。

タスク3:
もし、例えば「us-east-1、ap-northeast-1リージョンで、インバウンド通信で0.0.0.0/0が許可されているインスタンスの情報を教えてください」というように、特定のリージョンでインバウンド通信が解放されたインスタンスの有無について聞かれたら、リージョン名をパラメーターとして受け取り”/check-open-permission/{regions}”というapiPathを呼び出してください。「全リージョン」を指示された場合は、パラメーターを空で設定して関数を呼び出してください。

タスク4:
タスク1からタスク3以外の場合は、Lambda関数を実行せずに一般的な質問への回答をしてください。わからない質問には「その質問には回答できません」と回答してください。

出力形式:
出力形式の指示が特にない場合は、項目を網羅して整形し、リスト形式で結果を返してください。
例えば「マークダウンの表で回答してください。」や「結果のJSONをそのまま返してください。」など、ユーザーから出力形式が指定された場合に限って、指示に合わせた回答をしてください。

特記事項:
関数の実行結果に関する質問をユーザーから追加で受けた場合は、再度同様の関数を実行することなく、前回の結果を参照し、文脈にあった回答をしてください。

検証

ではここまでの変更をリソースに反映し、マネコンで確認してみましょう。

まずはリージョンを含めて指示してみます。問題なく parameters にリージョンを指定し、パラメーターありで実行できていることがわかります。

104769_2

では複数リージョンを指定してみましょう。こちらも問題なさそうです。カンマ区切りの文字列として渡されるため、関数側で制御できます。

104769_3

最後に全リージョンを指定して指示します。regionsvalue が空白 "" になっており、意図を解釈できています。出力も問題ありません。

104769_4

補足

当初 Instruction で「リージョンが特に指示されなかった場合は全リージョンと解釈する」旨を指示しようとしましたが、リージョン名を延々聞き返してしまったりと、なかなか安定しませんでした。このため今回は、ユーザーから「全リージョン」と指示があった場合に全リージョンを対象とするように指示しています。

このあたりを確実にコントロールしたい場合は、個別に apiPath を払い出し、別のタスクとして指示するのが有効だと思います。モデルを変更して試してみてもよいでしょう (今回は Anthropic Claude 3 Haiku を使っています)

ただしドキュメントにある通り、ひとつのエージェントで定義できる API または関数の上限値が 11 であることには注意が必要です。

おわりに

Agents for Amazon Bedrock で、パラメーターを配列で渡したい場合の実装方法について紹介しました。こういうちょっとしたノウハウも、ほかの機能と組み合わせて使うことでさまざまなユースケースに対応できると思います。

追記

本記事では regions パラメーターを空 "" にするよう指示することで全リージョンを対象とした操作を実現しようと試みましたが、改めて検証したところ "all" のように特定の文字列を渡したほうがより精度が上がりました。後続の記事ではそのように内容を一部修正しています。