Bevor einer der vier Agenten aus Teil 1 einen Log ansehen, eine Workload bepreisen oder einen Fix vorschlagen kann, braucht diese Plattform eine Kontogrenze und zwei IAM-Rollen, die "read-only per Default" zu einer Eigenschaft machen, die AWS erzwingt, nicht zu einem Versprechen, das in einem System-Prompt steht. Diese Grenze, die Anfrage für Modellzugriff, die Tage passieren muss, bevor irgendjemand plant, etwas zu demonstrieren, und die Entscheidung zwischen On-Demand, Provisioned Throughput und Cross-Region-Inference-Profiles entstehen in diesem Teil, vollständig in Terraform, bevor auch nur eine Zeile Agenten-Code existiert.

Teil 1 hat die Bühne bereitet: ein mittelgroßes SaaS-Unternehmen mit 30 AWS-Konten und 40 Pages pro Woche, das sich entscheidet, eine Multi-Agenten-Ops-Plattform auf Bedrock AgentCore und Strands zu bauen statt zu kaufen, mit Read-only als nicht verhandelbarer Design-Zwang, durchgesetzt von IAM. Dieser Teil ist der Punkt, an dem dieser Zwang aufhört, ein Diagramm zu sein, und zu HCL wird. Der begleitende Code liegt auf github.com/flightlesstux/agents-on-call, unter terraform/00-foundation/, und jeder Ausschnitt unten stammt direkt aus diesem Verzeichnis, nicht für den Beitrag vereinfacht.

Warum ein dediziertes ops-tooling-Konto

Das Kontolayout aus Teil 1 packt die gesamte Agenten-Plattform in ein Konto, getrennt von den rund 30 Workload-Konten, auf denen sie operiert:

KontoRolle
managementOrg-Root, SCPs, keine Workloads
securityOrg-CloudTrail, delegierte Administration für GuardDuty und Security Hub
ops-toolingDie gesamte Agenten-Plattform: Runtime, Gateway, Memory, die Step-Functions-State-Machine für Freigaben
workload × ~30Spokes. Stellen ops-tooling genau zwei Rollen zur Verfügung

Ein einzelnes dediziertes Konto kauft, anstatt die Agenten-Infrastruktur in jedem Workload-Konto zu deployen, drei Dinge, die ein gemeinsames Konto nicht kann. Erstens Blast Radius: Eine fehlkonfigurierte Lambda oder ein geleakter Credential in ops-tooling kann nichts in einem Spoke direkt berühren, weil das Erreichen eines Spokes immer einen expliziten kontoübergreifenden sts:AssumeRole-Aufruf erfordert, keine implizite Same-Account-Berechtigung. Zweitens ein einziger Ort zum Auditieren: CloudTrail im security-Konto sieht jeden kontoübergreifenden Assume-Role-Aufruf aus einer einzigen Quelle, statt Agenten-Aktivität über 30 verstreute Trails korrelieren zu müssen. Drittens ein unabhängiger Blast Radius für die Plattform selbst: Hat ops-tooling einen Incident, fällt die Situation überall sonst auf den Status quo zurück, genau die additive, nicht kritische Eigenschaft, die Teil 1 als Anforderung festgelegt hat.

Die zwei Rollen, die jeder Spoke zur Verfügung stellt

Jedes Workload-Konto stellt ops-tooling genau zwei Rollen zur Verfügung, und sonst nichts. Beide liegen in einem wiederverwendbaren Terraform-Modul, modules/spoke-roles/, gedacht zum einmaligen Anwenden pro Spoke-Konto: Ein Provider-Alias pro Konto funktioniert für eine Handvoll Spokes, und bei rund 30 Konten läuft das stattdessen aus einer Pro-Konto-Pipeline, zum Beispiel ein Terraform-Cloud-Workspace pro Konto oder eine Landing-Zone-Account-Factory-Customization. Das Root-Modul in diesem Repo instanziiert es einmal gegen einen einzigen Provider, damit das Beispiel mit terraform validate lauffähig bleibt.

ops-readonly: aus Managed Policies gebaut, gedeckelt durch einen expliziten Deny

Die Read-Tools (cloudwatch-read, logs-read, cost-read) nehmen alle diese eine Rolle an. Ihre Trust Policy nennt exakt die Lambda-Execution-Role-ARNs, die sie annehmen dürfen, plus eine gemeinsame External ID als zweiten Faktor gegen das Confused-Deputy-Problem:

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
}

