A linha mais consequente no Terraform desta plataforma é uma que ninguém escreve: version_upgrade_option em um model deployment do Azure tem como default OnceNewDefaultVersionAvailable, então o deployment se move sozinho para uma versão mais nova do modelo sempre que o Azure decide que existe um novo default. Um gateway cuja justificativa inteira é ser dono do ciclo de vida dos modelos não pode deixar isso em um default. Esta parte constrói a camada de infraestrutura sob a arquitetura da Parte 1, escolhe entre Terraform, Bicep e ARM com base em critérios que realmente sustentam peso, e desliga os dois defaults que retomam o ciclo de vida em silêncio.

Três linguagens, e só uma pergunta de verdade

A comparação costuma ser discutida em torno de sintaxe, que é o eixo menos interessante. O ARM JSON é o substrato: tudo eventualmente vira um ARM template, e ninguém mais o escreve à mão, exceto ao depurar o que o Bicep emitiu. Sobram dois candidatos e uma pergunta que decide entre eles.

Bicep é uma linguagem de domínio específico que transpila para ARM, vem junto com o Azure CLI, não precisa de state file, e tem uma resposta real para gestão de ciclo de vida em deployment stacks. Uma stack rastreia os recursos que gerencia, e actionOnUnmanage decide o que acontece com um recurso que sai do template: detachAll o deixa rodando e sem rastreamento, deleteResources apaga os recursos, deleteAll apaga recursos e resource groups. Stacks também carregam deny settings, então uma stack pode ser criada com denySettingsMode definido como denyDelete ou denyWriteAndDelete, com ações e principals específicos excluídos. Essa última capacidade não tem equivalente direto no Terraform: é o próprio Azure recusando o delete, não um plan se recusando a gerar um.

Terraform traz um state file, um plan que você pode revisar em um pull request, e, de forma decisiva para esta plataforma, mais de um provider em um único grafo de dependências. Essa é a pergunta que de fato decide a escolha: a plataforma gerencia alguma coisa fora do control plane do Azure Resource Manager?

Gerencia. O onboarding de tenants da Parte 1 entrega a um time uma aplicação no Entra ID, um service principal e uma client credential, e liga o object ID resultante a uma subscription do API Management e a uma quota. Objetos do Entra ID não são recursos ARM. O Bicep não consegue criá-los. Uma plataforma que começa pelo Bicep termina com uma segunda ferramenta para identidade e um passo manual unindo as duas, que é exatamente a costura onde um time desativado mantém credenciais funcionando por mais um ano. O Terraform segura azurerm, azuread e azapi em um único grafo, então o registro da aplicação e a subscription do API Management que depende dele são criados, alterados e destruídos juntos.

Então: Terraform para esta plataforma. O contrapeso honesto é que, se a sua plataforma é infraestrutura puramente do plano ARM e a sua organização já opera bem Azure Policy e deployment stacks, o Bicep com denyWriteAndDelete é uma garantia mais forte do que um plan do Terraform que qualquer pessoa pode contornar com um apply direcionado.

Dois planos, dois state files

A divisão da Parte 1 (API Management como data plane, um serviço Python no AKS como control plane) tem uma consequência de infraestrutura que vale explicitar antes de escrever qualquer HCL. Essas duas camadas mudam em ritmos que diferem por duas ordens de grandeza. A instância do API Management, o cluster AKS e a rede mudam algumas vezes por ano. Model deployments, backends e policies mudam toda semana, às vezes todo dia.

Colocar os dois no mesmo state file significa que uma mudança rotineira de model deployment roda um plan contra o cluster AKS, e um apply que falha no meio do caminho deixa as duas camadas parcialmente aplicadas. Separe:

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

Cada diretório é seu próprio root module com seu próprio state, e os posteriores leem os anteriores por terraform_remote_state ou, melhor, por data sources que buscam recursos pelo nome. Data sources são mais lentos e mais verbosos, e valem o custo: eles significam que 20-models não quebra quando o state de 10-platform é refatorado.

A decisão de tier, que não é uma decisão de custo

A Parte 1 disse que o gateway faz a mediação do Anthropic Claude nativamente. Esse único requisito seleciona o tier do API Management, porque o schema da Anthropic Messages API é suportado nos tiers v2, não nos clássicos. Os tiers v2 também são implantados em minutos, em vez da longa espera de provisionamento pela qual os tiers clássicos são conhecidos, escalam até 10 unidades no Basic v2 e no Standard v2 e até 30 no Premium v2, e do Standard v2 para cima suportam integração com virtual network e private endpoints de entrada.

O que você abre mão não é pouco, e é melhor saber disso agora do que durante um incidente. Os tiers v2 atualmente não suportam deployment multi-região, backup e restore da instância, envio de eventos para o Event Grid, configuração do serviço baseada em Git, acesso direto à Management API, self-hosted gateways, nem Azure DDoS Protection. Também não existe caminho de upgrade de um tier clássico para um tier v2, e não existe resource move. Um gateway que começa no Developer ou no Standard e depois precisa de suporte a Anthropic é uma migração, não uma operação de escala.

O módulo de plataforma, portanto, começa aqui:

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

Duas restrições do provider pertencem a um comentário ao lado desse bloco, não a um postmortem. Blocos delegation são rejeitados de imediato em qualquer SKU V2 e no Consumption. E public_ip_address_id só é aceito para instâncias Developer e Premium implantadas em uma virtual network, então uma instância V2 não pode fixar o IP de saída por esse caminho, o que importa se um backend de modelo fica atrás de uma allowlist de IP.

Model deployments, e o default que move a produção

