このプラットフォームのTerraformで最も重大な1行は、誰も書かない1行だ。Azureのモデルデプロイメントのversion_upgrade_optionは既定でOnceNewDefaultVersionAvailableであり、Azureが新しい既定バージョンの存在を決めた瞬間、デプロイメントは自分から新しいモデルバージョンへ移る。モデルライフサイクルを自分の手に置くことが存在理由のすべてであるゲートウェイが、そこを既定値のまま放置するわけにはいかない。本稿は第1回のアーキテクチャの下にインフラ層を築き、実際に荷重を支える論点でTerraform、Bicep、ARMを選び分け、ライフサイクルを静かに奪い返す2つの既定値を無効にする。

3つの言語と、本当の問いはただ1つ

この比較はたいてい構文で論じられるが、それは最も面白みのない軸だ。ARM JSONは基盤である。すべては最終的にARMテンプレートになり、Bicepが出力したものをデバッグするときを除けば、もはや手書きする者はいない。残るのは2つの候補と、その間を決める1つの問いだ。

BicepはARMにトランスパイルされるドメイン固有言語で、Azure CLIに同梱され、state fileを必要とせず、ライフサイクル管理にはdeployment stacksという本物の答えを持つ。スタックは自分が管理するリソースを追跡し、テンプレートから外れたリソースの扱いはactionOnUnmanageが決める。detachAllは稼働したまま追跡から外し、deleteResourcesはリソースを削除し、deleteAllはリソースとリソースグループを削除する。スタックはdeny設定も備えており、denySettingsModedenyDeleteまたはdenyWriteAndDeleteに設定し、特定のアクションとプリンシパルを除外した形でスタックを作成できる。この最後の能力にはTerraformの直接の等価物がない。削除を拒むのはAzure自身であって、削除を生成することを拒むplanではない。

Terraformが持ち込むのは、state file、プルリクエストでレビューできるplan、そしてこのプラットフォームにとって決定的なことに、単一の依存グラフに収まる複数のproviderだ。実際に決着をつけるのはこの問いである。このプラットフォームはAzure Resource Managerコントロールプレーンの外にあるものを管理するか?

管理する。第1回のテナントオンボーディングは、チームにEntra IDアプリケーション、サービスプリンシパル、クライアントクレデンシャルを渡し、得られたオブジェクトIDをAPI Managementのサブスクリプションとクォータに配線する。Entra IDオブジェクトはARMリソースではない。Bicepには作れない。Bicepを第一に据えたプラットフォームは、アイデンティティ用の第二のツールと、両者をつなぐ手作業の工程を抱えることになる。そしてその継ぎ目こそ、廃止されたチームが有効な認証情報をもう1年持ち続ける場所だ。Terraformはazurermazureadazapiを1つのグラフに収めるため、アプリケーション登録と、それに依存するAPI Managementサブスクリプションは、一緒に作られ、一緒に変更され、一緒に破棄される。

よって、このプラットフォームはTerraformだ。正直に対抗馬を挙げるなら、プラットフォームが純粋なARMプレーンのインフラだけで構成され、組織がすでにAzure Policyとdeployment stacksをうまく運用しているなら、denyWriteAndDeleteを伴うBicepは、targeted applyで誰でも覆せるTerraformのplanより強い保証になる。

2つのプレーンに2つのstate file

第1回の分割(データプレーンとしてのAPI Management、コントロールプレーンとしてのAKS上のPythonサービス)には、HCLを書き始める前に明示しておく価値のあるインフラ上の帰結がある。この2つの層は変更の速度が2桁違う。API Managementインスタンス、AKSクラスター、ネットワークが変わるのは年に数回。モデルデプロイメント、バックエンド、ポリシーは毎週、時には毎日変わる。

両方を1つのstate fileに入れると、日常的なモデルデプロイメントの変更がAKSクラスターに対してplanを走らせ、途中で失敗したapplyは両方の層を中途半端に適用された状態で残す。分割する。

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