Die Berechtigungen kommen aus zwei AWS Managed Policies: CloudWatchReadOnlyAccess deckt sowohl CloudWatch-Metriken als auch CloudWatch-Logs-Reads in einer einzigen Anlage ab, weshalb cloudwatch-read und logs-read sich eine einzige IAM-Rolle teilen können statt zwei zu brauchen, und AWSBillingReadOnlyAccess deckt die Cost-Explorer-Aufrufe ab, die cost-read braucht. Zusätzlich zu beiden blockiert ein expliziter Deny-Statement eine feste Liste mutierender Aktionen (iam:*, ssm:StartAutomationExecution, ec2:TerminateInstances, s3:PutObject* und rund ein Dutzend weitere), unabhängig davon, was die angehängten Managed Policies gewähren. Dieser letzte Teil ist keine Dekoration. AWS Managed Policies bekommen im Lauf der Zeit neue Aktionen hinzugefügt, wenn Services Features ergänzen, und ein expliziter Deny ist das eine, das eine IAM-Auswertung durch kein Allow überschreiben kann, egal welche Policy, oder welche zukünftige Version einer Policy, es erzeugt hat.

ops-mutate: ein Caller, eine Allowlist, gar keine Managed Policy

Die Trust Policy ist noch enger: genau ein Principal, die Post-Approval-Executor-Lambda, die nur läuft, nachdem ein Mensch in Slack auf Freigeben geklickt hat.

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]
    }
  }
}

Die Permission Policy ist die interessantere Hälfte. ssm:StartAutomationExecution unterstützt Resource-Level-Einschränkung auf bestimmte SSM-Automation-Document-ARNs, sodass die Allowlist der Runbooks, die diese Rolle ausführen kann, direkt von IAM durchgesetzt wird, nicht von Anwendungslogik, die einen Bug haben könnte:

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 = ["*"]
  }
}

Der zweite Statement steht aus einem bestimmten Grund auf Resource = "*": GetAutomationExecution und die übrigen Inspektions-Aufrufe operieren auf einer Execution-ID, die erst existiert, nachdem StartAutomationExecution bereits eine zurückgegeben hat, es gibt also nichts, worauf man sie im Voraus scopen könnte. Die relevante Grenze dieser Rolle ist, welche Dokumente sie starten kann, nicht welche ihrer eigenen bereits laufenden Executions sie prüfen darf. Und anders als ops-readonly hat diese Rolle null AWS-Managed-Policy-Anlagen. Jede Berechtigung, die sie hält, steht ausdrücklich in diesem einen Policy-Dokument, sodass eine AWS-seitige Änderung an einer Managed Policy niemals still erweitern kann, was sie tun darf.

Bedrock-Modellzugriff ist ein Konsolen-Tage-Problem

Es gibt keine Terraform-Ressource, um Zugriff auf Bedrock-Foundation-Modelle zu gewähren, und diese Lücke ist es wert, genannt zu werden, bevor sie eine Debugging-Session kostet. Ein Modell für ein Konto zu aktivieren ist ein manueller Schritt, entweder über die "Model access"-Seite der Bedrock-Konsole oder den entsprechenden einmaligen API-Aufruf, der einen EULA-Akzeptanz-Workflow auslöst, den AWS asynchron verarbeitet. Das geht nicht sofort, und es ist keine idempotente Infrastruktur, die ein Plan und ein Apply ausdrücken können, es kann also nicht im selben Repo liegen wie die IAM-Rollen oben.

Der praktische Effekt: Zugriff für jedes Modell anfordern, das diese Plattform aufrufen wird, in jeder Region, in die ein Cross-Region-Inference-Profile routen kann, vor dem ersten terraform apply, nicht danach. Ein fehlendes Entitlement in einer Region eines Multi-Region-Profils lässt nur die Aufrufe fehlschlagen, die dorthin geroutet werden, was den Fehler intermittierend aussehen lässt, ein flakiger Agent, ein transientes Throttling, statt dem, was es tatsächlich ist: eine Region von mehreren, die immer noch auf einen Konsolenklick wartet, den niemand gemacht hat. Das als Tag-eins-Aufgabe einplanen, nicht als Während-des-Testens-Aufgabe, denn der Freigabe-Workflow selbst liegt außerhalb der Kontrolle von Terraform, und außerhalb der Kontrolle dieses Projekts.

