JavaScriptを有効にしてください

Azure 利用金額が閾値を超えたら強制停止し隊

 ·   16 分で読めます  ·   [Kento GitHub Copilot]

本記事は GitHub Copilot を活用して作成しています。

テスト環境では、予算を超過した後の利用をできるだけ早く止めたい場合があります。

Azure Cost Management の予算をトリガーにして、開発用 VM と Application Gateway を停止し、サブスクリプションを変更不可にする構成を検討します。翌月 1 日には Application Gateway だけを再開し、VM は開発者が必要になったときまで停止したままにします。

要約

  • 予算アラートは利用額の上限を強制する機能ではないため、コスト超過を完全には防げません。
  • 閾値到達後に、VM を割り当て解除し、Application Gateway を停止してから、サブスクリプションに ReadOnly ロックを設定します。
  • ロックを設定したサブスクリプション内では Automation Runbook の開始も妨げられるため、復旧用の Automation Account は別の管理用サブスクリプションに配置します。
  • 毎月 1 日にロックを解除して Application Gateway を起動します。VM は Stopped (deallocated) のまま維持します。

前提と注意事項

予算は通知と自動化の起点であり、課金をリアルタイムに停止するハードリミットではありません。コストと使用量データには遅延があり、予算の評価後にも追加料金が発生する可能性があります。これは、超過後の追加コストを抑制するためのガードレールです。

また、VM はゲスト OS をシャットダウンしただけではコンピューティング料金が継続します。Runbook では Stop-AzVM-StayProvisioned を指定せず、Stopped (deallocated) にします。ディスク、Public IP、ログ、データ転送などには停止後も料金が発生する場合があります。

構成

flowchart LR
    subgraph MS[管理用サブスクリプション]
        STOP[コスト抑制 Runbook]
        RESTORE[月次復旧 Runbook]
    end

    subgraph WS[対象サブスクリプション]
        VM[開発用 VM]
        GW[Application Gateway]
        LOCK[ReadOnly ロック]
    end

    B[Cost Management の予算] -->|閾値到達| AG[アクション グループ]
    AG --> STOP
    STOP -->|割り当て解除| VM
    STOP -->|停止| GW
    STOP -->|設定| LOCK
    S[毎月 1 日のスケジュール] --> RESTORE
    RESTORE -->|解除| LOCK
    RESTORE -->|開始| GW

なぜ管理用サブスクリプションを分けるのか

ReadOnly ロックは子リソースへ継承され、VM の開始・再起動のような変更操作をブロックします。同じ理由で、ロック対象サブスクリプション内の Automation Account では Runbook ジョブの開始もブロックされます。

そのため、毎月の復旧 Runbook をロック対象サブスクリプション内に置くと、自身でロックを解除できません。Automation Account とログの保存先は管理用サブスクリプションに配置し、そのマネージド ID に対象サブスクリプションを操作する最小限の権限を付与します。

ざっくり手順

  1. 管理用サブスクリプションに Automation Account を作成する
  2. Automation Account のマネージド ID に必要最小限の RBAC ロールを割り当てる
  3. コスト抑制 Runbook で VM、Application Gateway、ロックを順に処理する
  4. 月次復旧 Runbook と毎月 1 日のスケジュールを作成する
  5. Action Group と月次予算を作成し、閾値到達時にコスト抑制 Runbook を起動する
  6. テストとジョブログの確認を実施する

1. Automation Account を作成する

管理用サブスクリプションに Automation Account を作成し、システム割り当てマネージド ID を有効にします。Runbook で使用する Az.AccountsAz.ComputeAz.NetworkAz.Resources モジュールも追加します。

Action Group から Runbook を直接起動する場合、Azure のアラートから Automation Webhook へ到達できる必要があります。Automation Account で Private Link を使用し、パブリック ネットワーク アクセスを無効にした構成では利用できない点に注意してください。

2. マネージド ID に最小権限を付与する

管理用 Automation Account のシステム割り当てマネージド ID に、今回は次の組み込みロールを割り当てました。

ロール 推奨スコープ 用途
仮想マシン共同作成者 対象 VM、または対象 VM を含むリソース グループ VM の参照、停止、割り当て解除
ネットワーク共同作成者 対象 Application Gateway、または対象リソース グループ Application Gateway の参照、停止、起動
Locks Contributor 対象サブスクリプション サブスクリプション スコープのロック確認、作成、削除