各ディレクトリは自前のstateを持つ独立したルートモジュールで、後段のものは前段のものをterraform_remote_state経由か、より良くは名前でリソースを引くdata source経由で読む。data sourceは遅く冗長だが、それだけの価値がある。10-platformのstateがリファクタリングされても20-modelsが壊れない、という意味だからだ。

ティアの決定は、コストの決定ではない

第1回で、ゲートウェイはAnthropic Claudeをネイティブに仲介すると述べた。このたった1つの要件がAPI Managementのティアを選ぶ。Anthropic Messages APIスキーマがサポートされるのはv2ティアであり、クラシックティアではないからだ。v2ティアはまた、クラシックティアで知られる長いプロビジョニング待ちではなく数分でデプロイされ、Basic v2とStandard v2では10ユニット、Premium v2では30ユニットまでスケールし、Standard v2以上は仮想ネットワーク統合とインバウンドのプライベートエンドポイントをサポートする。

引き換えに手放すものは小さくなく、インシデントの最中に知るより今知っておく方がいい。v2ティアは現時点で、マルチリージョンデプロイ、インスタンスのバックアップとリストア、Event Gridへのイベント送信、Gitベースのサービス構成、Management APIへの直接アクセス、セルフホステッドゲートウェイ、Azure DDoS Protectionをサポートしない。さらにクラシックティアからv2ティアへのアップグレードパスは存在せず、リソース移動もできない。DeveloperやStandardで始めて、後からAnthropicサポートが必要になったゲートウェイを待つのは、スケール操作ではなく移行案件だ。

したがってプラットフォームモジュールはここから始まる。

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

2つのprovider制約は、ポストモーテムではなく、このブロックの隣のコメントに置いておくべきものだ。delegationブロックはあらゆるV2 SKUとConsumptionで問答無用に拒否される。そしてpublic_ip_address_idは仮想ネットワークにデプロイされたDeveloperおよびPremiumインスタンスでしか受け付けられないため、V2インスタンスはその方法でアウトバウンドIPを固定できない。モデルバックエンドがIP許可リストの背後にあるなら、これが効いてくる。

モデルデプロイメントと、本番を動かす既定値

モデルはazurerm_cognitive_account1つと、論理モデルごとのazurerm_cognitive_deployment1つだ。本稿のリード文はここに住んでいる。

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

このブロックには間違えやすい点が3つある。capacityはユニット数でも生のTPM値でもない。tokens-per-minuteを千単位で表したもので、既定値は1、つまり1,000 TPMだ。capacityを明示せずに作られたデプロイメントは、テストスクリプト1本でスロットリングされる。versionフィールドは省略可能で、省略すると作成時点の既定バージョンが割り当てられ、リソースの挙動はapplyされた日付の関数になる。そしてversion_upgrade_optionにはOnceNewDefaultVersionAvailableOnceCurrentVersionExpiredNoAutoUpgradeの3つの値があり、決定権をこちらに残すのは最後の1つだけだ。OnceCurrentVersionExpiredは擁護できる中間解で、固定したバージョンをAzureが引退させるまで保持し、そこで壊れる代わりに移る。モデルごとに意図して1つを選び、どれを選んだかを第9回で構築するカタログに記録する。

このリソースにはもう1つ、エラーではなく謎に遅いapplyとして現れる性質がある。providerはデプロイメントの作成中にアカウントレベルのロックを取るため、1つのFoundryアカウント上の11個のデプロイメントは、-parallelismが何と言おうと並列には作成されない。モデルデプロイメントをワークロード別に複数アカウントへ分けるのは、クォータの判断であると同時にスループットの判断でもある。

azurermが止まり、azapiが始まる場所

AzureのAIサーフェスは、どのTerraform providerのリリースサイクルよりも速く動く。これは不満ではなく構造的な事実だ。Microsoft自身のガイダンスが両方のproviderを名指ししている。安定したリソースにはazurerm、そしてAzure Resource Manager APIを直接操作するazapiは、providerの更新を待たずに最新の機能に追随する。