On-Demand, Provisioned oder Cross-Region: die Entscheidungsregel lautet "on-demand bis gemessen wird"

Es gibt drei Wege, ein Bedrock-Modell aufzurufen, und zwischen ihnen zu wählen, bevor es überhaupt Traffic zum Messen gibt, ist eine Vermutung, die als Architekturentscheidung verkleidet wurde.

ModusWie abgerechnet wirdWas es kauft
On-DemandPro Input-/Output-Token, standardmäßig veröffentlichter SatzKein Commitment, skaliert auf null, der einzig vernünftige Default, bevor es eine Traffic-Baseline gibt
Provisioned ThroughputStündlich, nach Model Units; der Satz hängt von Modell, MU-Anzahl und Commitment-Länge ab (kein Commitment, ein Monat, sechs Monate, längere Commitments kosten pro Stunde weniger)Garantierter, dedizierter Durchsatz; kostet Kapazität unabhängig davon, ob sie genutzt wird
Cross-Region-Inference-ProfileDerselbe Pro-Token-Satz wie in der Quellregion, kein Routing-AufpreisAutomatisches Routing über die Regionen einer Geografie für Burst-Reserve und Zuverlässigkeit, nur On-Demand

Cross-Region-Inference-Profiles sind die leichte Entscheidung: Sie kosten nichts extra gegenüber On-Demand und bringen zusätzliche Burst-Kapazität plus einen Failover-Pfad, falls eine Region throttelt, deshalb nutzt diese Plattform sie von Tag eins an überall. Provisioned Throughput ist die schwierigere Entscheidung, und die Regel, die dieses Projekt anwendet, lautet on-demand bis gemessen wird: Eine Model Unit kauft eine feste Obergrenze für Input- und Output-Tokens pro Minute, die stündlich abgerechnet wird, unabhängig davon, ob die Plattform in einer bestimmten Stunde auch nur eine einzige Anfrage schickt, sich vorab darauf festzulegen, bevor die reale Verteilung der Request-Rate über vier Agenten hinweg bekannt ist, heißt sich auf eine Zahl festzulegen, die niemand verteidigen kann. Teil 7 arbeitet die tatsächliche Token-Rechnung durch und den Punkt, an dem der Stundensatz von Provisioned Throughput den Pro-Token-Satz von On-Demand schlägt; bis diese Rechnung existiert, verschiebt das Raten eines Commitment-Levels das Risiko nur von "das Modell ist langsam" zu "die Rechnung stimmt nicht".

Was Terraform hier tatsächlich erzeugt, ist nicht das Cross-Region-Profile selbst, das ist AWS-verwaltet und existiert in dem Moment, in dem eine Region es unterstützt, sondern ein Application Inference Profile pro Agent, das das systemdefinierte Profil umschließt:

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
  })
}

Der Agent-Tag ist der ganze Sinn, das Systemprofil einzuwickeln statt es direkt aufzurufen: Ohne ihn landet jeder Bedrock-Spend jedes Agenten in einer einzigen undifferenzierten Zeile, und die Cost-Allocation-pro-Agent-Anforderung aus der Architektur von Teil 1 hat nichts, woran sie sich festmachen kann. Mit ihm kann das eigene Budgets- und Cost-Anomaly-Detection-Setup der Plattform, das die Agenten beobachten soll, die alles andere beobachten, den Spend tatsächlich dem Agenten zuordnen, der ihn erzeugt hat.

Der erste terraform apply

terraform -chdir=terraform/00-foundation init und dann plan auszuführen ist nach Zeilenzahl ein kleiner Plan, zwei IAM-Rollen, zwei Inline-Policies, zwei Managed-Policy-Anlagen und so viele Application Inference Profiles, wie es Agenten gibt, aber es ist das eine Apply in dieser ganzen Serie, das stimmen muss, bevor irgendetwas Nachgelagertes vertraut werden kann. Das committete Root-Modul instanziiert spoke_roles einmal gegen den Default-Provider, damit dieses Beispiel in ein einziges Konto appliziert und mit terraform validate und plan lauffähig bleibt. Ein echtes Deployment übergibt diesem Modul stattdessen einen Spoke-scoped Provider-Alias; ohne das landen die zwei Rollen nebeneinander in einem Konto, und die kontoübergreifende Grenze, die dieser Teil bauen soll, existiert nicht. Jeder spätere Teil setzt voraus, dass ops-readonly und ops-mutate bereits genau in dieser Form existieren: Die Principal-Liste der Trust Policy hier falsch zu setzen lässt einen AssumeRole-Aufruf einer Tool-Lambda in Teil 4 auf eine Weise fehlschlagen, die wie eine Gateway-Fehlkonfiguration aussieht, nicht wie ein Tippfehler aus Teil 2, drei Wochen stromaufwärts.

