0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Azure Budget で Foundry を自動 Pause

0
Last updated at Posted at 2026-05-28

本記事記述および実装内容は、多くの部分をAIで処理しており、人間チェックは甘いです

結論

Subscription レベルの Azure Budget を非常ブレーキにして、サブスクリプションのコストが閾値を超えたら タグ AutoPauseOnBudget=true のついた Azure AI Foundry の全 deployment を Logic Apps で一括 Pause する 構成が動きました。

以下、本記事ではこの構成を v4 と呼びます(私の過去記事の v2 = Metric Alert ベース、v3 = Logic Apps + 月次トークン累計 と区別するため)。

ポイントは次の 4 点です:

  1. Forecast 80% (警告) と Actual 100% (Pause) を経路分離 — Forecast は Budget の contactEmails で直接 Email、Actual は Action Group → Logic Apps。thresholdType を payload から読まない設計
  2. Resource Graph タグマッチで Foundry を動的検出 — Bicep ハードコードではなく kind in ('AIServices','OpenAI') + tags.AutoPauseOnBudget == 'true' でクエリ
  3. 3 層冪等性 — Trigger concurrency.runs=1 + If deploymentState == 'Paused' 分岐 + Pause API 409 を success 扱い
  4. 同月再発火しない Budget の仕様を逆手に取る — Budget 通知は月 1 回だけ。Logic Apps の重複制御を最小化

TL;DR

  • Budget の contactEmails プロパティを使うと Action Group を 1 個削減できる(単純 Email 通知なら AG 不要、Logic Apps を起動する Actual 100% 側だけ AG が必要)
  • Budget 評価は 24h 周期、データ ingest は 8〜24h 遅延、通知は評価後 1h 以内。最悪検知遅延は 〜49 時間
  • もっと速くしたいなら Logic Apps Recurrence + Cost Management Query API で評価サイクルを 1h に短縮できる(ただしデータ遅延は同じ)。詳細は最後の v4 vs v5 比較で
  • Pause API は preview (2026-01-15-preview) でまだ az provider operation show のカタログにも出ない。accounts/deployments/pause/action のスペルで叩く
  • Logic Apps の trigger に concurrency.runs=1 を入れると、Response action を operationOptions: Asynchronous にしないと デプロイ時に InvalidConcurrencyConfiguration で落ちる

背景

Foundry の暴走(プロンプト無限ループとかバグった agent loop とか)でサブスクリプションのコストが瞬間的に爆発するシナリオを考えました。

過去に書いた v2 / v3 は deployment 単位 のトークン消費量を見て止める設計でした。これは Foundry に特化していて精度は高いですが、Foundry 以外のリソース(VM / Storage / Marketplace)が暴走したとき には反応しません。

そこで「サブスクリプション全体の コスト が閾値超過したら、その瞬間に Foundry を緊急停止する」というメタなブレーキを別レイヤで作りました。これが v4 です。

v4 と v2/v3 は 置換ではなく併用 想定です。deployment-level と subscription-level の二重 guard。

対象読者は Azure AI Foundry / Azure OpenAI のコストガードレールを IaC で作りたい Azure エンジニア、特に dev / sandbox サブスクリプションで「予算が尽きたら自動で全 Foundry を止めたい」人です。

アーキテクチャ

ポイント 1: Forecast / Actual の経路分離

Budget の通知は同じ schema (Common Alert Schema) で来るので、Logic Apps 内で data.alertContext.AlertData.ThresholdType を読んで ForecastedActual か分岐する設計も可能です。ただしこれは payload パースに脆弱性が出る ので避けました。

代わりに 送信先を 2 つ用意して経路自体で分ける:

  • Forecast 80% → 人間が手動対応するためのリードタイム通知 = Email のみ
  • Actual 100% → 自動 Pause = Logic Apps のみ

これで Logic Apps は「呼ばれたら Pause」とだけ書けばよく、payload を全く読まない設計になります。

ポイント 2: contactEmails で Action Group を 1 個削減

最初のバージョン (rev1) では Forecast 80% も Action Group の emailReceivers 経由で Email を送っていました。ただ Budget には contactEmails プロパティがあり、AG を介さず直接 Email を送れることに気付いたので rev2 で簡略化しました。

