第1回で登場した4体のエージェントがログを1件でも確認し、ワークロードの価格を試算し、修正案を提案できるようになる前に、このプラットフォームにはアカウント境界と2つのIAMロールが必要だ。「read-onlyがデフォルト」をシステムプロンプトの約束ではなく、AWSが強制するプロパティにするためのものである。そのアカウント境界、誰かがデモを計画するよりも数日前に済ませておかなければならないモデルアクセスのリクエスト、そしてオンデマンド、プロビジョンドスループット、クロスリージョン推論プロファイルのどれを使うかという判断。これらすべてが、エージェントのコードが1行も存在しない段階で、完全にTerraformの中で構築されるのがこの回だ。

第1回は舞台設定だった。AWSアカウント約30個、週40回のオンコールを抱える中堅SaaS企業が、1つを買うのではなくBedrock AgentCoreとStrandsの上にマルチエージェントの運用基盤を自前で構築することを選び、read-onlyをIAMで強制することを譲れない設計制約に据えた。この回は、その制約が図であることをやめてHCLになる場所だ。コンパニオンコードはgithub.com/flightlesstux/agents-on-callterraform/00-foundation/にあり、以下のスニペットはすべてそのディレクトリからの抜粋であり、投稿用に簡略化したものではない。

なぜops-tooling専用アカウントが必要か

第1回のアカウント構成では、エージェント基盤全体を1つのアカウントに置き、それが操作対象とする約30個のワークロードアカウントとは分離している。

アカウント役割
managementOrg root、SCP、ワークロードなし
securityOrg CloudTrail、GuardDutyおよびSecurity Hubの委任管理者
ops-toolingエージェント基盤全体:Runtime、Gateway、Memory、承認用のStep Functionsステートマシン
workload × ~30スポーク。ops-toolingに公開するのはちょうど2つのロールのみ

各ワークロードアカウントにエージェント基盤をデプロイするのではなく、専用の単一アカウントにまとめることで、共有アカウントでは得られない3つの利点が手に入る。第一に影響範囲(ブラストラジウス)。ops-tooling内で設定を誤ったLambdaや漏洩した認証情報は、スポークに直接触れることができない。スポークに到達するには常に、同一アカウント内の暗黙的な権限ではなく、アカウント境界をまたぐ明示的なsts:AssumeRoleが必要だからだ。第二に監査の一元化。securityアカウントのCloudTrailは、単一のソースアカウントからのすべてのクロスアカウントAssumeRole呼び出しを把握できる。30個のトレイルに散らばったエージェントの活動を相関させる必要がない。第三に基盤自体の独立した影響範囲。ops-toolingで障害が起きても、それ以外の場所は現状維持まで劣化するだけであり、これはまさに第1回が要件として掲げた「付加的であり、クリティカルパスではない」という性質そのものだ。

すべてのスポークが公開する2つのロール

すべてのワークロードアカウントは、ops-toolingに対してちょうど2つのロールだけを公開し、それ以外は何も公開しない。両方とも再利用可能なTerraformモジュールmodules/spoke-roles/に収められており、スポークアカウントごとに1回ずつ適用することを想定している。アカウントごとのプロバイダエイリアスは数個のスポークまでなら機能するが、約30アカウントの規模になるとアカウントごとのパイプライン、たとえばアカウントごとに1つのTerraform Cloudワークスペースや、Landing Zone Account Factoryのカスタマイズから実行する方が現実的だ。このリポジトリのルートモジュールは、terraform validateで実行可能な例のままにするため、単一のプロバイダに対して一度だけこれをインスタンス化している。

ops-readonly:管理ポリシーの上に明示的なdenyで蓋をする

read系ツール(cloudwatch-readlogs-readcost-read)はすべてこの1つのロールを引き受ける。信頼ポリシーは、このロールを引き受けられる正確なLambda実行ロールのARNを名指しし、confused deputy問題に対する第二の要素として共有のexternal IDも要求する:

data "aws_iam_policy_document" "ops_readonly_trust" {
  statement {
    sid     = "AssumeFromOpsToolingReadTools"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = var.ops_readonly_assumer_role_arns
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

resource "aws_iam_role" "ops_readonly" {
  name                 = "ops-readonly"
  assume_role_policy   = data.aws_iam_policy_document.ops_readonly_trust.json
  max_session_duration = 3600
}

権限は2つのAWS管理ポリシーから来る。CloudWatchReadOnlyAccessはCloudWatchメトリクスとCloudWatch Logsの読み取り両方を1つのアタッチメントでカバーする。これがcloudwatch-readlogs-readが2つに分ける必要なく1つのIAMロールを共有できる理由であり、AWSBillingReadOnlyAccesscost-readに必要なCost Explorer呼び出しをカバーする。この両方の上に、明示的なdeny文が変更系アクションの固定リスト(iam:*ssm:StartAutomationExecutionec2:TerminateInstancess3:PutObject*、その他十数個)を、アタッチされた管理ポリシーが何を許可していようとブロックする。この最後の部分は飾りではない。AWS管理ポリシーはサービスが機能を追加するにつれて時間とともに新しいアクションが加わっていくものであり、明示的なdenyこそが、どのポリシーが、あるいはポリシーのどの将来バージョンが生成したものであっても、IAM評価がallowで上書きできない唯一のものだ。

ops-mutate:呼び出し元は1つ、許可リストも1つ、管理ポリシーはゼロ

信頼ポリシーはさらに狭い。プリンシパルはただ1つ、人間がSlackでapproveをクリックした後にのみ実行される承認後実行者Lambdaだけだ。

data "aws_iam_policy_document" "ops_mutate_trust" {
  statement {
    sid     = "AssumeFromPostApprovalExecutorOnly"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = [var.ops_mutate_assumer_role_arn]
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

権限ポリシーの方がより興味深い側面だ。ssm:StartAutomationExecutionは特定のSSM Automationドキュメントのarnへのリソースレベルの制限をサポートしているため、このロールが実行できるランブックの許可リストはアプリケーションロジックのバグに委ねられることなく、IAMによって直接強制される:

data "aws_iam_policy_document" "ops_mutate_permissions" {
  statement {
    sid       = "StartOnlyAllowlistedRunbooks"
    effect    = "Allow"
    actions   = ["ssm:StartAutomationExecution"]
    resources = var.allowed_ssm_automation_document_arns
  }

  statement {
    sid    = "InspectAndStopOwnExecutions"
    effect = "Allow"
    actions = [
      "ssm:GetAutomationExecution",
      "ssm:DescribeAutomationExecutions",
      "ssm:StopAutomationExecution",
    ]
    resources = ["*"]
  }
}

2つ目の文がResource = "*"になっているのには明確な理由がある。GetAutomationExecutionやその他の確認系呼び出しは、StartAutomationExecutionがすでに返した実行IDに対して操作するものであり、事前にスコープできる対象が存在しないからだ。このロールにおける意味のある境界は、どのドキュメントを開始できるかであって、すでに実行中の自分自身の実行のどれを確認できるかではない。そしてops-readonlyとは異なり、このロールにはAWS管理ポリシーのアタッチメントが一切ない。保持しているすべての権限はその1つのポリシードキュメントに列挙されているため、AWS側で管理ポリシーに変更が入っても、このロールにできることが密かに広がることは決してない。

Bedrockのモデルアクセスはコンソールで日単位かかる問題だ

Bedrockの基盤モデルアクセスを付与するTerraformリソースは存在せず、そのギャップはデバッグセッションを1回無駄にする前に指摘しておく価値がある。あるアカウントでモデルを有効化するのは手作業のステップであり、Bedrockコンソールの「Model access」ページか、それに相当する一度限りのAPI呼び出しのどちらかで、AWSが非同期に処理するEULA同意ワークフローを引き起こす。即座には終わらないし、planとapplyで表現できる冪等なインフラでもないため、上記のIAMロールと同じリポジトリには置けない。

実務上の意味はこうだ。この基盤が呼び出すすべてのモデルについて、クロスリージョン推論プロファイルがルーティングしうるすべてのリージョンで、最初のterraform applyより前にアクセスをリクエストしておく。後からではだめだ。マルチリージョンプロファイルの1リージョンでエンタイトルメントが欠けていると、そのリージョンにルーティングされた呼び出しだけが失敗するため、障害が断続的に見える。エージェントの不調、一時的なスロットリングのように見えるが、実際には複数リージョンのうち1つだけが、誰もまだクリックしていないコンソール操作を待っているだけだ。これを初日タスクとして予算に組み込むこと。テスト中タスクにしてはいけない。承認ワークフローそのものがTerraformの、そしてこのプロジェクトの制御の外にあるからだ。

オンデマンド、プロビジョンド、クロスリージョン:判断ルールは「測定するまではオンデマンド」

Bedrockモデルを呼び出す方法は3通り存在し、トラフィックを測定する前にその中から選ぶのは、アーキテクチャ上の意思決定を装った当て推量にすぎない。

モード課金方法得られるもの
オンデマンド入出力トークンごと、標準の公開レートコミットメントゼロ、ゼロまでスケールダウン可能。トラフィックのベースラインができるまでの唯一まともなデフォルト
プロビジョンドスループット時間単位、Model Unit数で課金。レートはモデル、MU数、コミット期間に依存(コミットなし、1か月、6か月、それより長いコミットほど時間単価は下がる)保証された専用スループット。使っても使わなくてもキャパシティ分を支払う
クロスリージョン推論プロファイルソースリージョンと同じトークン単価、ルーティングの上乗せなしあるジオグラフィー内のリージョンをまたぐ自動ルーティングでバースト余力と信頼性を確保、オンデマンドのみ対応

クロスリージョン推論プロファイルは判断が簡単だ。オンデマンドに対して追加コストはゼロで、あるリージョンがスロットリングした際のバーストキャパシティとフェイルオーバー経路が手に入るため、この基盤は初日からあらゆる場所でこれを使う。プロビジョンドスループットはより難しい判断であり、このプロジェクトが採用しているルールは「測定するまではオンデマンド」だ。Model Unitは固定の入出力トークン毎分の上限を買うものであり、その時間、基盤が1件もリクエストを送らなくても時間単位で課金される。4体のエージェントにまたがる実際のリクエストレート分布を知る前にこれへコミットするのは、誰もまだ弁護できない数字にコミットすることに等しい。実際のトークン数の計算と、プロビジョンドスループットの時間単価がオンデマンドのトークン単価を上回る分岐点は第7回で扱う。その計算が存在するまでは、コミットメントのレベルを当て推量することは、リスクを「モデルが遅い」から「請求が間違っている」に移すだけだ。

ここで実際にTerraformが作成するのはクロスリージョンプロファイルそのものではない。それはAWS管理下にあり、あるリージョンがそれをサポートした瞬間に存在する。作成するのは、システム定義プロファイルをラップするエージェントごとのアプリケーション推論プロファイルだ:

resource "aws_bedrock_inference_profile" "agent" {
  for_each = var.agents

  name        = "${var.platform_name}-${each.key}"
  description = "Application inference profile for the ${each.key} agent, tagged for per-agent cost allocation."

  model_source {
    copy_from = each.value.cross_region_profile_arn
  }

  tags = merge(var.tags, {
    Agent = each.key
  })
}

Agentタグこそが、システムプロファイルを直接呼ばずにラップする理由のすべてだ。これがなければ、すべてのエージェントのBedrock支出は1本の未分化のラインに集約されてしまい、第1回のアーキテクチャが掲げたエージェントごとのコスト配分要件には紐づけるものが何もなくなる。これがあれば、他のすべてを監視するエージェントたちを監視するためのこの基盤自身のBudgetsおよびCost Anomaly Detection構成が、発生したコストをそれを生んだエージェントに実際に帰属させられる。

最初のterraform apply

terraform -chdir=terraform/00-foundation initに続けてplanを実行するのは、行数で見れば小さなプランだ。IAMロール2つ、インラインポリシー2つ、管理ポリシーのアタッチメント2つ、そしてエージェントの数だけ存在するアプリケーション推論プロファイル。だがこのシリーズ全体の中で唯一、下流のすべてが信頼できるようになる前に正しくなければならないapplyでもある。コミットされているルートモジュールは、spoke_rolesをデフォルトプロバイダに対して一度だけインスタンス化しているため、この例はterraform validateplanで実行可能なままにするために単一アカウントへ適用される。実際のデプロイでは、代わりにスポークスコープのプロバイダエイリアスをそのモジュールに渡す。そうしなければ2つのロールが1つのアカウント内に並んで存在するだけになり、この回が構築するはずのクロスアカウント境界は存在しないことになる。以降のすべての回はops-readonlyops-mutateがすでにまさにこの形で存在することを前提にしている。ここで信頼ポリシーのプリンシパルリストを間違えると、あるツールLambdaのAssumeRole呼び出しが第4回で失敗する。しかもそれは3週間上流の第2回のタイプミスではなく、Gatewayの設定ミスのように見える形で失敗する。

最初の1周で数分を無駄にした実装の細部が1つある。aws_iam_role.max_session_durationには3600秒というハードな下限がある。ops-mutateについては、すでにその影響範囲がこれほど狭くスコープされていることを踏まえると、できるだけ短いセッションを求めたくなるのが自然な発想だが、IAMはロールレベルの上限を1時間未満に設定することを許可しない。承認後実行者が実行時に自分自身のSTS呼び出しで実際にリクエストする認証情報の有効期間は、その上限よりもずっと短くできるし、そうすべきだ。ロールの設定はあくまで最大値を制限するだけであり、デフォルト値を決めるものではない。

ステートとバックエンドの衛生管理

この回では意図的にリモートバックエンドに一切触れていない。このセクションで伝えたい要点は、選んだ特定のバックエンドそのものより重要だからだ。ステートはロックと暗号化を伴う場所にどこかに置かれていなければならない。2人目の人間、あるいは2回目のCI実行が同じアカウントに対してapplyする前に。そしてそれはエージェントやツールLambdaが到達できるどこにも置いてはならない。バージョニングを有効にしたS3バケットとDynamoDBのロックテーブル、あるいはTerraform Cloud、どちらも機能する。重要なのはops-readonlyops-mutateがそのステートを読み書きする権限を一切持たないこと、そしてこのリポジトリに対してterraform applyを実行する人間たちが、このリポジトリが構築するプラットフォームとは別の、監査された経路であることだ。コンソールドリフトはこの規律のもう半分だ。ぽちぽちクリックして行われた本番変更は、ステートファイルを現実からずれさせるが、それはすぐにエラーとして表面化するのではなく、後になって混乱を招くplanとして現れる。これはまさに、ercan.cloudのIaC-First: Why We Never Touch the AWS Console in Productionでより詳しく扱っている、IaCを本番への唯一の経路として扱うべきだという論点そのものだ。

この回特有の、注視すべき失敗モード

2回先で混乱を招く症状として現れる前に知っておく価値のあることが3つある。クロスリージョンプロファイルの1リージョンでモデルのエンタイトルメントが欠けていると、そのリージョンにルーティングされたリクエストだけが失敗するため、きれいなエラーではなく断続的な不調として現れる。誰かがコードではなくコンソールのアクセスページを確認しようと思いつくまでは。ops_readonly_assumer_role_arnsops_mutate_assumer_role_arnのタイプミスは、AssumeRoleを、信頼ポリシー側と呼び出し元側のどちらが間違っているのかヒントを与えないaccess-deniedで失敗させる。だからこれらのLambdaロールARNは、それらが作られた場所からのTerraformの出力として保持し参照すること。ARNを手で打ち直してはいけない。そして空のallowed_ssm_automation_document_arnsリストは有効なTerraformであり、ロールはクリーンにapplyされ、クリーンにAssumeもされるが、その後は実際には何も開始できなくなる。plan時点のvalidationブロックがこの特定のケースを捕まえるが、それは誰かがそれを追加しようと思いついたからにすぎず、Terraformが自動的に捕まえてくれるわけではない。

次に読む

上記のスニペットから省いた部分も含むterraform/00-foundation/モジュールの全体は、コンパニオンリポジトリgithub.com/flightlesstux/agents-on-callにある。この種の基盤を大規模に運用する際のデータベースとインフラ側の話はercan.cloudのフィールドノートで、ハブはercanermis.comにある。