La ligne la plus lourde de conséquences dans le Terraform de cette plateforme est une ligne que personne n'écrit : version_upgrade_option sur un déploiement de modèle Azure vaut par défaut OnceNewDefaultVersionAvailable, si bien que le déploiement migre de lui-même vers une version de modèle plus récente dès qu'Azure décide qu'un nouveau défaut existe. Une gateway dont toute la justification est de posséder le cycle de vie des modèles ne peut pas laisser cela sur un défaut. Cette partie construit la couche d'infrastructure sous l'architecture de la partie 1, tranche entre Terraform, Bicep et ARM sur des critères qui portent réellement la charge, et désactive les deux défauts qui reprennent silencieusement le cycle de vie.

Trois langages, et une seule vraie question

La comparaison se joue d'habitude sur la syntaxe, qui est l'axe le moins intéressant. Le JSON ARM est le substrat : tout finit par devenir un template ARM, et plus personne ne l'écrit à la main, sauf pour déboguer ce que Bicep a émis. Restent deux candidats et une question qui tranche entre eux.

Bicep est un langage spécifique au domaine qui se transpile en ARM, est livré avec l'Azure CLI, n'a besoin d'aucun state file, et apporte une vraie réponse à la gestion du cycle de vie avec les deployment stacks. Une stack suit les ressources qu'elle gère, et actionOnUnmanage décide du sort d'une ressource qui quitte le template : detachAll la laisse tourner sans la suivre, deleteResources supprime les ressources, deleteAll supprime les ressources et les resource groups. Les stacks portent aussi des deny settings, si bien qu'une stack peut être créée avec denySettingsMode réglé sur denyDelete ou denyWriteAndDelete, en excluant des actions et des principals précis. Cette dernière capacité n'a pas d'équivalent direct en Terraform : c'est Azure lui-même qui refuse la suppression, pas un plan qui refuse d'en générer une.

Terraform apporte un state file, un plan que l'on relit dans une pull request et, de façon décisive pour cette plateforme, plusieurs providers dans un seul graphe de dépendances. Voilà la question qui tranche réellement : la plateforme gère-t-elle quoi que ce soit en dehors du control plane Azure Resource Manager ?

Oui. L'onboarding des tenants de la partie 1 remet à une équipe une application Entra ID, un service principal et un identifiant client, puis câble l'object ID résultant dans un abonnement API Management et un quota. Les objets Entra ID ne sont pas des ressources ARM. Bicep ne peut pas les créer. Une plateforme construite d'abord en Bicep finit avec un deuxième outil pour l'identité et une étape manuelle pour joindre les deux, et c'est exactement la couture où une équipe décommissionnée garde des identifiants valides pendant un an de plus. Terraform tient azurerm, azuread et azapi dans un seul graphe, donc l'application registration et l'abonnement API Management qui en dépend sont créés, modifiés et détruits ensemble.

Donc : Terraform pour cette plateforme. Le contrepoids honnête : si votre plateforme est de la pure infrastructure du plan ARM et que votre organisation maîtrise déjà Azure Policy et les deployment stacks, Bicep avec denyWriteAndDelete est une garantie plus forte qu'un plan Terraform que n'importe qui peut contourner avec un apply ciblé.

Deux plans, deux state files

La séparation posée dans la partie 1 (API Management comme data plane, un service Python sur AKS comme control plane) a une conséquence d'infrastructure qu'il vaut la peine d'expliciter avant d'écrire le moindre HCL. Ces deux couches changent à des rythmes séparés par deux ordres de grandeur. L'instance API Management, le cluster AKS et le réseau changent quelques fois par an. Les déploiements de modèles, les backends et les politiques changent chaque semaine, parfois chaque jour.

Mettre les deux dans un seul state file signifie qu'un changement de routine sur un déploiement de modèle exécute un plan contre le cluster AKS, et qu'un apply qui échoue à mi-course laisse les deux couches partiellement appliquées. Séparez-les :

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