modules/budget-v4.bicep
@description('Email recipients for Forecasted >80% notification (Budget contactEmails, no AG needed)')
param warnEmailRecipients array

@description('Action Group resource id for Actual >100% notification (logic app)')
param pauseActionGroupId string

resource budget 'Microsoft.Consumption/budgets@2023-05-01' = {
  name: budgetName
  properties: {
    amount: amount
    category: 'Cost'
    timeGrain: 'Monthly'
    timePeriod: { startDate: startDate }
    notifications: {
      Forecasted_GreaterThan_80_Percent: {
        enabled: true
        operator: 'GreaterThan'
        threshold: 80
        thresholdType: 'Forecasted'
        contactEmails: warnEmailRecipients     // rev2: AG 経由ではなく直接 Email
        locale: 'en-us'
      }
      Actual_GreaterThan_100_Percent: {
        enabled: true
        operator: 'GreaterThan'
        threshold: 100
        thresholdType: 'Actual'
        contactGroups: [ pauseActionGroupId ]  // Logic Apps 起動のため AG 必須
        locale: 'en-us'
      }
    }
  }
}

Budget の通知先プロパティは contactEmailscontactGroupscontactRoles3 つの OR 関係 で、1 つ以上指定されていれば OK です。Bicep の型定義側で BCP035: missing required properties: 'contactEmails' という警告が出ますが、これは Bicep 側のメタデータバグで誤検出です。実際のデプロイは成功します。

Budget の通知言語プロパティは locale: 'en-us' です。古い API の notificationLanguage ではありません。新しい API バージョンでは notificationLanguage を書いてもエラーにならず、ただ無視されるので注意。

ポイント 3: Resource Graph で動的に Foundry を検出

Bicep に Foundry の Resource ID をハードコードすると、新しい Foundry を立てるたびに Logic Apps を更新する必要が出ます。タグ AutoPauseOnBudget=true をマーカーにして Resource Graph で動的検出する設計にしました。

Logic Apps 内の Resource Graph クエリ
resources
| where type =~ 'microsoft.cognitiveservices/accounts'
| where kind in ('AIServices', 'OpenAI')
| where tolower(tostring(tags['AutoPauseOnBudget'])) == 'true'
| project id, name, resourceGroup

Bicep 側で Foundry に opt-in タグを付ける部分:

modules/rg-stack.bicep
// Foundry account is tagged AutoPauseOnBudget=true so v4 Resource Graph query discovers it
var foundryTags = union(tags, foundryV4OptInTag)

module aiServices './ai-services.bicep' = {
  name: 'aiServices'
  params: {
    accountName: accountName
    location: location
    tags: foundryTags    // ← AutoPauseOnBudget=true がここで合流
    deploymentName: deploymentName
  }
}

運用上は タグを外せばその Foundry は v4 の対象外 になるので、特定 deployment を例外扱いしたい場合に便利です。

ポイント 4: 3 層冪等性

Pause API は idempotent ですが、念のため 3 段重ねで防御しました:

  1. Trigger concurrency.runs=1 で並列実行を 1 に制限
  2. If deploymentState == 'Paused' で既に Paused なら skip
  3. Pause API の HTTP 409 AlreadyPaused を success 扱い

Logic Apps の Workflow JSON 抜粋(Foreach の中):

workflow-v4.json (excerpt)
{
  "Compose_state": {
    "type": "Compose",
    "inputs": "@outputs('Get_deployment')?['body']?['properties']?['deploymentState']"
  },
  "If_already_paused": {
    "type": "If",
    "expression": {
      "and": [
        { "equals": [ "@outputs('Compose_state')", "Paused" ] }
      ]
    },
    "actions": {
      "Append_skipped": {
        "type": "AppendToArrayVariable",
        "inputs": {
          "name": "results",
          "value": {
            "accountId": "@items('Foreach_account')?['id']",
            "deployment": "@items('Foreach_deployment')?['name']",
            "action": "skipped",
            "success": true,
            "httpStatus": 0,
            "reason": "already-paused"
          }
        }
      }
    },
    "else": {
      "actions": {
        "Pause_deployment": { ... },
        "Append_paused": { ... }
      }
    }
  }
}