Os modelos são um azurerm_cognitive_account mais um azurerm_cognitive_deployment por modelo lógico. É aqui que mora o lead deste 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"
}

Três coisas nesse bloco são fáceis de errar. capacity não é uma contagem de unidades nem um número bruto de TPM: é tokens-per-minute em milhares, e o default é 1, ou seja, 1.000 TPM. Um deployment criado sem capacity explícita sofre throttling com um único script de teste. O campo version é opcional, e omiti-lo atribui a versão que for a default no momento da criação, o que torna o comportamento do recurso uma função do dia em que o apply rodou. E version_upgrade_option tem três valores, OnceNewDefaultVersionAvailable, OnceCurrentVersionExpired e NoAutoUpgrade, dos quais só o último deixa a decisão com você. OnceCurrentVersionExpired é o meio-termo defensável: mantém a versão fixada até o Azure aposentá-la, e então se move em vez de quebrar. Escolha um deliberadamente por modelo, e registre qual no catálogo que a Parte 9 constrói.

Mais uma propriedade desse recurso aparece como um apply misteriosamente lento, não como um erro. O provider adquire um lock no nível da conta ao criar um deployment, então onze deployments em uma mesma conta do Foundry não são criados em paralelo, não importa o que -parallelism diga. Dividir model deployments entre contas por workload é uma decisão de throughput tanto quanto de quota.

Onde o azurerm para e o azapi começa

A superfície de IA do Azure se move mais rápido do que qualquer ciclo de release de provider do Terraform, o que é um fato estrutural, não uma reclamação. A orientação da própria Microsoft nomeia os dois providers: azurerm para recursos estáveis, e azapi para falar diretamente com as APIs do Azure Resource Manager, o que acompanha as funcionalidades mais novas sem esperar uma atualização do provider.

A regra prática que impede isso de virar uma bagunça: azapi é para recursos, nunca para subsistemas inteiros. Um único azapi_resource para uma capacidade em preview, ao lado de vinte recursos azurerm e referenciando-os por ID, é aceitável e reversível. Uma plataforma em que metade dos recursos são corpos de API crus abriu mão da validação de schema e do plan legível que eram a razão de usar Terraform. Todo bloco azapi ganha um comentário nomeando o recurso azurerm pelo qual ele espera, para que a migração de volta seja uma tarefa, não um projeto de arqueologia.

State, backend, e a parte sem graça que salva você

State remoto em uma Azure Storage account, um container por ambiente, com locking por blob lease, que o Terraform trata nativamente no backend azurerm. A própria storage account não está no Terraform: ela é criada uma única vez por um script documentado, porque um state backend que vive no state que ele armazena é uma dependência circular esperando um dia ruim.

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 é a linha pela qual vale brigar. Sem ela, o pipeline se autentica no state backend com uma storage account key, que é um segredo de longa duração com acesso total ao state de todos os ambientes, guardado em um variable group. Com ela, a workload identity do pipeline é autorizada por role assignment, e o state file herda o mesmo modelo de identidade de todo o resto.

XML de policy pertence a arquivos

A Parte 1 nomeou a proliferação de policies como um modo de falha do primeiro dia. A defesa mecânica é que XML de policy nunca é escrito no portal. Ele vive no repositório, é templateado, e é aplicado pelo mesmo pipeline que todo o 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"
  })
}

O arquivo templateado é XML comum com as policies de limite de tokens e de métricas da Parte 1 dentro, revisado como um diff, igual a qualquer outro código:

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

Note estimate-prompt-tokens="true", que é a configuração que faz uma requisição acima do limite falhar no gateway em vez de consumir quota do backend para descobrir. Ela custa um pouco de precisão na estimativa e economiza exatamente os tokens que você estava tentando proteger.

Quatro pegadinhas que valem ser escritas na parede

  • Apagar um recurso do Foundry não libera o nome dele por 48 horas. O soft delete o mantém recuperável, e um terraform destroy seguido de um apply com o mesmo nome falha até o recurso ser purgado explicitamente. Pior, as cobranças de deployments provisionados continuam até o purge, então um ambiente destruído pode continuar faturando. Apague os deployments antes de apagar a conta, e faça o purge deliberadamente.
  • PTU não se compra pelo Terraform. Os SKUs provisionados (ProvisionedManaged, GlobalProvisionedManaged, DataZoneProvisionedManaged) são comprados em base horária pela contagem de PTU implantada, com descontos de prazo via Azure Reservations, e esse passo de compra não é algo que o provider consiga completar. O deployment é código; o compromisso é uma ação de procurement que acontece ao lado dele.
  • Quota não é infraestrutura como código. A capacity de um model deployment não pode exceder a quota da subscription para aquele modelo e região, e quota chega por um support request. A saída do plan não é a restrição; a página de quota é.
  • Não há upgrade de clássico para v2. Vale repetir porque é o único erro deste post que custa uma migração em vez de um apply.

O que a Parte 3 herda

Ao final desta parte existe uma instância do API Management no Standard v2 com identidade system-assigned, uma conta do Foundry com model deployments de versão fixada que não vão se mover debaixo de ninguém, um cluster AKS ainda sem nada em cima, Log Analytics e Application Insights ainda sem receber nada, e três state files que podem ser aplicados de forma independente. Nenhuma aplicação consegue chamar nada disso, porque nenhuma API existe ainda e nenhum tenant tem uma credencial. Isso são as próximas duas partes.

Leia isso a seguir

Para o lado de infraestrutura e plataforma de rodar isso em escala, as notas de campo estão em ercan.cloud, e o hub fica em ercanermis.com.

Referências