Die folgenreichste Zeile im Terraform dieser Plattform ist eine, die niemand schreibt: version_upgrade_option auf einem Azure-Modell-Deployment steht per Default auf OnceNewDefaultVersionAvailable, das Deployment zieht sich also selbst auf eine neuere Modellversion, sobald Azure entscheidet, dass ein neuer Default existiert. Ein Gateway, dessen gesamte Rechtfertigung darin besteht, den Modell-Lifecycle zu besitzen, kann das nicht einem Default überlassen. Dieser Teil baut die Infrastrukturschicht unter der Architektur aus Teil 1, entscheidet zwischen Terraform, Bicep und ARM anhand von Kriterien, die tatsächlich tragen, und schaltet die zwei Defaults ab, die den Lifecycle leise zurückholen.

Drei Sprachen, und nur eine echte Frage

Der Vergleich wird üblicherweise über die Syntax geführt, die uninteressanteste Achse. ARM JSON ist das Substrat: Alles wird am Ende ein ARM-Template, und niemand schreibt es mehr von Hand, außer beim Debuggen dessen, was Bicep emittiert hat. Bleiben zwei Kandidaten und eine Frage, die zwischen ihnen entscheidet.

Bicep ist eine domänenspezifische Sprache, die nach ARM transpiliert, in der Azure CLI mitgeliefert wird, kein State File braucht und mit deployment stacks eine echte Antwort auf Lifecycle-Management hat. Ein Stack verfolgt die Ressourcen, die er verwaltet, und actionOnUnmanage entscheidet, was mit einer Ressource passiert, die das Template verlässt: detachAll lässt sie laufen und untracked, deleteResources löscht die Ressourcen, deleteAll löscht Ressourcen und Resource Groups. Stacks tragen außerdem Deny-Einstellungen, ein Stack kann also mit denySettingsMode auf denyDelete oder denyWriteAndDelete angelegt werden, mit ausgenommenen Aktionen und Principals. Diese letzte Fähigkeit hat kein direktes Terraform-Äquivalent: Hier verweigert Azure selbst das Löschen, nicht ein plan, der sich weigert, eines zu erzeugen.

Terraform bringt ein State File mit, einen plan, der sich in einem Pull Request reviewen lässt, und, für diese Plattform entscheidend, mehr als einen provider in einem einzigen Dependency-Graphen. Das ist die Frage, die tatsächlich entscheidet: Verwaltet die Plattform irgendetwas außerhalb der Control Plane des Azure Resource Manager?

Tut sie. Das Tenant-Onboarding aus Teil 1 übergibt einem Team eine Entra-ID-Application, einen Service Principal und ein Client-Credential und verdrahtet die resultierende Object-ID in eine API-Management-Subscription und eine Quota. Entra-ID-Objekte sind keine ARM-Ressourcen. Bicep kann sie nicht anlegen. Eine Bicep-first-Plattform endet mit einem zweiten Tool für Identität und einem manuellen Schritt, der beide verbindet, und genau das ist die Naht, an der ein aufgelöstes Team noch ein Jahr lang funktionierende Credentials behält. Terraform hält azurerm, azuread und azapi in einem Graphen, die Application-Registrierung und die davon abhängige API-Management-Subscription werden also zusammen angelegt, geändert und zerstört.

Also: Terraform für diese Plattform. Das ehrliche Gegengewicht: Ist die Plattform reine ARM-Plane-Infrastruktur und betreibt die Organisation Azure Policy und deployment stacks bereits gut, dann ist Bicep mit denyWriteAndDelete eine stärkere Garantie als ein Terraform-plan, den jeder mit einem gezielten apply übersteuern kann.

Zwei Planes, zwei State Files

Die Aufteilung aus Teil 1 (API Management als Data Plane, ein Python-Service auf AKS als Control Plane) hat eine Infrastruktur-Konsequenz, die es wert ist, vor der ersten Zeile HCL explizit gemacht zu werden. Diese zwei Schichten ändern sich unterschiedlich schnell, um zwei Größenordnungen. Die API-Management-Instanz, der AKS-Cluster und das Netzwerk ändern sich ein paarmal im Jahr. Modell-Deployments, Backends und Policies ändern sich wöchentlich, manchmal täglich.