Trigger concurrency + Response action の罠: concurrency.runs=1 を有効にしたまま素の Response action を置くと、初回デプロイで InvalidConcurrencyConfiguration: ... concurrency control is not supported when the workflow contains actions of type 'response' without the operationOptions flag set to 'asynchronous' で失敗します。

対処: Response action に "operationOptions": "Asynchronous" を追加。呼出元(Action Group)には HTTP 202 が即返り、実 Pause 処理は非同期で継続します。AG は Logic Apps から返るボディを消費しないので機能影響なし。

 "Response": {
   "type": "Response",
+  "operationOptions": "Asynchronous",
   "inputs": { "statusCode": 200, "body": "@variables('results')" }
 }

ポイント 5: Budget の発火頻度と限界

ここが v4 の 最大の弱点 です。公式ドキュメントによると:

項目 仕様 公式根拠
評価頻度 24 時間ごと(1 日 1 回) Use cost alerts to monitor usage and spending - Budget alerts
コストデータの可用性 発生から 8〜24 時間遅延 Understand Cost Management data - Data refresh schedule
通知タイミング しきい値到達後、1 時間以内にメール送信 Tutorial - Create and manage Azure budgets
同一しきい値の発火 その月 1 回だけ(再発火しない) Use cost alerts to monitor usage and spending - Budget alerts
リセット 翌月の予算期間開始時に自動リセット Tutorial - Create and manage Azure budgets - Reset period

つまり「最悪検知遅延 〜49 時間 (= 24h ingest + 24h 次回評価 + 1h 通知)」がこの設計の根本的な制約です。

これより速くしたい場合は Logic Apps を定期実行して Cost Management Query API を自前で叩く 設計 (本記事中 v5 と呼びます) に切り替える選択肢があります。後述します。

デプロイ手順 (Bicep + azd)

main.biceptargetScope='subscription' に書いて、Resource Group をその中で作ります。

main.bicep
targetScope = 'subscription'

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
  name: resourceGroupName
  location: location
  tags: tags
}

var uniqueSuffix = substring(uniqueString(subscription().id, resourceGroupName), 0, 4)

module stack './modules/rg-stack.bicep' = {
  scope: rg
  name: 'rg-stack'
  params: { ... }
}

module rolesV4Sub './modules/role-assignments-v4-sub.bicep' = {
  name: 'rolesV4Sub'
  params: {
    principalId: stack.outputs.logicAppV4PrincipalId
  }
}

module budgetV4 './modules/budget-v4.bicep' = {
  name: 'budgetV4'
  params: {
    budgetName: 'budget-falertfire-v4-${uniqueSuffix}-${budgetVersionSuffix}'
    budgetAmount: budgetAmountV4
    startDate: budgetStartDateV4
    endDate: '2030-01-01'
    warnEmailRecipients: [ notifyEmail ]
    pauseActionGroupId: stack.outputs.pauseActionGroupV4Id
  }
}

azd でデプロイ:

azd provision --no-prompt

subscription-scope Incremental デプロイの落とし穴: targetScope='subscription'azd provision は Incremental モードで動きます。Bicep template から消したリソース(オーファン)は 自動削除されません。rev2 で AG-warn を削除した時にこれを踏みました。

what-if* Ignore 出力を確認したらリソースが残っているサインです。手動で az resource delete するか、影響範囲を把握した上で --mode Complete を明示してください。

RBAC

Logic Apps の Managed Identity に 3 つロールを付けます。

Principal Scope Role 用途
Logic Apps MSI Subscription Reader (acdd72a7-3385-48ef-bd42-f606fba81ae7) Resource Graph 検索
Logic Apps MSI Subscription Cognitive Services Contributor (25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68) dev 環境向け(本番はカスタムロール推奨)
Logic Apps MSI DCR Monitoring Metrics Publisher (3913510d-42f4-4e42-8a64-420c390055eb) Logs Ingestion API

本番運用するなら以下のカスタムロールに置換するのを強く推奨します:

custom-role.json
{
  "Name": "Foundry Deployment Pauser",
  "Description": "Read CS accounts/deployments, pause/resume deployments only.",
  "Actions": [
    "Microsoft.CognitiveServices/accounts/read",
    "Microsoft.CognitiveServices/accounts/deployments/read",
    "Microsoft.CognitiveServices/accounts/deployments/pause/action",
    "Microsoft.CognitiveServices/accounts/deployments/resume/action"
  ],
  "AssignableScopes": [ "/subscriptions/<SUBSCRIPTION_ID>" ]
}

pause/action / resume/action は preview API (2026-01-15-preview) 由来のため az provider operation show --namespace Microsoft.CognitiveServices のカタログには 出ません。実稼働で 200 OK を返すことは v3 / v4 の検証で確認済みなので、operation 名は直書きで OK です。

検証結果

10 シナリオで検証し、9 PASS / 1 SKIP でした。

ID シナリオ 結果
v4-S1 Bicep デプロイ (subscription scope Budget + RG-scope v4 stack) ✅ PASS
v4-S2 Foundry に AutoPauseOnBudget=true タグ付与 ✅ PASS
v4-S3 Resource Graph KQL でタグ付き Foundry を検出(count=1) ✅ PASS
v4-S4 経路分離(contactEmails for warn / contactGroups for pause) ✅ PASS
v4-L1a L1 単体: 手動 HTTP POST → Resource Graph → Pause API → LAW ✅ PASS
v4-L1b L1 冪等性: 2 度目の POST で already-paused skip 経路 ✅ PASS
v4-L2 L2 結合: AG createNotifications → 実 Common Alert Schema → Pause ✅ PASS
v4-S5 Trigger concurrency=1 + Async Response ✅ PASS
v4-S6 LAW ingestion(DCR Logs Ingestion API、bool/int 型保持) ✅ PASS
v4-L3 L3 e2e: overnight Budget 自然発火 ⏭ SKIPPED (cost ingest 8-24h、L2 で代替)

L2 で確認できた実 payload 形式

Budget からの Common Alert Schema は、ドキュメントを読んでもどこに何が入るか曖昧です。createNotifications で実 payload を発生させて確認しました:

logic-app-trigger-output.json (excerpt)
{
  "schemaId": "azureMonitorCommonAlertSchema",
  "data": {
    "essentials": {
      "monitoringService": "CostAlerts",
      "alertContext": { "AlertCategory": "budgets" }
    },
    "alertContext": {
      "AlertData": { "ThresholdType": "Actual", "Amount": 1.0, "Unit": "USD" }
    }
  }
}
  • monitoringService = "CostAlerts" ("Budget" ではない)
  • ThresholdTypedata.alertContext.AlertData入れ子(top-level alertContext ではない)

経路分離設計のおかげで Logic Apps は payload を読みませんが、この入れ子構造を見ると payload パース型は脆くて当然 だと再確認できました。

ハマったポイント集

H1: InvalidConcurrencyConfiguration

trigger concurrency + 同期 Response 併用は不可。Response.operationOptions: 'Asynchronous' を追加。詳細はポイント 4 の :::note warn 参照。

H2: Logic Apps の @{expr} vs @expr で型が文字列化する

Build_log_rows の Select で "@{item()?['success']}" の形にしていると bool が文字列 "True" で送られ、DCR transform の tobool()大小文字判定に厳格で null になる という落とし穴があります。

bool / int フィールドは @expression 単独形にする(@{...} で囲まない)のが正解:

"Build_log_rows": {
  "type": "Select",
  "inputs": {
    "from": "@variables('results')",
    "select": {
      "Success": "@item()?['success']",       // bool 保持
      "HttpStatus": "@item()?['httpStatus']", // int 保持
      "Reason": "@coalesce(item()?['reason'], '')"
    }
  }
}
H3: AG createNotifications API は body に callbackUrl の再指定が必要

AG リソース定義に既に callbackUrl が保存されていても、createNotifications REST API は payload 内に もう一度 指定が必要です(仕様)。

CALLBACK=$(az monitor action-group show -g $RG -n $AG_PAUSE \
  --query "logicAppReceivers[0].callbackUrl" -o tsv)
