La riga più consequenziale nel Terraform di questa piattaforma è una che nessuno scrive: version_upgrade_option su un model deployment Azure ha come default OnceNewDefaultVersionAvailable, quindi il deployment si sposta da solo a una versione più recente del modello ogni volta che Azure decide che esiste un nuovo default. Un gateway la cui intera giustificazione è possedere il ciclo di vita dei modelli non può lasciare quella scelta a un default. Questa parte costruisce il layer infrastrutturale sotto l'architettura della Parte 1, sceglie tra Terraform, Bicep e ARM su basi realmente portanti, e disattiva i due default che si riprendono in silenzio il ciclo di vita.

Tre linguaggi, e una sola domanda vera

Il confronto viene di solito discusso sulla sintassi, che è l'asse meno interessante. L'ARM JSON è il substrato: tutto diventa prima o poi un template ARM, e nessuno lo scrive più a mano se non per debuggare ciò che Bicep ha emesso. Restano due candidati e una domanda che decide tra loro.

Bicep è un linguaggio domain-specific che transpila in ARM, arriva con la Azure CLI, non ha bisogno di uno state file, e ha una risposta concreta alla gestione del ciclo di vita nei deployment stacks. Uno stack traccia le risorse che gestisce, e actionOnUnmanage decide cosa succede a una risorsa che esce dal template: detachAll la lascia in esecuzione e non tracciata, deleteResources elimina le risorse, deleteAll elimina risorse e resource group. Gli stack portano anche i deny settings, quindi uno stack può essere creato con denySettingsMode impostato su denyDelete o denyWriteAndDelete, con azioni e principal specifici esclusi. Quest'ultima capacità non ha un equivalente diretto in Terraform: è Azure stesso a rifiutare la delete, non un plan che rifiuta di generarne una.

Terraform porta uno state file, un plan revisionabile in una pull request e, in modo decisivo per questa piattaforma, più di un provider in un unico grafo di dipendenze. È questa la domanda che decide davvero: la piattaforma gestisce qualcosa fuori dal control plane di Azure Resource Manager?

Sì. L'onboarding dei tenant della Parte 1 consegna a un team un'applicazione Entra ID, un service principal e una client credential, e collega l'object ID risultante a una subscription di API Management e a una quota. Gli oggetti Entra ID non sono risorse ARM. Bicep non può crearli. Una piattaforma Bicep-first finisce con un secondo strumento per l'identità e un passaggio manuale che unisce i due, che è esattamente la cucitura dove un team dismesso conserva credenziali funzionanti per un altro anno. Terraform tiene azurerm, azuread e azapi in un unico grafo, quindi la application registration e la subscription di API Management che ne dipende vengono create, modificate e distrutte insieme.

Quindi: Terraform per questa piattaforma. Il contrappeso onesto è che se la tua piattaforma è pura infrastruttura sul piano ARM e la tua organizzazione usa già bene Azure Policy e i deployment stacks, Bicep con denyWriteAndDelete è una garanzia più forte di un plan Terraform che chiunque può scavalcare con un apply mirato.

Due piani, due state file

La separazione della Parte 1 (API Management come data plane, un servizio Python su AKS come control plane) ha una conseguenza infrastrutturale che vale la pena rendere esplicita prima di scrivere qualsiasi HCL. Questi due layer cambiano a ritmi diversi di due ordini di grandezza. L'istanza API Management, il cluster AKS e la rete cambiano poche volte l'anno. Model deployment, backend e policy cambiano ogni settimana, a volte ogni giorno.

Mettere entrambi in un unico state file significa che una modifica di routine a un model deployment esegue un plan contro il cluster AKS, e un apply fallito a metà lascia entrambi i layer parzialmente applicati. Separali:

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

Ogni directory è un root module a sé con il proprio state, e le successive leggono le precedenti tramite terraform_remote_state o, meglio, tramite data source che cercano le risorse per nome. I data source sono più lenti e più verbosi, e valgono il costo: significano che 20-models non si rompe quando lo state di 10-platform viene rifattorizzato.

La decisione sul tier, che non è una decisione di costo

La Parte 1 diceva che il gateway media Anthropic Claude nativamente. Quel singolo requisito seleziona il tier di API Management, perché lo schema della Anthropic Messages API è supportato nei tier v2, non in quelli classici. I tier v2 inoltre si deployano in minuti invece della lunga attesa di provisioning per cui i tier classici sono noti, scalano a 10 unità su Basic v2 e Standard v2 e a 30 su Premium v2, e da Standard v2 in su supportano l'integrazione con le virtual network e i private endpoint in ingresso.

Quello a cui rinunci non è poco, ed è meglio saperlo adesso che durante un incidente. I tier v2 al momento non supportano il deployment multi-region, il backup e il restore dell'istanza, l'invio di eventi a Event Grid, la configurazione del servizio basata su Git, l'accesso diretto alla Management API, i self-hosted gateway, né Azure DDoS Protection. Inoltre non esiste un percorso di upgrade da un tier classico a un tier v2, e nessun resource move. Un gateway che parte su Developer o Standard e più tardi ha bisogno del supporto Anthropic è una migrazione, non un'operazione di scaling.

Il modulo platform quindi parte da qui:

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

Due vincoli del provider stanno meglio in un commento accanto a quel blocco che in un postmortem. I blocchi delegation vengono rifiutati in blocco su qualsiasi SKU V2 e su Consumption. E public_ip_address_id è accettato solo per istanze Developer e Premium deployate in una virtual network, quindi un'istanza V2 non può fissare così il proprio IP in uscita, il che conta se un backend di modello sta dietro un allowlist di IP.

I model deployment, e il default che muove la produzione

I modelli sono un azurerm_cognitive_account più un azurerm_cognitive_deployment per ogni modello logico. È qui che vive l'apertura di questo post:

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