Beides in einem State File bedeutet, dass eine routinemäßige Änderung an einem Modell-Deployment einen plan gegen den AKS-Cluster laufen lässt, und ein apply, das auf halbem Weg scheitert, lässt beide Schichten teilweise angewendet zurück. Also trennen:

infra/
  10-platform/        # resource group, network, APIM instance, AKS, Log Analytics
  20-models/          # Foundry account, model deployments, APIM backends
  30-tenants/         # Entra ID apps, APIM subscriptions, quotas

Jedes Verzeichnis ist ein eigenes Root-Modul mit eigenem State, und die späteren lesen die früheren über terraform_remote_state oder, besser, über Data Sources, die Ressourcen per Name nachschlagen. Data Sources sind langsamer und wortreicher, und sie sind es wert: Sie bedeuten, dass 20-models nicht bricht, wenn der State von 10-platform refactored wird.

Die Tier-Entscheidung, die keine Kostenentscheidung ist

Teil 1 hat festgelegt, dass das Gateway Anthropic Claude nativ vermittelt. Diese eine Anforderung wählt den API-Management-Tier aus, denn das Schema der Anthropic Messages API wird in den v2-Tiers unterstützt, nicht in den klassischen. Die v2-Tiers deployen außerdem in Minuten statt der langen Provisionierungswartezeit, für die die klassischen Tiers bekannt sind, skalieren auf 10 Units bei Basic v2 und Standard v2 und auf 30 bei Premium v2, und ab Standard v2 aufwärts gibt es Virtual-Network-Integration und eingehende Private Endpoints.

Was man aufgibt, ist nicht wenig, und man kennt es besser jetzt als während eines Incidents. Die v2-Tiers unterstützen derzeit kein Multi-Region-Deployment, kein Backup und Restore der Instanz, kein Senden von Events an Event Grid, keine Git-basierte Service-Konfiguration, keinen direkten Zugriff auf die Management API, keine Self-hosted Gateways und kein Azure DDoS Protection. Es gibt außerdem keinen Upgrade-Pfad von einem klassischen Tier zu einem v2-Tier und keinen Resource Move. Ein Gateway, das auf Developer oder Standard startet und später Anthropic-Support braucht, ist eine Migration, keine Skalierungsoperation.

Das Plattform-Modul beginnt deshalb hier:

resource "azurerm_api_management" "gw" {
  name                = "apim-genai-${var.env}"
  location            = azurerm_resource_group.platform.location
  resource_group_name = azurerm_resource_group.platform.name
  publisher_name      = "Platform Engineering"
  publisher_email     = "platform@example.net"

  # StandardV2 is required for the Anthropic Messages API schema.
  # Capacity is units, not tokens: 1 unit, scale to 10 without redeploying.
  sku_name = "StandardV2_1"

  identity {
    type = "SystemAssigned"
  }
}

Zwei Provider-Einschränkungen gehören als Kommentar neben diesen Block statt in ein Postmortem. delegation-Blöcke werden auf jeder V2-SKU und auf Consumption rundheraus abgelehnt. Und public_ip_address_id wird nur für Developer- und Premium-Instanzen in einem Virtual Network akzeptiert, eine V2-Instanz kann ihre Outbound-IP also nicht auf diesem Weg festnageln, was zählt, wenn ein Modell-Backend hinter einer IP-Allowlist sitzt.

Modell-Deployments, und der Default, der Produktion bewegt

Die Modelle sind ein azurerm_cognitive_account plus ein azurerm_cognitive_deployment pro logischem Modell. Hier lebt der Aufmacher dieses Posts:

resource "azurerm_cognitive_deployment" "chat_default" {
  name                 = "chat-default"
  cognitive_account_id = azurerm_cognitive_account.foundry.id

  model {
    format  = "OpenAI"
    name    = "gpt-4.1"
    version = "2025-04-14"   # pinned, never omitted
  }

  sku {
    name     = "GlobalStandard"
    capacity = 300           # thousands of TPM: 300 = 300,000 TPM
  }

  # Default is OnceNewDefaultVersionAvailable, which upgrades this
  # deployment when Azure changes the default version. The whole point
  # of the catalog is that we decide when a model version changes.
  version_upgrade_option = "NoAutoUpgrade"
}

