🧠

SaaS で AI Agent を提供するあなたへ贈る、Bedrock AgentCore マルチテナント実装 - Runtime 編

に公開

SaaS で AI Agent を提供するあなたへ贈る、Amazon Bedrock AgentCore マルチテナント実装 - Runtime 編

初めまして、AWS Japan でソリューションアーキテクトをしている showish & fukumo_to です。

「テナントごとにリソースは分けた方がいいの!?」、「テナント分離ってどうすればいいの!?」 などなど、マルチテナントなサービスやシステムを開発する上で悩まれる方も多いのではないでしょうか?

本記事では、Amazon Bedrock AgentCore Runtime を題材に、サイロモデルとプールモデルの 2 つのデプロイモデルを軸に、実践でも使える知識をお届けします。

この記事で得られること:

  • サイロ/プールモデルの選択基準と、AgentCore Runtime での実装パターン
  • サイロモデルにおける IAM 認証・JWT 認証それぞれの実装コード
  • セッション ID にテナント情報を埋め込む設計と、その運用上の意味
  • 実装時に遭遇しやすい落とし穴と対処法

1. マルチテナントにおけるデプロイモデル

1.1 サイロモデルとプールモデル

SaaS の実装において、マルチテナント環境のオンボーディング、認証、管理、運用、分析などを担うコントロールプレーンと、ユーザーに提供される機能を含むアプリケーションプレーンの二つの要素に分かれます。アプリケーションプレーンにはいくつかのデプロイパターンが存在します。

  • テナントごとにリソースを占有する「サイロモデル」
  • テナント間でリソースを共有する「プールモデル」
  • レイヤーやコンポーネントによってそれらを組み合わせた「ブリッジモデル」
  • 特定のテナントにはサイロモデルで、他のテナント群にはプールモデルで提供するといったハイブリッドモデル

これらのモデルはセキュリティやコスト、運用の複雑さなどトレードオフを考慮して選択することになります。

1.2 Amazon Bedrock AgentCore Runtime におけるデプロイモデル

AgentCore Runtime において、上述したモデルを実装するにはどうすれば良いでしょうか?
Runtime 単体で考えると、採用できるモデルはサイロモデルもしくはプールモデルのみなので、それぞれについて考えてみます。

1.2.1 サイロモデル

Runtime は「ランタイムリソース」という単位でリソースとそれに紐づく設定を作成します。
サイロモデルを選択する場合はこのランタイムリソースをテナントごとに作成する形になります。

運用や管理の複雑性が上がること、過度に各テナントごとの設定を固有にするのは避けるべきですが、要件に応じて設定を変えられること自体はメリットです。例えばテナントごとに異なる ID プロバイダーを紐づける要件があると言った場合には、サイロモデルを選択することでシンプルに対応できます。

上の図にあるように、サイロモデルではテナントごとにリソースが分かれるため、テナントからのリクエストを適切にルーティングするレイヤーが必要になります。

このルーティングのレイヤーはロードバランサーや DNS などインフラレイヤーで実装することもあれば、アプリケーションコンポーネントとして実装することも考えられます。

AWS 上での実装に関しては、AWS における SaaS アプリケーションのテナントルーティング戦略 が参考になります。

1.2.2 プールモデル

プールモデルは、すべてのテナントでランタイムリソースを共有します。

ランタイムリソースに紐づく設定が共通になり、リソースレベルで個別の要件に対応することは難しくなります。管理対象が減るという意味では楽に見えますが、あるテナントの利用が他のテナントに影響を与える「ノイジーネイバー」問題や、テナント間のデータアクセスなどを防ぐテナント分離の仕組みを確実に設ける必要があるなど、運用のハードルは上がります。

プールモデルの実装(JWT ベースの認証、テナント分離の多層防御、Token Vending Machine など)は本記事では扱いきれないため、次回の「Identity・認証編」でまとめてお届けします。

本記事では、以降でサイロモデルにおけるテナント分離とセッション管理を Dive Deep していきます。


2. サイロモデルにおけるテナント分離

