本記事記述および実装内容は、多くの部分をAIで処理しており、人間チェックは甘いです
結論
Subscription レベルの Azure Budget を非常ブレーキにして、サブスクリプションのコストが閾値を超えたら タグ AutoPauseOnBudget=true のついた Azure AI Foundry の全 deployment を Logic Apps で一括 Pause する 構成が動きました。
以下、本記事ではこの構成を v4 と呼びます(私の過去記事の v2 = Metric Alert ベース、v3 = Logic Apps + 月次トークン累計 と区別するため)。
ポイントは次の 4 点です:
-
Forecast 80% (警告) と Actual 100% (Pause) を経路分離 — Forecast は Budget の
contactEmailsで直接 Email、Actual は Action Group → Logic Apps。thresholdTypeを payload から読まない設計 -
Resource Graph タグマッチで Foundry を動的検出 — Bicep ハードコードではなく
kind in ('AIServices','OpenAI')+tags.AutoPauseOnBudget == 'true'でクエリ -
3 層冪等性 — Trigger
concurrency.runs=1+If deploymentState == 'Paused'分岐 + Pause API 409 を success 扱い - 同月再発火しない 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 を読んで Forecasted か Actual か分岐する設計も可能です。ただしこれは 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 で簡略化しました。
@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 の通知先プロパティは contactEmails、contactGroups、contactRoles の 3 つの 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 で動的検出する設計にしました。
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 タグを付ける部分:
// 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 段重ねで防御しました:
-
Trigger
concurrency.runs=1で並列実行を 1 に制限 -
If deploymentState == 'Paused'で既に Paused なら skip -
Pause API の
HTTP 409 AlreadyPausedを success 扱い
Logic Apps の Workflow JSON 抜粋(Foreach の中):
{
"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.bicep を targetScope='subscription' に書いて、Resource Group をその中で作ります。
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 |
本番運用するなら以下のカスタムロールに置換するのを強く推奨します:
{
"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 を発生させて確認しました:
{
"schemaId": "azureMonitorCommonAlertSchema",
"data": {
"essentials": {
"monitoringService": "CostAlerts",
"alertContext": { "AlertCategory": "budgets" }
},
"alertContext": {
"AlertData": { "ThresholdType": "Actual", "Amount": 1.0, "Unit": "USD" }
}
}
}
-
monitoringService = "CostAlerts"("Budget"ではない) -
ThresholdTypeはdata.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:
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 環境のコスト爆発が怖いから、最後の砦として常駐させておく」用途には十分機能する構成だと思います。