Tre cose in quel blocco sono facili da sbagliare. capacity non è un numero di unità e non è una cifra TPM grezza: è tokens-per-minute in migliaia, e il default è 1, cioè 1.000 TPM. Un deployment creato senza una capacity esplicita verrà strozzato da un singolo script di test. Il campo version è opzionale, e ometterlo assegna la versione di default del momento della creazione, il che rende il comportamento della risorsa una funzione del giorno in cui è stata applicata. E version_upgrade_option ha tre valori, OnceNewDefaultVersionAvailable, OnceCurrentVersionExpired e NoAutoUpgrade, di cui solo l'ultimo lascia la decisione a te. OnceCurrentVersionExpired è la via di mezzo difendibile: mantiene la versione fissata finché Azure non la ritira, poi si sposta invece di rompersi. Scegline uno deliberatamente per ogni modello, e registra quale nel catalogo che la Parte 9 costruisce.

Un'altra proprietà di questa risorsa si presenta come un apply misteriosamente lento invece che come un errore. Il provider prende un lock a livello di account mentre crea un deployment, quindi undici deployment su un unico account Foundry non vengono creati in parallelo qualunque cosa dica -parallelism. Dividere i model deployment tra più account per workload è una decisione di throughput tanto quanto di quota.

Dove finisce azurerm e dove inizia azapi

La superficie AI di Azure si muove più in fretta di qualsiasi ciclo di release di un provider Terraform, il che è un fatto strutturale, non una lamentela. La guida di Microsoft stessa nomina entrambi i provider: azurerm per le risorse stabili, e azapi per pilotare direttamente le API di Azure Resource Manager, che tiene il passo con le funzionalità più recenti senza aspettare un aggiornamento del provider.

La regola pratica che evita che tutto questo diventi un pasticcio: azapi è per le risorse, mai per interi sottosistemi. Un singolo azapi_resource per una capacità in preview, accanto a venti risorse azurerm che referenzia per ID, va bene ed è reversibile. Una piattaforma dove metà delle risorse sono corpi API grezzi ha rinunciato alla validazione dello schema e al plan leggibile che erano la ragione per usare Terraform. Ogni blocco azapi riceve un commento che nomina la risorsa azurerm che sta aspettando, così la migrazione di ritorno è un task e non un progetto di archeologia.

State, backend e la parte noiosa che ti salva

Remote state in uno storage account Azure, un container per environment, con locking a blob lease, che Terraform gestisce nativamente per il backend azurerm. Lo storage account stesso non sta in Terraform: viene creato una volta da uno script documentato, perché un backend di state che vive nello state che conserva è una dipendenza circolare in attesa di una brutta giornata.

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 è la riga per cui vale la pena discutere. Senza, la pipeline si autentica al backend dello state con una chiave dello storage account, un secret a lunga vita con pieno accesso allo state di ogni environment, parcheggiato in un variable group. Con, la workload identity della pipeline è autorizzata tramite role assignment, e lo state file eredita lo stesso modello di identità di tutto il resto.

La policy XML appartiene ai file

La Parte 1 nominava la proliferazione delle policy come una modalità di fallimento del primo giorno. La difesa meccanica è che la policy XML non viene mai scritta nel portale. Vive nel repository, è templata e viene applicata dalla stessa pipeline di tutto il resto:

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

Il file templato è XML ordinario con dentro le policy di token limit e di metriche della Parte 1, revisionato come diff come qualsiasi altro codice:

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

Nota estimate-prompt-tokens="true", che è l'impostazione che fa fallire al gateway una richiesta oltre il limite invece di consumare quota di backend per scoprirlo. Costa un po' di accuratezza sulla stima e risparmia esattamente i token che stavi cercando di proteggere.

Quattro insidie da scrivere sul muro

  • Eliminare una risorsa Foundry non ne libera il nome per 48 ore. Il soft delete la mantiene recuperabile, e un terraform destroy seguito da un apply con lo stesso nome fallisce finché la risorsa non viene purgata esplicitamente. Peggio, gli addebiti per i provisioned deployment continuano fino al purge, quindi un environment distrutto può continuare a fatturare. Elimina i deployment prima di eliminare l'account, e purga deliberatamente.
  • Le PTU non si comprano da Terraform. Gli SKU provisioned (ProvisionedManaged, GlobalProvisionedManaged, DataZoneProvisionedManaged) si acquistano su base oraria per numero di PTU deployate, con sconti a termine tramite Azure Reservations, e quel passaggio d'acquisto non è qualcosa che il provider può completare. Il deployment è codice; l'impegno è un'azione di procurement che avviene accanto.
  • La quota non è infrastructure as code. La capacity di un model deployment non può superare la quota della sottoscrizione per quel modello e quella region, e la quota arriva tramite una richiesta di supporto. L'output del plan non è il vincolo; la pagina della quota lo è.
  • Nessun upgrade da classico a v2. Vale la pena ripeterlo perché è l'unico errore in questo post che costa una migrazione invece di un apply.

Cosa eredita la Parte 3

Alla fine di questa parte c'è un'istanza API Management su Standard v2 con una identity system-assigned, un account Foundry con model deployment fissati che non si muoveranno sotto i piedi di nessuno, un cluster AKS con ancora niente sopra, Log Analytics e Application Insights che non ricevono ancora nulla, e tre state file applicabili in modo indipendente. Nessuna applicazione può chiamare niente di tutto questo, perché nessuna API esiste ancora e nessun tenant ha una credenziale. Sono le prossime due parti.

Leggi questo dopo

Per il lato infrastruttura e piattaforma di come far girare tutto questo su scala, gli appunti tecnici sono su ercan.cloud, e l'hub è su ercanermis.com.

Riferimenti