Owner を安易に付与せず、VM と Application Gateway の操作権限は可能な限り対象リソースに限定します。サブスクリプション スコープが必要な Locks Contributor は Automation Account のマネージド ID にだけ割り当て、開発者には Microsoft.Authorization/locks/* を含むロール、特に OwnerUser Access Administrator を付与しません。

3. コスト抑制 Runbook を作成する

停止処理は、すべての停止操作が成功してから最後にロックを設定します。先にロックすると、後続の停止処理も拒否されます。

コスト抑制 Runbook のコードを表示する
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
#Requires -Version 7.2
#Requires -Modules Az.Accounts, Az.Compute, Az.Network, Az.Resources

[CmdletBinding()]
param(
    [Parameter()]
    [string]$SubscriptionId,

    [Parameter()]
    [string]$VmTargetsJson,

    [Parameter()]
    [string]$ApplicationGatewayTargetsJson,

    [Parameter()]
    [string]$LockName,

    [Parameter()]
    [string]$LockNotes
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# この Runbook はコスト抑制のため、対象 VM と Application Gateway を停止したうえで
# サブスクリプション単位の ReadOnly ロックを付与する。停止時にロックが既に存在する場合は
# 既に停止済みとみなし、復旧処理の前提として再実行を避ける。

function Write-RunbookLog {
    param(
        [Parameter(Mandatory)]
        [string]$Message
    )

    Write-Output ('[{0:yyyy-MM-dd HH:mm:ss} UTC] {1}' -f [DateTime]::UtcNow, $Message)
}

# Runbook の実行時に、直接引数か Automation 変数かデフォルト値のどれを使うかを決定する。
# これにより、手動実行と Automation 実行で同じスクリプトを共用できる。
function Get-RunbookConfigurationValue {
    param(
        [Parameter()]
        [AllowNull()]
        [string]$ProvidedValue,

        [Parameter(Mandatory)]
        [string]$VariableName,

        [Parameter()]
        [AllowNull()]
        [object]$DefaultValue
    )

    if (-not [string]::IsNullOrWhiteSpace($ProvidedValue)) {
        return $ProvidedValue
    }

    $variableError = $null
    try {
        $variableValue = [string](Get-AutomationVariable -Name $VariableName -ErrorAction Stop)
        if (-not [string]::IsNullOrWhiteSpace($variableValue)) {
            return $variableValue
        }
    }
    catch {
        $variableError = $_.Exception.Message
    }

    if ($null -ne $DefaultValue) {
        return [string]$DefaultValue
    }

    $message = "Runbook パラメーターまたは Automation 変数 '$VariableName' を設定してください。"
    if ($variableError) {
        $message = "$message $variableError"
    }

    throw $message
}

# VM の電源状態は「PowerState/xxxx」の形式で返るため、停止済みかどうかを判定するための補助関数。
function Get-VmPowerState {
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Statuses
    )

    $powerStatus = $Statuses | Where-Object { $_.Code -like 'PowerState/*' } | Select-Object -First 1
    if ($null -eq $powerStatus) {
        return 'PowerState/unknown'
    }

    return $powerStatus.Code
}

function ConvertFrom-TargetJson {
    param(
        [Parameter(Mandatory)]
        [string]$Json,

        [Parameter(Mandatory)]
        [string]$ParameterName
    )

    try {
        $targets = @($Json | ConvertFrom-Json -ErrorAction Stop)
    }
    catch {
        throw "$ParameterName は有効な JSON 配列ではありません。$($_.Exception.Message)"
    }

    foreach ($target in $targets) {
        if ($null -eq $target -or
            $target.PSObject.Properties.Name -notcontains 'ResourceGroupName' -or
            $target.PSObject.Properties.Name -notcontains 'Name' -or
            [string]::IsNullOrWhiteSpace([string]$target.ResourceGroupName) -or
            [string]::IsNullOrWhiteSpace([string]$target.Name)) {
            throw "$ParameterName の各要素には ResourceGroupName と Name が必要です。"
        }
    }

    return $targets
}

function Invoke-WithRetry {
    param(
        [Parameter(Mandatory)]
        [scriptblock]$Operation,

        [Parameter(Mandatory)]
        [string]$Description,

        [Parameter()]
        [ValidateRange(1, 5)]
        [int]$MaxAttempts = 3
    )

    # 再試行しても解消しない構成・権限エラーは即座に失敗させる。
    $nonRetryablePattern = 'ScopeLocked|AuthorizationFailed|LinkedAuthorizationFailed|ResourceNotFound|ResourceGroupNotFound|SubscriptionNotFound|InvalidAuthenticationToken'

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return & $Operation
        }
        catch {
            if ($attempt -eq $MaxAttempts -or $_.Exception.Message -match $nonRetryablePattern) {
                throw
            }

            $delaySeconds = [int][Math]::Pow(2, $attempt)
            Write-Warning "$Description に失敗しました。$delaySeconds 秒後に再試行します ($attempt/$MaxAttempts)。$($_.Exception.Message)"
            Start-Sleep -Seconds $delaySeconds
        }
    }
}

$SubscriptionId = Get-RunbookConfigurationValue `
    -ProvidedValue $SubscriptionId `
    -VariableName 'CostAlertSubscriptionId'
$VmTargetsJson = Get-RunbookConfigurationValue `
    -ProvidedValue $VmTargetsJson `
    -VariableName 'CostAlertVmTargetsJson' `
    -DefaultValue '[]'
$ApplicationGatewayTargetsJson = Get-RunbookConfigurationValue `
    -ProvidedValue $ApplicationGatewayTargetsJson `
    -VariableName 'CostAlertApplicationGatewayTargetsJson' `
    -DefaultValue '[]'
$LockName = Get-RunbookConfigurationValue `
    -ProvidedValue $LockName `
    -VariableName 'CostAlertLockName' `
    -DefaultValue 'Subscription-ReadOnly-After-Shutdown'
$LockNotes = Get-RunbookConfigurationValue `
    -ProvidedValue $LockNotes `
    -VariableName 'CostAlertLockNotes' `
    -DefaultValue 'Automation Runbook によるリソース停止後の読み取り専用ロック'

$vmTargets = @(ConvertFrom-TargetJson -Json $VmTargetsJson -ParameterName 'VmTargetsJson')
$applicationGatewayTargets = @(
    ConvertFrom-TargetJson `
        -Json $ApplicationGatewayTargetsJson `
        -ParameterName 'ApplicationGatewayTargetsJson'
)

# 停止対象が 1 件もない場合は何も処理できないため即時終了する。
if ($vmTargets.Count -eq 0 -and $applicationGatewayTargets.Count -eq 0) {
    throw '停止対象が指定されていません。VM または Application Gateway を 1 件以上指定してください。'
}

Write-RunbookLog 'Azure へシステム割り当てマネージド ID でサインインします。'
Disable-AzContextAutosave -Scope Process | Out-Null
$azureContext = (Connect-AzAccount -Identity).Context
$azureContext = Set-AzContext -SubscriptionId $SubscriptionId -DefaultProfile $azureContext
Write-RunbookLog "サブスクリプション $SubscriptionId を選択しました。"

$subscriptionScope = "/subscriptions/$SubscriptionId"

# 既に ReadOnly ロックが付与されている場合は、停止処理の二重実行を避ける。
# これは「既に停止済み」または「運用者が停止済みとして扱っている」状態を安全に防ぐため。
$existingLock = Get-AzResourceLock `
    -LockName $LockName `
    -Scope $subscriptionScope `
    -AtScope `
    -DefaultProfile $azureContext `
    -ErrorAction SilentlyContinue

if ($null -ne $existingLock) {
    if ($existingLock.Properties.Level -ne 'ReadOnly') {
        throw "同名のロック '$LockName' が異なるレベル '$($existingLock.Properties.Level)' で存在します。"
    }

    # ReadOnly ロックは停止操作自体を拒否するため、停止処理は実行済みとみなして終了する。
    Write-RunbookLog "サブスクリプションの ReadOnly ロック '$LockName' が既に存在するため、停止処理は完了済みとみなします。"
    Write-RunbookLog '停止対象を追加した場合は、復旧 Runbook でロックを解除してから再実行してください。'
    return
}

$failures = [System.Collections.Generic.List[string]]::new()

# VM を順次停止し、割り当て解除によりコスト発生を抑制する。
foreach ($target in $vmTargets) {
    $resourceLabel = "VM '$($target.ResourceGroupName)/$($target.Name)'"

    try {
        $vm = Invoke-WithRetry -Description "$resourceLabel の状態取得" -Operation {
            Get-AzVM `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -Status `
                -DefaultProfile $azureContext
        }
        $powerState = Get-VmPowerState -Statuses $vm.Statuses

        if ($powerState -eq 'PowerState/deallocated') {
            Write-RunbookLog "$resourceLabel は既に割り当て解除済みです。"
            continue
        }

        Write-RunbookLog "$resourceLabel を停止して割り当て解除します。現在の状態: $powerState"
        Invoke-WithRetry -Description "$resourceLabel の停止" -Operation {
            Stop-AzVM `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -Force `
                -DefaultProfile $azureContext | Out-Null
        }

        $vm = Invoke-WithRetry -Description "$resourceLabel の停止確認" -Operation {
            Get-AzVM `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -Status `
                -DefaultProfile $azureContext
        }
        $powerState = Get-VmPowerState -Statuses $vm.Statuses
        if ($powerState -ne 'PowerState/deallocated') {
            throw "停止後の状態が $powerState です。"
        }

        Write-RunbookLog "$resourceLabel の停止と割り当て解除が完了しました。"
    }
    catch {
        $message = "$resourceLabel の処理に失敗しました: $($_.Exception.Message)"
        $failures.Add($message)
        Write-Error -Message $message -ErrorAction Continue
    }
}

# Application Gateway も停止対象に含め、ネットワーク帯域やインスタンスの稼働を止める。
foreach ($target in $applicationGatewayTargets) {
    $resourceLabel = "Application Gateway '$($target.ResourceGroupName)/$($target.Name)'"

    try {
        $applicationGateway = Invoke-WithRetry -Description "$resourceLabel の状態取得" -Operation {
            Get-AzApplicationGateway `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -DefaultProfile $azureContext
        }

        if ($applicationGateway.OperationalState -eq 'Stopped') {
            Write-RunbookLog "$resourceLabel は既に停止済みです。"
            continue
        }

        Write-RunbookLog "$resourceLabel を停止します。現在の状態: $($applicationGateway.OperationalState)"
        Invoke-WithRetry -Description "$resourceLabel の停止" -Operation {
            Stop-AzApplicationGateway `
                -ApplicationGateway $applicationGateway `
                -DefaultProfile $azureContext | Out-Null
        }

        $applicationGateway = Invoke-WithRetry -Description "$resourceLabel の停止確認" -Operation {
            Get-AzApplicationGateway `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -DefaultProfile $azureContext
        }
        if ($applicationGateway.OperationalState -ne 'Stopped') {
            throw "停止後の状態が $($applicationGateway.OperationalState) です。"
        }

        Write-RunbookLog "$resourceLabel の停止が完了しました。"
    }
    catch {
        $message = "$resourceLabel の処理に失敗しました: $($_.Exception.Message)"
        $failures.Add($message)
        Write-Error -Message $message -ErrorAction Continue
    }
}

if ($failures.Count -gt 0) {
    $failureSummary = $failures -join [Environment]::NewLine
    throw "停止処理で $($failures.Count) 件のエラーが発生したため、ReadOnly ロックは作成しません。$([Environment]::NewLine)$failureSummary"
}

# すべての停止が成功したら、サブスクリプション全体で変更禁止の ReadOnly ロックを設定する。
# これにより、コスト抑制のためにリソースが勝手に再起動されることを防止する。
Write-RunbookLog "サブスクリプションに ReadOnly ロック '$LockName' を作成します。"
Invoke-WithRetry -Description "ReadOnly ロック '$LockName' の作成" -Operation {
    New-AzResourceLock `
        -LockName $LockName `
        -LockLevel ReadOnly `
        -Scope $subscriptionScope `
        -LockNotes $LockNotes `
        -Force `
        -DefaultProfile $azureContext | Out-Null
}

$createdLock = Get-AzResourceLock `
    -LockName $LockName `
    -Scope $subscriptionScope `
    -AtScope `
    -DefaultProfile $azureContext
if ($null -eq $createdLock -or $createdLock.Properties.Level -ne 'ReadOnly') {
    throw "作成したロック '$LockName' のレベルを確認できませんでした。"
}

Write-RunbookLog "サブスクリプションの ReadOnly ロック '$LockName' を作成しました。"
Write-RunbookLog 'すべての処理が正常に完了しました。'

この Runbook は複数の VM と Application Gateway を JSON で指定でき、Automation 変数からも設定を取得できます。停止済みリソースを安全にスキップし、一時的なエラーを再試行したうえで、すべての停止に成功した場合だけ ReadOnly ロックを作成します。ロックが既に存在する場合は処理済みとして終了します。

4. 毎月 1 日の復旧 Runbook を作成する

管理用 Automation Account に毎月 1 日のスケジュールを設定し、復旧 Runbook にリンクします。ReadOnly ロックがあると Application Gateway の開始は拒否されるため、必ずロックを先に削除します。

スケジュール作成の詳細な手順は割愛します。発行済みの復旧 Runbook で スケジュールへのリンクを選択し、毎月 1 日に実行するスケジュールと必要なパラメーターを設定します。

Azure Automation の復旧 Runbook でスケジュールへのリンクを選択する画面
復旧 Runbook のスケジュール設定:
月次復旧 Runbook のコードを表示する
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
#Requires -Version 7.2
#Requires -Modules Az.Accounts, Az.Network, Az.Resources

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$SubscriptionId,

    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$ApplicationGatewayTargetsJson,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$LockName = 'Subscription-ReadOnly-After-Shutdown'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# この Runbook は、停止中にサブスクリプションへ付与された ReadOnly ロックを解除したうえで
# Application Gateway を再起動し、通常運用を戻すための復旧処理を行う。

function Write-RunbookLog {
    param(
        [Parameter(Mandatory)]
        [string]$Message
    )

    Write-Output ('[{0:yyyy-MM-dd HH:mm:ss} UTC] {1}' -f [DateTime]::UtcNow, $Message)
}

# JSON 形式で渡された対象リソースの一覧を検証し、必要な ResourceGroupName / Name の形式を保証する。
function ConvertFrom-TargetJson {
    param(
        [Parameter(Mandatory)]
        [string]$Json
    )

    try {
        $targets = @($Json | ConvertFrom-Json -ErrorAction Stop)
    }
    catch {
        throw "ApplicationGatewayTargetsJson は有効な JSON 配列ではありません。$($_.Exception.Message)"
    }

    if ($targets.Count -eq 0) {
        throw 'Application Gateway の復旧対象を 1 件以上指定してください。'
    }

    foreach ($target in $targets) {
        if ($null -eq $target -or
            $target.PSObject.Properties.Name -notcontains 'ResourceGroupName' -or
            $target.PSObject.Properties.Name -notcontains 'Name' -or
            [string]::IsNullOrWhiteSpace([string]$target.ResourceGroupName) -or
            [string]::IsNullOrWhiteSpace([string]$target.Name)) {
            throw 'ApplicationGatewayTargetsJson の各要素には ResourceGroupName と Name が必要です。'
        }
    }

    return $targets
}

function Invoke-WithRetry {
    param(
        [Parameter(Mandatory)]
        [scriptblock]$Operation,

        [Parameter(Mandatory)]
        [string]$Description,

        [Parameter()]
        [ValidateRange(1, 5)]
        [int]$MaxAttempts = 3
    )

    # 再試行しても解消しない構成・権限エラーは即座に失敗させる。
    $nonRetryablePattern = 'ScopeLocked|AuthorizationFailed|LinkedAuthorizationFailed|ResourceNotFound|ResourceGroupNotFound|SubscriptionNotFound|InvalidAuthenticationToken'

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return & $Operation
        }
        catch {
            if ($attempt -eq $MaxAttempts -or $_.Exception.Message -match $nonRetryablePattern) {
                throw
            }

            $delaySeconds = [int][Math]::Pow(2, $attempt)
            Write-Warning "$Description に失敗しました。$delaySeconds 秒後に再試行します ($attempt/$MaxAttempts)。$($_.Exception.Message)"
            Start-Sleep -Seconds $delaySeconds
        }
    }
}

$applicationGatewayTargets = @(ConvertFrom-TargetJson -Json $ApplicationGatewayTargetsJson)

# まず対象のサブスクリプションへ接続し、ロックの状態を確認してから復旧を開始する。
Write-RunbookLog 'Azure へシステム割り当てマネージド ID でサインインします。'
Disable-AzContextAutosave -Scope Process | Out-Null
$azureContext = (Connect-AzAccount -Identity).Context
$azureContext = Set-AzContext -SubscriptionId $SubscriptionId -DefaultProfile $azureContext
Write-RunbookLog "サブスクリプション $SubscriptionId を選択しました。"

$subscriptionScope = "/subscriptions/$SubscriptionId"

# ReadOnly ロックが存在する場合のみ解除を行う。ロックがなければ停止時の復旧が未実行と見なす。
$existingLock = Get-AzResourceLock `
    -LockName $LockName `
    -Scope $subscriptionScope `
    -AtScope `
    -DefaultProfile $azureContext `
    -ErrorAction SilentlyContinue

if ($null -eq $existingLock) {
    Write-RunbookLog "サブスクリプションのロック '$LockName' は存在しません。ロック解除をスキップします。"
}
else {
    if ($existingLock.Properties.Level -ne 'ReadOnly') {
        throw "同名のロック '$LockName' が異なるレベル '$($existingLock.Properties.Level)' で存在するため、削除しません。"
    }

    Write-RunbookLog "サブスクリプションの ReadOnly ロック '$LockName' を削除します。"
    Invoke-WithRetry -Description "ReadOnly ロック '$LockName' の削除" -Operation {
        Remove-AzResourceLock `
            -LockId $existingLock.LockId `
            -Force `
            -DefaultProfile $azureContext | Out-Null
    }

    $remainingLock = Get-AzResourceLock `
        -LockName $LockName `
        -Scope $subscriptionScope `
        -AtScope `
        -DefaultProfile $azureContext `
        -ErrorAction SilentlyContinue
    if ($null -ne $remainingLock) {
        throw "ReadOnly ロック '$LockName' の削除を確認できませんでした。"
    }

    Write-RunbookLog "サブスクリプションの ReadOnly ロック '$LockName' を削除しました。"
}

$failures = [System.Collections.Generic.List[string]]::new()

# Application Gateway を 1 件ずつ復旧し、正常に Running 状態に戻ったことを確認する。
foreach ($target in $applicationGatewayTargets) {
    $resourceLabel = "Application Gateway '$($target.ResourceGroupName)/$($target.Name)'"

    try {
        $applicationGateway = Invoke-WithRetry -Description "$resourceLabel の状態取得" -Operation {
            Get-AzApplicationGateway `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -DefaultProfile $azureContext
        }

        if ($applicationGateway.OperationalState -eq 'Running') {
            Write-RunbookLog "$resourceLabel は既に起動済みです。"
            continue
        }

        Write-RunbookLog "$resourceLabel を起動します。現在の状態: $($applicationGateway.OperationalState)"
        Invoke-WithRetry -Description "$resourceLabel の起動" -Operation {
            Start-AzApplicationGateway `
                -ApplicationGateway $applicationGateway `
                -DefaultProfile $azureContext | Out-Null
        }

        $applicationGateway = Invoke-WithRetry -Description "$resourceLabel の起動確認" -Operation {
            Get-AzApplicationGateway `
                -ResourceGroupName $target.ResourceGroupName `
                -Name $target.Name `
                -DefaultProfile $azureContext
        }
        if ($applicationGateway.OperationalState -ne 'Running') {
            throw "起動後の状態が $($applicationGateway.OperationalState) です。"
        }

        Write-RunbookLog "$resourceLabel の起動が完了しました。"
    }
    catch {
        $message = "$resourceLabel の処理に失敗しました: $($_.Exception.Message)"
        $failures.Add($message)
        Write-Error -Message $message -ErrorAction Continue
    }
}

if ($failures.Count -gt 0) {
    $failureSummary = $failures -join [Environment]::NewLine
    throw "Application Gateway の復旧処理で $($failures.Count) 件のエラーが発生しました。$([Environment]::NewLine)$failureSummary"
}

Write-RunbookLog 'すべての復旧処理が正常に完了しました。'

この Runbook は複数の Application Gateway を JSON で受け取り、ロック解除後に各 Gateway の起動と状態確認を行います。VM は開始しないため、利用を再開する開発者が必要なタイミングで起動します。

コスト抑制用と月次復旧用の Runbook を作成し、PowerShell 7.2 で発行した状態が次の画面です。

Azure Automation Account に発行済みのコスト抑制 Runbook と月次復旧 Runbook が表示されている画面
Automation Account に作成した Runbook:

5. Action Group と月次予算を作成する

まず Azure Monitor で Action Group を作成します。アクション タイプで Automation Runbook を選び、管理用 Automation Account と発行済みのコスト抑制 Runbook を指定します。これにより、Logic Apps を経由せずに Action Group から Runbook を直接起動できます。

作成した Action Group では、ユーザー Runbook として Stop-AzureResourcesAndLock を選択し、一般的なアラート スキーマを有効にしています。

Azure Monitor の Action Group で Automation Account とコスト抑制 Runbook を選択した画面
Action Group の Automation Runbook アクション:

パラメーターの構成を開き、対象サブスクリプション ID、VM の一覧、Application Gateway の一覧を設定します。複数の対象リソースは、ResourceGroupNameName を持つ JSON 配列で指定します。

Action Group から起動するコスト抑制 Runbook にサブスクリプション ID、VM、Application Gateway のパラメーターを設定した画面
コスト抑制 Runbook のパラメーター設定:

次に、Cost Management の対象サブスクリプションで月次予算を作成し、この Action Group を通知先に指定します。Action Group を利用できるのはサブスクリプションまたはリソース グループ スコープの予算です。

いきなり停止する前に、たとえば次のように段階的な通知を設定します。

閾値 アクション
80% 管理者と開発チームへ通知
90% 管理者へ再通知、利用状況を確認
100% Action Group からコスト抑制 Runbook を起動

検証では、実際のコストが予算の 90% に到達したときに、作成した Action Group を呼び出す警告条件を設定しました。

Azure Cost Management の予算で実際のコストが予算の 90 パーセントに到達したときに Action Group を呼び出す通知設定画面
予算アラートの Action Group 設定:

6. 実行結果を検証する

予算の評価を待たずに Action Group の動作を確認するには、Action Group の テストを開き、サンプルの種類で コスト予算アラート、アクション タイプで Automation Runbook を選択して実行します。

Azure Monitor の Action Group テスト画面でコスト予算アラートと Automation Runbook を選択している画面
Action Group のテスト実行:

サンプルアラートを実行するとコスト抑制 Runbook のジョブが開始され、VM の割り当て解除、Application Gateway の停止、ReadOnly ロックの作成まで正常に完了したことを出力ログで確認できました。

コスト予算のサンプルアラートで起動した Runbook が VM と Application Gateway を停止し ReadOnly ロックを作成して正常完了した出力ログ
サンプルアラートによる Runbook の実行結果:

実際に検証したこと

項目 確認結果
Action Group コスト予算アラートのサンプルからコスト抑制 Runbook が起動した
VM 実行前の PowerState/running から停止・割り当て解除が完了した
Application Gateway 実行前の Running から停止が完了した
ロック サブスクリプションに ReadOnly ロックが作成された
ジョブログ 各処理の時刻と結果が記録され、ジョブが正常完了した

想定動作

項目 想定する動作
Application Gateway 停止後はアプリケーションへ接続できない
ロック 開発者による VM の開始など、サブスクリプション内の変更操作が拒否される
月次復旧 ロック解除後に Application Gateway が起動する
VM 月次復旧後も PowerState/deallocated のまま維持される

ロックを使う場合の注意点

CanNotDelete ロックは削除だけを防ぎ、VM の開始や Application Gateway の構成変更は防げません。停止済みリソースを開発者が再開できないようにする目的では ReadOnly が必要です。

一方で、ReadOnly ロックは影響範囲が大きく、デプロイ、スケール変更、バックアップ設定の更新、障害対応なども止める可能性があります。ロックはコントロール プレーンの制御であり、データ プレーン操作を止めるセキュリティ境界ではありません。また、ロックを削除できる高権限者はこの制御を回避できます。

専用のテスト サブスクリプションに適用し、緊急解除の担当者、連絡手段、解除手順を事前に合意しておくことが重要です。

まとめ

Cost Management の予算、Action Group、Automation Account を組み合わせることで、コスト超過を検知した後の VM と Application Gateway を自動停止できます。さらにサブスクリプションの ReadOnly ロックを使うことで、開発者による再開を防げます。

ただし、予算は厳密な利用上限ではなく、ReadOnly ロックは対象サブスクリプションの運用に広く影響します。復旧用の自動化を管理用サブスクリプションに分離し、停止・復旧・緊急解除をテスト環境で十分に検証してから利用しましょう。

参考

共有

Kento
著者
[Kento GitHub Copilot]
2020年に新卒で IT 企業に入社. インフラエンジニア(主にクラウド)として活動中