2.1 Amazon Bedrock AgentCore Runtime におけるテナント分離

Runtime はセッション分離の仕組みが提供されており、リクエスト時にヘッダーに含めた値によって、実行環境である MicroVM が分離されます。

プロトコル セッションヘッダー
HTTP / A2A / AG-UI X-Amzn-Bedrock-AgentCore-Runtime-Session-Id
MCP Mcp-Session-Id

※含めない場合は、レスポンスに含まれる自動生成された値を引き続き使うことでセッションを継続して利用できます。

もう一つキーになるのが、ランタイムリソースの設定にあるインバウンド認証です。

インバウンド認証タイプ

  • IAM 許可を使用 — AWS コンソールへのサインインに使用した IAM プリンシパルが使用されます。
  • JSON Web Tokens (JWT) を使用 — JWT (OAuth トークンなど) をインバウンド認証として設定して、受信トークンの署名とスコープを検証します。

サイロモデルでは、リソースが分かれているためテナント分離は比較的シンプルです。

上図では、例として Amazon API Gateway を経由して AWS Lambda が起動し、Lambda 関数内で Amazon DynamoDB からテナントに応じたランタイムリソースのエンドポイントを取得して Runtime を実行します。また、テナントごとのセッション情報の管理も DynamoDB で行っています。

ここで、Runtime と Identity が連携した認証では、IAM と JWT の認証方式のうちどちらかを設定します。両パターンにおける実装について解説します。

2.1.1 サイロモデルにおける認証方式 - JWT

サイロモデルではランタイムリソースが分かれているため、IdP もテナントごとに分ける構成が素直に見えますが、実運用では共通の IdP を使うケースも多くあります。ユーザー管理やサインイン体験を一元化したい一方で、JWT からテナントを識別する仕組みが別途必要になる、というトレードオフがあります。

上図で示したサンプルアーキテクチャーにおいて、JWT はクライアントからのリクエスト時にヘッダーに載せられ、Lambda 関数へと伝播します。

Lambda 関数内では、JWT の検証を行い、トークン内の属性情報をもとに、DynamoDB に格納されたルーティング先を取得します。

Runtime にて JWT 認証を利用する場合、Runtime のエンドポイントは boto3 等の AWS SDK を利用した呼び出しができず、HTTP クライアントを用いて HTTP リクエストを送る必要があります。

# JWT 認証時は boto3 ではなく HTTP リクエストを送る
import urllib.parse
import requests

encoded_arn = urllib.parse.quote(runtime_arn, safe='')
url = (
    f"https://bedrock-agentcore.{region}.amazonaws.com"
    f"/runtimes/{encoded_arn}/invocations"
)
headers = {
    "Authorization": f"Bearer {access_token}",
    "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": session_id,
    "Content-Type": "application/json",
}
response = requests.post(url, headers=headers, json={"prompt": user_input})

Runtime 側でも JWT の検証を行い、適切な認可は実行されますが、仮に Lambda 関数のロジックが間違っていると、他のテナントのリソースにアクセスを行いかねないため、注意が必要です。

2.1.2 サイロモデルにおける認証方式 - IAM

引き続き先ほどのアーキテクチャー図を元に説明を続けます。
IAM 認証を利用する場合、リクエスト元が利用する IAM Role にてエンドポイントへのアクセスが許可されているかがポイントになります。

ただし、このケースでも Lambda はどのテナントからのアクセスなのかを判断する材料、いわゆるテナントコンテキストが必要です。それは ID Token でもいいですし、Cookie などを利用したセッション情報などいくつかの方法が考えられます。

Lambda が Runtime のエンドポイントにアクセスする際に、すべてのテナントのリソースへの権限を持つ方法としては

  • Lambda にアタッチする IAM Role で全テナントをカバーする
  • テナントごとの IAM Role を用意して、Lambda はそれらへの Assume Role 権限を持つ

と言った方法がまずは思いつくのではないでしょうか?