Chaque répertoire est son propre root module avec son propre state, et les suivants lisent les précédents via terraform_remote_state ou, mieux, via des data sources qui retrouvent les ressources par leur nom. Les data sources sont plus lents et plus verbeux, et ils valent ce prix : ils font que 20-models ne casse pas quand le state de 10-platform est refactoré.

Le choix du tier, qui n'est pas un choix de coût

La partie 1 disait que la gateway sert d'intermédiaire natif pour Anthropic Claude. Cette seule exigence sélectionne le tier API Management, parce que le schéma de l'API Anthropic Messages est supporté dans les tiers v2, pas dans les tiers classiques. Les tiers v2 se déploient aussi en quelques minutes au lieu de la longue attente de provisionnement pour laquelle les tiers classiques sont connus, montent à 10 unités sur Basic v2 et Standard v2 et à 30 sur Premium v2, et à partir de Standard v2 ils supportent l'intégration au virtual network et les private endpoints entrants.

Ce que vous abandonnez n'est pas mineur, et mieux vaut le savoir maintenant que pendant un incident. Les tiers v2 ne supportent pas aujourd'hui le déploiement multi-région, la sauvegarde et la restauration de l'instance, l'envoi d'événements vers Event Grid, la configuration du service basée sur Git, l'accès direct à la Management API, les gateways auto-hébergées, ni Azure DDoS Protection. Il n'existe pas non plus de chemin de mise à niveau d'un tier classique vers un tier v2, ni de déplacement de ressource. Une gateway qui démarre sur Developer ou Standard et a besoin plus tard du support Anthropic, c'est une migration, pas une opération de scaling.

Le module plateforme commence donc ici :

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

Deux contraintes du provider ont leur place dans un commentaire à côté de ce bloc plutôt que dans un postmortem. Les blocs delegation sont rejetés d'emblée sur tout SKU V2 et sur Consumption. Et public_ip_address_id n'est accepté que pour des instances Developer et Premium déployées dans un virtual network, donc une instance V2 ne peut pas figer son IP sortante par ce moyen, ce qui compte si un backend de modèle se trouve derrière une liste blanche d'IP.

Les déploiements de modèles, et le défaut qui déplace la production

Les modèles sont un azurerm_cognitive_account plus un azurerm_cognitive_deployment par modèle logique. C'est ici que vit l'accroche de ce billet :

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

Trois choses dans ce bloc sont faciles à rater. capacity n'est ni un nombre d'unités ni un chiffre TPM brut : ce sont des tokens-per-minute en milliers, avec 1 comme valeur par défaut, soit 1 000 TPM. Un déploiement créé sans capacité explicite étranglera un simple script de test. Le champ version est optionnel, et l'omettre assigne la version par défaut du moment de la création, ce qui fait du comportement de la ressource une fonction du jour où elle a été appliquée. Et version_upgrade_option a trois valeurs, OnceNewDefaultVersionAvailable, OnceCurrentVersionExpired et NoAutoUpgrade, dont seule la dernière vous laisse la décision. OnceCurrentVersionExpired est le compromis défendable : il conserve la version épinglée jusqu'à ce qu'Azure la retire, puis migre au lieu de casser. Choisissez délibérément pour chaque modèle, et notez le choix dans le catalogue que la partie 9 construit.

Une propriété de plus de cette ressource se manifeste par un apply mystérieusement lent plutôt que par une erreur. Le provider prend un verrou au niveau du compte pendant la création d'un déploiement, donc onze déploiements sur un seul compte Foundry ne se créent pas en parallèle, quoi que dise -parallelism. Répartir les déploiements de modèles entre plusieurs comptes par workload est une décision de débit autant que de quota.

Où azurerm s'arrête et où azapi commence

La surface IA d'Azure évolue plus vite que le cycle de release de n'importe quel provider Terraform, ce qui est un fait structurel plutôt qu'une plainte. La documentation de Microsoft nomme elle-même les deux providers : azurerm pour les ressources stables, et azapi pour piloter directement les API Azure Resource Manager, ce qui suit les fonctionnalités les plus récentes sans attendre une mise à jour du provider.