Ein Implementierungsdetail, das beim ersten Durchlauf ein paar Minuten gekostet hat: aws_iam_role.max_session_duration hat eine harte Untergrenze von 3600 Sekunden. Der Instinkt bei ops-mutate ist, angesichts wie eng ihr Blast Radius ohnehin schon begrenzt ist, nach der kürzestmöglichen Session zu fragen, aber IAM erlaubt keine Rollen-Obergrenze unter einer Stunde. Die tatsächliche Credential-Lebensdauer, die der Post-Approval-Executor zur Laufzeit über seinen eigenen STS-Aufruf anfordert, kann und sollte trotzdem deutlich kürzer sein als diese Obergrenze; die Rolleneinstellung begrenzt nur das Maximum, sie setzt nicht den Default.

State- und Backend-Hygiene

Nichts in diesem Teil rührt absichtlich ein Remote-Backend an, weil der Punkt, der in diesem Abschnitt gemacht wird, wichtiger ist als das konkret gewählte Backend: State muss irgendwo mit Locking und Verschlüsselung liegen, bevor eine zweite Person, oder ein zweiter CI-Lauf, jemals gegen dasselbe Konto appliziert, und er muss außerhalb von allem liegen, was ein Agent oder eine Tool-Lambda erreichen kann. Ein S3-Bucket mit Versionierung und einer DynamoDB-Lock-Tabelle, oder Terraform Cloud, funktionieren beide; entscheidend ist, dass ops-readonly und ops-mutate keine Berechtigung haben, diesen State zu lesen oder zu schreiben, und dass die Menschen, die terraform apply gegen dieses Repo ausführen, einen anderen, auditierten Pfad nehmen als die Plattform, die das Repo baut. Console Drift ist die andere Hälfte dieser Disziplin: Produktions-Änderungen, die durch Herumklicken entstehen, entsynchronisieren die State-Datei von der Realität, auf eine Weise, die später als verwirrender Plan auftaucht, nicht als sofortiger Fehler, was genau das Argument dafür ist, IaC als einzigen Weg nach Produktion zu behandeln, ausführlicher beschrieben in IaC-First: Why We Never Touch the AWS Console in Production auf ercan.cloud.

Fehlerbilder, auf die man aus diesem Teil speziell achten muss

Drei Dinge, die es wert sind zu wissen, bevor sie zwei Teile von jetzt an als verwirrendes Symptom auftauchen. Ein fehlendes Modell-Entitlement in einer Region eines Cross-Region-Profils lässt nur Requests fehlschlagen, die dorthin geroutet werden, es zeigt sich also als intermittierende Flakiness, nicht als sauberer Fehler, bis jemand daran denkt, die Konsolen-Zugriffsseite zu prüfen statt den Code. Ein Tippfehler in ops_readonly_assumer_role_arns oder ops_mutate_assumer_role_arn lässt AssumeRole mit einem Access-Denied fehlschlagen, das keinen Hinweis darauf gibt, welche Seite, Trust Policy oder Caller, falsch ist, also die ARNs dieser Lambda-Rollen als Terraform-Outputs von dort halten, wo sie erzeugt werden, und referenzieren, niemals eine ARN von Hand neu abtippen. Und eine leere allowed_ssm_automation_document_arns-Liste ist gültiges Terraform, die Rolle appliziert sauber, nimmt sauber an, und kann dann nie tatsächlich etwas starten; ein Plan-Time-Validation-Block fängt genau diesen Fall ab, aber nur, weil jemand daran gedacht hat, ihn hinzuzufügen, nicht weil Terraform ihn von sich aus abfängt.

Weiterlesen

Das vollständige terraform/00-foundation/-Modul, einschließlich der Teile, die aus den Ausschnitten oben gekürzt wurden, liegt im begleitenden Repository unter github.com/flightlesstux/agents-on-call. Für die Datenbank- und Infrastruktur-Seite, solche Plattformen in diesem Maßstab zu betreiben, gibt es die Field Notes auf ercan.cloud, und der Hub liegt auf ercanermis.com.