一つめの方法はクロステナントアクセスの可能性を高めるため、できれば採用したくありませんし、テナントが追加されるたびに IAM Role に紐づくポリシーを修正する必要があります。自動化をしていたとしても、Policy の文字数サイズの制限が気になります。

二つめの方法にしても、IAM Role をテナント分作成して管理することやクオータが気になります。

このような状況で利用できるアプローチに、IAM の動的生成があります。

詳細は動的に生成された IAM ポリシーで SaaS テナントを分離するを読んでいただきたいのですが、概要を説明します。

上の図のように、テナントに共通する要素だけを記述した IAM ポリシーを紐づけたテナント間共通の IAM ロールを用意しておきます。Lambda にアタッチする IAM Role では、そのテナント共通 IAM ロールを引き受ける権限を付与しておきます。

Lambda 関数内では、テナントコンテキストを元にテナントのエンドポイントを取得し、そのエンドポイントに権限のスコープを狭めるポリシー内容をコード内で組み立てます。

AssumeRole API 実行時にはテナント共通の Role と動的に組み立てたポリシーを与えることで、ポリシー間のアンドが取られ、特定のテナントにスコープを絞ったアクセス権限を動的に発行することができます。

# AssumeRole 時に session policy を渡して権限を絞り込む例
scoped_policy = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": "bedrock-agentcore:InvokeAgentRuntime",
        "Resource": tenant_runtime_arn,  # テナント固有の Runtime ARN のみ
    }],
}

credentials = sts.assume_role(
    RoleArn=common_tenant_role_arn,
    RoleSessionName=f"tenant-{tenant_id}",
    Policy=json.dumps(scoped_policy),  # ← ここで動的に絞り込む
)["Credentials"]

この方法を取ることで、テナントの数分の IAM Role を管理することなく、スケールする実装とすることができます。同じ考え方は、プールモデルにおける DynamoDB 項目レベルのテナント分離でも重要になります(次回以降で詳しく扱います)。

2.1.3 サイロモデルにおける Runtime のセッション管理

Runtime はセッション分離の仕組みが提供されており、リクエスト時にヘッダーに含めた値によって、実行環境である MicroVM が分離されます。先述の通り、HTTP/A2A/AG-UI では X-Amzn-Bedrock-AgentCore-Runtime-Session-Id、MCP では Mcp-Session-Id ヘッダーを使います。

ここで重要なのは、MicroVM の分離単位は「セッション」であって「テナント」や「ユーザー」ではない という点です。AgentCore はセッションとユーザーのマッピングを強制しないため、どの粒度でセッションを切るかは呼び出し側の設計に委ねられています。

サイロモデルでは Runtime リソース自体がテナントごとに分かれているため、セッションレベルでのテナント分離は既に保証されています。残る論点は 「同一テナントの中でユーザーやビジネスコンテキスト(チャットの会話、サポートチケットなど)をどう繋ぐか」 です。

ここで意識したいのが、Runtime 側が管理するセッションと、アプリケーション側が扱いたいセッションは粒度も寿命も異なる、という点です。整理すると次の 2 軸になります。

セッション ID 分離単位 寿命 用途
Runtime セッション ID MicroVM デフォルト 15 分の idle timeout / 最大 8 時間 エージェントの実行環境の分離
ビジネスセッション ID 会話・チケット・タスク等 業務要件次第(数時間〜永続) ユーザー体験としての会話の連続性

Runtime セッションは、公式ドキュメント によると idle timeout のデフォルトが 15 分、最大 lifecycle は 8 時間です。つまり カスタマーサポートのチャットのように「ユーザーが翌日また話しかけてくる」ようなユースケースでは、Runtime セッションだけでは会話の継続性を担保できません

この二層のセッション ID をどこに永続化するかは、ユースケースや採用しているフレームワークによっていくつかの選択肢があります。

  • DynamoDB などの KVS にビジネスセッションと Runtime セッションのマッピングを持つ
  • Strands Agents SDKFileSessionManager などを使い、ファイルストレージ(ローカル / S3 等)に会話状態ごと保存する
  • AgentCore Memory にユーザー単位の長期記憶を寄せ、ビジネスセッション側は最小限のメタデータだけ管理する