La règle pratique qui empêche le désordre : azapi sert pour des ressources, jamais pour des sous-systèmes entiers. Un seul azapi_resource pour une capacité en préversion, posé à côté de vingt ressources azurerm qu'il référence par ID, est acceptable et réversible. Une plateforme où la moitié des ressources sont des corps d'API bruts a renoncé à la validation de schéma et au plan lisible qui étaient la raison même d'utiliser Terraform. Chaque bloc azapi reçoit un commentaire nommant la ressource azurerm qu'il attend, pour que la migration retour soit une tâche et non un chantier d'archéologie.

Le state, le backend, et la partie ennuyeuse qui vous sauve

Le state distant vit dans un compte Azure Storage, un conteneur par environnement, avec un verrouillage par blob lease que Terraform gère nativement pour le backend azurerm. Le compte de stockage lui-même n'est pas dans Terraform : il est créé une fois par un script documenté, parce qu'un backend de state qui vit dans le state qu'il stocke est une dépendance circulaire qui attend son mauvais jour.

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 est la ligne qui mérite qu'on se batte pour elle. Sans elle, le pipeline s'authentifie auprès du backend de state avec une clé de compte de stockage, un secret longue durée avec un accès complet au state de tous les environnements, posé dans un variable group. Avec elle, la workload identity du pipeline est autorisée par role assignment, et le state file hérite du même modèle d'identité que tout le reste.

Le XML de politique vit dans des fichiers

La partie 1 nommait la prolifération des politiques comme mode de défaillance dès le premier jour. La défense mécanique : le XML de politique n'est jamais écrit dans le portail. Il vit dans le dépôt, est templété, et est appliqué par le même pipeline que tout le reste :

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

Le fichier templété est du XML ordinaire, contenant les politiques de limite de tokens et de métriques de la partie 1, relu comme un diff au même titre que n'importe quel 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>

Notez estimate-prompt-tokens="true", le réglage qui fait échouer une requête au-dessus de la limite à la gateway au lieu de consommer du quota backend pour le découvrir. Il coûte un peu de précision sur l'estimation et économise exactement les tokens que vous cherchiez à protéger.

Quatre pièges à écrire sur le mur

  • Supprimer une ressource Foundry ne libère pas son nom avant 48 heures. Le soft delete la garde récupérable, et un terraform destroy suivi d'un apply avec le même nom échoue tant que la ressource n'est pas purgée explicitement. Pire, la facturation des déploiements provisionnés continue jusqu'à la purge, donc un environnement détruit peut continuer à coûter. Supprimez les déploiements avant de supprimer le compte, et purgez délibérément.
  • Les PTU ne s'achètent pas depuis Terraform. Les SKU provisionnés (ProvisionedManaged, GlobalProvisionedManaged, DataZoneProvisionedManaged) s'achètent à l'heure selon le nombre de PTU déployés, avec des remises d'engagement via Azure Reservations, et cette étape d'achat n'est pas quelque chose que le provider peut accomplir. Le déploiement est du code ; l'engagement est un acte d'achat qui se déroule à côté.
  • Le quota n'est pas de l'infrastructure as code. La capacité d'un déploiement de modèle ne peut pas dépasser le quota de l'abonnement pour ce modèle et cette région, et le quota arrive par une demande de support. La sortie du plan n'est pas la contrainte ; la page de quota l'est.
  • Pas de mise à niveau de classique vers v2. Cela mérite d'être répété, parce que c'est la seule erreur de ce billet qui coûte une migration plutôt qu'un apply.

Ce dont hérite la partie 3

À la fin de cette partie, il existe une instance API Management sur Standard v2 avec une identité system-assigned, un compte Foundry avec des déploiements de modèles épinglés qui ne bougeront sous les pieds de personne, un cluster AKS encore vide, Log Analytics et Application Insights qui ne reçoivent encore rien, et trois state files applicables indépendamment. Aucune application ne peut encore rien appeler, parce qu'aucune API n'existe et qu'aucun tenant n'a d'identifiant. C'est l'objet des deux parties suivantes.

À lire ensuite

Pour le volet infrastructure et plateforme de l'exploitation de tout cela à grande échelle, les notes de terrain sont sur ercan.cloud, et le hub se trouve sur ercanermis.com.

Références