jq --arg cb "$CALLBACK" '.logicAppReceivers[0].callbackUrl=$cb' template.json > payload.json
az rest --method post --url "$AG_ID/createNotifications?api-version=2021-09-01" --body @payload.json
H4: azd の event-postdeploy フックが Foundry agents extension で失敗
step 'event-postdeploy' failed: extension azure.ai.agents project hook postdeploy failed:
AZURE_AI_PROJECT_ENDPOINT is not set in the environment

agent を使わない検証なら 無視可(全リソースは正常デプロイされており、hook が exit code を非ゼロにするだけ)。

H5: Pause operation 名は az provider operation show に出ない

pause/action / resume/action は preview API 由来でカタログ未登録。v3 / v4 の実稼働実績から直書きで OK。

v4 vs v5 比較 — もっと速くしたい人向け

Budget 評価は 24h 周期なので、最悪 49h かかります。これを縮めたい場合は Logic Apps Recurrence で 1h ごとに Cost Management Query API を叩く 設計が考えられます(本記事では v5 と呼ぶ、仮想設計です)。

v5 の構成イメージ

主要 API:

cost-management-query.http
POST https://management.azure.com/subscriptions/<SUBSCRIPTION_ID>/providers/Microsoft.CostManagement/query?api-version=2025-03-01
Authorization: Bearer <MSI token>
Content-Type: application/json

{
  "type": "ActualCost",
  "timeframe": "MonthToDate",
  "dataset": {
    "granularity": "None",
    "aggregation": { "totalCost": { "name": "Cost", "function": "Sum" } }
  }
}

遅延比較

ルート データ遅延 評価サイクル 合計(最悪)
v4 (Budget) 6–24h 24h 〜49h
v5 (Logic Apps PT1H + CM Query) 6–24h 1h 〜25h
v5 (Logic Apps PT15M + CM Query) 6–24h 15min 〜24.25h
サービス固有メトリック(例: AzureOpenAIRequestUsage 数分 1–5min 〜数分

データ遅延 6–24h は Azure 側の仕様で短縮不可能(公式: "Cost data is typically available within 8 to 24 hours")。評価サイクルだけを縮められます。
根本的にリアルタイム化したいならコスト API ではなくサービス固有メトリック(v3 で扱った TokenTransaction など)を使うべきで、これはコスト単位ではなくトークン単位の判定になります。

v4 vs v5 トレードオフ

項目 v4 (Budget) v5 (Logic Apps Recurrence + CM Query API)
検知遅延 最悪 〜49h 最悪 〜24h+α
データの新しさ 6–24h 遅延 同じ(6–24h 遅延)
Logic Apps 実行頻度 月数回(しきい値到達時のみ) 1h ごと ≒ 月 720 回
Logic Apps コスト 月 1 円未満 月数十円程度
Cost Management API rate limit あり(subscription scope で 60 req/h 推奨上限)
同月重複 Pause 制御 Budget が「月 1 回」を保証 自分で実装(state machine)
月初リセット 自動 自前で月初判定
設計の複雑さ 中〜高(state 管理 + rate limit + 月境界処理)

採用判断

状況 推奨
数十時間の検知遅延を許容できる v4 のまま(運用負荷最小)
24h を切る検知が必要 v5 を v4 への追加として導入(v4 を safety net として残す)
分単位で止めたい / トークン単位で制御したい v3 や AzureOpenAIRequestUsage ベースに切り替え
workload 単位で予算を切りたい v4 の Budget スコープを RG / Management Group に変更

まとめ

  • Subscription Budget + Action Group + Logic Apps + Resource Graph で タグベースで動的検出した Foundry を一括 Pause する 構成を Bicep で実装し、L1 / L1 冪等 / L2 結合まで検証完了
  • 設計の核は Forecast/Actual の経路分離(payload パースを避ける)と 3 層冪等性(Pause API は idempotent だが念のため)
  • rev2 最適化で Budget contactEmails を使い AG を 1 個削減
  • v4 の弱点は検知遅延(最悪 49h)。要件次第で v5(Logic Apps + CM Query API)併用や、サービス固有メトリックへの切り替えを検討

「dev 環境のコスト爆発が怖いから、最後の砦として常駐させておく」用途には十分機能する構成だと思います。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?