実装例として、DynamoDB にマッピングを持つ場合のスキーマは以下のようになります。

属性 説明
business_session_id (PK) ticket-12345 ビジネス的に永続する識別子
tenant_id tenant_acme ルーティングと監査用
runtime_session_id tenant_acme:user-123:a1b2c3... Runtime 呼び出し時のヘッダー値
last_access_time 2026-01-15T10:30:00Z idle timeout を跨いだかの判定
runtime_arn arn:aws:... サイロモデルでのルーティング先
セッション ID そのものにテナント情報を埋め込む

もう一つ、実用上とても効くプラクティスとして、セッション ID の中にテナント ID を含める というものがあります。

# 推奨: テナント・ユーザー・一意 ID を連結
runtime_session_id = f"{tenant_id}:{user_id}:{uuid.uuid4()}"
# 例: "tenant_acme:user-123:a1b2c3d4-e5f6-7890-abcd-ef1234567890"

これは AgentCore Memory の actorId 設計({tenant_id}:{user_id} 形式)とも揃えられる考え方で、次のようなメリットがあります。

  • CloudWatch Logs / X-Ray で tenant 単位のグルーピングが容易session.id を prefix 検索するだけでテナント別のトレース抽出ができる
  • 誤ってセッション ID を跨いだとしても、ログを見ればクロステナントだとすぐ気づける
  • Observability 編で後述する trace_attributes と整合させやすい
過去の会話を再開するときのフロー

二層のセッション ID と永続化レイヤーを組み合わせると、過去の会話の再開は次のような流れになります。

  1. ユーザーが過去のチケット(business_session_id)を開く
  2. サーバー側で business_session_id をキーに runtime_session_idlast_access_time を取得する
  3. last_access_time が idle timeout(デフォルト 15 分)以内であれば、同じ runtime_session_id で呼び出して実行コンテキストを継続する
  4. idle timeout を超えていれば、新しい runtime_session_id を発行したうえで、過去の会話履歴を AgentCore Memory や Strands Agents SDK の Session Manager 等から読み出してエージェントに渡す

この設計により、Runtime セッションのライフサイクルとユーザー体験としての会話の連続性を、別々の関心事として扱えるようになります。

セッション ID のライフサイクル設計

AgentCore Runtime はセッション ID 単位で実行コンテキストを管理するため、同じセッション ID で呼び出している間は会話の状態がそのまま維持されます。逆に新しいセッション ID で呼び出すと、別の実行コンテキストとして扱われます。

この特性を活かすために、ビジネスセッション(チケット ID など)と Runtime セッションを上記のように分けておくと、last_access_time が idle timeout(デフォルト 15 分)を超えた場合だけ新しい Runtime セッション ID を発行する、といったライフサイクル制御が可能になります。会話の連続性が必要な間はセッション ID を維持することで、ユーザー体験を損なわずに済みます。


3. 次回予告

次回は、より実装ハードルが高い プールモデルでの認証とテナント分離 を扱います。

  • Amazon Cognito と Pre Token Generation Lambda V2 による JWT へのテナント情報注入
  • TenantMetadata テーブルによるユーザー ↔ テナントマッピング
  • Token Vending Machine パターン による DynamoDB 項目レベルのテナント分離
  • Runtime Inbound Auth の JWT Authorizer 設定
  • 多層防御(JWT 検証 → アプリケーション層 → IAM 層)

ハンズオンで手を動かしたい方へ

本記事の内容は、以下の AWS Workshop として実際に手を動かしながら学べます。Lab 1 でシンプルなエージェントを Runtime にデプロイし、Lab 3 以降でプールモデルでのテナント分離を段階的に実装していく構成になっています。

マルチテナント AI エージェント with Amazon Bedrock AgentCore

脚注
  1. シングルテナントという用語の削除 ↩︎

アマゾン ウェブ サービス ジャパン (有志)

Discussion