Drei Dinge in diesem Block gehen leicht schief. capacity ist keine Unit-Anzahl und keine rohe TPM-Zahl: Es sind tokens-per-minute in Tausendern, mit Default 1, also 1.000 TPM. Ein Deployment ohne explizite Capacity drosselt schon ein einzelnes Testskript. Das Feld version ist optional, und es wegzulassen weist die zum Anlagezeitpunkt gültige Default-Version zu, was das Verhalten der Ressource zu einer Funktion des Tages macht, an dem sie applied wurde. Und version_upgrade_option hat drei Werte, OnceNewDefaultVersionAvailable, OnceCurrentVersionExpired und NoAutoUpgrade, von denen nur der letzte die Entscheidung bei einem selbst lässt. OnceCurrentVersionExpired ist die vertretbare Mitte: Es hält die gepinnte Version, bis Azure sie abschaltet, und wechselt dann, statt zu brechen. Pro Modell bewusst einen Wert wählen und ihn im Katalog festhalten, den Teil 9 baut.

Eine weitere Eigenschaft dieser Ressource zeigt sich als mysteriös langsames apply statt als Fehler. Der provider nimmt beim Anlegen eines Deployments einen Lock auf Account-Ebene, elf Deployments auf einem Foundry-Account entstehen also nicht parallel, egal was -parallelism sagt. Modell-Deployments nach Workload auf mehrere Accounts zu verteilen ist ebenso eine Durchsatz- wie eine Quota-Entscheidung.

Wo azurerm endet und azapi beginnt

Die AI-Oberfläche von Azure bewegt sich schneller als jeder Release-Zyklus eines Terraform-providers, ein struktureller Fakt und keine Beschwerde. Microsofts eigene Anleitung nennt beide provider: azurerm für stabile Ressourcen und azapi, um die APIs des Azure Resource Manager direkt anzusteuern, was mit der neuesten Funktionalität Schritt hält, ohne auf ein Provider-Update zu warten.

Die praktische Regel, die daraus kein Chaos werden lässt: azapi ist für Ressourcen, nie für ganze Subsysteme. Eine einzelne azapi_resource für eine Preview-Fähigkeit, neben zwanzig azurerm-Ressourcen und per ID auf sie verweisend, ist in Ordnung und umkehrbar. Eine Plattform, in der die Hälfte der Ressourcen rohe API-Bodies sind, hat die Schema-Validierung und den lesbaren plan aufgegeben, die der Grund waren, Terraform überhaupt zu benutzen. Jeder azapi-Block bekommt einen Kommentar mit der azurerm-Ressource, auf die er wartet, damit die Rückmigration eine Aufgabe ist und kein Archäologieprojekt.

State, Backend und der langweilige Teil, der einen rettet

Remote State in einem Azure Storage Account, ein Container pro Environment, mit Blob-Lease-Locking, das Terraform für das azurerm-Backend nativ beherrscht. Der Storage Account selbst steht nicht im Terraform: Er wird einmalig von einem dokumentierten Skript angelegt, denn ein State-Backend, das in dem State lebt, den es speichert, ist eine zirkuläre Abhängigkeit, die auf einen schlechten Tag wartet.

terraform {
  required_version = "~> 1.9"

  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstategenai"
    container_name       = "prod"
    key                  = "10-platform.tfstate"
    use_azuread_auth     = true      # no storage keys in the pipeline
  }

  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.36" }
    azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
    azapi   = { source = "Azure/azapi",       version = "~> 2.0" }
  }
}

use_azuread_auth ist die Zeile, für die es sich zu streiten lohnt. Ohne sie authentifiziert sich die Pipeline am State-Backend mit einem Storage-Account-Key, einem langlebigen Secret mit Vollzugriff auf den State jedes Environments, abgelegt in einer Variable Group. Mit ihr wird die Workload Identity der Pipeline per Role Assignment autorisiert, und das State File erbt dasselbe Identity-Modell wie alles andere.