これを混沌にしないための実務ルールはこうだ。azapiはリソースのためのものであり、サブシステム全体のためのものでは決してない。プレビュー機能のためのazapi_resourceが1つ、20個のazurermリソースの隣に座ってそれらをIDで参照するのは、健全で後戻りも効く。リソースの半分が生のAPIボディになったプラットフォームは、Terraformを使う理由そのものだったスキーマ検証と読めるplanを手放している。すべてのazapiブロックには、待っているazurermリソースの名前を記したコメントを付ける。そうすれば戻す作業は考古学の発掘ではなくタスクになる。

stateとバックエンド、そして命綱になる地味な部分

リモートstateはAzure Storageアカウントに置き、環境ごとに1コンテナ、blobリースによるロック付きとする。azurermバックエンドならTerraformがこれをネイティブに処理する。Storageアカウント自体はTerraformの中にない。文書化されたスクリプトで一度だけ作る。自分が保存するstateの中に住むstateバックエンドは、悪い日を待つ循環依存だからだ。

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こそ、主張して勝ち取る価値のある1行だ。これがなければ、パイプラインはstorage account keyでstateバックエンドに認証する。全環境のstateへのフルアクセスを持つ長寿命シークレットが、variable groupに座っている状態だ。これがあれば、パイプラインのワークロードアイデンティティがロール割り当てで認可され、state fileは他のすべてと同じアイデンティティモデルを継承する。

ポリシーXMLはファイルに住む

第1回は、初日から注視すべき失敗モードとしてポリシーの無秩序な増殖を名指しした。機械的な防御は、ポリシーXMLをポータルでは決して書かないことだ。リポジトリに置き、テンプレート化し、他のすべてと同じパイプラインで適用する。

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

テンプレート化されたファイルは、第1回で挙げたトークン制限ポリシーとメトリクスポリシーを含む普通のXMLで、他のコードと同じようにdiffとしてレビューされる。

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

estimate-prompt-tokens="true"に注目してほしい。これは、上限超過のリクエストが、バックエンドのクォータを消費して初めて発覚するのではなく、ゲートウェイで失敗するようにする設定だ。見積もりの精度を少し犠牲にして、まさに守ろうとしていたトークンそのものを節約する。

壁に書いておく価値のある4つの落とし穴

  • Foundryリソースを削除しても、その名前は48時間解放されない。Soft deleteが復旧可能な状態で保持するため、terraform destroyの後に同じ名前でapplyしても、リソースを明示的にパージするまで失敗する。さらに悪いことに、プロビジョン済みデプロイメントの課金はパージまで続くので、破棄したはずの環境が課金し続けることがある。アカウントを削除する前にデプロイメントを削除し、パージは意図して行う。
  • PTUはTerraformからは買えない。プロビジョン済みSKU(ProvisionedManagedGlobalProvisionedManagedDataZoneProvisionedManaged)はデプロイ済みPTU数に応じた時間単位で購入され、期間割引はAzure Reservations経由で受ける。その購入ステップはproviderには完了できない。デプロイメントはコードであり、コミットメントはその隣で行われる調達行為だ。
  • クォータはinfrastructure as codeではない。モデルデプロイメントのcapacityは、そのモデルとリージョンに対するサブスクリプションのクォータを超えられず、クォータはサポートリクエストを通じて届く。制約はplanの出力ではなく、クォータのページにある。
  • クラシックからv2へのアップグレードはない。繰り返す価値がある。本稿の中で唯一、applyではなく移行を代償として支払わせる間違いだからだ。

第3回が受け継ぐもの

本稿の終わりに手元に残るのは、システム割り当てアイデンティティを持つStandard v2のAPI Managementインスタンス、誰の足元でも勝手に動かない固定済みモデルデプロイメントを載せたFoundryアカウント、まだ何も載っていないAKSクラスター、まだ何も受信していないLog AnalyticsとApplication Insights、そして独立にapplyできる3つのstate fileだ。まだAPIが存在せず、どのテナントも認証情報を持たないため、どのアプリケーションもこれらを呼び出せない。それが次の2回で扱うものだ。

次に読む

これを大規模に運用する際のインフラおよびプラットフォーム側の話はercan.cloudのフィールドノートに、ハブはercanermis.comにある。

参考資料