Policy-XML gehört in Dateien

Teil 1 hat Policy-Wildwuchs als Fehlerbild von Tag eins benannt. Die mechanische Verteidigung: Policy-XML wird nie im Portal geschrieben. Es lebt im Repository, wird templatisiert und von derselben Pipeline angewendet wie alles andere:

resource "azurerm_api_management_api_policy" "chat" {
  api_name            = azurerm_api_management_api.chat.name
  api_management_name = azurerm_api_management.gw.name
  resource_group_name = azurerm_resource_group.platform.name

  xml_content = templatefile("${path.module}/policies/chat.xml", {
    tpm_default = var.tpm_default
    metric_ns   = "llm-metrics"
  })
}

Die templatisierte Datei ist gewöhnliches XML mit den Token-Limit- und Metrik-Policies aus Teil 1 darin, als Diff reviewt wie jeder andere Code:

<inbound>
  <base />
  <llm-token-limit counter-key="@(context.Subscription.Id)"
                   tokens-per-minute="${tpm_default}"
                   estimate-prompt-tokens="true"
                   remaining-tokens-variable-name="remainingTokens" />
  <llm-emit-token-metric namespace="${metric_ns}">
    <dimension name="Tenant" value="@(context.Subscription.Name)" />
  </llm-emit-token-metric>
</inbound>

Man beachte estimate-prompt-tokens="true", die Einstellung, die einen Request über dem Limit am Gateway scheitern lässt, statt Backend-Quota zu verbrauchen, um es herauszufinden. Sie kostet etwas Genauigkeit bei der Schätzung und spart genau die Tokens, die geschützt werden sollten.

Vier Gotchas, die an die Wand gehören

  • Das Löschen einer Foundry-Ressource gibt ihren Namen 48 Stunden lang nicht frei. Soft delete hält sie wiederherstellbar, und ein terraform destroy gefolgt von einem apply mit demselben Namen scheitert, bis die Ressource explizit gepurgt wird. Schlimmer: Die Kosten für Provisioned Deployments laufen bis zum Purge weiter, ein zerstörtes Environment kann also weiter Rechnungen erzeugen. Deployments vor dem Account löschen, und bewusst purgen.
  • PTU lässt sich nicht aus Terraform kaufen. Die Provisioned-SKUs (ProvisionedManaged, GlobalProvisionedManaged, DataZoneProvisionedManaged) werden stundenweise nach deployter PTU-Anzahl abgerechnet, mit Laufzeitrabatten über Azure Reservations, und diesen Kaufschritt kann der provider nicht abschließen. Das Deployment ist Code; das Commitment ist ein Beschaffungsvorgang, der daneben stattfindet.
  • Quota ist kein Infrastructure as Code. Die Capacity eines Modell-Deployments kann die Quota der Subscription für dieses Modell und diese Region nicht überschreiten, und Quota kommt über einen Support-Request. Nicht der plan-Output ist die Grenze, die Quota-Seite ist es.
  • Kein Upgrade von classic auf v2. Es lohnt die Wiederholung, denn es ist der eine Fehler in diesem Post, der eine Migration kostet statt ein apply.

Was Teil 3 erbt

Am Ende dieses Teils stehen eine API-Management-Instanz auf Standard v2 mit System-assigned Identity, ein Foundry-Account mit gepinnten Modell-Deployments, die sich unter niemandem bewegen werden, ein AKS-Cluster, auf dem noch nichts läuft, Log Analytics und Application Insights, die noch nichts empfangen, und drei State Files, die unabhängig voneinander applied werden können. Keine Anwendung kann irgendetwas davon aufrufen, denn noch existiert keine API, und kein Tenant hat ein Credential. Das sind die nächsten zwei Teile.

Weiterlesen

Für die Infrastruktur- und Plattform-Seite, so etwas in diesem Maßstab zu betreiben, gibt es die Field Notes auf ercan.cloud, und der Hub liegt auf ercanermis.com.

Referenzen